diff --git a/scripts/maintainer-app-readiness.mjs b/scripts/maintainer-app-readiness.mjs index 86c33ff7d..ba34dc169 100644 --- a/scripts/maintainer-app-readiness.mjs +++ b/scripts/maintainer-app-readiness.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { @@ -10,6 +10,10 @@ import { import { readDelegatedGithubToken as readHardenedDelegatedGithubToken, } from "./lib/delegated-github-token.mjs"; +import { + assertAcquisitionPrivatePathParents, + writeAcquisitionPrivateFile, +} from "./lib/acquisition-private-output.mjs"; import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; const MAX_ERROR_CHARS = 4_000; @@ -271,8 +275,9 @@ function readJson(path, label) { function writeReport(path, report) { const absolutePath = resolve(path); + assertAcquisitionPrivatePathParents(absolutePath); mkdirSync(dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + writeAcquisitionPrivateFile(absolutePath, `${JSON.stringify(report, null, 2)}\n`); return absolutePath; } diff --git a/test/maintainer-app-readiness-output-safety.test.ts b/test/maintainer-app-readiness-output-safety.test.ts new file mode 100644 index 000000000..833a87ca5 --- /dev/null +++ b/test/maintainer-app-readiness-output-safety.test.ts @@ -0,0 +1,72 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { main } from "../scripts/maintainer-app-readiness.mjs"; + +const originalEnvironment = { ...process.env }; +const originalExitCode = process.exitCode; +const directories: string[] = []; + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "noema-maintainer-output-safety-")); + directories.push(directory); + return directory; +} + +function configureCollectionFailure(reportPath: string): void { + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/not-noema"; + process.env.NOEMA_MAINTAINER_READINESS_PATH = reportPath; + delete process.env.GITHUB_OUTPUT; + delete process.env.GITHUB_STEP_SUMMARY; +} + +function restoreProcessState(): void { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnvironment)) delete process.env[key]; + } + Object.assign(process.env, originalEnvironment); + process.exitCode = originalExitCode; +} + +afterEach(() => restoreProcessState()); + +afterAll(() => { + restoreProcessState(); + for (const directory of directories) rmSync(directory, { recursive: true, force: true }); +}); + +describe("maintainer App readiness output path integrity", () => { + it("refuses a pre-existing symbolic-link report leaf without modifying its target", () => { + const directory = temporaryDirectory(); + const targetPath = join(directory, "target.json"); + const reportPath = join(directory, "report.json"); + writeFileSync(targetPath, "sentinel\n", "utf8"); + symlinkSync(targetPath, reportPath); + configureCollectionFailure(reportPath); + + expect(() => main()).toThrow(/output path|symbolic link|regular file/i); + expect(readFileSync(targetPath, "utf8")).toBe("sentinel\n"); + }); + + it("refuses a symbolic-link report parent without creating evidence through it", () => { + const directory = temporaryDirectory(); + const realParent = join(directory, "real-parent"); + const linkedParent = join(directory, "linked-parent"); + const reportPath = join(linkedParent, "report.json"); + mkdirSync(realParent); + symlinkSync(realParent, linkedParent, "dir"); + configureCollectionFailure(reportPath); + + expect(() => main()).toThrow(/output parent|symbolic link|real directory/i); + expect(existsSync(join(realParent, "report.json"))).toBe(false); + }); +});