diff --git a/llp/0049-hypignore-usage-policy.spec.md b/llp/0049-hypignore-usage-policy.spec.md index 7e28dc26..405e25b3 100644 --- a/llp/0049-hypignore-usage-policy.spec.md +++ b/llp/0049-hypignore-usage-policy.spec.md @@ -62,6 +62,12 @@ governs. Because V1 has only `ignore` and no "un-ignore" directive mechanism is not repo-bound: a `.hypignore` anywhere in the ancestor chain (including outside any git repo) governs its subtree. +**Extended-by: [LLP 0050 §canonicalization](./0050-ignore-enforced-in-adapters.decision.md#canonicalization)**: +"the ancestor chain" is over **real** paths, not lexical ones. A `cwd` is +matched over the set of spellings that denote it (as-given plus +symlink-resolved), and the most restrictive verdict any spelling produces wins, +so a `.hypignore` cannot be escaped by reaching its subtree through a symlink. + ## Classes {#classes} | Class | V1 | Meaning | diff --git a/llp/0050-ignore-enforced-in-adapters.decision.md b/llp/0050-ignore-enforced-in-adapters.decision.md index 617406f2..22d145f9 100644 --- a/llp/0050-ignore-enforced-in-adapters.decision.md +++ b/llp/0050-ignore-enforced-in-adapters.decision.md @@ -79,6 +79,121 @@ imported by both adapters exactly as they already import concept and does not inspect rows (only the adapter knows which field is the `cwd`). +## What "the same directory" means to the shared matcher {#canonicalization} + +The matcher compares directory *paths*, and a directory has more than one path. +Until this section existed, the matcher used `path.resolve` on both sides, which +is purely **lexical**: it normalizes `.`/`..` and makes a path absolute but +follows no symlinks. So the ancestor walk from a symlinked `cwd` climbed the +*link's* parents and never met the `.hypignore` governing the real directory: a +user's `.hypignore` was silently not in force for a project reached through a +symlink, which on macOS is ordinary (`/tmp` to `/private/tmp`, Homebrew +prefixes, iCloud-backed `~/Documents`). Because this is the *single shared* +matcher, every consumer inherited the hole: all four adapter capture seams, +`hyp purge`, the query-seam visibility filter (LLP 0105), `hyp ignore --check` / +`policy show`, and the machine-local list membership test. + +**Decision: a directory is matched over the *set* of path spellings that denote +it, and the most restrictive verdict any spelling produces wins.** The set is +the as-given (lexical) spelling plus the canonical, symlink-resolved spelling +when it differs, computed by `src/core/usage-policy/canonical.js`. Both sides of +every comparison get this treatment: the incoming `cwd`, and every stored +machine-local entry's `dir`. + +Resolving over the *set* rather than switching to the canonical form alone is +the load-bearing part, and it is a privacy argument rather than a tidiness one. +Canonicalizing only the incoming `cwd` does close the capture leak, but the +machine-local store keeps whatever path the user supplied (LLP 0071, LLP 0103), +so an entry the user marked by its symlink spelling stops governing: the class +drops from `local-only` to `full` and that directory **starts forwarding**. That +trades a capture leak for a forwarding leak. Taking the most restrictive verdict +across both spellings closes the first without opening the second, and makes the +fail-safe structural rather than a special case: a `realpath` that fails removes +a *candidate* spelling, never a verdict some other spelling already produced. + +**The invariant is that canonicalization only ever moves the gate toward more +restrictive, never toward `full`**, and one step needs explicit care to hold it. +Merging verdicts across spellings is monotone, but the machine-local list's +*nearest-governs* step is an argmax over match depth, and an argmax discards +verdicts instead of merging them. A less restrictive entry (an explicit `sync` +carve-out, say) that gains reach through its canonical spelling can therefore +become the deepest match and displace a broader restrictive entry that already +governed - which would punch a hole in a private tree declared under the other +spelling and start that directory recording and forwarding. So the +nearest-governs rule is evaluated **twice**, once over the declared spellings +alone (what the lexical matcher decided) and once over the widened set, and the +more restrictive of the two answers wins, the declared one breaking a class tie +because it is the spelling the user typed. Widening an entry's reach can then +only add restriction. + +The visible cost is that a nested loosening does not cross spellings *when the +broader restrictive entry is one the declared pass already found*: an explicit +`sync`/`full` carve-out declared under one spelling does not loosen a broader +restrictive entry that matches the `cwd` by its own declared spelling, and a +user who wants that carve-out has to declare it under the same spelling as the +entry it carves out of (`hyp policy show` on the path reports the class actually +in force, so the situation is diagnosable). That is the privacy-safe direction +of the trade, and it is the same direction the `cwd` side already takes. + +That condition is load-bearing and the rule should not be read without it, +because the two-pass guard can only preserve a verdict the declared pass +actually produced. When the broader restrictive entry reaches the `cwd` *only* +through canonicalization, the declared pass matches nothing, there is no lexical +verdict to preserve, and ordinary nearest-governs decides among entries that are +all in the canonical namespace: a deeper carve-out does then win. That is not a +hole in the invariant above. The lexical matcher matched neither entry in that +shape, so `full` is exactly what it returned as well, and the outcome is the one +the user would have got by declaring both entries canonically in the first +place. Pinned by `resolve: between two entries that both reach only by +canonicalization, the deeper carve-out governs`. + +Three consequences worth stating outright, because they are what a reader of the +privacy gate will ask: + +- **A declaration stays authoritative as written.** Stored entries keep their + as-declared `dir` on disk; nothing is rewritten. An entry governs both its + declared spelling and, when resolvable, the canonical spelling of what it + points at. If the declared path's target changes or disappears, the + declaration still governs the declared spelling, so **no stored entry ever + silently loses its class**; it merely stops governing the canonical form of a + target it no longer names. A filesystem change never revokes a user's + declaration. +- **Migration is canonicalize-on-read, additively.** There is no on-disk + migration and no version bump: entries written before this decision gain + canonical reach the moment it ships. Writes still store the caller-resolved + path, so `policy show` and `--check` echo the spelling the user typed. The one + write-side change is *upsert identity*: re-marking a directory through a + different spelling replaces the existing entry rather than appending a second + governor for the same directory (which would let the nearest-governs + tie-break, not the user, decide the class). +- **`realpath` is not free, so it rides the existing cache.** The per-`cwd` + memoization is keyed on the lexical path and consulted *before* + canonicalization, so the cost is one `realpath` per distinct `cwd` per TTL + window - the identical bound [LLP 0049](./0049-hypignore-usage-policy.spec.md#requirements) + R6 already sets for the ancestor walk - and **zero** syscalls on a cache hit, + which is the per-exchange hot path. Entry spellings are computed once per list + parse, inside the same TTL. `hyp purge`'s subtree predicate runs per row, so it + memoizes the verdict per distinct row `cwd` for the life of one purge run. + +A canonicalization that does not fully resolve is reported as a structured +`usage_policy.canonicalize_failed` event carrying `error_kind: +path_canonicalize_failed`, the `errno`, how far it got, and a **hashed** path - +never a raw local path, the same discipline the `usage_policy.export_drop` +aggregate uses. `ENOENT` is routine (a deleted `cwd`, a not-yet-created +directory), so it logs at `debug`; only a wholly unresolvable path escalates to +`warn`. `realpath` is all-or-nothing, so rather than discard a failure entirely +the canonicalizer resolves the deepest existing ancestor and rejoins the +unresolved tail: with `/tmp` a symlink, `/tmp/proj/not-created-yet` still +canonicalizes usefully, which matters because the symlink is almost always an +*ancestor*, not the leaf. + +`isEqualOrDescendant` stays lexical and pure, for callers comparing two strings +that are already canonical and must not touch the filesystem. The +spelling-agnostic predicate a CLI verb wants when it asks "which stored entry +governs this directory?" is `scopeGoverns`, which has to be the same predicate +`resolve` used, or `policy show` names a governor the gate did not use and +`policy unset` refuses to remove an entry the gate is enforcing. + ## Why not the gateway - The gateway is the **provider-agnostic** proxy ([LLP 0016](./0016-ai-gateway.decision.md)). @@ -101,6 +216,10 @@ worse coupling than both importing core. - Code that lands this carries `@ref LLP 0050 [implements]` on the adapter projector/backfill drop sites and on the `src/core/usage-policy/` matcher. +- Every consumer of the shared matcher inherits + [§canonicalization](#canonicalization) for free; no adapter, `hyp purge`, or + query-seam change is needed to gain it, which is the same argument that put the + matcher in core in the first place. - The gateway source and recorder are not modified. - A future caller-supplied `cwd` for raw-proxy traffic would add a *new* call site that reuses the same core matcher — no change to this decision. diff --git a/llp/0071-machine-local-exclusion-list.decision.md b/llp/0071-machine-local-exclusion-list.decision.md index 93d686a8..f6b0a48a 100644 --- a/llp/0071-machine-local-exclusion-list.decision.md +++ b/llp/0071-machine-local-exclusion-list.decision.md @@ -47,7 +47,12 @@ absolute directory paths: already defines: a `cwd` is `local-only` when it equals, or is a path-segment descendant of, any listed directory. This is a second source into the one shared resolver ([LLP 0070 §resolver](./0070-local-only-export-seam.decision.md#resolver)), - not a second matcher. + not a second matcher. **Extended-by: + [LLP 0050 §canonicalization](./0050-ignore-enforced-in-adapters.decision.md#canonicalization)**: + membership is over **real** paths: an entry governs through any spelling of its + declared directory, so an entry the user marked by a symlink spelling still + covers the real directory (and vice versa) and never silently drops to `full`. + The stored `dir` is left exactly as declared; canonicalization happens on read. - The file is **read locally by the export driver and the CLI**, written by the login picker and the durable authoring command ([LLP 0072 §cli](./0072-enrollment-dir-picker.decision.md#cli)), under the same diff --git a/src/core/cache/purge.js b/src/core/cache/purge.js index c8af1c75..cc7cc689 100644 --- a/src/core/cache/purge.js +++ b/src/core/cache/purge.js @@ -2,7 +2,7 @@ import path from 'node:path' -import { isEqualOrDescendant } from '../usage-policy/matcher.js' +import { scopeGoverns } from '../usage-policy/matcher.js' import { discoverCachePartitions, readCursorSync, writeCursor } from './partition.js' import { deleteMatchingRows, scanRowsFromTable, tableExists } from './iceberg/store.js' import { resolveIcebergDir } from './storage.js' @@ -22,9 +22,10 @@ import { resolveIcebergDir } from './storage.js' * Four target shapes (LLP 0104 decision): * * - `{ kind: 'subtree', path }` — rows whose `cwd` equals or descends from - * `path` (the LLP 0049 §scope ancestor rule via {@link isEqualOrDescendant}), - * regardless of the path's usage class: an explicit purge may remove any - * data, `local-only` and synced included. + * `path` (the LLP 0049 §scope ancestor rule via {@link scopeGoverns}, so a + * row recorded under one spelling of a directory is still purged when the + * target names the other), regardless of the path's usage class: an explicit + * purge may remove any data, `local-only` and synced included. * - `{ kind: 'session', id }` — one session's rows. `session_id` is the * partition key (LLP 0030); the predicate still scans every partition * because the on-disk cache is partitioned by source, not session. @@ -87,11 +88,24 @@ function buildPredicate(target, purgedCwds) { switch (target.kind) { case 'subtree': { const base = path.resolve(target.path) + // `scopeGoverns` canonicalizes both sides, which costs a `realpath`; the + // predicate runs per row, and a cache holds many rows per distinct `cwd`, + // so memoize the verdict per `cwd` for the lifetime of this one purge run + // (short-lived by construction, so staleness is not a concern). + // @ref LLP 0050#canonicalization [implements]: canonical-aware subtree purge, one `realpath` per distinct row `cwd` + /** @type {Map} */ + const inScope = new Map() return { columns: ['cwd'], predicate: (row) => { if (typeof row.cwd !== 'string' || row.cwd === '') return false - if (!isEqualOrDescendant(path.resolve(row.cwd), base)) return false + const cwd = path.resolve(row.cwd) + let governed = inScope.get(cwd) + if (governed === undefined) { + governed = scopeGoverns(cwd, base, { component: 'cache-purge' }) + inScope.set(cwd, governed) + } + if (!governed) return false noteCwd(row) return true }, diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index a92d18cd..128ff452 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -26,8 +26,10 @@ import { CLASS_RANK, createUsagePolicyResolver, findRepoRoot, - isEqualOrDescendant, + governingListEntry, localOnlyListPath, + sameDirectory, + scopeGoverns, readLocalOnlyEntries, writeLocalOnlyEntries, } from '../usage-policy/index.js' @@ -910,7 +912,13 @@ export async function runMarkMachineLocal({ targetDir, ctx, targetClass, compone } const entries = await readLocalOnlyEntries({ stateDir }) - const withoutTarget = entries.filter((entry) => entry.dir !== resolvedTarget) + // Upsert identity is "denotes the same directory", not "is the same string": + // re-marking a directory through a different spelling must update its class, + // not append a second entry that governs the same directory at a different + // class (which would make the resolver's nearest-governs tie-break decide the + // user's privacy for them). + // @ref LLP 0050#canonicalization [implements]: one stored entry per directory, whichever spelling declared it + const withoutTarget = entries.filter((entry) => !sameDirectory(entry.dir, resolvedTarget)) await writeLocalOnlyEntries({ stateDir, entries: [...withoutTarget, { dir: resolvedTarget, class: targetClass }] }) getLogger('usage-policy').info('usage_policy.mark', { [Attr.COMPONENT]: component, @@ -990,7 +998,7 @@ export async function runUnignore(argv, ctx) { * * Removes every machine-local entry that governs `targetDir`, equal to it, * or an ancestor of it (the same segment-aware rule the shared resolver - * applies, reused here via {@link isEqualOrDescendant} rather than + * applies, reused here via {@link scopeGoverns} rather than * re-derived, R8), mirroring dotfile `unignore`'s "remove the governing * thing" semantics. When `targetClass` is given, removal is scoped to that * one class and entries of a different class are left alone (LLP 0104 @@ -1014,7 +1022,9 @@ export async function runUnmarkMachineLocal({ targetDir, ctx, targetClass, compo const stateDir = readObservabilityEnv(ctx.env).stateDir const entries = await readLocalOnlyEntries({ stateDir }) const governing = entries.filter( - (entry) => (targetClass === undefined || entry.class === targetClass) && isEqualOrDescendant(targetDir, entry.dir) + (entry) => + (targetClass === undefined || entry.class === targetClass) && + scopeGoverns(targetDir, entry.dir, { component }) ) if (governing.length === 0) { if (targetClass === undefined) { @@ -1128,12 +1138,16 @@ export async function runIgnoreCheck({ targetDir, ctx, json, vocabulary = INTERN * should count: the directory containing the governing `.hypignore` when * governed by a dotfile (unchanged from before the machine-local list * existed), or — when governed by the machine-local store - * (`result.governedBy === listPath`) — the most specific (longest) entry - * that actually matches `base`, found via the shared - * {@link isEqualOrDescendant} predicate rather than a second copy of path - * logic (R8). The `resolve()` call already decided *whether* something - * governs; this only identifies *which* listed directory did, for display - * and scoping the residual count. + * (`result.governedBy === listPath`), the entry the gate itself used, from + * the shared {@link governingListEntry} selector rather than a second copy of + * the selection rule (R8). The `resolve()` call already decided *whether* + * something governs; this only identifies *which* listed directory did, for + * display and scoping the residual count - so it has to make the same choice + * the resolver made. Re-deriving it from `scopeGoverns` plus "longest declared + * string" does not: once an entry can match through its canonical spelling, + * the longest declared string and the deepest matching spelling are different + * entries, and `--check` would scope its residual count to one while + * reporting the other's class. * * @param {{ result: ResolveResult, base: string, stateDir: string, listPath: string }} args * @returns {Promise} @@ -1142,9 +1156,8 @@ async function resolveCheckScopeDir({ result, base, stateDir, listPath }) { if (!result.governedBy) return base if (result.governedBy !== listPath) return path.dirname(result.governedBy) const entries = await readLocalOnlyEntries({ stateDir }) - const matches = entries.filter((entry) => isEqualOrDescendant(base, entry.dir)) - if (matches.length === 0) return base - return matches.reduce((best, entry) => (entry.dir.length > best.dir.length ? entry : best)).dir + const governing = governingListEntry(base, entries, { component: 'cmd-ignore-check' }) + return governing === null ? base : governing.dir } /** diff --git a/src/core/usage-policy/canonical.js b/src/core/usage-policy/canonical.js new file mode 100644 index 00000000..c14659c0 --- /dev/null +++ b/src/core/usage-policy/canonical.js @@ -0,0 +1,137 @@ +// @ts-check + +import { createHash } from 'node:crypto' +import nodeFs from 'node:fs' +import path from 'node:path' + +import { Attr } from '../observability/attrs.js' +import { getLogger } from '../observability/logger.js' + +/** + * `error_kind` for a `realpath(2)` that could not fully canonicalize a path. + * Never fatal: the caller keeps the lexical spelling and the gate stays at + * least as restrictive as it was (see {@link canonicalSpellings}). + */ +export const PATH_CANONICALIZE_ERROR_KIND = 'path_canonicalize_failed' + +/** + * Short one-way digest of a path, so a canonicalization failure is diagnosable + * (which path, how often, which errno) without dev telemetry ever carrying a + * raw local path. Same discipline as the `usage_policy.export_drop` aggregate + * in `src/core/cache/storage.js`. + * + * @param {string} p + * @returns {string} + */ +function hashPath(p) { + return createHash('sha256').update(p).digest('hex').slice(0, 16) +} + +/** + * The `errno` code of a filesystem error, as a lowercase token suitable for a + * log attribute (`enoent`, `eacces`, `eloop`), or `unknown`. + * + * @param {unknown} err + * @returns {string} + */ +function errnoOf(err) { + const code = /** @type {{ code?: unknown }} */ (err)?.code + return typeof code === 'string' && code !== '' ? code.toLowerCase() : 'unknown' +} + +/** + * Canonicalize an absolute directory path, resolving every symlink component + * that *can* be resolved and re-appending the components that cannot. + * + * `fs.realpathSync` is all-or-nothing: it throws `ENOENT` for a path whose leaf + * does not exist, which is routine here (a `cwd` that has since been deleted, a + * `local-only` entry for a directory not yet created, a unit test driving the + * matcher over an injected fs of paths that never existed on disk). Throwing + * that away entirely would forfeit the symlinked *ancestors*, which is where + * the interesting case lives: with `/tmp` a symlink to `/private/tmp`, + * `/tmp/proj/gone` still canonicalizes usefully to `/private/tmp/proj/gone`. + * So this walks up to the deepest resolvable ancestor and rejoins the tail, + * reporting how far it got. + * + * Never throws. `resolved: 'none'` means not even the filesystem root could be + * read, and `path` is then the input unchanged. + * + * @param {string} abs absolute path (already `path.resolve`d) + * @param {{ realpathSync?: (p: string) => string }} [deps] + * @returns {{ path: string, resolved: 'full' | 'partial' | 'none', errno: string | null }} + */ +export function canonicalizeDirSync(abs, { realpathSync = nodeFs.realpathSync } = {}) { + /** @type {string[]} */ + const missing = [] + /** @type {string | null} */ + let firstErrno = null + let dir = abs + while (true) { + try { + const real = realpathSync(dir) + if (missing.length === 0) return { path: real, resolved: 'full', errno: null } + return { path: path.join(real, ...missing.reverse()), resolved: 'partial', errno: firstErrno } + } catch (err) { + if (firstErrno === null) firstErrno = errnoOf(err) + const parent = path.dirname(dir) + if (parent === dir) return { path: abs, resolved: 'none', errno: firstErrno } + missing.push(path.basename(dir)) + dir = parent + } + } +} + +/** + * Every path spelling that denotes the same directory as `target`: the lexical + * absolute form first, then the canonical (symlink-resolved) form when it + * differs. + * + * This is the shape the whole usage-policy gate is built on, and the reason it + * is a *set* rather than a single canonical answer is a privacy argument, not a + * convenience. `path.resolve` is lexical, so on `master` an ancestor walk from + * a symlinked `cwd` climbed the symlink's parents and never met the + * `.hypignore` governing the real directory. Canonicalizing *instead of* the + * lexical form fixes that leak and opens another: a `local-only` entry the user + * declared by its symlink spelling stops governing, so a directory the user + * marked private starts forwarding. Resolving over both spellings and taking + * the most restrictive verdict closes the first without opening the second, and + * makes the failure mode structural: a `realpath` that fails can only remove a + * *candidate* spelling, never a verdict some other spelling already produced, + * so canonicalization can only ever move the gate toward more restrictive, + * never toward `full`. + * + * Producing a set of spellings is necessary but not sufficient for that + * invariant: a consumer that picks *one* of the matching spellings (the + * machine-local list's nearest-governs argmax) can still lose a restrictive + * verdict, so it also has to evaluate its rule over the declared spellings + * alone and keep the more restrictive of the two answers. See + * `selectGoverning` in `matcher.js`. + * + * A failure is reported as a `debug` aggregate (routine: a deleted or + * not-yet-created directory), escalating to `warn` only for `resolved: 'none'`, + * which means the filesystem itself refused every ancestor. Paths are hashed. + * + * @ref LLP 0050#canonicalization [implements]: the gate resolves over as-given and canonical spellings, most restrictive wins + * @param {string} target + * @param {{ realpathSync?: (p: string) => string, component?: string }} [deps] + * @returns {string[]} lexical spelling first; length 1 when the two coincide + */ +export function canonicalSpellings(target, { realpathSync, component = 'usage-policy' } = {}) { + const lexical = path.resolve(target) + const outcome = canonicalizeDirSync(lexical, realpathSync ? { realpathSync } : undefined) + if (outcome.resolved !== 'full') { + const logger = getLogger('usage-policy') + const attrs = { + [Attr.COMPONENT]: component, + [Attr.OPERATION]: 'usage_policy.canonicalize', + status: 'degraded', + [Attr.ERROR_KIND]: PATH_CANONICALIZE_ERROR_KIND, + errno: outcome.errno, + resolved: outcome.resolved, + path_hash: hashPath(lexical), + } + if (outcome.resolved === 'none') logger.warn('usage_policy.canonicalize_failed', attrs) + else logger.debug('usage_policy.canonicalize_failed', attrs) + } + return outcome.path === lexical ? [lexical] : [lexical, outcome.path] +} diff --git a/src/core/usage-policy/index.js b/src/core/usage-policy/index.js index 3f562752..089d4b32 100644 --- a/src/core/usage-policy/index.js +++ b/src/core/usage-policy/index.js @@ -4,7 +4,19 @@ // The shared, cwd-agnostic matcher lives in core; the Claude/Codex adapters // import it exactly as they import `src/core/observability`. export { parseHypignore } from './format.js' -export { CLASS_RANK, createUsagePolicyResolver, isEqualOrDescendant } from './matcher.js' +export { + CLASS_RANK, + createUsagePolicyResolver, + governingListEntry, + isEqualOrDescendant, + sameDirectory, + scopeGoverns, +} from './matcher.js' +// Symlink canonicalization for the gate (LLP 0050 #canonicalization): a +// directory is matched over the set of spellings that denote it, so a +// `.hypignore` or a machine-local entry cannot be escaped (or lost) by reaching +// the same directory through a symlink. +export { canonicalizeDirSync, canonicalSpellings, PATH_CANONICALIZE_ERROR_KIND } from './canonical.js' // The terminal capture-seam drop sentinel (LLP 0050): an adapter projector // returns it for an `.hypignore`-ignored exchange, and the gateway dispatcher // stops on it (never falls through to a later projector) and logs it as a drop. diff --git a/src/core/usage-policy/matcher.js b/src/core/usage-policy/matcher.js index 12e1c375..2e209ded 100644 --- a/src/core/usage-policy/matcher.js +++ b/src/core/usage-policy/matcher.js @@ -3,6 +3,7 @@ import nodeFs from 'node:fs' import path from 'node:path' +import { canonicalSpellings } from './canonical.js' import { parseHypignore } from './format.js' import { LocalOnlyListUnreadableError } from './local_only.js' @@ -71,10 +72,16 @@ const LOCAL_ONLY_LIST_VERSION_V2 = 2 * collapsing the apply latency from "within the TTL" to zero. Until that path * exists, the TTL is the leak bound. * + * Both sides of every comparison are resolved over the *set* of spellings that + * denote a directory (as-given/lexical plus canonical), never over one chosen + * spelling: see {@link canonicalSpellings} for why the set, not the canonical + * form alone, is the privacy-preserving choice. + * * fs, the clock, and the TTL are injected for tests; fs defaults to `node:fs`, * the clock to `Date.now`, and the TTL to `CACHE_TTL_MS`. * * @ref LLP 0050 [implements]: the single shared matcher for all four adapter call sites; no per-adapter copies + * @ref LLP 0050#canonicalization [implements]: one `realpath` per cache miss, inside the existing TTL, on both sides of the comparison * @ref LLP 0049#scope [implements]: gitignore-style ancestor walk from cwd, nearest .hypignore wins; per-cwd cache (R6) * @ref LLP 0052#matcher [implements]: bounded-TTL staleness so a mid-run .hypignore is honored without a daemon restart * @ref LLP 0070#resolver [implements]: one shared resolver, two sources, most-restrictive class wins @@ -82,6 +89,9 @@ const LOCAL_ONLY_LIST_VERSION_V2 = 2 * @param {object} [deps] * @param {(path: string, encoding: 'utf8') => string} [deps.readFileSync] * @param {(path: string) => boolean} [deps.existsSync] + * @param {(path: string) => string} [deps.realpathSync] symlink canonicalizer; + * defaults to `node:fs`. Failure is expected and handled (see + * `canonicalizeDirSync`), so an injected fs need not supply one. * @param {() => number} [deps.now] injectable clock in ms; defaults to Date.now * @param {number} [deps.ttlMs] cache entry lifetime in ms; defaults to CACHE_TTL_MS * @param {string} [deps.localOnlyListPath] absolute path of the machine-local @@ -92,16 +102,25 @@ const LOCAL_ONLY_LIST_VERSION_V2 = 2 export function createUsagePolicyResolver({ readFileSync = nodeFs.readFileSync, existsSync = nodeFs.existsSync, + realpathSync = nodeFs.realpathSync, now = Date.now, ttlMs = CACHE_TTL_MS, localOnlyListPath, } = {}) { /** @type {Map} */ const cache = new Map() - /** @type {{ entries: LocalOnlyEntry[], expiresAt: number } | null} */ + /** @type {{ scopes: { entry: LocalOnlyEntry, spellings: string[] }[], expiresAt: number } | null} */ let listCache = null /** + * Resolve `cwd` over every spelling that denotes it, returning the most + * restrictive verdict any spelling produces. + * + * The cache is keyed on the *lexical* path, so a hit costs no `realpath` at + * all: the canonicalization syscall happens once per distinct `cwd` per TTL + * window, the same bound LLP 0049 R6 already sets for the ancestor walk, not + * once per recorded exchange. + * * @param {string} cwd * @returns {ResolveResult} */ @@ -110,11 +129,16 @@ export function createUsagePolicyResolver({ const at = now() const cached = cache.get(key) if (cached && cached.expiresAt > at) return cached.result - const dotfileResult = walk(key) - const listResult = localOnlyListPath ? matchList(key, at) : null - const result = mostRestrictive(dotfileResult, listResult) - cache.set(key, { result, expiresAt: at + ttlMs }) - return result + /** @type {ResolveResult | null} */ + let result = null + for (const spelling of canonicalSpellings(key, { realpathSync })) { + const dotfileResult = walk(spelling) + const listResult = localOnlyListPath ? matchList(spelling, at) : null + const merged = mostRestrictive(dotfileResult, listResult) + result = result === null ? merged : mostRestrictive(result, merged) + } + cache.set(key, { result: /** @type {ResolveResult} */ (result), expiresAt: at + ttlMs }) + return /** @type {ResolveResult} */ (result) } /** @@ -172,37 +196,48 @@ export function createUsagePolicyResolver({ * mirroring the `.hypignore` walk's nearest-governs rule; a tie is broken * by the more restrictive class. * + * An entry governs `cwd` when *any* spelling of the entry's directory + * equals-or-contains `cwd`, so an entry declared by a symlink spelling still + * governs the real directory and vice versa. Specificity is measured on the + * spelling that actually matched, so nested entries still resolve + * nearest-governs regardless of which spelling each was declared with. + * Widening an entry's reach must not *loosen* the list, which is what + * {@link selectGoverning} guarantees. + * * @ref LLP 0071 [implements]: segment-aware equal-or-descendant list membership, second resolver source + * @ref LLP 0050#canonicalization [implements]: an entry governs through any spelling of its declared directory * @ref LLP 0103 [implements]: the entry's own class governs, not a hardcoded `local-only` * @param {string} cwd absolute, already `path.resolve`d * @param {number} at current clock reading (ms) * @returns {ResolveResult | null} `null` when nothing in the list governs `cwd` */ function matchList(cwd, at) { - const entries = getListEntries(at) - const matches = entries.filter((entry) => isEqualOrDescendant(cwd, entry.dir)) - if (matches.length === 0) return null - const governing = matches.reduce((best, entry) => { - if (entry.dir.length > best.dir.length) return entry - if (entry.dir.length === best.dir.length && CLASS_RANK[entry.class] > CLASS_RANK[best.class]) return entry - return best - }) + const governing = selectGoverning(cwd, getListScopes(at)) + if (governing === null) return null return { - class: governing.class, + class: governing.entry.class, governedBy: /** @type {string} */ (localOnlyListPath), - declared: governing.class, + declared: governing.entry.class, } } /** + * The list entries paired with every spelling of each entry's declared + * directory, computed once per TTL window along with the parse, so resolving + * many `cwd`s in one window costs one `realpath` per entry rather than one per + * entry per `cwd`. + * * @param {number} at - * @returns {LocalOnlyEntry[]} + * @returns {{ entry: LocalOnlyEntry, spellings: string[] }[]} */ - function getListEntries(at) { - if (listCache && listCache.expiresAt > at) return listCache.entries - const entries = readListEntriesSync() - listCache = { entries, expiresAt: at + ttlMs } - return entries + function getListScopes(at) { + if (listCache && listCache.expiresAt > at) return listCache.scopes + const scopes = readListEntriesSync().map((entry) => ({ + entry, + spellings: canonicalSpellings(entry.dir, { realpathSync }), + })) + listCache = { scopes, expiresAt: at + ttlMs } + return scopes } /** @@ -294,27 +329,205 @@ export function isEqualOrDescendant(cwd, dir) { } /** - * Merge the `.hypignore` walk result with an optional list-membership result, - * returning whichever is strictly more restrictive (`ignore` > `local-only` > - * `full`); a tie (e.g. both `local-only`) keeps the dotfile result, which is - * already the more specific, already-computed answer. + * Length of the spelling in `dirSpellings` that equals-or-contains `cwd`, or + * `null` when none does. The length stands in for specificity, the same way the + * `.hypignore` walk's nearest-governs rule does; measuring it on the *matched* + * spelling keeps nested entries ordered correctly even when they were declared + * with different spellings of the same tree. + * + * Returning the first match rather than the longest is not a shortcut: two + * spellings of one directory can only both contain the same `cwd` if one is a + * lexical ancestor of the other, and the canonical form can never be a strict + * lexical descendant of the as-given form (that would need a symlink pointing + * inside itself, which `realpath` reports as `ELOOP`). So when both match, the + * as-given spelling - which `canonicalSpellings` puts first - is the longer one. + * + * @param {string} cwd absolute, already `path.resolve`d + * @param {readonly string[]} dirSpellings ordered as-given first + * @returns {number | null} + */ +function matchDepth(cwd, dirSpellings) { + for (const dir of dirSpellings) { + if (isEqualOrDescendant(cwd, dir)) return dir.length + } + return null +} + +/** + * The nearest-governs winner over `scopes`: the entry whose matched spelling is + * the longest, ties broken by the more restrictive class. `spellingLimit` caps + * how many of each entry's spellings may match, so the same rule can be run + * over the declared spellings alone or over the widened set. + * + * @param {string} cwd absolute, already `path.resolve`d + * @param {readonly { entry: LocalOnlyEntry, spellings: readonly string[] }[]} scopes + * @param {number} spellingLimit + * @returns {{ entry: LocalOnlyEntry, depth: number } | null} + */ +function deepestMatch(cwd, scopes, spellingLimit) { + /** @type {{ entry: LocalOnlyEntry, depth: number } | null} */ + let best = null + for (const { entry, spellings } of scopes) { + const depth = matchDepth(cwd, spellingLimit >= spellings.length ? spellings : spellings.slice(0, spellingLimit)) + if (depth === null) continue + if ( + best === null || + depth > best.depth || + (depth === best.depth && CLASS_RANK[entry.class] > CLASS_RANK[best.entry.class]) + ) { + best = { entry, depth } + } + } + return best +} + +/** + * The machine-local entry that governs `cwd`, over precomputed spellings. + * + * Nearest-governs alone is *not* monotone in the set of spellings, which is the + * one place canonicalization could have made the gate **less** restrictive than + * the lexical matcher it replaced. An explicit `full` (or merely less + * restrictive) entry that gains reach through its canonical spelling can become + * the deepest match and so displace a broader restrictive entry that already + * governed: a carve-out declared under one spelling would punch a hole in a + * private tree declared under the other, and the directory would start + * recording and forwarding. Nothing about "resolve over a set of spellings" + * prevents that on its own, because the argmax-over-depth step in the middle + * discards verdicts rather than merging them. + * + * So the rule is run twice - once over the declared spellings alone (exactly + * what the pre-canonicalization matcher decided) and once over the widened set + * - and the more restrictive of the two answers wins, the declared one breaking + * a class tie because it is the spelling the user typed. Widening an entry's + * reach can then only ever add restriction, never remove it, which is the + * fail-toward-privacy property LLP 0050 §canonicalization claims. + * + * Note the exact reach of the guard: it preserves a verdict the **declared** + * pass produced, so it blocks a cross-spelling loosening only when the broader + * restrictive entry matches `cwd` by its own declared spelling. If that entry + * reaches `cwd` only through canonicalization, the declared pass matches + * nothing and plain nearest-governs picks between entries that are all in the + * canonical namespace, so a deeper carve-out wins. That is still never a + * demotion: the lexical matcher matched neither entry in that shape either. + * + * @ref LLP 0050#canonicalization [implements]: canonicalization only ever moves the gate toward more restrictive, entry side included + * @ref LLP 0049#fail-safe [constrained-by]: a widened reach must resolve to "suppress more", never to "starts forwarding" + * @param {string} cwd absolute, already `path.resolve`d + * @param {readonly { entry: LocalOnlyEntry, spellings: readonly string[] }[]} scopes + * @returns {{ entry: LocalOnlyEntry, depth: number } | null} + */ +function selectGoverning(cwd, scopes) { + const asDeclared = deepestMatch(cwd, scopes, 1) + const widened = deepestMatch(cwd, scopes, Number.POSITIVE_INFINITY) + if (asDeclared === null) return widened + if (widened === null) return asDeclared + return CLASS_RANK[widened.entry.class] > CLASS_RANK[asDeclared.entry.class] ? widened : asDeclared +} + +/** + * Which stored machine-local entry governs `dir`, by the identical rule + * `resolve()` applies (spelling-agnostic membership, nearest-governs, + * most-restrictive-wins across both the declared and the widened reach, then + * across the spellings of `dir` itself), or `null` when none does. + * + * Exported because a CLI verb that has already been told "the machine-local + * store governs this" still has to name *which* entry, for display and for + * scoping the residual row count. Re-deriving that choice at the call site is + * how `--check` / `policy show` ends up naming an entry the gate did not use + * (R8: one shared thing, not a second copy of the selection rule). + * + * Does up to two `realpath` calls per entry plus two for `dir`, so it is for + * one-shot CLI use, not a per-row loop. + * + * @ref LLP 0069#requirements [implements]: R8, the governing-entry choice is shared, not re-derived per call site + * @ref LLP 0050#canonicalization [implements]: the CLI names the entry the gate actually used + * @param {string} dir + * @param {readonly LocalOnlyEntry[]} entries + * @param {{ realpathSync?: (p: string) => string, component?: string }} [deps] + * @returns {LocalOnlyEntry | null} + */ +export function governingListEntry(dir, entries, deps = {}) { + const scopes = entries.map((entry) => ({ entry, spellings: canonicalSpellings(entry.dir, deps) })) + /** @type {{ entry: LocalOnlyEntry, depth: number } | null} */ + let best = null + for (const spelling of canonicalSpellings(dir, deps)) { + const found = selectGoverning(spelling, scopes) + if (found === null) continue + if (best === null || CLASS_RANK[found.entry.class] > CLASS_RANK[best.entry.class]) best = found + } + return best === null ? null : best.entry +} + +/** + * Canonical-aware {@link isEqualOrDescendant}: true when *any* spelling of + * `cwd` equals or descends *any* spelling of `dir`. + * + * This is the predicate a CLI verb wants when it asks "which stored entry + * governs this directory?", because the answer has to agree with what + * `resolve()` just decided; a lexical-only answer makes `hyp policy show` name + * a governor the gate did not use, and makes `policy unset` refuse to remove an + * entry the gate is enforcing. `isEqualOrDescendant` stays lexical and pure for + * callers that are comparing two already-canonical strings and must not touch + * the filesystem. + * + * Does up to two `realpath` calls, so callers on a per-row loop should memoize + * per distinct path (`src/core/cache/purge.js` does). + * + * @ref LLP 0069#requirements [implements]: R8, one shared equal-or-descendant test, now spelling-agnostic + * @ref LLP 0050#canonicalization [implements]: CLI membership answers agree with the gate's verdict + * @param {string} cwd + * @param {string} dir + * @param {{ realpathSync?: (p: string) => string, component?: string }} [deps] + * @returns {boolean} + */ +export function scopeGoverns(cwd, dir, deps = {}) { + const dirSpellings = canonicalSpellings(dir, deps) + return canonicalSpellings(cwd, deps).some((spelling) => matchDepth(spelling, dirSpellings) !== null) +} + +/** + * True when two paths denote the same directory, i.e. they share a spelling. + * The identity a stored `local-only`/`ignore`/`full` entry is upserted on, so + * re-marking a directory through a different spelling updates its class rather + * than adding a second governor for the same directory. + * + * @ref LLP 0050#canonicalization [implements]: entry identity is the directory, not the string + * @param {string} a + * @param {string} b + * @param {{ realpathSync?: (p: string) => string, component?: string }} [deps] + * @returns {boolean} + */ +export function sameDirectory(a, b, deps = {}) { + const bSpellings = canonicalSpellings(b, deps) + return canonicalSpellings(a, deps).some((spelling) => bSpellings.includes(spelling)) +} + +/** + * Merge two verdicts, returning whichever is strictly more restrictive + * (`ignore` > `local-only` > `full`); a tie (e.g. both `local-only`) keeps + * `preferred`, which is the more specific, already-computed answer. + * + * Used for both merges the resolver performs: the `.hypignore` walk against the + * list-membership result (`preferred` = the dotfile walk), and one spelling's + * verdict against the next spelling's (`preferred` = the earlier, as-given + * spelling, so a tie reports the governor the user would recognize). * * @ref LLP 0070#resolver [implements]: most-restrictive-wins merge of the two sources - * @param {ResolveResult} dotfileResult - * @param {ResolveResult | null} listResult + * @ref LLP 0050#canonicalization [implements]: also the merge across spellings, so the union can only be more restrictive + * @param {ResolveResult} preferred + * @param {ResolveResult | null} other * @returns {ResolveResult} */ -function mostRestrictive(dotfileResult, listResult) { - if (!listResult) return dotfileResult - if (CLASS_RANK[listResult.class] > CLASS_RANK[dotfileResult.class]) return listResult - if (CLASS_RANK[listResult.class] < CLASS_RANK[dotfileResult.class]) return dotfileResult - // Tie (e.g. both `full`, or both `local-only`): the dotfile walk's result - // wins as the more specific, already-computed answer - UNLESS it's the - // unrecorded implicit default (`governedBy: null`) tying against a list - // entry that actually recorded an explicit answer (LLP 0103's explicit - // `full` marker resolves identically to "nothing governs" but must still - // name its governor, so the classification hook can tell "asked; syncs" - // apart from "never asked"). - if (dotfileResult.governedBy === null && listResult.governedBy !== null) return listResult - return dotfileResult +function mostRestrictive(preferred, other) { + if (!other) return preferred + if (CLASS_RANK[other.class] > CLASS_RANK[preferred.class]) return other + if (CLASS_RANK[other.class] < CLASS_RANK[preferred.class]) return preferred + // Tie (e.g. both `full`, or both `local-only`): `preferred` wins as the more + // specific, already-computed answer - UNLESS it's the unrecorded implicit + // default (`governedBy: null`) tying against a result that actually recorded + // an explicit answer (LLP 0103's explicit `full` marker resolves identically + // to "nothing governs" but must still name its governor, so the + // classification hook can tell "asked; syncs" apart from "never asked"). + if (preferred.governedBy === null && other.governedBy !== null) return other + return preferred } diff --git a/test/core/ignore-local-only-command.test.js b/test/core/ignore-local-only-command.test.js index 42a46994..8b534d2c 100644 --- a/test/core/ignore-local-only-command.test.js +++ b/test/core/ignore-local-only-command.test.js @@ -1,7 +1,7 @@ // @ts-check import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' @@ -10,7 +10,12 @@ import { asyncRow } from 'squirreling' import { registerCoreCommands } from '../../src/core/cli/core_commands.js' import { createCommandRegistry } from '../../src/core/registry/commands.js' -import { localOnlyListPath, readLocalOnlyDirs, writeLocalOnlyDirs } from '../../src/core/usage-policy/local_only.js' +import { + localOnlyListPath, + readLocalOnlyDirs, + readLocalOnlyEntries, + writeLocalOnlyDirs, +} from '../../src/core/usage-policy/local_only.js' /** * @import { CommandRegistration, CommandRunContext } from '../../hypaware-plugin-kernel-types.js' @@ -239,6 +244,51 @@ test('hyp unignore --local-only does not remove a sibling that merely shares a s }) }) +// The CLI's view of the machine-local store has to agree with the resolver's, +// or a user can be told a directory is unmarked while the gate is enforcing a +// mark on it (or end up with two entries governing one directory at different +// classes). Real `symlink(2)`, because the defect these guard is that +// `path.resolve` is lexical. +// +// @ref LLP 0050#canonicalization [tests]: marking and unmarking identify a directory, not a spelling + +test('hyp ignore --private on the real path upgrades an entry declared by its symlink spelling, not duplicating it', async () => { + await withSandbox(async ({ root: sandbox, hypHome }) => { + const root = realpathSync(sandbox) + const real = path.join(root, 'real', 'proj') + mkdirSync(real, { recursive: true }) + const link = path.join(root, 'link') + symlinkSync(real, link) + await writeLocalOnlyDirs({ stateDir: stateDirOf(hypHome), dirs: [link] }) + + const res = await run('ignore', ['--private', real], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: real, class: 'ignore' }], 'the one entry for that directory now says ignore') + }) +}) + +test('hyp unignore --local-only by symlink spelling removes an entry declared canonically', async () => { + await withSandbox(async ({ root: sandbox, hypHome }) => { + const root = realpathSync(sandbox) + const real = path.join(root, 'real', 'proj') + mkdirSync(real, { recursive: true }) + const link = path.join(root, 'link') + symlinkSync(real, link) + const unrelated = path.join(root, 'other') + mkdirSync(unrelated) + await writeLocalOnlyDirs({ stateDir: stateDirOf(hypHome), dirs: [real, unrelated] }) + + const res = await run('unignore', ['--local-only', link], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.deepEqual( + await readLocalOnlyDirs({ stateDir: stateDirOf(hypHome) }), + [unrelated], + 'the entry governing that directory is gone; the unrelated one survives' + ) + }) +}) + /* ------------------------------ ignore --check ------------------------------ */ test('hyp ignore --check reports the local-only class and the list file as the governor', async () => { @@ -316,6 +366,34 @@ test('hyp ignore --check on a clean path with a populated-but-non-matching list }) }) +test('hyp ignore --check scopes the residual count to the entry the gate used, not the longest declared string', async () => { + await withSandbox(async ({ root: sandbox, hypHome }) => { + const root = realpathSync(sandbox) + const scope = path.join(root, 'r') + const deep = path.join(scope, 'p', 'deep') + mkdirSync(deep, { recursive: true }) + // The link's *declared* spelling is the longest string in the store, but + // the entry the resolver's verdict came from is `/r`. Scoping the + // residual count by "longest declared string" counts rows under the link + // path, which nothing was ever recorded under, and reports 0. + const link = path.join(root, 'a-very-long-link-name') + symlinkSync(path.join(scope, 'p'), link) + await writeLocalOnlyDirs({ stateDir: stateDirOf(hypHome), dirs: [link, scope] }) + + const { query, storage } = makeAiGatewayCache([ + { cwd: deep, repo_root: scope }, + { cwd: path.join(scope, 'src'), repo_root: scope }, + { cwd: '/elsewhere', repo_root: '/elsewhere' }, + ]) + + const res = await run('ignore', ['--check', '--json'], { cwd: deep, hypHome, query, storage }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.class, 'local-only') + assert.equal(parsed.residualCachedRows, 2, 'both rows under the governing entry are counted') + }) +}) + /* -------------------------------- helpers -------------------------------- */ /** diff --git a/test/core/purge-command.test.js b/test/core/purge-command.test.js index 2a02cd1e..79469333 100644 --- a/test/core/purge-command.test.js +++ b/test/core/purge-command.test.js @@ -121,6 +121,36 @@ test('purge subtree is segment-aware: /home/u/repoA does not match /home/u/repoA } }) +// @ref LLP 0050#canonicalization [tests]: purge matches the directory, not the spelling the row happened to record +test('purge subtree matches a row recorded under a symlink spelling of the target', async () => { + const cacheRoot = await makeTmpDir('symlink') + const projects = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-purge-projects-')) + try { + const root = await fs.realpath(projects) + const real = path.join(root, 'real', 'proj') + await fs.mkdir(real, { recursive: true }) + const link = path.join(root, 'link') + await fs.symlink(real, link) + await seed(cacheRoot, [ + // The capture seam recorded the `cwd` the client reported: the symlink. + { session_id: 's1', cwd: path.join(link, 'sub'), part_id: 'm1#0', timestamp: '2026-07-01T00:00:00Z' }, + { session_id: 's2', cwd: path.join(root, 'real', 'projx'), part_id: 'm2#0', timestamp: '2026-07-01T00:00:01Z' }, + ]) + // The user purges by the real path. + const summary = await purgeCache({ cacheRoot, target: { kind: 'subtree', path: real } }) + assert.equal(summary.rowsDeleted, 1, 'the symlink-spelled row is in the purged subtree') + const rows = await remainingRows(cacheRoot) + assert.deepEqual( + rows.map((r) => r.part_id), + ['m2#0'], + 'the sibling-prefix directory is still not a descendant' + ) + } finally { + await fs.rm(cacheRoot, { recursive: true, force: true }) + await fs.rm(projects, { recursive: true, force: true }) + } +}) + test('purge --session deletes only that session', async () => { const cacheRoot = await makeTmpDir('session') try { diff --git a/test/core/usage-policy-symlink.test.js b/test/core/usage-policy-symlink.test.js new file mode 100644 index 00000000..cba4a301 --- /dev/null +++ b/test/core/usage-policy-symlink.test.js @@ -0,0 +1,403 @@ +// @ts-check + +// Regression suite for the symlink escape (issue #479). `path.resolve` is +// lexical, so a string-only test can pass while the real filesystem leaks: +// every case below builds a genuine `symlink(2)` with `node:fs` and drives the +// real `node:fs` reader, no injection. +// +// @ref LLP 0050#canonicalization [tests]: an ignored directory reached by a symlink is still ignored, and a machine-local entry declared by either spelling still governs + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { + canonicalizeDirSync, + createUsagePolicyResolver, + governingListEntry, + sameDirectory, + scopeGoverns, +} from '../../src/core/usage-policy/index.js' + +/** + * A canonical temp root: `mkdtemp` hands back `/var/folders/...` on macOS, + * whose `/var` is itself a symlink, so the fixtures below canonicalize their + * own root first and introduce exactly one symlink of their own. + * + * @returns {string} + */ +function tempRoot() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-usage-policy-symlink-')) + const root = fs.realpathSync(dir) + test.after(() => fs.rmSync(root, { recursive: true, force: true })) + return root +} + +/** + * @param {string} root + * @param {string} listPath + * @param {readonly { dir: string, class: 'ignore' | 'local-only' | 'full' }[]} entries + */ +function writeList(root, listPath, entries) { + fs.mkdirSync(path.dirname(listPath), { recursive: true }) + fs.writeFileSync(listPath, JSON.stringify({ version: 2, entries })) +} + +test('resolve: an ignored directory reached by its symlink spelling is still ignored', () => { + const root = tempRoot() + const real = path.join(root, 'work', 'ignored', 'a', 'b', 'c') + fs.mkdirSync(real, { recursive: true }) + fs.writeFileSync(path.join(root, 'work', 'ignored', '.hypignore'), 'ignore\n') + fs.mkdirSync(path.join(root, 'home', 'me'), { recursive: true }) + const link = path.join(root, 'home', 'me', 'link') + fs.symlinkSync(real, link) + + const resolver = createUsagePolicyResolver() + assert.equal(resolver.resolve(real).class, 'ignore', 'the canonical spelling was never in doubt') + const viaLink = resolver.resolve(link) + assert.equal(viaLink.class, 'ignore', 'the symlink spelling denotes the same ignored directory') + assert.equal(viaLink.governedBy, path.join(root, 'work', 'ignored', '.hypignore')) + assert.equal(resolver.isIgnored(link), true) +}) + +test('resolve: a descendant of a symlink that points into an ignored tree is ignored', () => { + const root = tempRoot() + // The link points *below* the governing file, so the lexical walk from + // `link/b/c` climbs out of the ignored tree without ever meeting it. (A link + // that points *at* the governing directory leaks nothing even on master: + // `existsSync` follows symlinks, so `link/.hypignore` is a real hit.) + fs.mkdirSync(path.join(root, 'work', 'ignored', 'a', 'b', 'c'), { recursive: true }) + fs.writeFileSync(path.join(root, 'work', 'ignored', '.hypignore'), 'ignore\n') + const link = path.join(root, 'link') + fs.symlinkSync(path.join(root, 'work', 'ignored', 'a'), link) + + const resolver = createUsagePolicyResolver() + assert.equal(resolver.resolve(path.join(link, 'b', 'c')).class, 'ignore') +}) + +test('resolve: a `.hypignore` governing the symlink\'s own ancestors still governs (the converse spelling is not lost)', () => { + const root = tempRoot() + // The `.hypignore` sits above the *link*, not above the link's target: the + // one-sided "canonicalize the incoming cwd" patch throws this verdict away. + const real = path.join(root, 'work', 'proj') + fs.mkdirSync(real, { recursive: true }) + fs.mkdirSync(path.join(root, 'private'), { recursive: true }) + fs.writeFileSync(path.join(root, 'private', '.hypignore'), 'ignore\n') + const link = path.join(root, 'private', 'link') + fs.symlinkSync(real, link) + + const resolver = createUsagePolicyResolver() + const viaLink = resolver.resolve(link) + assert.equal(viaLink.class, 'ignore', 'the as-given spelling is under an ignored tree') + assert.equal(viaLink.governedBy, path.join(root, 'private', '.hypignore')) +}) + +test('resolve: a local-only entry declared by its symlink spelling governs the real directory', () => { + const root = tempRoot() + const real = path.join(root, 'real', 'proj') + fs.mkdirSync(real, { recursive: true }) + const link = path.join(root, 'plink') + fs.symlinkSync(real, link) + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [{ dir: link, class: 'local-only' }]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + assert.equal(resolver.resolve(link).class, 'local-only', 'the spelling the user typed still governs') + const viaReal = resolver.resolve(real) + assert.equal(viaReal.class, 'local-only', 'so does the directory it denotes') + assert.equal(viaReal.governedBy, listPath) + assert.equal(resolver.resolve(path.join(real, 'nested')).class, 'local-only') +}) + +test('resolve: a local-only entry declared canonically governs a cwd that arrives by symlink', () => { + const root = tempRoot() + const real = path.join(root, 'real', 'proj') + fs.mkdirSync(real, { recursive: true }) + const link = path.join(root, 'plink') + fs.symlinkSync(real, link) + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [{ dir: real, class: 'local-only' }]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + assert.equal(resolver.resolve(real).class, 'local-only') + assert.equal(resolver.resolve(link).class, 'local-only') + assert.equal(resolver.resolve(path.join(link, 'nested')).class, 'local-only') +}) + +test('resolve: nested entries keep nearest-governs when the outer one matched by its canonical spelling', () => { + const root = tempRoot() + const real = path.join(root, 'real') + fs.mkdirSync(path.join(real, 'public', 'deep'), { recursive: true }) + fs.mkdirSync(path.join(real, 'private'), { recursive: true }) + const link = path.join(root, 'link') + fs.symlinkSync(real, link) + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + // The outer entry was declared by the symlink spelling, the inner one by the + // real one: specificity has to be measured on the spelling that matched. + writeList(root, listPath, [ + { dir: link, class: 'local-only' }, + { dir: path.join(real, 'public'), class: 'full' }, + ]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + assert.equal(resolver.resolve(path.join(real, 'private')).class, 'local-only') + assert.equal(resolver.resolve(path.join(real, 'public', 'deep')).class, 'full', 'the inner entry is more specific') + // Reached by the *other* spelling, the inner carve-out does not apply (it was + // declared under the real path, and `link/public/deep` is not lexically under + // it), so only the outer `local-only` entry governs that spelling. Across + // spellings the gate keeps the more restrictive verdict, so a nested + // loosening declared under one spelling never loosens the other. Documented + // as a consequence of most-restrictive-wins, not an accident. + assert.equal(resolver.resolve(path.join(link, 'public', 'deep')).class, 'local-only') +}) + +// The one direction canonicalization must never move the gate: less +// restrictive than the lexical matcher it replaced. Resolving over a *set* of +// spellings is monotone only where verdicts are merged; the machine-local +// list's nearest-governs step is an argmax over depth, so a less restrictive +// entry that gains reach through its canonical spelling could otherwise become +// the deepest match and displace a broader restrictive entry that already +// governed. +// +// @ref LLP 0050#canonicalization [tests]: a widened entry reach only ever adds restriction + +test('resolve: a carve-out that gains reach by canonicalization does not punch a hole in a broader restrictive entry', () => { + const root = tempRoot() + const real = path.join(root, 'real') + fs.mkdirSync(path.join(real, 'proj', 'sub'), { recursive: true }) + fs.mkdirSync(path.join(real, 'other'), { recursive: true }) + // `link` lives outside the ignored tree but denotes a directory inside it, + // and it is declared `full`: its canonical spelling is strictly deeper than + // the `ignore` entry's, so nearest-governs alone would hand it the verdict. + const link = path.join(root, 'link') + fs.symlinkSync(path.join(real, 'proj'), link) + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [ + { dir: real, class: 'ignore' }, + { dir: link, class: 'full' }, + ]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + assert.equal( + resolver.resolve(path.join(real, 'proj', 'sub')).class, + 'ignore', + 'the lexical matcher said ignore here; canonicalization must not demote it to full' + ) + assert.equal(resolver.resolve(path.join(real, 'proj')).class, 'ignore') + assert.equal(resolver.resolve(path.join(link, 'sub')).class, 'ignore') + assert.equal(resolver.resolve(path.join(real, 'other')).class, 'ignore', 'untouched by the carve-out either way') + // The carve-out still works where it was declared *under the same spelling* + // as the entry it carves out of, which is the supported way to write one. + const listPath2 = path.join(root, 'state', 'usage-policy', 'same-spelling.json') + writeList(root, listPath2, [ + { dir: real, class: 'ignore' }, + { dir: path.join(real, 'proj'), class: 'full' }, + ]) + const resolver2 = createUsagePolicyResolver({ localOnlyListPath: listPath2 }) + assert.equal(resolver2.resolve(path.join(real, 'proj', 'sub')).class, 'full', 'a same-spelling carve-out is honored') +}) + +// The exact reach of the two-pass guard, pinned because the obvious reading of +// "a nested loosening does not cross spellings" is too strong. The guard +// compares the declared-spelling answer with the widened one, so it can only +// preserve a restrictive verdict the *declared* pass actually produced. When +// the broader restrictive entry reaches this `cwd` only through +// canonicalization, the declared pass finds nothing to preserve and ordinary +// nearest-governs decides between two entries that are both in the canonical +// namespace: the deeper carve-out wins. That is the same verdict the lexical +// matcher gave (neither entry matched it at all), so it is never a demotion, +// and it is the same verdict the user would get by declaring both entries +// canonically. Documented so a reader does not mistake it for the leak the +// test above closes. +// +// @ref LLP 0050#canonicalization [tests]: the loosening block is conditioned on the restrictive entry matching by its declared spelling + +test('resolve: between two entries that both reach only by canonicalization, the deeper carve-out governs', () => { + const root = tempRoot() + const real = path.join(root, 'real') + fs.mkdirSync(path.join(real, 'r', 'p', 'q', 'x'), { recursive: true }) + // Two *different* symlink spellings, nested one inside the other's target. + // Neither declared spelling lexically contains the real `cwd` below. + const outer = path.join(root, 'l1') + const inner = path.join(root, 'l2') + fs.symlinkSync(path.join(real, 'r', 'p'), outer) + fs.symlinkSync(path.join(real, 'r', 'p', 'q'), inner) + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [ + { dir: outer, class: 'ignore' }, + { dir: inner, class: 'full' }, + ]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + const deep = path.join(real, 'r', 'p', 'q', 'x') + assert.equal( + resolver.resolve(deep).class, + 'full', + 'nearest-governs applies normally when both entries are in the canonical namespace' + ) + // Never a demotion: the lexical matcher matched neither entry, so `full` is + // exactly what it returned here too. + const lexicalOnly = createUsagePolicyResolver({ + localOnlyListPath: listPath, + realpathSync: (p) => p, + }) + assert.equal(lexicalOnly.resolve(deep).class, 'full', 'the lexical matcher said full here as well') + // Outside the carve-out the widened `ignore` entry still governs, which is + // the reach canonicalization added. + assert.equal(resolver.resolve(path.join(real, 'r', 'p')).class, 'ignore') +}) + +test('governingListEntry names the entry whose verdict the gate used, not the longest declared string', () => { + const root = tempRoot() + fs.mkdirSync(path.join(root, 'r', 'p', 'deep'), { recursive: true }) + // Declared spelling deliberately longer than the entry that actually + // governs, so "longest declared string" and "the gate's choice" differ. + const link = path.join(root, 'a-very-long-link-name') + fs.symlinkSync(path.join(root, 'r', 'p'), link) + const entries = /** @type {const} */ ([ + { dir: link, class: 'local-only' }, + { dir: path.join(root, 'r'), class: 'local-only' }, + ]) + + assert.equal(governingListEntry(path.join(root, 'r', 'p', 'deep'), entries)?.dir, path.join(root, 'r')) + assert.equal(governingListEntry(path.join(root, 'r'), entries)?.dir, path.join(root, 'r')) + assert.equal(governingListEntry(path.join(root, 'unrelated'), entries), null) +}) + +test('resolve: a cwd reached through a dangling symlink keeps the class its as-given spelling produces', () => { + const root = tempRoot() + // A link whose target has been deleted: `realpath` throws and the partial + // walk can recover no more than the link's own parent, so no canonical reach + // is gained. The as-given verdict must still stand, in both directions. + fs.mkdirSync(path.join(root, 'ignored', 'target'), { recursive: true }) + fs.writeFileSync(path.join(root, 'ignored', '.hypignore'), 'ignore\n') + const inside = path.join(root, 'ignored', 'dangling') + fs.symlinkSync(path.join(root, 'ignored', 'target'), inside) + const outside = path.join(root, 'outside-dangling') + fs.symlinkSync(path.join(root, 'ignored', 'target'), outside) + fs.rmSync(path.join(root, 'ignored', 'target'), { recursive: true, force: true }) + + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [{ dir: outside, class: 'local-only' }]) + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + + assert.equal(resolver.resolve(inside).class, 'ignore', 'the dangling link is itself under an ignored tree') + assert.equal(resolver.resolve(path.join(inside, 'child')).class, 'ignore') + assert.equal( + resolver.resolve(outside).class, + 'local-only', + 'an entry declared as a now-dangling link keeps governing its declared spelling' + ) + assert.equal(resolver.resolve(path.join(outside, 'child')).class, 'local-only') + // A failed canonicalization loses reach it would have added; it never throws + // and never demotes. + const outcome = canonicalizeDirSync(outside) + assert.equal(outcome.path, outside, 'nothing beyond the link itself could be resolved') + assert.equal(outcome.resolved, 'partial') + assert.equal(outcome.errno, 'enoent') + assert.equal(scopeGoverns(path.join(outside, 'child'), outside), true) +}) + +test('resolve: a symlinked cwd is still not matched by a mere string-prefix sibling (segment-aware after canonicalization)', () => { + const root = tempRoot() + fs.mkdirSync(path.join(root, 'a', 'b'), { recursive: true }) + fs.mkdirSync(path.join(root, 'a', 'bc'), { recursive: true }) + const link = path.join(root, 'link-bc') + fs.symlinkSync(path.join(root, 'a', 'bc'), link) + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [{ dir: path.join(root, 'a', 'b'), class: 'local-only' }]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + assert.equal(resolver.resolve(link).class, 'full', '/a/bc merely shares a string prefix with /a/b') + assert.equal(resolver.resolve(path.join(root, 'a', 'b')).class, 'local-only') +}) + +test('resolve: a list entry whose declared target has been deleted keeps governing its declared spelling', () => { + const root = tempRoot() + const gone = path.join(root, 'gone', 'proj') + const listPath = path.join(root, 'state', 'usage-policy', 'local-only.json') + writeList(root, listPath, [{ dir: gone, class: 'ignore' }]) + + const resolver = createUsagePolicyResolver({ localOnlyListPath: listPath }) + const result = resolver.resolve(gone) + assert.equal(result.class, 'ignore', 'canonicalization failed on both sides; the declaration still stands') + assert.equal(result.governedBy, listPath) +}) + +test('resolve: an unresolvable cwd under a symlinked ancestor still meets the .hypignore governing its real tree', () => { + const root = tempRoot() + fs.mkdirSync(path.join(root, 'work', 'ignored', 'deep'), { recursive: true }) + fs.writeFileSync(path.join(root, 'work', 'ignored', '.hypignore'), 'ignore\n') + const link = path.join(root, 'link') + fs.symlinkSync(path.join(root, 'work', 'ignored', 'deep'), link) + + // `realpath` on the whole path throws ENOENT: the leaf does not exist. The + // partial canonicalization keeps the resolvable ancestors, which is where the + // symlink was, so the walk still meets the governing file above the target. + const resolver = createUsagePolicyResolver() + assert.equal(resolver.resolve(path.join(link, 'not-created-yet')).class, 'ignore') +}) + +test('resolve: canonicalization costs one realpath per cwd per TTL window, not one per call', () => { + const root = tempRoot() + const real = path.join(root, 'proj') + fs.mkdirSync(real, { recursive: true }) + let realpathCalls = 0 + let clock = 1_000 + const resolver = createUsagePolicyResolver({ + realpathSync: (p) => { + realpathCalls += 1 + return fs.realpathSync(p) + }, + now: () => clock, + ttlMs: 5_000, + }) + + for (let i = 0; i < 50; i++) assert.equal(resolver.resolve(real).class, 'full') + assert.equal(realpathCalls, 1, 'the per-cwd cache is consulted before any canonicalization') + + clock += 5_001 + assert.equal(resolver.resolve(real).class, 'full') + assert.equal(realpathCalls, 2, 'and re-canonicalizes exactly once when the entry expires') +}) + +test('canonicalizeDirSync: full, partial, and unresolvable outcomes', () => { + const root = tempRoot() + const real = path.join(root, 'work', 'proj') + fs.mkdirSync(real, { recursive: true }) + const link = path.join(root, 'link') + fs.symlinkSync(path.join(root, 'work'), link) + + const full = canonicalizeDirSync(path.join(link, 'proj')) + assert.deepEqual(full, { path: real, resolved: 'full', errno: null }) + + const partial = canonicalizeDirSync(path.join(link, 'proj', 'a', 'b')) + assert.equal(partial.path, path.join(real, 'a', 'b'), 'the resolvable prefix is canonicalized, the tail rejoined') + assert.equal(partial.resolved, 'partial') + assert.equal(partial.errno, 'enoent') + + const unresolvable = canonicalizeDirSync(path.join(root, 'nope'), { + realpathSync: () => { + throw Object.assign(new Error('boom'), { code: 'EACCES' }) + }, + }) + assert.deepEqual(unresolvable, { path: path.join(root, 'nope'), resolved: 'none', errno: 'eacces' }) +}) + +test('scopeGoverns / sameDirectory: spelling-agnostic, still segment-aware', () => { + const root = tempRoot() + fs.mkdirSync(path.join(root, 'real', 'proj', 'nested'), { recursive: true }) + fs.mkdirSync(path.join(root, 'real', 'projx'), { recursive: true }) + const link = path.join(root, 'link') + fs.symlinkSync(path.join(root, 'real', 'proj'), link) + + assert.equal(scopeGoverns(path.join(root, 'real', 'proj', 'nested'), link), true) + assert.equal(scopeGoverns(path.join(link, 'nested'), path.join(root, 'real', 'proj')), true) + assert.equal(scopeGoverns(path.join(root, 'real', 'projx'), link), false, 'sibling prefix is not a descendant') + + assert.equal(sameDirectory(link, path.join(root, 'real', 'proj')), true) + assert.equal(sameDirectory(link, path.join(root, 'real', 'projx')), false) + assert.equal(sameDirectory(path.join(root, 'gone-a'), path.join(root, 'gone-b')), false, 'neither side resolves') +})