refactor(#4653): drain the containment duplicates and record the two rulings
Phase 3 of epic #4636, stage 3c. ADR-4650 decision 6: a wrapper may decide HOW to degrade, never WHETHER a path is contained. Four implementations are drained on that rule; two are retained, with the reasons recorded rather than assumed. DRAINED — the containment decision now comes from the canonical predicate: scripts/check-glossary-refs.cjs local isWithinRoot deleted outright. src/installer-migrations.cts ensureInsideConfig keeps its throw and its lexical fullPath; only the decision moves. src/planning-inspect.cts isPathContained keeps must-exist as its own condition; only the decision moves. Two of those are wrappers rather than deletions, and each is a wrapper for a reason that would have been a silent behavior change if collapsed naively: - `isPathContained` returns FALSE for a path that does not exist, because fs.realpathSync throws ENOENT and its catch swallows it. The canonical predicate does the opposite: for a missing target it walks up to the nearest existing ancestor and ACCEPTS a not-yet-created path under the root. Its callers at planning-inspect.cts:747 and :839 guard a phaseDir immediately before readdirSync, so under a naive swap a missing phaseDir would stop reporting scope UNREADABLE and start throwing ENOENT out of readdirSync. Existence is therefore kept as an explicit local requirement. - `ensureInsideConfig` returns a LEXICAL fullPath that both callers consume for existsSync and for journal entries. The canonical predicate realpath-resolves, so if configDir is itself a symlink the two differ. The decision is canonical; the returned value stays lexical. Its message is likewise preserved verbatim, which is why this uses tryWithinRoot plus an explicit throw rather than assertWithinRoot. `isWithinRoot` in planning-inspect is left in place and documented: it is a pure comparison over paths the CALLER has already resolved, which readDocument does inline specifically to keep a third degradation shape (exists-but-unreadable vs absent) that neither isPathContained nor the canonical predicate expresses. It is the comparison step of one implementation, not a second implementation. RETAINED, DELIBERATELY — gsd-core/bin/gsd-tools.cjs. My own design document said "collapse" and that was wrong. The file carries an explicit comment forbidding it, and the comment is correct: its three checks reject symlinks OUTRIGHT, which is strictly stricter than the canonical predicate, not a reimplementation of it. The canonical predicate accepts a link whose target lands inside the root — for a restore that is still wrong, because writing through the link overwrites whatever it points at instead of materializing a regular file. Collapsing would have reintroduced that hole. The comment is updated to name the current exported predicate, to record that this was reviewed under this phase and deliberately not collapsed, and to note that isInsideDir treats target === root as NOT contained — the one implementation in the repo that does. THE configHome RULING — retained lexical, and a false safety claim corrected. isPathConfined stays lexical because two of its callers must validate a destSubpath BEFORE the mkdirSync that creates it (install-engine.cts:1608, install-profiles.cts:880), where realpath cannot resolve and a realpath-based predicate would reject every legitimate install. Its docstring's justification, however, did not survive being checked. It cited capability-source.cts:491,577,675 as the upstream symlink rejection that made the lexical form safe. Read directly: :491 is a blank line before assertSafeId's JSDoc and :577 is an entry-count budget check. Neither is a symlink check. The real guards are :585-586 and :671-674. Worse than stale line numbers, the claim that this "keeps every caller of this function's callers symlink-safe" is false: that rejection lives in capability-source's staging path and covers only the capability-loader route to assertDescriptorConfined. Three other callers do not reach it, and only retired-artifact-cleanup.cts:69 carries its own defense (its lstatSync check at :77). The docstring now states what is actually true and cites the lines that actually exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { tryWithinRoot, PathAcceptance } = require('../gsd-core/bin/lib/security.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
|
||||
@@ -131,20 +132,6 @@ function isTracked(token) {
|
||||
return TRACKED_EXACT.has(token) || TRACKED_PREFIXES.some((prefix) => token.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* True if joining `token` to ROOT stays inside ROOT. `PATH_TOKEN_RE` admits `.`
|
||||
* inside a segment, so a token like `src/../../../etc/passwd` matches and (via
|
||||
* the `src/` prefix) reads as "tracked" — `path.join(ROOT, token)` would then
|
||||
* normalize to an out-of-tree absolute path and `fs.existsSync` would probe it,
|
||||
* turning a doc lint into a filesystem-existence oracle on the CI host. A
|
||||
* CONTEXT.md reference is always a plain in-repo path, so a `..` escape is never
|
||||
* legitimate: confine to ROOT and drop anything that climbs out.
|
||||
*/
|
||||
function isWithinRoot(token) {
|
||||
const resolved = path.resolve(ROOT, token);
|
||||
return resolved === ROOT || resolved.startsWith(ROOT + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct, trackable file-path token referenced in `text`, with any
|
||||
* trailing `:<line>` suffix stripped.
|
||||
@@ -177,7 +164,8 @@ function extractTrackedRefs(text) {
|
||||
if (!/[A-Za-z0-9_]$/.test(token)) return;
|
||||
if (token.includes('NNNN')) return;
|
||||
if (!isTracked(token)) return;
|
||||
if (!isWithinRoot(token)) return;
|
||||
// Containment decision is the canonical predicate's, per ADR-4650.
|
||||
if (tryWithinRoot(token, ROOT, PathAcceptance.AbsoluteInsideRoot) === null) return;
|
||||
tokens.add(token);
|
||||
};
|
||||
const subTokenRe = /[\w.-]+(?:\/[\w.-]+)*/g;
|
||||
|
||||
Reference in New Issue
Block a user