/** * Pure, synchronous token-classification helpers for bash path extraction. * * Exports three classifiers consumed by `bash-path-resolver.ts`: * - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard. * - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules. * - `classifyBareTokenCandidate` — prelude-only gate for a bare token (e.g. * `id_rsa`, `outside-link`) that `classifyTokenAsRuleCandidate` rejects for * shape. It answers only "is this shape capable of naming a path?"; whether * it *does* name one is settled by the resolver's existence probe (#645). * * Token classification is three-valued: definitely-path (shape), definitely-not * (prelude), and unknown (a bare word). These functions own the first two; the * third is resolved against the filesystem rather than against policy, so no * classifier here consults the ruleset — see * `docs/decisions/0009-bash-path-projection-completeness-contract.md`. * * All three classifiers share the private `rejectNonPathToken` predicate that * captures the six rejection cases common to them (the production clone this * module was extracted to eliminate). * * Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize * Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all * platforms. On POSIX the token resolves as a real in-CWD relative path and is * gated by the `path` surface; on Windows the `PathNormalizer` routes it through * the absolute-path branch. Shape recognition is platform-independent string * matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`. * * `classifyTokenAsRuleCandidate` takes the resolved {@link PathFlavor}: a * backslash-relative token (`dir\file`, no leading `.`, no `/`, no `..`, not a * drive-letter absolute) is accepted as path-shaped only under the win32 flavor, * whose `hasPathSeparator` counts `\` as a separator (#520). This is the one * genuinely platform-sensitive shape rule the classifier owns — on POSIX `\` is * a legal filename character — and the flavor owns the bit, so the classifier * never reads `process.platform` itself. */ import type { PathFlavor } from "#src/path/path-flavor"; // ── Public classifiers ───────────────────────────────────────────────────── /** * Strict path-candidate classifier for the external-directory guard. * * Accepts tokens that unambiguously look like filesystem paths: * - Absolute paths (starting with `/`) * - Home-relative paths (starting with `~/`) * - Parent-traversal paths (containing `..`) * - Windows drive-letter absolute paths (`C:/…` or `C:\…`) * * Returns the raw token string if it qualifies, or `null` to skip. */ export function classifyTokenAsPathCandidate(token: string): string | null { if (rejectNonPathToken(token)) return null; if (token.startsWith("/")) return token; if (token.startsWith("~/")) return token; if (token.includes("..")) return token; if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; return null; } /** * Broader token classifier for cross-cutting `path` permission rules. * * Accepts the same shapes as `classifyTokenAsPathCandidate`, plus: * - Dot-files and `./`-relative paths (starting with `.`) * - Any token carrying a path separator under `flavor` (`src/foo.ts`, and on * win32 the backslash-relative `dir\file`, #520) — `flavor.hasPathSeparator` * owns the platform bit (POSIX: `/` only; win32: `/` or `\`), so this * classifier never reads `process.platform`. * - Windows drive-letter absolute paths (`C:/…` or `C:\…`) * * The `~/foo` case is covered by `hasPathSeparator` — no separate `~/` branch needed. * The forward-slash drive form (`C:/…`) is also caught by `hasPathSeparator`, but the * explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class * and order-independent, and covers the backslash-only form (`D:\…`) which the POSIX * flavor's `hasPathSeparator` cannot reach. * * Does NOT require the strict "must start with `/` or `~/` or contain `..`" * gate that the external-directory classifier uses. * * Returns the raw token string if it qualifies, or `null` to skip. */ export function classifyTokenAsRuleCandidate( token: string, flavor: PathFlavor, ): string | null { if (rejectNonPathToken(token)) return null; if (token.startsWith(".")) return token; if (flavor.hasPathSeparator(token)) return token; // ~/ paths, relative paths with /, and win32 dir\file if (token.includes("..")) return token; // bare ".." (no slash) if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form return null; } /** * Prelude-only classifier for a bare token (#645). * * A bare token (`id_rsa`, `outside-link`) has none of the shapes * `classifyTokenAsRuleCandidate` accepts, because most bash argument tokens are * not file paths (subcommands, branch names, search patterns). This classifier * answers the narrower question the existence probe needs: could this token's * *shape* name a path at all? * * It runs only the shared `rejectNonPathToken` prelude, so a flag, * env-assignment, URL, `@scope` token, or regex-shaped token is never a * candidate. Everything else is returned for the caller to probe. * * Deliberately consults no policy: candidacy is settled by the filesystem and * the decision by the ruleset, which keeps this module a pure shape function * (ADR 0009). It replaced the rule-driven promotion of #509, which matched a * token's *spelling* against `path` rules and so could never see that a * symlink's target is what a rule names. * * Returns the raw token string if it qualifies, or `null` to skip. */ export function classifyBareTokenCandidate(token: string): string | null { return rejectNonPathToken(token) ? null : token; } // ── Private rejection predicate ──────────────────────────────────────────── /** * Windows drive-letter absolute path: a single ASCII letter, a colon, then a * separator (`/` or `\`). Matches `C:/…` and `C:\…` but not drive-relative * `C:foo` (no separator) or multi-letter schemes (`https:`, `mailto:`). * Single-letter schemes with `//` (e.g. `c://x`) are already rejected by * `URL_PATTERN` before this pattern is tested. */ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/; /** * URL pattern to skip tokens that look like URLs rather than paths. */ const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i; /** * Regex metacharacter sequences that are never found in real filesystem paths. * If a token contains any of these, it is almost certainly a regex pattern * (e.g. a grep argument) rather than a path. */ const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//; /** * Shared rejection prelude: returns `true` when a token can never be a * filesystem path, regardless of which classifier is asking. * * Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`), * URLs, `@scope/package` patterns, and regex metacharacter sequences. * * A bare `/` (or `//`, `///`) is NOT rejected: it denotes the filesystem root, * a deliberate external-directory access (`find /`, `ls /`), so it must reach * the path surfaces like any other absolute token (#583). */ function rejectNonPathToken(token: string): boolean { if (!token) return true; if (token.startsWith("-")) return true; // Env assignment: = appears before any / (FOO=/bar is an assignment, // /foo=bar is not because the slash comes first). const eqIndex = token.indexOf("="); const slashIndex = token.indexOf("/"); if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) return true; if (URL_PATTERN.test(token)) return true; // @scope/package patterns (npm scoped packages) — but @/ is allowed through // since it looks like an absolute-rooted path, not an npm scope. if (token.startsWith("@") && !token.startsWith("@/")) return true; if (REGEX_METACHAR_PATTERN.test(token)) return true; return false; }