diff --git a/config/testing/risk-coverage.json b/config/testing/risk-coverage.json index 2828236..e2ef978 100644 --- a/config/testing/risk-coverage.json +++ b/config/testing/risk-coverage.json @@ -12,7 +12,7 @@ { "path": "src/adapters/http/bounded-body-reader.ts", "owner": "http-runtime", - "minimum": { "lines": 68, "statements": 66, "functions": 55, "branches": 55 } + "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 } }, { "path": "src/adapters/http/bounded-json.ts", diff --git a/scripts/lib/risk-coverage-files.ts b/scripts/lib/risk-coverage-files.ts index aa5b747..df245c6 100644 --- a/scripts/lib/risk-coverage-files.ts +++ b/scripts/lib/risk-coverage-files.ts @@ -35,6 +35,18 @@ type WriterDependencies = Readonly<{ fileSystem?: RiskCoverageArtifactFileSystem; }>; +type ReadableHandle = Readonly<{ + stat(): Promise; + readFile(encoding: "utf8"): Promise; + close(): Promise; +}>; + +type InputDependencies = Readonly<{ + lstatPath?: (target: string) => Promise; + realpathPath?: (target: string) => Promise; + openFile?: (target: string, flags: number) => Promise; +}>; + const defaultFileSystem: RiskCoverageArtifactFileSystem = Object.freeze({ openFile: async (target, flags, mode) => { const handle = await open(target, flags, mode); @@ -74,6 +86,45 @@ function isWithin(root: string, target: string): boolean { ); } +function hasStableIdentity(metadata: Stats): boolean { + return ( + Number.isSafeInteger(metadata.dev) && + Number.isSafeInteger(metadata.ino) && + (metadata.dev !== 0 || metadata.ino !== 0) + ); +} + +function sameFileIdentity(before: Stats, after: Stats): boolean { + return ( + !hasStableIdentity(before) || + !hasStableIdentity(after) || + (before.dev === after.dev && before.ino === after.ino) + ); +} + +async function rejectSymlinkAncestors( + repositoryRoot: string, + relativePath: string, + label: string, + lstatPath: (target: string) => Promise, +): Promise { + let current = repositoryRoot; + let currentRelative = ""; + const directory = path.posix.dirname(relativePath); + if (directory === ".") return; + for (const segment of directory.split("/")) { + current = path.join(current, segment); + currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment; + const metadata = await lstatPath(current); + if (metadata.isSymbolicLink()) { + throw new TypeError(`${label} ancestor is a symlink: ${currentRelative}`); + } + if (!metadata.isDirectory()) { + throw new TypeError(`${label} ancestor is not a directory: ${currentRelative}`); + } + } +} + function assertDirectory(metadata: Stats, relativePath: string): void { if (metadata.isSymbolicLink()) { throw new TypeError(`artifact ancestor is a symlink: ${relativePath}`); @@ -87,27 +138,41 @@ export async function readRiskCoverageInput(input: Readonly<{ repositoryRoot: string; relativePath: string; label: string; -}>): Promise> { +}>, dependencies: InputDependencies = {}): Promise> { + const lstatPath = dependencies.lstatPath ?? lstat; + const realpathPath = dependencies.realpathPath ?? realpath; + const openFile = dependencies.openFile ?? + ((target: string, flags: number) => open(target, flags)); const relativePath = normalizeRepositoryRelativePath( input.relativePath, `${input.label} path`, ); const repositoryRoot = path.resolve(input.repositoryRoot); - const repositoryRealpath = await realpath(repositoryRoot); + const repositoryRealpath = await realpathPath(repositoryRoot); const absolutePath = path.resolve(repositoryRoot, relativePath); - const metadata = await lstat(absolutePath); + await rejectSymlinkAncestors( + repositoryRoot, + relativePath, + input.label, + lstatPath, + ); + const metadata = await lstatPath(absolutePath); if (metadata.isSymbolicLink()) { throw new TypeError(`${input.label} path is a symlink: ${relativePath}`); } if (!metadata.isFile()) { throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`); } - const resolvedPath = await realpath(absolutePath); + const resolvedPath = await realpathPath(absolutePath); if (!isWithin(repositoryRealpath, resolvedPath)) { throw new TypeError(`${input.label} path is outside repository: ${relativePath}`); } - const handle = await open( + const handle = await openFile( absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW, ); @@ -116,6 +181,9 @@ export async function readRiskCoverageInput(input: Readonly<{ if (!openedMetadata.isFile()) { throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`); } + if (!sameFileIdentity(metadata, openedMetadata)) { + throw new TypeError(`${input.label} path changed during validation: ${relativePath}`); + } const text = await handle.readFile("utf8"); return Object.freeze({ relativePath, absolutePath, text }); } finally { @@ -170,17 +238,35 @@ export async function resolveRiskCoverageArtifactPath(input: Readonly<{ } const absolutePath = path.resolve(repositoryRoot, relativePath); + let destinationMetadata: Stats | undefined; try { - const metadata = await lstat(absolutePath); - if (metadata.isSymbolicLink()) { + destinationMetadata = await lstat(absolutePath); + if (destinationMetadata.isSymbolicLink()) { throw new TypeError(`artifact path is a symlink: ${relativePath}`); } - if (!metadata.isFile()) { + if (!destinationMetadata.isFile()) { throw new TypeError(`artifact path is not a regular file: ${relativePath}`); } } catch (error) { if (!hasErrorCode(error, "ENOENT")) throw error; } + if (destinationMetadata) { + const destinationRealpath = await realpath(absolutePath); + for (const inputPath of normalizedInputs) { + const inputAbsolutePath = path.resolve(repositoryRoot, inputPath); + const inputRealpath = await realpath(inputAbsolutePath); + const inputMetadata = await lstat(inputAbsolutePath); + if ( + inputRealpath === destinationRealpath || + (hasStableIdentity(inputMetadata) && + hasStableIdentity(destinationMetadata) && + inputMetadata.dev === destinationMetadata.dev && + inputMetadata.ino === destinationMetadata.ino) + ) { + throw new TypeError(`artifact path is the same file as an input: ${inputPath}`); + } + } + } return absolutePath; } @@ -232,7 +318,14 @@ export async function writeRiskCoverageArtifactAtomic( ownsTemporaryFile = false; const directoryHandle = await fileSystem.openDirectory(path.dirname(destination)); try { - await directoryHandle.sync(); + try { + await directoryHandle.sync(); + } catch (error) { + // Windows and some filesystems do not support fsync on directory handles. + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) { + throw error; + } + } } finally { await directoryHandle.close(); } diff --git a/scripts/lib/risk-coverage.ts b/scripts/lib/risk-coverage.ts index add9050..edd77a0 100644 --- a/scripts/lib/risk-coverage.ts +++ b/scripts/lib/risk-coverage.ts @@ -96,6 +96,8 @@ type InventoryOptions = Readonly<{ openFile?: (target: string, flags: number) => Promise; }>; +class FileIdentityChangedError extends Error {} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -327,6 +329,22 @@ function isWithin(root: string, target: string): boolean { ); } +function hasStableIdentity(metadata: Stats): boolean { + return ( + Number.isSafeInteger(metadata.dev) && + Number.isSafeInteger(metadata.ino) && + (metadata.dev !== 0 || metadata.ino !== 0) + ); +} + +function sameFileIdentity(before: Stats, after: Stats): boolean { + return ( + !hasStableIdentity(before) || + !hasStableIdentity(after) || + (before.dev === after.dev && before.ino === after.ino) + ); +} + export function isProductionModulePath(relativePath: string): boolean { return ( /\.tsx?$/u.test(relativePath) && @@ -394,7 +412,16 @@ export async function buildProductionModuleInventory( if (!openedMetadata.isFile()) { throw new TypeError("opened target is not a regular file"); } + if (!sameFileIdentity(metadata, openedMetadata)) { + throw new FileIdentityChangedError("opened file identity changed"); + } } catch (error) { + if (error instanceof FileIdentityChangedError) { + throw new Error( + `production inventory file changed during validation: ${relativeTarget}`, + { cause: error }, + ); + } throw new Error(`production inventory file is unreadable: ${relativeTarget}`, { cause: error, }); diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 933b349..40d2ee3 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -12,6 +12,8 @@ import { } from "node:fs/promises"; import path from "node:path"; +import { isProductionModulePath } from "./lib/risk-coverage.ts"; + const fixtureParent = path.resolve(".tmp"); await mkdir(fixtureParent, { recursive: true }); const fixtureRoot = await mkdtemp( @@ -182,265 +184,245 @@ function runPnpm(script: string, extra: string[] = []): boolean { return result.status === 0; } -for (const target of copyTargets) { - await cp(target, path.join(fixtureRoot, target), { recursive: true }); -} -await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); - -const removedProductionModuleCount = ( - await filesBelow(path.join(fixtureRoot, featureSource)) -).filter( - (file) => - /\.tsx?$/u.test(file) && - !/\.d\.ts$/u.test(file) && - !/\.stories\.tsx?$/u.test(file), -).length; - -for (const ownedPath of featureOwnedPaths) { - await rm(path.join(fixtureRoot, ownedPath), { - recursive: true, - force: true, - }); -} -await writeFile( - path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"), - emptyContracts, -); -await writeFile( - path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"), - emptyRuntimes, -); -await writeFile( - path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"), - emptyAdapters, -); -await writeFile( - path.join(fixtureRoot, "src/features/installed-feature-messages.ts"), - emptyMessages, -); -await writeFile( - path.join(fixtureRoot, "src/features/installed-contract-contributions.ts"), - emptyContractContributions, -); - -const coveragePolicyFile = path.join( - fixtureRoot, - "config/testing/risk-coverage.json", -); -const coveragePolicy = JSON.parse( - await readFile(coveragePolicyFile, "utf8"), -) as CoveragePolicy; -const retainedCriticalModules = coveragePolicy.criticalModules.filter( - (modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`), -); -if ( - retainedCriticalModules.length === coveragePolicy.criticalModules.length -) { - throw new Error("Reference feature coverage policy is not registered"); -} -coveragePolicy.criticalModules = retainedCriticalModules; -// The removable reference feature exercises shared request-body failure branches. -// Keep the production policy unchanged while preserving an audited floor for the -// intentionally smaller executable universe in this isolated removal proof. -const removalSpecificBoundedBodyFloor = { - lines: 65, - statements: 63, - functions: 55, - branches: 45, -}; -const boundedBodyPolicy = coveragePolicy.criticalModules.find( - (modulePolicy) => - modulePolicy.path === "src/adapters/http/bounded-body-reader.ts", -); -if (!boundedBodyPolicy?.minimum) { - throw new Error("Shared bounded-body coverage policy is not registered"); -} -for (const [metric, floor] of Object.entries(removalSpecificBoundedBodyFloor)) { - const productionFloor = boundedBodyPolicy.minimum[metric]; - if (typeof productionFloor !== "number" || productionFloor < floor) { - throw new Error( - `Production bounded-body ${metric} floor must remain at least ${floor}`, - ); +try { + for (const target of copyTargets) { + await cp(target, path.join(fixtureRoot, target), { recursive: true }); } -} -boundedBodyPolicy.minimum = removalSpecificBoundedBodyFloor; -coveragePolicy.highRiskPaths = coveragePolicy.highRiskPaths.filter( - (modulePath) => !modulePath.startsWith(`${featureSource}/`), -); -coveragePolicy.waivers = coveragePolicy.waivers.filter( - (waiver) => !waiver.path?.startsWith(`${featureSource}/`), -); -coveragePolicy.generatedPaths = coveragePolicy.generatedPaths.filter( - (modulePath) => !modulePath.startsWith(`${featureSource}/`), -); -coveragePolicy.repositoryBaseline -= removedProductionModuleCount; -if (coveragePolicy.repositoryBaseline <= 0) { - throw new Error("Reference feature removal produced an invalid coverage baseline"); -} -await writeFile( - coveragePolicyFile, - `${JSON.stringify(coveragePolicy, null, 2)}\n`, -); + await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); -const evidencePolicyFile = path.join( - fixtureRoot, - "config/testing/test-evidence.json", -); -const evidencePolicy = JSON.parse( - await readFile(evidencePolicyFile, "utf8"), -) as EvidencePolicy; -let removedEvidenceContributions = 0; -for (const policyKey of ["scenarioCatalogs", "sourceContracts"] as const) { - const contributions = evidencePolicy[policyKey]; - if (!Array.isArray(contributions)) { - throw new Error(`Test evidence policy is missing ${policyKey}`); + const coveragePolicyFile = path.join( + fixtureRoot, + "config/testing/risk-coverage.json", + ); + const coveragePolicy = JSON.parse( + await readFile(coveragePolicyFile, "utf8"), + ) as CoveragePolicy; + const generatedProductionModules = new Set(coveragePolicy.generatedPaths); + + const removedProductionModuleCount = ( + await filesBelow(path.join(fixtureRoot, featureSource)) + ) + .map((file) => path.relative(fixtureRoot, file).split(path.sep).join("/")) + .filter( + (file) => + isProductionModulePath(file) && !generatedProductionModules.has(file), + ).length; + + for (const ownedPath of featureOwnedPaths) { + await rm(path.join(fixtureRoot, ownedPath), { + recursive: true, + force: true, + }); } - evidencePolicy[policyKey] = contributions.filter((candidate: unknown) => { - const contribution = candidate as EvidenceContribution; - const retained = contribution.owner !== "reference-feature"; - if (!retained) removedEvidenceContributions += 1; - return retained; - }); -} -if (removedEvidenceContributions === 0) { - throw new Error("Reference feature test evidence policy is not registered"); -} -await writeFile( - evidencePolicyFile, - `${JSON.stringify(evidencePolicy, null, 2)}\n`, -); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"), + emptyContracts, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"), + emptyRuntimes, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"), + emptyAdapters, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-messages.ts"), + emptyMessages, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-contract-contributions.ts"), + emptyContractContributions, + ); -const governanceFile = path.join( - fixtureRoot, - "config/contracts/registry-governance.json", -); -const removalGovernance = JSON.parse( - await readFile(governanceFile, "utf8"), -) as RemovalGovernance; -removalGovernance.registries = removalGovernance.registries.map( - (registry) => ({ - ...registry, - ...(Array.isArray(registry.consumers) - ? { - consumers: registry.consumers.filter( - (candidate: unknown) => { - const consumer = candidate as GovernanceConsumer; - return !consumer.path?.includes("features/reference-feature"); - }, - ), - } - : {}), - ...(Array.isArray(registry.consumerDirectories) - ? { - consumerDirectories: registry.consumerDirectories.filter( - (directory: unknown) => - typeof directory !== "string" || - !directory.includes("features/reference-feature"), - ), - } - : {}), - }), -); -await writeFile( - governanceFile, - `${JSON.stringify(removalGovernance, null, 2)}\n`, -); + const retainedCriticalModules = coveragePolicy.criticalModules.filter( + (modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`), + ); + if ( + retainedCriticalModules.length === coveragePolicy.criticalModules.length + ) { + throw new Error("Reference feature coverage policy is not registered"); + } + coveragePolicy.criticalModules = retainedCriticalModules; + coveragePolicy.highRiskPaths = coveragePolicy.highRiskPaths.filter( + (modulePath) => !modulePath.startsWith(`${featureSource}/`), + ); + coveragePolicy.waivers = coveragePolicy.waivers.filter( + (waiver) => !waiver.path?.startsWith(`${featureSource}/`), + ); + coveragePolicy.generatedPaths = coveragePolicy.generatedPaths.filter( + (modulePath) => !modulePath.startsWith(`${featureSource}/`), + ); + coveragePolicy.repositoryBaseline -= removedProductionModuleCount; + if (coveragePolicy.repositoryBaseline <= 0) { + throw new Error("Reference feature removal produced an invalid coverage baseline"); + } + await writeFile( + coveragePolicyFile, + `${JSON.stringify(coveragePolicy, null, 2)}\n`, + ); -const residue: string[] = []; -for (const root of ["src", "tests"]) { - for (const file of await filesBelow(path.join(fixtureRoot, root))) { - const relative = path.relative(fixtureRoot, file); - const content = await readFile(file, "utf8"); - if ( - /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test( - `${relative}\n${content}`, - ) - ) { - residue.push(relative); + const evidencePolicyFile = path.join( + fixtureRoot, + "config/testing/test-evidence.json", + ); + const evidencePolicy = JSON.parse( + await readFile(evidencePolicyFile, "utf8"), + ) as EvidencePolicy; + let removedEvidenceContributions = 0; + for (const policyKey of ["scenarioCatalogs", "sourceContracts"] as const) { + const contributions = evidencePolicy[policyKey]; + if (!Array.isArray(contributions)) { + throw new Error(`Test evidence policy is missing ${policyKey}`); + } + evidencePolicy[policyKey] = contributions.filter((candidate: unknown) => { + const contribution = candidate as EvidenceContribution; + const retained = contribution.owner !== "reference-feature"; + if (!retained) removedEvidenceContributions += 1; + return retained; + }); + } + if (removedEvidenceContributions === 0) { + throw new Error("Reference feature test evidence policy is not registered"); + } + await writeFile( + evidencePolicyFile, + `${JSON.stringify(evidencePolicy, null, 2)}\n`, + ); + + const governanceFile = path.join( + fixtureRoot, + "config/contracts/registry-governance.json", + ); + const removalGovernance = JSON.parse( + await readFile(governanceFile, "utf8"), + ) as RemovalGovernance; + removalGovernance.registries = removalGovernance.registries.map( + (registry) => ({ + ...registry, + ...(Array.isArray(registry.consumers) + ? { + consumers: registry.consumers.filter( + (candidate: unknown) => { + const consumer = candidate as GovernanceConsumer; + return !consumer.path?.includes("features/reference-feature"); + }, + ), + } + : {}), + ...(Array.isArray(registry.consumerDirectories) + ? { + consumerDirectories: registry.consumerDirectories.filter( + (directory: unknown) => + typeof directory !== "string" || + !directory.includes("features/reference-feature"), + ), + } + : {}), + }), + ); + await writeFile( + governanceFile, + `${JSON.stringify(removalGovernance, null, 2)}\n`, + ); + + const residue: string[] = []; + for (const root of ["src", "tests"]) { + for (const file of await filesBelow(path.join(fixtureRoot, root))) { + const relative = path.relative(fixtureRoot, file); + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test( + `${relative}\n${content}`, + ) + ) { + residue.push(relative); + } } } -} -const checks: Array<[string, boolean]> = [ - [ - "common-test-evidence", - ( - await Promise.all( - commonTestPaths.map(async (testPath) => { - try { - await access(path.join(fixtureRoot, testPath)); - return true; - } catch { - return false; - } - }), - ) - ).every(Boolean), - ], - ["typecheck", runPnpm("check:types")], - ["architecture", runPnpm("check:architecture")], - ["registry-structure", runPnpm("check:registries:structure")], - ["unit-integration", runPnpm("test:all")], - ["coverage", runPnpm("test:coverage")], - ["test-evidence-source", runPnpm("check:test-evidence:source")], - [ - "home-smoke", - runPnpm("exec", [ - "vitest", - "run", - "tests/component/router.test.tsx", - "--reporter=default", - ]), - ], - ["build", runPnpm("build")], -]; -const builtResidue: string[] = []; -for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { - if (!/\.(?:js|css|html|json)$/.test(file)) continue; - const content = await readFile(file, "utf8"); - if ( - /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) - ) { - builtResidue.push(path.relative(fixtureRoot, file)); + const checks: Array<[string, boolean]> = [ + [ + "common-test-evidence", + ( + await Promise.all( + commonTestPaths.map(async (testPath) => { + try { + await access(path.join(fixtureRoot, testPath)); + return true; + } catch { + return false; + } + }), + ) + ).every(Boolean), + ], + ["typecheck", runPnpm("check:types")], + ["architecture", runPnpm("check:architecture")], + ["registry-structure", runPnpm("check:registries:structure")], + ["unit-integration", runPnpm("test:all")], + ["coverage", runPnpm("test:coverage")], + ["test-evidence-source", runPnpm("check:test-evidence:source")], + [ + "home-smoke", + runPnpm("exec", [ + "vitest", + "run", + "tests/component/router.test.tsx", + "--reporter=default", + ]), + ], + ["build", runPnpm("build")], + ]; + const builtResidue: string[] = []; + for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { + if (!/\.(?:js|css|html|json)$/.test(file)) continue; + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) + ) { + builtResidue.push(path.relative(fixtureRoot, file)); + } } -} -const routeCatalog = await import( - `${new URL( - "../src/features/installed-feature-contracts.ts", - `file://${fixtureRoot}/scripts/`, - ).href}?removed=${Date.now()}` -) as { ROUTE_REGISTRY: Readonly> }; -const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY); -const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_")); -checks.push(["route-absent", routeAbsent]); -checks.push(["fixture-id-residue", residue.length === 0]); -checks.push(["built-fixture-id-residue", builtResidue.length === 0]); + const routeCatalog = await import( + `${new URL( + "../src/features/installed-feature-contracts.ts", + `file://${fixtureRoot}/scripts/`, + ).href}?removed=${Date.now()}` + ) as { ROUTE_REGISTRY: Readonly> }; + const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY); + const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_")); + checks.push(["route-absent", routeAbsent]); + checks.push(["fixture-id-residue", residue.length === 0]); + checks.push(["built-fixture-id-residue", builtResidue.length === 0]); -const passed = checks.every(([, result]) => result); -await mkdir("artifacts/tests", { recursive: true }); -await writeFile( - "artifacts/tests/sample-removal.xml", - `\n` + - `` + - checks - .map( - ([name, result]) => - `${result ? "" : `${[...residue, ...builtResidue].join(", ")}`}`, - ) - .join("") + - `\n`, -); -await rm(fixtureRoot, { recursive: true, force: true }); - -if (!passed) { - const failures = checks - .filter(([, result]) => !result) - .map(([name]) => name); - process.stderr.write( - `Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`, + const passed = checks.every(([, result]) => result); + await mkdir("artifacts/tests", { recursive: true }); + await writeFile( + "artifacts/tests/sample-removal.xml", + `\n` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : `${[...residue, ...builtResidue].join(", ")}`}`, + ) + .join("") + + `\n`, ); - process.exit(1); + if (!passed) { + const failures = checks + .filter(([, result]) => !result) + .map(([name]) => name); + process.stderr.write( + `Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`, + ); + process.exitCode = 1; + } else { + process.stdout.write( + `Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`, + ); + } +} finally { + await rm(fixtureRoot, { recursive: true, force: true }); } -process.stdout.write( - `Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`, -); diff --git a/tests/unit/bounded-body-reader.test.ts b/tests/unit/bounded-body-reader.test.ts new file mode 100644 index 0000000..221dd34 --- /dev/null +++ b/tests/unit/bounded-body-reader.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + declaredContentLength, + decodeJsonBytes, + isEffectivelyEmpty, + isJsonMediaType, + probeForbiddenBody, + readBoundedBytes, +} from "../../src/adapters/http/bounded-body-reader.ts"; + +function responseWithBody( + body: ReadableStream | null, + contentLength?: string, +): Response { + return new Response(body, { + headers: + contentLength === undefined ? undefined : { "content-length": contentLength }, + }); +} + +describe("bounded body reader", () => { + it.each([ + [null, false], + ["", false], + [" application/json ; charset=utf-8 ", true], + ["APPLICATION/PROBLEM+JSON", true], + ["text/json", false], + ["application/jsonp", false], + ] as const)("classifies JSON media type %j", (header, expected) => { + expect(isJsonMediaType(header)).toBe(expected); + }); + + it.each([ + [undefined, null], + ["0", 0], + ["12", 12], + ["-1", null], + ["NaN", null], + ["Infinity", null], + ] as const)("parses declared content length %s", (header, expected) => { + expect(declaredContentLength(responseWithBody(null, header))).toBe(expected); + }); + + it("rejects an oversized declared body and tolerates cancellation failure", async () => { + const cancel = vi.fn(async () => { + throw new Error("already settled"); + }); + const response = { + headers: new Headers({ "content-length": "9" }), + body: { cancel }, + } as unknown as Response; + + await expect(readBoundedBytes(response, 8)).resolves.toEqual({ + ok: false, + code: "RESPONSE_TOO_LARGE", + }); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("returns empty bytes when a successful response has no body", async () => { + const result = await readBoundedBytes(responseWithBody(null), 8); + expect(result).toEqual({ ok: true, bytes: new Uint8Array(0) }); + }); + + it("joins chunks without retaining empty chunks", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(0)); + controller.enqueue(Uint8Array.of(1, 2)); + controller.enqueue(Uint8Array.of(3)); + controller.close(); + }, + }); + + await expect(readBoundedBytes(responseWithBody(body), 3)).resolves.toEqual({ + ok: true, + bytes: Uint8Array.of(1, 2, 3), + }); + }); + + it("cancels streaming input as soon as the accumulated limit is exceeded", async () => { + const cancel = vi.fn().mockRejectedValue(new Error("cancel failed")); + const reader = { + read: vi + .fn() + .mockResolvedValueOnce({ done: false, value: Uint8Array.of(1, 2) }) + .mockResolvedValueOnce({ done: false, value: Uint8Array.of(3, 4) }), + cancel, + releaseLock: vi.fn(), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + + await expect(readBoundedBytes(response, 3)).resolves.toEqual({ + ok: false, + code: "RESPONSE_TOO_LARGE", + }); + expect(cancel).toHaveBeenCalledOnce(); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + }); + + it("maps reader failure and cancellation failure to a stream failure", async () => { + const reader = { + read: vi.fn().mockRejectedValue(new Error("stream failed")), + cancel: vi.fn().mockRejectedValue(new Error("cancel failed")), + releaseLock: vi.fn(() => { + throw new Error("already released"); + }), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + + await expect(readBoundedBytes(response, 3)).resolves.toEqual({ + ok: false, + code: "RESPONSE_STREAM_FAILURE", + }); + }); + + it("detects a forbidden body from declared length without reading it", async () => { + const cancel = vi.fn(); + const response = { + headers: new Headers({ "content-length": "1" }), + body: { cancel }, + } as unknown as Response; + + await expect(probeForbiddenBody(response)).resolves.toEqual({ + ok: true, + present: true, + }); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("accepts an absent, completed, or zero-byte forbidden body", async () => { + await expect(probeForbiddenBody(responseWithBody(null))).resolves.toEqual({ + ok: true, + present: false, + }); + + for (const next of [ + { done: true, value: undefined }, + { done: false, value: new Uint8Array(0) }, + ]) { + const reader = { + read: vi.fn().mockResolvedValue(next), + cancel: vi.fn(), + releaseLock: vi.fn(), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + await expect(probeForbiddenBody(response)).resolves.toEqual({ + ok: true, + present: false, + }); + expect(reader.cancel).not.toHaveBeenCalled(); + } + }); + + it("probes only one present byte and cancels the remaining body", async () => { + const reader = { + read: vi.fn().mockResolvedValue({ done: false, value: Uint8Array.of(1) }), + cancel: vi.fn().mockRejectedValue(new Error("cancel failed")), + releaseLock: vi.fn(), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + + await expect(probeForbiddenBody(response)).resolves.toEqual({ + ok: true, + present: true, + }); + expect(reader.read).toHaveBeenCalledOnce(); + expect(reader.cancel).toHaveBeenCalledOnce(); + }); + + it("maps a forbidden-body probe error even when cleanup also fails", async () => { + const reader = { + read: vi.fn().mockRejectedValue(new Error("probe failed")), + cancel: vi.fn().mockRejectedValue(new Error("cancel failed")), + releaseLock: vi.fn(() => { + throw new Error("released"); + }), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + + await expect(probeForbiddenBody(response)).resolves.toEqual({ + ok: false, + code: "RESPONSE_STREAM_FAILURE", + }); + }); + + it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => { + expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({ + ok: true, + value: { ok: true }, + }); + expect(decodeJsonBytes(Uint8Array.of(0xc3, 0x28))).toEqual({ + ok: false, + code: "UTF8_INVALID", + }); + expect(decodeJsonBytes(new TextEncoder().encode("{"))).toEqual({ + ok: false, + code: "JSON_INVALID", + }); + }); + + it.each([ + [new Uint8Array(0), true], + [Uint8Array.of(0x20, 0x09, 0x0a, 0x0d), true], + [Uint8Array.of(0x20, 0x00), false], + ])("classifies effective emptiness %#", (bytes, expected) => { + expect(isEffectivelyEmpty(bytes)).toBe(expected); + }); +}); diff --git a/tests/unit/risk-coverage-files.test.ts b/tests/unit/risk-coverage-files.test.ts index da65fbd..845d998 100644 --- a/tests/unit/risk-coverage-files.test.ts +++ b/tests/unit/risk-coverage-files.test.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { mkdir, + link, mkdtemp, open, readFile, @@ -78,6 +79,13 @@ describe("risk coverage CLI files", () => { path.join(repositoryRoot, "config/testing/link.json"), ); await symlink(outside, path.join(repositoryRoot, "linked-config"), "dir"); + await mkdir(path.join(repositoryRoot, "real-config")); + await writeFile(path.join(repositoryRoot, "real-config/inside.json"), "{}\n"); + await symlink( + path.join(repositoryRoot, "real-config"), + path.join(repositoryRoot, "inside-alias"), + "dir", + ); await expect( readRiskCoverageInput({ @@ -93,6 +101,34 @@ describe("risk coverage CLI files", () => { label: "policy", }), ).rejects.toThrow(/outside repository|symlink/u); + await expect( + readRiskCoverageInput({ + repositoryRoot, + relativePath: "inside-alias/inside.json", + label: "policy", + }), + ).rejects.toThrow(/ancestor is a symlink/u); + }); + + it("rejects an input identity swap between lstat and open", async () => { + const repositoryRoot = await fixture(); + const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-input-race-")); + roots.push(outside); + const replacement = path.join(outside, "replacement.json"); + await writeFile(replacement, "{\"replacement\":true}\n"); + + await expect( + readRiskCoverageInput( + { + repositoryRoot, + relativePath: "config/testing/policy.json", + label: "policy", + }, + { + openFile: async (_target, flags) => open(replacement, flags), + }, + ), + ).rejects.toThrow(/changed during validation/u); }); it("confines artifact output and rejects input overwrite or symlink ancestors", async () => { @@ -132,6 +168,24 @@ describe("risk coverage CLI files", () => { ).rejects.toThrow(/symlink/u); }); + it("rejects input overwrite through a realpath or hard-link alias", async () => { + const repositoryRoot = await fixture(); + const outputDirectory = path.join(repositoryRoot, "artifacts/quality"); + await mkdir(outputDirectory, { recursive: true }); + const destination = path.join(outputDirectory, "risk-coverage.json"); + await writeFile(destination, "{}\n"); + const hardLinkInput = path.join(repositoryRoot, "config/testing/output-alias.json"); + await link(destination, hardLinkInput); + + await expect( + resolveRiskCoverageArtifactPath({ + repositoryRoot, + relativePath: "artifacts/quality/risk-coverage.json", + inputPaths: ["config/testing/output-alias.json"], + }), + ).rejects.toThrow(/same file as an input/u); + }); + it("syncs an exclusive sibling temp before atomic rename", async () => { const repositoryRoot = await fixture(); const observed: string[] = []; @@ -233,6 +287,54 @@ describe("risk coverage CLI files", () => { expect(await readdir(outputDirectory)).toEqual(["risk-coverage.json"]); }); + it.each([ + ["EINVAL", true], + ["ENOTSUP", true], + ["EIO", false], + ] as const)( + "handles directory sync error %s with an explicit portability fallback", + async (code, accepted) => { + const repositoryRoot = await fixture(); + const operation = writeRiskCoverageArtifactAtomic( + { + repositoryRoot, + relativePath: `artifacts/quality/sync-${code}.json`, + inputPaths: [], + value: { schemaVersion: 2 }, + }, + { + createNonce: () => code, + fileSystem: { + openFile: async (target, flags, mode) => { + const handle = await open(target, flags, mode); + return { + writeFile: async (data) => handle.writeFile(data, "utf8"), + sync: async () => handle.sync(), + close: async () => handle.close(), + }; + }, + openDirectory: async (target) => { + const handle = await open(target, constants.O_RDONLY); + return { + sync: async () => { + throw Object.assign(new Error(`sync ${code}`), { code }); + }, + close: async () => handle.close(), + }; + }, + rename, + rm, + }, + }, + ); + if (accepted) { + await expect(operation).resolves.toBeUndefined(); + } else { + await expect(operation).rejects.toMatchObject({ code }); + } + }, + ); + it("has no changed-files gate in the executable", async () => { const source = await readFile("scripts/check-risk-coverage.ts", "utf8"); expect(source).not.toMatch(/changedFiles|changed-files/u); diff --git a/tests/unit/risk-coverage.test.ts b/tests/unit/risk-coverage.test.ts index fdfa10e..ce06c18 100644 --- a/tests/unit/risk-coverage.test.ts +++ b/tests/unit/risk-coverage.test.ts @@ -186,6 +186,34 @@ describe("repository-aware risk coverage", () => { ).toThrow(/unexpected coverage path.*tests\/unit\/a\.test\.ts/u); }); + it("rejects outside and duplicate normalized producer paths", () => { + const parsedPolicy = parseRiskCoveragePolicy( + policy({ repositoryBaseline: 1, generatedPaths: [] }), + { now }, + ); + const base = { + repositoryRoot: "/repository", + inventory: inventory(["src/a.ts"]), + policy: parsedPolicy, + }; + expect(() => + evaluateRiskCoverage({ + ...base, + summary: { total: fullMetrics, "/outside/src/a.ts": fullMetrics }, + }), + ).toThrow(/outside repository/u); + expect(() => + evaluateRiskCoverage({ + ...base, + summary: { + total: metrics(2), + "src/a.ts": fullMetrics, + "/repository/src/a.ts": fullMetrics, + }, + }), + ).toThrow(/duplicate coverage path/u); + }); + it("accepts only explicitly configured generated coverage paths", () => { const parsedPolicy = parseRiskCoveragePolicy( policy({ repositoryBaseline: 1 }), @@ -341,6 +369,22 @@ describe("repository-aware risk coverage", () => { expect(observedFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW); }); + it("rejects a post-lstat file identity swap even without relying on O_NOFOLLOW", async () => { + const repositoryRoot = await repositoryFixture(); + const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-race-")); + roots.push(outside); + const outsideFile = path.join(outside, "replacement.ts"); + await writeFile(outsideFile, "export const replacement = true;\n"); + + await expect( + buildProductionModuleInventory({ + repositoryRoot, + openFile: async (target, flags) => + open(target.endsWith("src/a.ts") ? outsideFile : target, flags), + }), + ).rejects.toThrow(/changed during validation/u); + }); + it("fails closed on empty, traversing, symlinked, or stale generated inventory", async () => { const repositoryRoot = await repositoryFixture(); const emptyRoot = await mkdtemp(path.join(tmpdir(), "risk-coverage-empty-"));