import type { PathFlavor } from "#src/path/path-flavor"; import { AccessPath } from "./access-intent/access-path"; import { canonicalNormalizePathForComparison, normalizePathForComparison, normalizePathPolicyLiteral, } from "./access-intent/path-normalization"; import { isPathOutsideWorkingDirectory } from "./path/path-containment"; import { isPiInfrastructureRead } from "./path/pi-infrastructure-read"; /** * The interpreted effect of a literal `cd` target on the effective base, under * the host platform's (and, on win32, Git Bash's) semantics. * * - `absolute` — the target names a resolvable absolute base (`value`); an * earlier unknown base is recovered. * - `relative` — the target folds into the current base. * - `unknown` — the target is not deterministically resolvable (a win32 * non-mount POSIX absolute like `cd /tmp`, or a device), so the base becomes * conservatively unknown. */ export type BashCdTarget = | { readonly kind: "absolute"; readonly value: string } | { readonly kind: "relative" } | { readonly kind: "unknown" }; /** * Path-interpretation collaborator, constructed once at the session edge with * the two ambient inputs — the resolved {@link PathFlavor} and the session * `cwd` — baked in, and handed raw path tokens thereafter. * * The bash path pipeline and the per-tool/external-directory gates ask this * object the platform-dependent questions ("is this path absolute *under our * flavor*?", "resolve this `cd` offset *against our cwd*") and receive prepared * {@link AccessPath} values, instead of reading `process.platform` ambiently or * threading `cwd` through every call. All platform semantics live on the * injected `flavor`; this class holds no platform discriminator and no * `win32`/`posix` branch — it delegates to `flavor` and the flavor-parameterized * `path-containment` / `path-normalization` / `AccessPath` primitives. */ export class PathNormalizer { /** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */ private readonly canonicalCwd: string; constructor( readonly flavor: PathFlavor, private readonly cwd: string, ) { this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor); } /** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */ forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath { return AccessPath.forPath(pathValue, { cwd: this.cwd, resolveBase: options?.resolveBase, flavor: this.flavor, }); } /** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */ forLiteral(literal: string, matchAliases?: readonly string[]): AccessPath { return AccessPath.forLiteral(literal, matchAliases); } /** * Build an AccessPath for a bash-command token, applying Git Bash/MSYS * semantics on a win32 host. * * Pi core always executes bash through Git Bash on Windows, so a POSIX-shaped * absolute token carries MSYS semantics, not `node:path.win32` semantics. The * flavor classifies the token's shape: on win32 the recognized safe device * paths (`/dev/null`, `/dev/std{in,out,err}`) are preserved verbatim as * devices instead of being resolved into `c:\dev\null`, and MSYS drive mounts * (`/c/…`) are translated to their Windows equivalent (`C:\…`) before * resolution; every other token delegates to {@link forPath}. On POSIX every * token is `plain`, so this is a straight delegation to {@link forPath}. */ forBashToken(token: string, options?: { resolveBase?: string }): AccessPath { const shape = this.flavor.bashTokenShape(token); switch (shape.kind) { case "device": return AccessPath.forDevice(token); case "drive-mount": return this.forPath(shape.windowsPath, options); case "posix-absolute": { // A non-mount POSIX absolute (`/tmp`, `/usr`) has an install-dependent // Windows target this package cannot know, so it is kept literal: always // external, matched and displayed as typed, never fabricated into // `c:\tmp` (#533). The win32 path matcher folds a rule's separators // (`/` -> `\`), so a forward-slash value is unmatchable; carry a // backslash match alias so a natural `/tmp/*` external_directory rule // still resolves, while `value()` stays as typed for display. const literal = normalizePathPolicyLiteral(token); return this.forLiteral(literal, [literal.replaceAll("/", "\\")]); } case "plain": return this.forPath(token, options); } } /** Platform-aware absoluteness (`win32` vs `posix` rules). */ isAbsolute(pathValue: string): boolean { return this.flavor.impl.isAbsolute(pathValue); } /** * Interpret a literal `cd` target's effect on the effective base. * * On win32 the target carries Git Bash/MSYS semantics: a drive mount * (`cd /c/x`) resolves to a translated Windows base (`C:\x`), a non-mount * POSIX absolute (`cd /tmp`) is not deterministically resolvable and yields an * `unknown` base, and a native/relative target is handled as usual. On POSIX * every token is `plain`, so an absolute target is absolute and everything * else is relative. */ interpretBashCdTarget(target: string): BashCdTarget { const shape = this.flavor.bashTokenShape(target); switch (shape.kind) { case "drive-mount": return { kind: "absolute", value: shape.windowsPath }; case "device": case "posix-absolute": return { kind: "unknown" }; case "plain": return this.flavor.impl.isAbsolute(target) ? { kind: "absolute", value: target } : { kind: "relative" }; } } /** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */ resolveBase(offset: string): string { return this.flavor.impl.resolve(this.cwd, offset); } /** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */ joinBase(offset: string, target: string): string { return this.flavor.impl.join(offset, target); } /** Containment of `pathValue` within `directory` (platform-aware). */ isWithinDirectory(pathValue: string, directory: string): boolean { return this.flavor.isWithin(pathValue, directory); } /** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */ isOutsideWorkingDirectory(pathValue: string): boolean { const canonicalPath = canonicalNormalizePathForComparison( pathValue, this.cwd, this.flavor, ); return isPathOutsideWorkingDirectory( canonicalPath, this.canonicalCwd, this.flavor, ); } /** * Outside-cwd test for an already-canonical boundary value (from * {@link AccessPath.boundaryValue}), against the baked cwd. * * Unlike {@link isOutsideWorkingDirectory}, it does not re-derive the * canonical form — the caller passes a value the {@link AccessPath} already * canonicalized, so a device's preserved `/dev/null` reaches the pure check's * `isSafeSystemPath` exclusion intact. */ isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean { return isPathOutsideWorkingDirectory( canonicalPath, this.canonicalCwd, this.flavor, ); } /** * Lexical (not symlink-resolved) comparison value, resolved against the baked * cwd. Mirrors the as-typed absolute form used for skill-prompt matching; * touches no filesystem, unlike {@link forPath}'s canonical alias. */ comparableValue(pathValue: string): string { return normalizePathForComparison(pathValue, this.cwd, this.flavor); } /** * Pi infrastructure-read containment for a read-only tool, decided against * the canonical (symlink-resolved) path and the baked cwd/flavor. Takes the * already-built {@link AccessPath} so the caller does not re-resolve it. */ isInfrastructureRead( toolName: string, accessPath: AccessPath, infraDirs: readonly string[], ): boolean { return isPiInfrastructureRead( toolName, accessPath.boundaryValue(), infraDirs, this.cwd, this.flavor, ); } }