mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
/**
|
||||
* Resolve symlinks in an absolute path, best-effort.
|
||||
*
|
||||
* Splits the path into components and tries `realpathSync` from the full path
|
||||
* down to `/`, re-appending the non-existent tail to the first ancestor that
|
||||
* resolves. Returns the input unchanged when no ancestor resolves (unreachable
|
||||
* in practice since `/` always exists) or when a non-ENOENT/ENOTDIR error is
|
||||
* encountered (e.g. `EACCES`, `ELOOP`), so callers fall back to lexical
|
||||
* containment for paths that cannot be resolved.
|
||||
*/
|
||||
export function canonicalizePath(
|
||||
absolutePath: string,
|
||||
flavor: PathFlavor,
|
||||
): string {
|
||||
if (!absolutePath) return absolutePath;
|
||||
|
||||
const { impl } = flavor;
|
||||
const root = impl.parse(absolutePath).root;
|
||||
const rest = absolutePath.slice(root.length);
|
||||
const parts = rest.split(impl.sep).filter(Boolean);
|
||||
for (let i = parts.length; i >= 0; i--) {
|
||||
const candidate = root + parts.slice(0, i).join(impl.sep);
|
||||
try {
|
||||
const real = realpathSync(candidate);
|
||||
const tail = parts.slice(i);
|
||||
return tail.length === 0 ? real : impl.join(real, ...tail);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") return absolutePath;
|
||||
}
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
import { isSafeSystemPath } from "#src/safe-system-paths";
|
||||
|
||||
/**
|
||||
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
|
||||
*
|
||||
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
|
||||
* — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
|
||||
* This predicate touches no filesystem and does no derivation; the containment
|
||||
* geometry lives on {@link PathFlavor.isWithin}.
|
||||
*/
|
||||
export function isPathOutsideWorkingDirectory(
|
||||
canonicalPath: string,
|
||||
canonicalCwd: string,
|
||||
flavor: PathFlavor,
|
||||
): boolean {
|
||||
if (!canonicalCwd || !canonicalPath) {
|
||||
return false;
|
||||
}
|
||||
if (isSafeSystemPath(canonicalPath)) {
|
||||
return false;
|
||||
}
|
||||
return !flavor.isWithin(canonicalPath, canonicalCwd);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { PlatformPath } from "node:path";
|
||||
import { posix as posixPath, win32 as winPath } from "node:path";
|
||||
|
||||
import {
|
||||
type BashTokenShape,
|
||||
classifyWin32BashToken,
|
||||
} from "#src/access-intent/bash/msys-bash-tokens";
|
||||
import type { WildcardMatchOptions } from "#src/wildcard-matcher";
|
||||
|
||||
/**
|
||||
* The resolved product of the single win32-vs-POSIX platform decision: the
|
||||
* platform's path *language* as one immutable collaborator.
|
||||
*
|
||||
* The win32-vs-POSIX difference is not variant growth (the set is closed) but
|
||||
* **connascence of algorithm** — every path leaf must re-derive the same
|
||||
* mapping identically, and in a permission system a leaf that misses the case
|
||||
* fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
|
||||
* captures that mapping once so the leaves consume the resolved capability
|
||||
* instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
|
||||
* **semantics** — syntax ({@link hasPathSeparator}), token shape
|
||||
* ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
|
||||
* {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
|
||||
* (lexical cleanup, alias generation, safe-system-path exclusions, rule
|
||||
* dispatch) stays in the functions that consume it.
|
||||
*/
|
||||
export interface PathFlavor {
|
||||
/**
|
||||
* Node's own platform path strategy (`path.win32` | `path.posix`). Exposed
|
||||
* directly — its post-migration consumers are all path-domain primitives and
|
||||
* `PlatformPath` is itself a maintained strategy object, so wrapping it would
|
||||
* be pure forwarding.
|
||||
*/
|
||||
readonly impl: PlatformPath;
|
||||
/**
|
||||
* Wildcard match options for path-surface rule matching: the win32
|
||||
* case-and-separator fold, or `undefined` on POSIX.
|
||||
*/
|
||||
readonly matchOptions: WildcardMatchOptions | undefined;
|
||||
/** Comparison case fold: win32 lowercases, POSIX returns the value unchanged. */
|
||||
fold(value: string): string;
|
||||
/**
|
||||
* Resolve `pathValue` against `base`, normalize, and fold — the single home
|
||||
* of the #382 case-fold invariant for absolute comparison values.
|
||||
*/
|
||||
comparable(pathValue: string, base: string): string;
|
||||
/** `path.relative`-based containment: is `pathValue` `directory` itself or nested inside it? */
|
||||
isWithin(pathValue: string, directory: string): boolean;
|
||||
/**
|
||||
* True when `token` contains a path separator under this platform: `/` on
|
||||
* POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
|
||||
*/
|
||||
hasPathSeparator(token: string): boolean;
|
||||
/**
|
||||
* The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
|
||||
* carries device / drive-mount / posix-absolute / plain semantics; on POSIX
|
||||
* every token is an ordinary path, so the shape is always `{ kind: "plain" }`.
|
||||
*/
|
||||
bashTokenShape(token: string): BashTokenShape;
|
||||
}
|
||||
|
||||
class PlatformPathFlavor implements PathFlavor {
|
||||
readonly matchOptions: WildcardMatchOptions | undefined;
|
||||
|
||||
constructor(
|
||||
readonly impl: PlatformPath,
|
||||
private readonly windows: boolean,
|
||||
) {
|
||||
this.matchOptions = windows
|
||||
? { caseInsensitive: true, windowsSeparators: true }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
fold(value: string): string {
|
||||
return this.windows ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
comparable(pathValue: string, base: string): string {
|
||||
return this.fold(this.impl.normalize(this.impl.resolve(base, pathValue)));
|
||||
}
|
||||
|
||||
isWithin(pathValue: string, directory: string): boolean {
|
||||
if (!pathValue || !directory) return false;
|
||||
if (pathValue === directory) return true;
|
||||
const rel = this.impl.relative(directory, pathValue);
|
||||
return (
|
||||
rel !== "" &&
|
||||
rel !== ".." &&
|
||||
!rel.startsWith(`..${this.impl.sep}`) &&
|
||||
!this.impl.isAbsolute(rel)
|
||||
);
|
||||
}
|
||||
|
||||
hasPathSeparator(token: string): boolean {
|
||||
return token.includes("/") || (this.windows && token.includes("\\"));
|
||||
}
|
||||
|
||||
bashTokenShape(token: string): BashTokenShape {
|
||||
return this.windows ? classifyWin32BashToken(token) : { kind: "plain" };
|
||||
}
|
||||
}
|
||||
|
||||
export const posixPathFlavor: PathFlavor = new PlatformPathFlavor(
|
||||
posixPath,
|
||||
false,
|
||||
);
|
||||
export const win32PathFlavor: PathFlavor = new PlatformPathFlavor(
|
||||
winPath,
|
||||
true,
|
||||
);
|
||||
|
||||
/** The one win32-vs-POSIX platform decision in the package. */
|
||||
export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor {
|
||||
return platform === "win32" ? win32PathFlavor : posixPathFlavor;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { join } from "node:path";
|
||||
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
|
||||
import { expandHomePath } from "#src/expand-home";
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
import { wildcardMatch } from "#src/wildcard-matcher";
|
||||
|
||||
function containsGlobChars(value: string): boolean {
|
||||
return value.includes("*") || value.includes("?");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given tool + normalized path combination qualifies for
|
||||
* automatic allow as a Pi infrastructure read.
|
||||
*
|
||||
* A path qualifies when:
|
||||
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
||||
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
||||
* OR within the project-local Pi package directories
|
||||
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
||||
*
|
||||
* `infrastructureDirs` entries may be absolute paths or patterns containing
|
||||
* `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
|
||||
* Project-local paths are computed fresh from `cwd` on each call so they
|
||||
* follow working-directory changes without a runtime rebuild.
|
||||
*/
|
||||
export function isPiInfrastructureRead(
|
||||
toolName: string,
|
||||
normalizedPath: string,
|
||||
infrastructureDirs: readonly string[],
|
||||
cwd: string,
|
||||
flavor: PathFlavor,
|
||||
): boolean {
|
||||
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// On Windows the path value is canonicalized + lowercased; the flavor's match
|
||||
// options fold case (and separators) so mixed-case infra dirs and glob
|
||||
// patterns still match.
|
||||
for (const dir of infrastructureDirs) {
|
||||
if (containsGlobChars(dir)) {
|
||||
if (wildcardMatch(dir, normalizedPath, flavor.matchOptions)) return true;
|
||||
} else {
|
||||
if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
||||
const projectNpmDir = join(cwd, ".pi", "npm");
|
||||
const projectGitDir = join(cwd, ".pi", "git");
|
||||
if (flavor.isWithin(normalizedPath, projectNpmDir)) {
|
||||
return true;
|
||||
}
|
||||
if (flavor.isWithin(normalizedPath, projectGitDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user