diff --git a/CHANGELOG.md b/CHANGELOG.md index f13d6ca..0db828b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ project adheres to [Semantic Versioning](https://semver.org/). - Playwright setup note in the Quick start section of `README.md`. ### Fixed +- Bootstrap and provenance now refuse a `deps` directory that is a symlink. + Checkout and later reads followed the link, so a planted `deps` link was + accepted as the component root. - Saved-case GUI paths with malformed percent-encoding now return HTTP 400. `decodeURIComponent` used to throw, and the request became HTTP 500. - The workbench comparison panel now says the comparison is unavailable when diff --git a/bin/aas.mjs b/bin/aas.mjs index f8052e8..7bef7a7 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -28,6 +28,7 @@ import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { + assertDependencyDirectory, assertFullStackNodeVersion, inspectDependencyDirectory, loadComponentLock, @@ -567,6 +568,7 @@ export function resolveComponentProvenance( depsDir = DEFAULT_PATHS.deps, lockPath = DEFAULT_PATHS.lock, ) { + assertDependencyDirectory(depsDir); const components = loadComponentLock(lockPath); return components.map((component) => { const target = join(depsDir, component.name); diff --git a/scripts/bootstrap.mjs b/scripts/bootstrap.mjs index 993e7a6..9140c9e 100644 --- a/scripts/bootstrap.mjs +++ b/scripts/bootstrap.mjs @@ -48,6 +48,23 @@ export function assertFullStackNodeVersion({ version = process.versions.node } = return parsed; } +/** + * The dependency root must be a real directory. A symlink is followed by + * checkout and provenance reads, so bootstrap and demo would use another tree. + */ +export function assertDependencyDirectory(depsDir) { + let stat; + try { + stat = lstatSync(depsDir); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error("Dependency directory must be a regular directory."); + } +} + function normalizeRemote(value) { return value.trim().replace(/\.git$/, "").replace(/\/$/, "").toLowerCase(); } @@ -200,6 +217,7 @@ function runNpm(target, args) { export function prepareDependencies({ root: projectRoot = root, deps = join(projectRoot, "deps"), components = loadComponentLock(join(projectRoot, "stack-lock.json")), nodeVersion } = {}) { assertFullStackNodeVersion(nodeVersion === undefined ? {} : { version: nodeVersion }); mkdirSync(deps, { recursive: true }); + assertDependencyDirectory(deps); const prepared = []; for (const component of components) { const target = join(deps, component.name); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index ce3a7d1..0b74777 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -180,6 +180,25 @@ test("pre-existing dependency symlinks are rejected", (t) => { ); }); +test("a symlinked dependency directory is not used as the checkout root", (t) => { + const outside = tempRoot(); + const marker = join(outside, "keep.txt"); + writeFileSync(marker, "keep"); + const project = tempRoot(); + const link = join(project, "deps"); + if (!linkOrSkip(t, outside, link)) return; + assert.throws( + () => prepareDependencies({ root: project, deps: link, components: [] }), + /regular directory/, + ); + assert.throws( + () => resolveComponentProvenance(link, LOCK), + /regular directory/, + ); + assert.equal(readFileSync(marker, "utf8"), "keep"); + assert.equal(existsSync(join(outside, ".git")), false); +}); + test("missing or non-Git pre-existing directories fail closed", () => { const component = loadComponentLock(LOCK)[1]; const target = join(tempRoot(), component.name);