diff --git a/.github/workflows/acquisition-readiness-scan.yml b/.github/workflows/acquisition-readiness-scan.yml index 91cf0d186..a2edbc3c7 100644 --- a/.github/workflows/acquisition-readiness-scan.yml +++ b/.github/workflows/acquisition-readiness-scan.yml @@ -43,9 +43,6 @@ jobs: fi test "$(git rev-parse HEAD)" = "${{ github.sha }}" - - name: build data-room manifest - run: npm run acquisition:manifest - - name: run acquisition audit env: NOEMA_AUDIT_REPORT_ONLY: ${{ github.event_name == 'schedule' && '1' || '0' }} diff --git a/.github/workflows/hourly-commercial-readiness.yml b/.github/workflows/hourly-commercial-readiness.yml index 09bb070c5..6d9a2d662 100644 --- a/.github/workflows/hourly-commercial-readiness.yml +++ b/.github/workflows/hourly-commercial-readiness.yml @@ -203,12 +203,6 @@ jobs: NOEMA_SMOKE_EVIDENCE_PATH: noema-smoke-evidence.json run: npm run readiness:audit - - name: refresh acquisition manifest when the queue is empty - if: steps.loop.outputs.remaining_open_pull_request_count == '0' - env: - NOEMA_AUDIT_REPORT_ONLY: "1" - run: npm run acquisition:manifest - - name: refresh acquisition-readiness evidence when the queue is empty if: steps.loop.outputs.remaining_open_pull_request_count == '0' env: @@ -246,4 +240,4 @@ jobs: exchange-30d.ndjson.provenance.json noema-smoke-evidence.json if-no-files-found: error - retention-days: 90 + retention-days: 90 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d77563a3a..47e3bdb90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. - production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다. +- acquisition tracked-byte 인증이 descriptor에서 읽은 bytes를 Git blob framing으로 Node 표준 crypto에서 직접 해시해, 파일마다 `git hash-object` subprocess를 만들던 대형 checkout 병목을 제거한다. exact tree inventory는 Git 2.36 전용 `ls-tree --format` 대신 호환되는 기본 NUL 형식을 사용하며, object ID, SHA-1/SHA-256 저장소, no-follow·descriptor identity·byte limit 실패-폐쇄 계약은 유지한다. dependency-license inventory가 실제로 소비한 `package-lock.json` bytes도 pinned source commit의 Git blob과 직접 대조해 transient file swap을 차단한다. 실패한 audit stage 뒤에도 source를 다시 인증한 다음 원래 child status로 종료하므로 failure evidence가 stale revision으로 남지 않으며, release·publication·deployment evidence producer와 acquisition consumer는 canonical SHA-1/SHA-256 commit identity를 동일하게 지원한다. +- `acquisition:audit`가 POSIX shell 문법 없이 Node 오케스트레이터로 exact HEAD 기반 단일 기본 output directory를 manifest·integrity·readiness·deployment 단계에 전달해 Windows에서도 새 manifest를 같은 실행에서 소비하며, 기존 `NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR`·`NOEMA_DATA_ROOM_OUTPUT_DIR` 경로 override는 유지한다. - Governance and Maintainer App GitHub CLI subprocesses now keep CLI config and XDG state inside the validated capability file's private parent directory, preventing a missing ambient home from writing `.local` state into the checkout. - Maintainer App readiness now requires the retained governance audit's `protected_main_sha` to equal the freshly collected default-branch head, preventing evidence from different protected-main revisions from being combined into one passing report. The governance collector authenticates every tracked checkout byte against exact HEAD before and after live collection, so modified audit source cannot emit PASS evidence attributed to protected main. Governance and readiness report paths also retain their existing non-symlink private-output authority. The product/technical gap baseline is refreshed to the same protected-main and live issue/run/release observation, and describes the hourly loop through `contextual-orchestrator` rather than retired direct-provider execution. - Exact-tree source authentication uses the long-supported NUL-delimited `git ls-tree` output instead of Git 2.36-only `--format`, preserving fail-closed byte verification on older supported Git installations. diff --git a/docs/acquisition-data-room-integrity.md b/docs/acquisition-data-room-integrity.md index 6669d56e4..8c79d87d1 100644 --- a/docs/acquisition-data-room-integrity.md +++ b/docs/acquisition-data-room-integrity.md @@ -21,7 +21,7 @@ Manifest와 integrity 단계가 별도 경로를 추측하지 않도록 `NOEMA_D - `schemaVersion: 1` - `repository: ContextualWisdomLab/noema` - `objective: NOEMA-GOAL-ACQUISITION-2B-2026-07-02` -- `source.commitSha`: manifest를 생성한 checkout의 정확한 40자리 Git commit +- `source.commitSha`: manifest를 생성한 checkout의 정확한 40자리 SHA-1 또는 64자리 SHA-256 Git commit - 선택된 release가 있을 때 `release.tag`와 그 tag가 실제로 가리키는 `release.commitSha` `NOEMA_DATA_ROOM_SOURCE_COMMIT`을 지정하면 현재 checkout `HEAD`와 정확히 같아야 한다. `NOEMA_RELEASE_UNDER_DILIGENCE_TAG`를 지정하면 immutable SemVer tag가 로컬 Git object database에서 exact commit으로 해석되어야 한다. 불일치하거나 해석할 수 없는 identity는 fail-closed이다. @@ -30,7 +30,7 @@ Manifest와 integrity 단계가 별도 경로를 추측하지 않도록 `NOEMA_D `source.commitSha`는 단순한 `git rev-parse HEAD` 기록이 아니다. Manifest generator와 integrity audit는 catalog/verifier를 읽기 전에 `scripts/lib/acquisition-git-preflight.mjs`로 tracked checkout을 인증한다. -1. `HEAD^{commit}`을 local Git object database에서 exact 40-character SHA로 해석한다. +1. `HEAD^{commit}`을 local Git object database에서 exact 40-character SHA-1 또는 64-character SHA-256 commit으로 해석한다. 2. system/global Git configuration, hooks, filesystem monitor, untracked cache, replacement objects, lazy fetch, terminal prompt를 비활성화하고 필요한 process-discovery 환경만 전달한다. 격리된 config에서도 CI checkout의 dubious-ownership 보호를 우회하지 않고 정확히 현재 command `cwd`만 command-scope `safe.directory`로 허용한다. 같은 exact `cwd`를 `GIT_WORK_TREE`에도 고정하여 repository-local `core.worktree`가 Git의 tracked-byte 비교를 다른 디렉터리로 redirect하지 못하게 한다. 3. `git ls-files -v -z --cached --`의 전체 NUL-delimited 결과를 최대 2 MiB로 bounded read하고, `S`로 표시되는 `skip-worktree` 또는 lowercase tag로 표시되는 `assume-unchanged` entry가 하나라도 있으면 tracked-byte 비교 전에 실패한다. 이 index hint들은 정상적인 working-tree 검사를 생략하게 할 수 있으므로 acquisition checkout에서는 허용하지 않는다. 4. `git diff --cached --quiet --no-ext-diff --no-textconv --ignore-submodules=none --`로 index의 staged content/mode가 exact commit과 같은지 확인한다. `--cached` 비교는 on-disk worktree를 고려하지 않으므로 repository-configured clean filter가 이 단계의 source identity를 바꿀 수 없다. @@ -80,7 +80,7 @@ Receipt는 다음을 모두 충족해야 한다. { "schemaVersion": 1, "repository": "ContextualWisdomLab/noema", - "source": { "commitSha": "" }, + "source": { "commitSha": "" }, "sourceUrl": "", "collectedAt": "", "collector": "", diff --git a/docs/hourly-commercial-readiness-loop.md b/docs/hourly-commercial-readiness-loop.md index b0301aa54..968633fa1 100644 --- a/docs/hourly-commercial-readiness-loop.md +++ b/docs/hourly-commercial-readiness-loop.md @@ -137,7 +137,7 @@ Credential-bearing loop가 실행되면 `commercial-readiness-loop-report` artif } ``` -PR 처리 후 남은 열린 PR이 0개이면 기존 `readiness:audit`, `acquisition:manifest`, `acquisition:audit`를 `NOEMA_AUDIT_REPORT_ONLY=1`로 실행하고 `no-pr-commercial-readiness-evidence` artifact를 남깁니다. +PR 처리 후 남은 열린 PR이 0개이면 기존 `readiness:audit`와 manifest 생성을 포함한 `acquisition:audit`를 `NOEMA_AUDIT_REPORT_ONLY=1`로 실행하고 `no-pr-commercial-readiness-evidence` artifact를 남깁니다. ## 실패-폐쇄 경계 diff --git a/package.json b/package.json index 3c494479f..a8bcbf59f 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "patch-validator:image:verify-receipts": "node scripts/verify-patch-validator-image.mjs", "acquisition:deployment-evidence": "node scripts/acquisition-deployment-evidence-audit.mjs", "acquisition:integrity": "node scripts/acquisition-data-room-integrity-audit.mjs", - "acquisition:audit": "npm run release:dependency-license-inventory && npm run acquisition:integrity && node scripts/acquisition-readiness-audit.mjs && npm run acquisition:deployment-evidence", + "acquisition:audit": "node scripts/acquisition-audit.mjs", "acquisition:manifest": "node scripts/acquisition-data-room-manifest.mjs", "readiness:audit": "node scripts/saleable-readiness-audit.mjs" }, diff --git a/scripts/acquisition-audit.mjs b/scripts/acquisition-audit.mjs new file mode 100644 index 000000000..69aab6806 --- /dev/null +++ b/scripts/acquisition-audit.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { verifyAcquisitionTrackedCheckout } from "./lib/acquisition-git-preflight.mjs"; + +const stages = [ + ["npm", "release:dependency-license-inventory"], + ["npm", "acquisition:manifest"], + ["npm", "acquisition:integrity"], + ["node", "scripts/acquisition-readiness-audit.mjs"], + ["npm", "acquisition:deployment-evidence"], +]; + +function checkedSpawn(command, args, options) { + const result = spawnSync(command, args, options); + if (result.error) throw result.error; + return result.status ?? 1; +} + +function resolveHeadRevision(cwd) { + const git = spawnSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }); + if (git.error) throw git.error; + if (git.status !== 0) throw new Error("git rev-parse HEAD failed"); + return git.stdout.trim(); +} + +function canonicalRevision(value) { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) { + throw new Error("one full commit SHA is required"); + } + return value.toLowerCase(); +} + +export function runAcquisitionAudit({ + cwd = process.cwd(), + env = process.env, + spawn = checkedSpawn, + revision, + resolveRevision, +} = {}) { + const requireLiveSource = revision === undefined; + const liveRevision = resolveRevision ?? (() => resolveHeadRevision(cwd)); + const expectedRevision = canonicalRevision( + requireLiveSource ? liveRevision() : revision, + ); + + const outputDirectory = env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR + || env.NOEMA_DATA_ROOM_OUTPUT_DIR + || join(cwd, "artifacts", "acquisition-readiness", expectedRevision); + const stageEnv = { + ...env, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDirectory, + NOEMA_DATA_ROOM_OUTPUT_DIR: outputDirectory, + NOEMA_DATA_ROOM_SOURCE_COMMIT: expectedRevision, + }; + const npmExecPath = env.npm_execpath; + if (!npmExecPath) throw new Error("npm_execpath is required"); + + const assertLiveSource = () => { + if (!requireLiveSource) return; + if (canonicalRevision(liveRevision()) !== expectedRevision) { + throw new Error("acquisition audit source revision changed during execution"); + } + if (resolveRevision === undefined) { + verifyAcquisitionTrackedCheckout({ + cwd, + expectedCommitSha: expectedRevision, + }); + } + }; + + for (const [runtime, name] of stages) { + assertLiveSource(); + const args = runtime === "npm" ? [npmExecPath, "run", name] : [name]; + const status = spawn(process.execPath, args, { cwd, env: stageEnv, stdio: "inherit" }); + assertLiveSource(); + if (Number.isInteger(status) && status !== 0) return status; + } + return 0; +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + process.exitCode = runAcquisitionAudit(); +} diff --git a/scripts/acquisition-data-room-integrity-audit.mjs b/scripts/acquisition-data-room-integrity-audit.mjs index 267fd083a..d93cbc8f1 100644 --- a/scripts/acquisition-data-room-integrity-audit.mjs +++ b/scripts/acquisition-data-room-integrity-audit.mjs @@ -10,7 +10,7 @@ import { writeAcquisitionPrivateFile, } from "./lib/acquisition-private-output.mjs"; -const fullShaPattern = /^[0-9a-f]{40}$/; +const fullShaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const releaseTagPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const now = new Date().toISOString(); const configuredOutputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR diff --git a/scripts/acquisition-data-room-manifest-secure.mjs b/scripts/acquisition-data-room-manifest-secure.mjs index fb3878b90..6a4899d44 100644 --- a/scripts/acquisition-data-room-manifest-secure.mjs +++ b/scripts/acquisition-data-room-manifest-secure.mjs @@ -10,7 +10,7 @@ import { writeAcquisitionPrivateFile, } from "./lib/acquisition-private-output.mjs"; -const fullShaPattern = /^[0-9a-f]{40}$/; +const fullShaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const releaseTagPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const now = new Date().toISOString(); const configuredOutputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR diff --git a/scripts/acquisition-readiness-audit.mjs b/scripts/acquisition-readiness-audit.mjs index df2e9da7d..01778c78c 100644 --- a/scripts/acquisition-readiness-audit.mjs +++ b/scripts/acquisition-readiness-audit.mjs @@ -486,7 +486,7 @@ function validateLicensingIpEvidence(value) { if (releaseUnderDiligenceTag && release.tag !== releaseUnderDiligenceTag) { failures.push("release_rights.tag must match the release under diligence"); } - if (!/^[0-9a-f]{40}$/i.test(String(release.commit_sha ?? ""))) { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(String(release.commit_sha ?? ""))) { failures.push("release_rights.commit_sha must be a full Git SHA"); } validateDigestBoundArtifact(release.sbom, "release_rights.sbom", failures); @@ -542,7 +542,7 @@ function validateReleasePublicationReceipt(value, expectedTag) { if (value.source?.tag !== expectedTag) { failures.push(`source.tag must match release under diligence ${expectedTag}`); } - if (!/^[0-9a-f]{40}$/i.test(String(value.source?.commitSha ?? ""))) { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(String(value.source?.commitSha ?? ""))) { failures.push("source.commitSha must be a full SHA"); } if (value.source?.tag !== `v${value.source?.version ?? ""}`) { diff --git a/scripts/dependency-license-inventory.mjs b/scripts/dependency-license-inventory.mjs index 88695ae6c..6a54bc070 100644 --- a/scripts/dependency-license-inventory.mjs +++ b/scripts/dependency-license-inventory.mjs @@ -7,6 +7,7 @@ import { import { dirname, normalize, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { writeAcquisitionPrivateFile } from "./lib/acquisition-private-output.mjs"; +import { verifyAcquisitionTrackedFileBytes } from "./lib/acquisition-git-preflight.mjs"; import { readStableRegularFile } from "./lib/stable-file-evidence.mjs"; import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; @@ -430,8 +431,20 @@ export function buildDependencyLicenseInventory( export function generateDependencyLicenseInventory({ lockPath = DEFAULT_LOCK_PATH, outputPath = DEFAULT_OUTPUT_PATH, + expectedCommitSha = process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || "", + cwd = process.cwd(), + readLock = readEvidenceFile, + authenticateLock = verifyAcquisitionTrackedFileBytes, } = {}) { - const lockBytes = readEvidenceFile(lockPath); + const lockBytes = readLock(lockPath); + if (expectedCommitSha) { + authenticateLock({ + cwd, + exactHead: expectedCommitSha, + path: lockPath, + bytes: Buffer.from(lockBytes, "utf8"), + }); + } const inventory = buildDependencyLicenseInventory(lockBytes, { sourcePath: lockPath }); assertCanonicalEvidencePath(outputPath, "output"); assertPathParents(outputPath, "output"); @@ -477,4 +490,4 @@ export function runIfDirect(metaUrl, argv, execute) { return true; } -runIfDirect(import.meta.url, process.argv, startCli); \ No newline at end of file +runIfDirect(import.meta.url, process.argv, startCli); diff --git a/scripts/deployment-evidence.mjs b/scripts/deployment-evidence.mjs index 92e453e4d..5b33ef759 100644 --- a/scripts/deployment-evidence.mjs +++ b/scripts/deployment-evidence.mjs @@ -22,7 +22,7 @@ const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const EXPECTED_WORKER = "noema"; const MAX_INPUT_BYTES = 16 * 1024 * 1024; const MAX_WRANGLER_RECORDS = 1_000; -const shaPattern = /^[0-9a-f]{40}$/; +const shaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const digestPattern = /^[0-9a-f]{64}$/; const opaqueIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -190,7 +190,7 @@ export function buildDeploymentEvidence(input) { fail(`release tag must be semantic version tag v, received ${releaseTag}`); } if (commitSha !== commitShaSource || !shaPattern.test(commitSha)) { - fail("deployment commit SHA must be a canonical 40-character lowercase hexadecimal SHA"); + fail("deployment commit SHA must be a canonical lowercase full hexadecimal Git identity"); } if (!new Set(["production", "staging"]).has(environment)) { fail(`deployment environment must be production or staging, received ${environment}`); diff --git a/scripts/external-scheduler-evidence-audit.mjs b/scripts/external-scheduler-evidence-audit.mjs old mode 100755 new mode 100644 diff --git a/scripts/lib/acquisition-data-room-catalog.mjs b/scripts/lib/acquisition-data-room-catalog.mjs index ea4a77f63..21241e955 100644 --- a/scripts/lib/acquisition-data-room-catalog.mjs +++ b/scripts/lib/acquisition-data-room-catalog.mjs @@ -35,6 +35,11 @@ function finalEvidenceFile(id, category, path, validatedBy, statusMeaning) { */ export const DATA_ROOM_CATALOG = Object.freeze([ ...BASE_DATA_ROOM_CATALOG, + requiredFile( + "acquisition-audit-script", + "automation", + "scripts/acquisition-audit.mjs", + ), requiredFile( "orchestrator-gateway-contract", "product", diff --git a/scripts/lib/acquisition-data-room-integrity.mjs b/scripts/lib/acquisition-data-room-integrity.mjs index 23b2e7e3f..89140c7a3 100644 --- a/scripts/lib/acquisition-data-room-integrity.mjs +++ b/scripts/lib/acquisition-data-room-integrity.mjs @@ -22,7 +22,7 @@ export const MAX_DATA_ROOM_JSON_BYTES = 2 * 1024 * 1024; export const MAX_DATA_ROOM_EVIDENCE_BYTES = 32 * 1024 * 1024; const MAX_ENTRY_COUNT = 256; const MAX_RELATIVE_PATH_BYTES = 1024; -const fullShaPattern = /^[0-9a-f]{40}$/i; +const fullShaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; const sha256Pattern = /^[0-9a-f]{64}$/i; const canonicalTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const unsafeControlPattern = /[\u0000-\u001f\u007f]/; @@ -625,7 +625,7 @@ export function materializeDataRoomManifest({ fileSystem = defaultFileSystem, } = {}) { if (!fullShaPattern.test(String(commitSha ?? ""))) { - throw new TypeError("commitSha must be the exact 40-character audited Git commit"); + throw new TypeError("commitSha must be an exact audited Git commit ID"); } const entries = catalog.map((expected) => { if (expected.kind === "file") { diff --git a/scripts/lib/acquisition-deployment-evidence.mjs b/scripts/lib/acquisition-deployment-evidence.mjs index 9e2e8bb98..357acb309 100644 --- a/scripts/lib/acquisition-deployment-evidence.mjs +++ b/scripts/lib/acquisition-deployment-evidence.mjs @@ -5,7 +5,7 @@ const EXPECTED_SIGNER_WORKFLOW = `${EXPECTED_REPOSITORY}/.github/workflows/cd.ym const EXPECTED_PREDICATE_TYPE = "https://contextualwisdomlab.org/attestations/noema-deployment/v1"; const EXPECTED_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; -const shaPattern = /^[0-9a-f]{40}$/; +const shaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const digestPattern = /^[0-9a-f]{64}$/; const tagPattern = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const isoCalendarPrefixPattern = /^(\d{4})-(\d{2})-(\d{2})T/; diff --git a/scripts/lib/acquisition-git-preflight.mjs b/scripts/lib/acquisition-git-preflight.mjs index 1e8c00e34..0becb82ed 100644 --- a/scripts/lib/acquisition-git-preflight.mjs +++ b/scripts/lib/acquisition-git-preflight.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { closeSync, constants, @@ -9,7 +10,7 @@ import { } from "node:fs"; import { resolve, sep } from "node:path"; -const fullShaPattern = /^[0-9a-f]{40}$/i; +const fullShaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; const fullObjectPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; const indexHeaderPattern = /^([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-3])$/; const treeHeaderPattern = /^([0-7]{6}) blob ([0-9a-f]{40}|[0-9a-f]{64})$/; @@ -322,22 +323,64 @@ function readTrackedRegularBytes(entry, fileSystem, remainingBytes) { } } -function hashTrackedEntry(entry, options, fileSystem, remainingBytes) { +function hashTrackedEntry(entry, fileSystem, remainingBytes) { const input = readTrackedRegularBytes(entry, fileSystem, remainingBytes); - const result = runGit(["hash-object", "--stdin"], { ...options, input }); - if (result.status !== 0) { - throw new Error("acquisition tracked-byte hashing failed"); - } - const objectId = String(result.stdout ?? "").trim().toLowerCase(); - if (!fullObjectPattern.test(objectId)) { - throw new Error("acquisition tracked-byte hashing returned malformed output"); - } + const algorithm = entry.objectId.length === 40 ? "sha1" : "sha256"; + const objectId = createHash(algorithm) + .update(`blob ${input.length}\0`) + .update(input) + .digest("hex"); if (objectId !== entry.objectId) { throw new Error("tracked checkout differs from its authenticated Git index bytes"); } return input.length; } +/** Authenticate caller-supplied bytes against one file in an immutable Git tree. */ +export function verifyAcquisitionTrackedFileBytes({ + cwd = process.cwd(), + exactHead, + path, + bytes, + spawnSyncImpl = spawnSync, + sourceEnvironment = process.env, + platform = process.platform, +} = {}) { + if (typeof exactHead !== "string" || !fullShaPattern.test(exactHead)) { + throw new TypeError("exact acquisition tree commit must be a full Git SHA"); + } + if (typeof path !== "string" || path.length === 0 || path.length > MAX_TRACKED_PATH_BYTES) { + throw new TypeError("tracked acquisition input path must be a bounded repository path"); + } + const input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? ""); + if (input.length > MAX_TRACKED_FILE_BYTES) { + throw new Error("tracked acquisition input exceeds the acquisition file-byte limit"); + } + const listing = runGit( + ["ls-tree", "-r", "--full-tree", "-z", exactHead.toLowerCase(), "--", path], + { cwd, spawnSyncImpl, sourceEnvironment, platform }, + MAX_GIT_OUTPUT_BYTES, + null, + ); + if (listing.status !== 0) { + throw new Error("tracked acquisition input source inspection failed"); + } + const entries = parseTrackedEntries(listing.stdout, cwd); + if (entries.length !== 1 || entries[0].path !== path) { + throw new Error("tracked acquisition input is not one exact regular file"); + } + const expected = entries[0].objectId; + const algorithm = expected.length === 40 ? "sha1" : "sha256"; + const actual = createHash(algorithm) + .update(`blob ${input.length}\0`) + .update(input) + .digest("hex"); + if (actual !== expected) { + throw new Error("tracked acquisition input bytes differ from the claimed acquisition commit's pinned Git tree"); + } + return expected; +} + /** * Recompute every tracked regular-file Git blob object from descriptor-bound * checkout bytes. Production callers must provide the already-resolved exact @@ -349,7 +392,8 @@ function hashTrackedEntry(entry, options, fileSystem, remainingBytes) { * * Every file is opened with O_NOFOLLOW, bound to pre/post path and descriptor * metadata, read through that descriptor with limit+1 growth detection, and - * hashed through standard input. Executable mode is checked independently. + * hashed locally with Git's standard blob framing. Executable mode is checked + * independently. * * The source listing, path count, path bytes, per-file bytes, and aggregate * bytes are bounded before each read. Symbolic links, gitlinks, sparse @@ -403,7 +447,6 @@ export function verifyAcquisitionTrackedBytes({ for (const entry of entries) { aggregateBytes += hashTrackedEntry( entry, - options, fileSystem, MAX_TRACKED_TOTAL_BYTES - aggregateBytes, ); @@ -412,7 +455,7 @@ export function verifyAcquisitionTrackedBytes({ } /** - * Resolve a local Git revision to one exact 40-character commit. The command + * Resolve a local Git revision to one exact SHA-1 or SHA-256 commit. The command * uses only the local object database and refuses malformed or ambiguous * output rather than allowing an approximate source identity. */ diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs index 7b58efa86..e6bc1b9ab 100644 --- a/scripts/release-evidence.mjs +++ b/scripts/release-evidence.mjs @@ -23,7 +23,7 @@ const MAX_SBOM_BYTES = 16 * 1024 * 1024; const MAX_SOURCE_BYTES = 512 * 1024 * 1024; const MAX_SBOM_NESTING_DEPTH = 128; const TAR_BLOCK_BYTES = 512; -const shaPattern = /^[0-9a-f]{40}$/; +const shaPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const versionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const canonicalUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const cycloneDxSerialNumberPattern = /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; @@ -220,7 +220,7 @@ function validateReleaseIdentity() { fail(`release repository must be ${EXPECTED_REPOSITORY}, received ${repository}`); } if (commitSha !== commitShaSource || !shaPattern.test(commitSha)) { - fail("release commit SHA must be a canonical 40-character lowercase hexadecimal SHA"); + fail("release commit SHA must be a canonical lowercase full hexadecimal Git identity"); } if (!versionPattern.test(version)) { fail(`release version is not valid SemVer: ${version}`); diff --git a/scripts/release-publication-receipt.mjs b/scripts/release-publication-receipt.mjs index 92df902df..56383c648 100644 --- a/scripts/release-publication-receipt.mjs +++ b/scripts/release-publication-receipt.mjs @@ -18,7 +18,7 @@ const MAX_JSON_BYTES = 16 * 1024 * 1024; const MAX_ASSET_BYTES = 512 * 1024 * 1024; const MAX_JSON_NESTING_DEPTH = 256; const MAXIMUM_SIGNED_OPEN_FLAG = 0x7fff_ffff; -const SHA_PATTERN = /^[0-9a-f]{40}$/; +const SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/; const SEMVER_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const WORKFLOW_RUN_URL_PATTERN = /^https:\/\/github\.com\/ContextualWisdomLab\/noema\/actions\/runs\/[1-9]\d*$/; @@ -574,7 +574,7 @@ function validateIdentity() { fail(`release repository must be ${EXPECTED_REPOSITORY}, received ${repository}`); } if (commitSha !== rawCommitSha || !SHA_PATTERN.test(commitSha)) { - fail("release commit SHA must be the canonical lowercase 40-character hexadecimal identity"); + fail("release commit SHA must be a canonical lowercase full hexadecimal Git identity"); } if (!SEMVER_PATTERN.test(version)) { fail(`release version is not valid SemVer: ${version}`); diff --git a/test/acquisition-data-room-catalog.test.ts b/test/acquisition-data-room-catalog.test.ts index 043624240..fadc2e7ce 100644 --- a/test/acquisition-data-room-catalog.test.ts +++ b/test/acquisition-data-room-catalog.test.ts @@ -11,6 +11,14 @@ describe("composed acquisition data-room catalog", () => { const appendedEntries = DATA_ROOM_CATALOG.slice(BASE_DATA_ROOM_CATALOG.length); expect(appendedEntries).toEqual([ + { + id: "acquisition-audit-script", + category: "automation", + kind: "file", + path: "scripts/acquisition-audit.mjs", + required: true, + requiredForFinalGate: true, + }, { id: "orchestrator-gateway-contract", category: "product", diff --git a/test/acquisition-data-room-integrity-branches.test.ts b/test/acquisition-data-room-integrity-branches.test.ts index 430bc5c68..4903ad75e 100644 --- a/test/acquisition-data-room-integrity-branches.test.ts +++ b/test/acquisition-data-room-integrity-branches.test.ts @@ -629,11 +629,27 @@ describe("acquisition data-room integrity defensive branches", () => { expect(() => materializeDataRoomManifest({ commitSha: "not-a-sha", catalog: [commandEntry()], - })).toThrow("commitSha must be the exact 40-character audited Git commit"); + })).toThrow("commitSha must be an exact audited Git commit ID"); expect(() => materializeDataRoomManifest({ commitSha: null as never, catalog: [commandEntry()], - })).toThrow("commitSha must be the exact 40-character audited Git commit"); + })).toThrow("commitSha must be an exact audited Git commit ID"); + }); + + it("materializes and verifies a SHA-256 repository manifest", () => { + const commitSha = "a".repeat(64); + const catalog = [commandEntry()]; + const materialized = materializeDataRoomManifest({ + manifestPath: "manifest.json", + commitSha, + catalog, + }); + + expect(materialized.source.commitSha).toBe(commitSha); + expect(verifyDataRoomManifest(materialized, { + expectedCommitSha: commitSha, + catalog, + }).integrityPassed).toBe(true); }); it("does not accept oversized manifest files", () => { diff --git a/test/acquisition-data-room-manifest.test.ts b/test/acquisition-data-room-manifest.test.ts index fb6d30e34..7bed2fbb8 100644 --- a/test/acquisition-data-room-manifest.test.ts +++ b/test/acquisition-data-room-manifest.test.ts @@ -34,6 +34,7 @@ describe("acquisition-data-room-manifest", () => { const pitchOutline = manifest.entries.find((entry: { id: string }) => entry.id === "buyer-pitch-outline"); const transferPlan = manifest.entries.find((entry: { id: string }) => entry.id === "transfer-readiness-plan"); const productionPreflight = manifest.entries.find((entry: { id: string }) => entry.id === "production-preflight-script"); + const acquisitionAuditScript = manifest.entries.find((entry: { id: string }) => entry.id === "acquisition-audit-script"); const acquisitionWorkflow = manifest.entries.find((entry: { id: string }) => entry.id === "acquisition-scan-workflow"); const releaseSupplyChain = manifest.entries.find((entry: { id: string }) => entry.id === "release-supply-chain"); const releaseEvidenceScript = manifest.entries.find((entry: { id: string }) => entry.id === "release-evidence-script"); @@ -73,6 +74,9 @@ describe("acquisition-data-room-manifest", () => { expect(pitchOutline.status).toBe("present"); expect(transferPlan.status).toBe("present"); expect(productionPreflight.status).toBe("present"); + expect(acquisitionAuditScript.status).toBe("present"); + expect(acquisitionAuditScript.bytes).toBeGreaterThan(0); + expect(acquisitionAuditScript.sha256).toMatch(/^[a-f0-9]{64}$/); expect(acquisitionWorkflow.status).toBe("present"); expect(releaseSupplyChain.status).toBe("present"); expect(releaseSupplyChain.sha256).toMatch(/^[a-f0-9]{64}$/); @@ -135,4 +139,4 @@ describe("acquisition-data-room-manifest", () => { rmSync(temp, { recursive: true, force: true }); } }, 30_000); -}); +}); \ No newline at end of file diff --git a/test/acquisition-deployment-evidence.test.ts b/test/acquisition-deployment-evidence.test.ts index e77e12da2..12c8f1d4d 100644 --- a/test/acquisition-deployment-evidence.test.ts +++ b/test/acquisition-deployment-evidence.test.ts @@ -125,6 +125,19 @@ function runAudit(root: string, paths: ReturnType, extraEnv } describe("acquisition deployment evidence", () => { + it("accepts matching SHA-256 repository commit identities", () => { + const input = fixture(); + const sha256Commit = "a".repeat(64); + input.deploymentEvidence.source.commitSha = sha256Commit; + input.verificationReceipt.commitSha = sha256Commit; + input.deploymentEvidenceSha256 = createHash("sha256") + .update(`${JSON.stringify(input.deploymentEvidence, null, 2)}\n`) + .digest("hex"); + input.verificationReceipt.deploymentEvidenceSha256 = input.deploymentEvidenceSha256; + + expect(evaluateAcquisitionDeploymentEvidence(input).pass).toBe(true); + }); + it("passes a cross-bound production deployment evidence set", () => { expect(evaluateAcquisitionDeploymentEvidence(fixture())).toEqual({ pass: true, @@ -249,9 +262,11 @@ describe("acquisition deployment evidence", () => { it("chains deployment evidence through the public acquisition audit command", () => { const packageJson = JSON.parse(readFileSync("package.json", "utf8")); + const orchestrator = readFileSync("scripts/acquisition-audit.mjs", "utf8"); expect(packageJson.scripts["acquisition:deployment-evidence"]) .toBe("node scripts/acquisition-deployment-evidence-audit.mjs"); expect(packageJson.scripts["acquisition:audit"]) - .toContain("npm run acquisition:deployment-evidence"); + .toBe("node scripts/acquisition-audit.mjs"); + expect(orchestrator).toContain('["npm", "acquisition:deployment-evidence"]'); }); }); diff --git a/test/acquisition-git-content-hash.test.ts b/test/acquisition-git-content-hash.test.ts index be5d4c6a3..7c7e3f575 100644 --- a/test/acquisition-git-content-hash.test.ts +++ b/test/acquisition-git-content-hash.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, @@ -9,11 +10,23 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { verifyAcquisitionTrackedBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; +import { + verifyAcquisitionTrackedBytes, + verifyAcquisitionTrackedFileBytes, +} from "../scripts/lib/acquisition-git-preflight.mjs"; const SHA1_A = "a".repeat(40); -const SHA1_B = "b".repeat(40); -const SHA256_A = "a".repeat(64); +const TRACKED_CONTENTS = Buffer.from("tracked\n"); + +function gitBlobId(contents: Buffer, algorithm: "sha1" | "sha256" = "sha1") { + return createHash(algorithm) + .update(`blob ${contents.length}\0`) + .update(contents) + .digest("hex"); +} + +const TRACKED_SHA1 = gitBlobId(TRACKED_CONTENTS); +const TRACKED_SHA256 = gitBlobId(TRACKED_CONTENTS, "sha256"); function runGit(root: string, args: string[]) { const result = spawnSync("git", args, { @@ -76,7 +89,7 @@ function trackedRecord( function descriptorFileSystem({ pathStates = [regularMetadata(), regularMetadata()], descriptorStates = [regularMetadata(), regularMetadata()], - contents = Buffer.from("tracked\n"), + contents = TRACKED_CONTENTS, readResults, constants = { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, }: { @@ -122,6 +135,79 @@ function descriptorFileSystem({ } describe("acquisition exact tracked-byte authentication", () => { + it.each([undefined, "short", 7])("rejects invalid pinned file commits (%s)", (exactHead) => { + expect(() => verifyAcquisitionTrackedFileBytes({ exactHead, path: "tracked.txt", bytes: "" })) + .toThrow("exact acquisition tree commit must be a full Git SHA"); + }); + + it.each([undefined, "", "x".repeat(4097)])("rejects invalid pinned file paths", (path) => { + expect(() => verifyAcquisitionTrackedFileBytes({ exactHead: SHA1_A, path, bytes: "" })) + .toThrow("tracked acquisition input path must be a bounded repository path"); + }); + + it("rejects oversized caller-supplied pinned file bytes", () => { + expect(() => verifyAcquisitionTrackedFileBytes({ + exactHead: SHA1_A, + path: "tracked.txt", + bytes: Buffer.alloc(32 * 1024 * 1024 + 1), + })).toThrow("tracked acquisition input exceeds the acquisition file-byte limit"); + }); + + it("fails closed when the pinned tree lookup fails or is not one exact path", () => { + const failed = spawnSequence({ status: 1 }); + expect(() => verifyAcquisitionTrackedFileBytes({ + exactHead: SHA1_A, + path: "tracked.txt", + bytes: TRACKED_CONTENTS, + spawnSyncImpl: failed, + })).toThrow("tracked acquisition input source inspection failed"); + + const missing = spawnSequence({ stdout: Buffer.alloc(0) }); + expect(() => verifyAcquisitionTrackedFileBytes({ + exactHead: SHA1_A, + path: "tracked.txt", + bytes: TRACKED_CONTENTS, + spawnSyncImpl: missing, + })).toThrow("tracked acquisition input is not one exact regular file"); + + const otherPath = spawnSequence({ + stdout: Buffer.from(`100644 blob ${TRACKED_SHA1}\tother.txt\0`), + }); + expect(() => verifyAcquisitionTrackedFileBytes({ + exactHead: SHA1_A, + path: "tracked.txt", + bytes: TRACKED_CONTENTS, + spawnSyncImpl: otherPath, + })).toThrow("tracked acquisition input is not one exact regular file"); + }); + + it.each([ + ["sha1", SHA1_A, TRACKED_SHA1], + ["sha256", "a".repeat(64), TRACKED_SHA256], + ])("authenticates caller-supplied bytes with %s Git object identity", (_name, exactHead, objectId) => { + const spawn = spawnSequence({ + stdout: Buffer.from(`100644 blob ${objectId}\ttracked.txt\0`), + }); + expect(verifyAcquisitionTrackedFileBytes({ + exactHead, + path: "tracked.txt", + bytes: TRACKED_CONTENTS, + spawnSyncImpl: spawn, + })).toBe(objectId); + }); + + it("rejects caller-supplied bytes that differ from the pinned blob", () => { + const spawn = spawnSequence({ + stdout: Buffer.from(`100644 blob ${TRACKED_SHA1}\ttracked.txt\0`), + }); + expect(() => verifyAcquisitionTrackedFileBytes({ + exactHead: SHA1_A, + path: "tracked.txt", + bytes: "tampered\n", + spawnSyncImpl: spawn, + })).toThrow("tracked acquisition input bytes differ from the claimed acquisition commit's pinned Git tree"); + }); + it.skipIf(process.platform === "win32")( "rejects same-size content drift even when Git's stat cache reports a clean worktree", () => { @@ -205,8 +291,7 @@ describe("acquisition exact tracked-byte authentication", () => { expect(verifyAcquisitionTrackedBytes({ cwd: "/", spawnSyncImpl: spawnSequence( - { stdout: Buffer.from(trackedRecord("tmp/tracked.txt", SHA256_A)) }, - { stdout: `${SHA256_A}\n` }, + { stdout: Buffer.from(trackedRecord("tmp/tracked.txt", TRACKED_SHA256)) }, ), fileSystem: fileSystem as never, })).toBe(1); @@ -276,8 +361,7 @@ describe("acquisition exact tracked-byte authentication", () => { expect(verifyAcquisitionTrackedBytes({ cwd: "/repo", spawnSyncImpl: spawnSequence( - { stdout: Buffer.from(trackedRecord("tracked.txt", SHA1_A, "100755")) }, - { stdout: SHA1_A }, + { stdout: Buffer.from(trackedRecord("tracked.txt", TRACKED_SHA1, "100755")) }, ), fileSystem: descriptorFileSystem({ pathStates: [metadata, metadata], @@ -367,44 +451,29 @@ describe("acquisition exact tracked-byte authentication", () => { })).toThrow("changed during raw-byte authentication"); }); - it("rejects failed, malformed, and mismatched hash evidence after closing the descriptor", () => { - const contents = Buffer.from("tracked\n"); + it("rejects mismatched locally computed hash evidence after closing the descriptor", () => { + const contents = TRACKED_CONTENTS; const metadata = regularMetadata({ size: contents.length }); - for (const [hashResult, message] of [ - [gitResult({ status: 2 }), "hashing failed"], - [gitResult({ stdout: null }), "malformed output"], - [gitResult({ stdout: SHA1_B }), "authenticated Git index bytes"], - ] as const) { - const fileSystem = descriptorFileSystem({ - pathStates: [metadata, metadata], - descriptorStates: [metadata, metadata], - contents, - }); - expect(() => verifyAcquisitionTrackedBytes({ - cwd: "/repo", - spawnSyncImpl: spawnSequence( - { stdout: Buffer.from(trackedRecord()) }, - hashResult, - ), - fileSystem: fileSystem as never, - })).toThrow(message); - expect(fileSystem.closeSync).toHaveBeenCalledWith(17); - } + const fileSystem = descriptorFileSystem({ + pathStates: [metadata, metadata], + descriptorStates: [metadata, metadata], + contents, + }); + expect(() => verifyAcquisitionTrackedBytes({ + cwd: "/repo", + spawnSyncImpl: spawnSequence({ stdout: Buffer.from(trackedRecord()) }), + fileSystem: fileSystem as never, + })).toThrow("authenticated Git index bytes"); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); }); it("enforces the aggregate byte budget before reading the first over-budget file", () => { const paths = Array.from({ length: 9 }, (_, index) => `file-${index}.bin`); - const listing = Buffer.from(paths.map((path) => trackedRecord(path)).join("")); - let gitCall = 0; - const spawn = ( - _command: string, - _args: string[], - _options: Record, - ) => { - gitCall += 1; - return gitCall === 1 ? gitResult({ stdout: listing }) : gitResult({ stdout: SHA1_A }); - }; - const metadata = regularMetadata({ size: 32 * 1024 * 1024 }); + const fileSize = 32 * 1024 * 1024; + const objectId = gitBlobId(Buffer.alloc(fileSize)); + const listing = Buffer.from(paths.map((path) => trackedRecord(path, objectId)).join("")); + const spawn = vi.fn(() => gitResult({ stdout: listing })); + const metadata = regularMetadata({ size: fileSize }); let readCall = 0; const fileSystem = { constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, @@ -428,6 +497,6 @@ describe("acquisition exact tracked-byte authentication", () => { spawnSyncImpl: spawn as never, fileSystem: fileSystem as never, })).toThrow("aggregate-byte limit"); - expect(gitCall).toBe(9); + expect(spawn).toHaveBeenCalledTimes(1); }); }); diff --git a/test/acquisition-git-descriptor-bound.test.ts b/test/acquisition-git-descriptor-bound.test.ts index b843fa603..cd4cead1b 100644 --- a/test/acquisition-git-descriptor-bound.test.ts +++ b/test/acquisition-git-descriptor-bound.test.ts @@ -1,7 +1,12 @@ +import { createHash } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import { verifyAcquisitionTrackedBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; -const OBJECT_ID = "a".repeat(40); +const TRACKED_CONTENTS = Buffer.from("tracked\n"); +const OBJECT_ID = createHash("sha1") + .update(`blob ${TRACKED_CONTENTS.length}\0`) + .update(TRACKED_CONTENTS) + .digest("hex"); function gitResult(overrides: Record = {}) { return { @@ -41,7 +46,7 @@ function regularMetadata(overrides: Record = {}) { function descriptorFileSystem({ pathStates = [regularMetadata(), regularMetadata()], descriptorStates = [regularMetadata(), regularMetadata()], - contents = Buffer.from("tracked\n"), + contents = TRACKED_CONTENTS, constants = { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, }: { pathStates?: Array>; @@ -97,17 +102,14 @@ describe("acquisition descriptor-bound tracked-byte verification", () => { }); it("opens a regular file with no-follow semantics and hashes only descriptor-read bytes", () => { - const contents = Buffer.from("tracked\n"); + const contents = TRACKED_CONTENTS; const metadata = regularMetadata({ size: contents.length }); const fileSystem = descriptorFileSystem({ pathStates: [metadata, metadata], descriptorStates: [metadata, metadata], contents, }); - const spawn = spawnSequence( - { stdout: Buffer.from(trackedRecord()) }, - { stdout: `${OBJECT_ID}\n` }, - ); + const spawn = spawnSequence({ stdout: Buffer.from(trackedRecord()) }); expect(verifyAcquisitionTrackedBytes({ cwd: "/repo", @@ -117,11 +119,7 @@ describe("acquisition descriptor-bound tracked-byte verification", () => { expect(fileSystem.openSync).toHaveBeenCalledWith("/repo/tracked.txt", 0x20000); expect(fileSystem.readSync).toHaveBeenCalled(); expect(fileSystem.closeSync).toHaveBeenCalledWith(17); - expect(spawn).toHaveBeenLastCalledWith( - "git", - ["hash-object", "--stdin"], - expect.objectContaining({ input: contents }), - ); + expect(spawn).toHaveBeenCalledTimes(1); }); it("rejects a pathname swap before reading when the opened descriptor identity differs", () => { diff --git a/test/acquisition-git-exact-tree-binding.test.ts b/test/acquisition-git-exact-tree-binding.test.ts index b5205e99b..8014d0beb 100644 --- a/test/acquisition-git-exact-tree-binding.test.ts +++ b/test/acquisition-git-exact-tree-binding.test.ts @@ -62,7 +62,7 @@ describe("acquisition exact-tree tracked-byte binding", () => { expect(() => verifyAcquisitionTrackedBytes({ cwd: "/repo", exactHead: "HEAD" })) .toThrow("exact acquisition tree commit must be a full Git SHA"); }); - it("uses the legacy-compatible exact-tree inventory command", () => { + it("accepts a full SHA-256 commit identity for exact-tree inspection", () => { const spawn = vi.fn(() => ({ status: 0, signal: null, @@ -70,7 +70,7 @@ describe("acquisition exact-tree tracked-byte binding", () => { stdout: Buffer.alloc(0), stderr: "", })); - const exactHead = "a".repeat(40); + const exactHead = "a".repeat(64); expect(verifyAcquisitionTrackedBytes({ cwd: "/repo", exactHead, spawnSyncImpl: spawn })) .toBe(0); diff --git a/test/acquisition-git-preflight-empty-bytes.test.ts b/test/acquisition-git-preflight-empty-bytes.test.ts new file mode 100644 index 000000000..7a6e045c3 --- /dev/null +++ b/test/acquisition-git-preflight-empty-bytes.test.ts @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { verifyAcquisitionTrackedFileBytes } from "../scripts/lib/acquisition-git-preflight.mjs"; + +function runGit(root: string, args: string[]) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.error) throw result.error; + if (result.signal) throw new Error(`Git fixture command terminated by ${result.signal}`); + if (result.status !== 0) { + throw new Error(`Git fixture command failed: git ${args.join(" ")}\n${result.stderr}`); + } + return result; +} + +describe("acquisition pinned empty-byte authentication", () => { + it.each([undefined, null])("authenticates omitted or null bytes as an empty tracked blob (%s)", (bytes) => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-empty-bytes-")); + try { + runGit(root, ["init", "--quiet"]); + writeFileSync(join(root, "empty.txt"), Buffer.alloc(0)); + runGit(root, ["add", "empty.txt"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "empty tracked blob", + ]); + const exactHead = String(runGit(root, ["rev-parse", "HEAD"]).stdout).trim(); + const objectId = String(runGit(root, ["rev-parse", `${exactHead}:empty.txt`]).stdout).trim(); + + expect(verifyAcquisitionTrackedFileBytes({ + cwd: root, + exactHead, + path: "empty.txt", + bytes, + })).toBe(objectId); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-readiness-scan-toolchain-integrity.test.ts b/test/acquisition-readiness-scan-toolchain-integrity.test.ts index 2cb289c13..48c89b1e4 100644 --- a/test/acquisition-readiness-scan-toolchain-integrity.test.ts +++ b/test/acquisition-readiness-scan-toolchain-integrity.test.ts @@ -32,19 +32,25 @@ describe("acquisition-readiness workflow supply-chain integrity", () => { expect(workflow).not.toContain(" - name: install"); }); - it("revalidates tracked buyer-evidence source before evidence generation", () => { + it("revalidates tracked buyer-evidence source before canonical audit generation", () => { const integrityIndex = workflow.indexOf( " - name: verify tracked acquisition source before evidence generation", ); - const manifestIndex = workflow.indexOf(" - name: build data-room manifest"); + const auditIndex = workflow.indexOf(" - name: run acquisition audit"); expect(integrityIndex).toBeGreaterThan(-1); - expect(manifestIndex).toBeGreaterThan(integrityIndex); + expect(auditIndex).toBeGreaterThan(integrityIndex); - const integrityBlock = workflow.slice(integrityIndex, manifestIndex); + const integrityBlock = workflow.slice(integrityIndex, auditIndex); expect(integrityBlock).toContain("git status --porcelain=v1 --untracked-files=no"); expect(integrityBlock).toContain('git rev-parse HEAD'); expect(integrityBlock).toContain('github.sha'); + const nextStepIndex = workflow.indexOf("\n - name:", auditIndex + 1); + const auditBlock = workflow.slice( + auditIndex, + nextStepIndex === -1 ? workflow.length : nextStepIndex, + ); + expect(auditBlock).toContain("npm run acquisition:audit"); }); it("fails closed when retained acquisition evidence is missing", () => { diff --git a/test/dependency-license-inventory-release-wiring.test.ts b/test/dependency-license-inventory-release-wiring.test.ts index 46f0a82f1..c152094e4 100644 --- a/test/dependency-license-inventory-release-wiring.test.ts +++ b/test/dependency-license-inventory-release-wiring.test.ts @@ -1,5 +1,10 @@ -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { runAcquisitionAudit } from "../scripts/acquisition-audit.mjs"; describe("release dependency-license evidence wiring", () => { it.each(["release:verify", "release:verify:strict"])( @@ -11,21 +16,258 @@ describe("release dependency-license evidence wiring", () => { const script = packageJson.scripts[scriptName]; expect(script).toContain("npm run release:dependency-license-inventory"); - expect(script.indexOf("npm run release:dependency-license-inventory")).toBeLessThan( - script.indexOf("npm run acquisition:manifest"), - ); + expect( + script.indexOf("npm run release:dependency-license-inventory"), + ).toBeLessThan(script.indexOf("npm run acquisition:manifest")); }, ); - it("acquisition:audit refreshes deterministic license evidence before integrity verification", () => { + it("acquisition:audit refreshes the manifest exactly once after deterministic license evidence and before integrity verification", () => { const packageJson = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), ); - const script = packageJson.scripts["acquisition:audit"]; + expect(packageJson.scripts["acquisition:audit"]).toBe( + "node scripts/acquisition-audit.mjs", + ); + }); + + it("direct execution resolves npm CLI and propagates a failed stage status", () => { + const root = fileURLToPath(new URL("..", import.meta.url)); + const temp = mkdtempSync(join(tmpdir(), "noema-acquisition-audit-spawn-")); + const failingNpm = join(temp, "npm-cli.cjs"); + try { + writeFileSync(failingNpm, "process.exit(7);\n", { mode: 0o600 }); + const result = spawnSync(process.execPath, ["scripts/acquisition-audit.mjs"], { + cwd: root, + env: { ...process.env, npm_execpath: failingNpm }, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(7); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("revalidates tracked source after a failed stage before propagating its status", () => { + const root = fileURLToPath(new URL("..", import.meta.url)); + const temp = mkdtempSync(join(tmpdir(), "noema-acquisition-audit-failed-drift-")); + const trackedPath = join(temp, "tracked.txt"); + const failingNpm = join(temp, "npm-cli.cjs"); + try { + expect(spawnSync("git", ["init", "--quiet"], { cwd: temp }).status).toBe(0); + writeFileSync(trackedPath, "original\n", "utf8"); + expect(spawnSync("git", ["add", "tracked.txt"], { cwd: temp }).status).toBe(0); + expect(spawnSync( + "git", + [ + "-c", "user.name=Noema Test", + "-c", "user.email=noema-test@example.invalid", + "commit", "--quiet", "-m", "fixture", + ], + { cwd: temp }, + ).status).toBe(0); + writeFileSync( + failingNpm, + 'require("node:fs").writeFileSync("tracked.txt", "mutated\\n"); process.exit(7);\n', + { mode: 0o600 }, + ); + + const result = spawnSync( + process.execPath, + [join(root, "scripts", "acquisition-audit.mjs")], + { + cwd: temp, + env: { ...process.env, npm_execpath: failingNpm }, + encoding: "utf8", + timeout: 10_000, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "tracked checkout differs from exact HEAD", + ); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("canonicalizes an uppercase exact revision before binding every stage", () => { + const calls: Array<{ env: NodeJS.ProcessEnv }> = []; + runAcquisitionAudit({ + cwd: "/repo", + revision: "A".repeat(64), + env: { npm_execpath: "npm-cli.js" }, + spawn: (_command, _args, options) => { + calls.push({ env: options.env }); + }, + }); + + expect(calls).toHaveLength(5); + expect(calls.every(({ env }) => + env.NOEMA_DATA_ROOM_SOURCE_COMMIT === "a".repeat(64) + && env.NOEMA_DATA_ROOM_OUTPUT_DIR?.endsWith("/" + "a".repeat(64)) + )).toBe(true); + }); - expect(script).toContain("npm run release:dependency-license-inventory"); - expect(script.indexOf("npm run release:dependency-license-inventory")).toBeLessThan( - script.indexOf("npm run acquisition:integrity"), + it("stops before the next stage when the live source revision moves", () => { + const originalRevision = "a".repeat(40); + const movedRevision = "b".repeat(40); + const observedRevisions = [originalRevision, originalRevision, movedRevision]; + const calls: Array<{ args: string[] }> = []; + + expect(() => runAcquisitionAudit({ + cwd: "/repo", + env: { npm_execpath: "npm-cli.js" }, + resolveRevision: () => observedRevisions.shift() ?? movedRevision, + spawn: (_command, args) => { + calls.push({ args }); + }, + })).toThrow("acquisition audit source revision changed during execution"); + + expect(calls.map(({ args }) => args.slice(-2).join(" "))).toEqual([ + "run release:dependency-license-inventory", + ]); + }); + + it("stops after a stage mutates tracked source without moving HEAD", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-acquisition-audit-drift-")); + const trackedPath = join(temp, "tracked.txt"); + const calls: string[] = []; + try { + expect(spawnSync("git", ["init", "--quiet"], { cwd: temp }).status).toBe(0); + writeFileSync(trackedPath, "original\n", "utf8"); + expect(spawnSync("git", ["add", "tracked.txt"], { cwd: temp }).status).toBe(0); + expect(spawnSync( + "git", + [ + "-c", "user.name=Noema Test", + "-c", "user.email=noema-test@example.invalid", + "commit", "--quiet", "-m", "fixture", + ], + { cwd: temp }, + ).status).toBe(0); + + expect(() => runAcquisitionAudit({ + cwd: temp, + env: { npm_execpath: "npm-cli.js" }, + spawn: (_command, args) => { + calls.push(args.slice(-2).join(" ")); + if (calls.length === 1) { + writeFileSync(trackedPath, "mutated\n", "utf8"); + } + }, + })).toThrow(); + + expect(calls).toEqual(["run release:dependency-license-inventory"]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("revalidates mutated source after a nonzero stage before aborting the audit", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-acquisition-audit-failed-drift-")); + const trackedPath = join(temp, "tracked.txt"); + const evidencePath = join(temp, "failed-stage-evidence.json"); + const calls: string[] = []; + try { + expect(spawnSync("git", ["init", "--quiet"], { cwd: temp }).status).toBe(0); + writeFileSync(trackedPath, "original\n", "utf8"); + expect(spawnSync("git", ["add", "tracked.txt"], { cwd: temp }).status).toBe(0); + expect(spawnSync( + "git", + [ + "-c", "user.name=Noema Test", + "-c", "user.email=noema-test@example.invalid", + "commit", "--quiet", "-m", "fixture", + ], + { cwd: temp }, + ).status).toBe(0); + + expect(() => runAcquisitionAudit({ + cwd: temp, + env: { npm_execpath: "npm-cli.js" }, + spawn: (_command, args) => { + calls.push(args.slice(-2).join(" ")); + writeFileSync(evidencePath, '{"status":"FAIL"}\n', "utf8"); + writeFileSync(trackedPath, "mutated\n", "utf8"); + return 7; + }, + })).toThrow("tracked checkout differs from exact HEAD"); + + expect(readFileSync(evidencePath, "utf8")).toBe('{"status":"FAIL"}\n'); + expect(calls).toEqual(["run release:dependency-license-inventory"]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it.each([ + { dataRoomName: undefined, auditName: undefined }, + { dataRoomName: "data-room-output", auditName: undefined }, + { dataRoomName: "conflicting-data-room", auditName: "audit-output" }, + ])( + "shares one acquisition output directory across every audit stage ($dataRoomName, $auditName)", + ({ dataRoomName, auditName }) => { + const root = fileURLToPath(new URL("..", import.meta.url)); + const dataRoomPath = dataRoomName + ? join(root, dataRoomName) + : undefined; + const auditPath = auditName + ? join(root, auditName) + : undefined; + const calls: Array<{ + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + }> = []; + const revision = "a".repeat(40); + runAcquisitionAudit({ + cwd: root, + revision, + env: { + npm_execpath: "npm-cli.js", + ...(dataRoomPath ? { NOEMA_DATA_ROOM_OUTPUT_DIR: dataRoomPath } : {}), + ...(auditPath ? { NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: auditPath } : {}), + }, + spawn: (command, args, options) => { + calls.push({ command, args, env: options.env }); + }, + }); + const expectedPath = + auditPath ?? + dataRoomPath ?? + join(root, "artifacts", "acquisition-readiness", revision); + expect(calls.every(({ command }) => command === process.execPath)).toBe(true); + expect(calls.map(({ args }) => args.slice(-2).join(" "))).toEqual([ + "run release:dependency-license-inventory", + "run acquisition:manifest", + "run acquisition:integrity", + "scripts/acquisition-readiness-audit.mjs", + "run acquisition:deployment-evidence", + ]); + expect(new Set(calls.map(({ env }) => + `${env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR}|${env.NOEMA_DATA_ROOM_OUTPUT_DIR}` + ))).toEqual(new Set([`${expectedPath}|${expectedPath}`])); + expect(new Set(calls.map(({ env }) => env.NOEMA_DATA_ROOM_SOURCE_COMMIT))) + .toEqual(new Set([revision])); + }, + ); + + it("scheduled acquisition scan delegates one audit and never pre-materializes the buyer manifest", () => { + const workflow = readFileSync( + new URL( + "../.github/workflows/acquisition-readiness-scan.yml", + import.meta.url, + ), + "utf8", ); + + expect(workflow.match(/npm run acquisition:audit/g) ?? []).toHaveLength(1); + expect(workflow).not.toContain("npm run acquisition:manifest"); }); }); diff --git a/test/dependency-license-inventory-source-binding.test.ts b/test/dependency-license-inventory-source-binding.test.ts new file mode 100644 index 000000000..eb42e352c --- /dev/null +++ b/test/dependency-license-inventory-source-binding.test.ts @@ -0,0 +1,69 @@ +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = process.cwd(); +const inventoryScript = join(repositoryRoot, "scripts", "dependency-license-inventory.mjs"); + +function run(cwd: string, command: string, args: string[]) { + return spawnSync(command, args, { cwd, encoding: "utf8", timeout: 10_000 }); +} + +function writeLock(path: string, version: string) { + writeFileSync( + path, + `${JSON.stringify({ + name: "dependency-license-source-binding-fixture", + version, + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "dependency-license-source-binding-fixture", + version, + }, + }, + }, null, 2)}\n`, + ); +} + +describe("dependency license inventory exact source binding", () => { + it("rejects lockfile bytes that do not belong to the claimed acquisition commit", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "noema-license-source-binding-"))); + try { + const lockPath = join(directory, "package-lock.json"); + writeLock(lockPath, "1.0.0"); + expect(run(directory, "git", ["init"]).status).toBe(0); + expect(run(directory, "git", ["config", "user.email", "fixture@example.invalid"]).status).toBe(0); + expect(run(directory, "git", ["config", "user.name", "Noema Fixture"]).status).toBe(0); + expect(run(directory, "git", ["add", "package-lock.json"]).status).toBe(0); + expect(run(directory, "git", ["commit", "-m", "fixture"]).status).toBe(0); + const revision = run(directory, "git", ["rev-parse", "HEAD"]); + expect(revision.status, revision.stderr).toBe(0); + const exactHead = revision.stdout.trim(); + + // Simulate a stage-time writer changing the lockfile while the parent audit + // still claims the immutable commit resolved at the start of the run. + writeLock(lockPath, "2.0.0"); + const completed = spawnSync(process.execPath, [inventoryScript], { + cwd: directory, + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + NOEMA_DATA_ROOM_SOURCE_COMMIT: exactHead, + }, + }); + + expect(completed.status).toBe(1); + expect(completed.stderr).toContain("claimed acquisition commit"); + const outputPath = join(directory, "artifacts", "release", "dependency-licenses.json"); + expect(existsSync(outputPath)).toBe(false); + expect(readFileSync(lockPath, "utf8")).toContain('"version": "2.0.0"'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/dependency-license-inventory.test.ts b/test/dependency-license-inventory.test.ts index b40ebcd21..ed87a4176 100644 --- a/test/dependency-license-inventory.test.ts +++ b/test/dependency-license-inventory.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; import { existsSync, linkSync, @@ -261,6 +262,52 @@ describe("dependency license inventory", () => { expect(firstBytes.endsWith("\n")).toBe(true); }); + it("rejects transient lock bytes restored before the inventory stage exits", () => { + const root = mkdtempSync(join(tmpdir(), "noema-license-transient-lock-")); + temporaryRoots.push(root); + const lockPath = join(root, "package-lock.json"); + const outputPath = join(root, "dependency-licenses.json"); + const committedBytes = `${JSON.stringify( + fixtureLock({ "node_modules/alpha": packageRecord() }), + null, + 2, + )}\n`; + const transientBytes = `${JSON.stringify( + fixtureLock({ + "node_modules/alpha": packageRecord({ version: "9.9.9" }), + }), + null, + 2, + )}\n`; + writeFileSync(lockPath, committedBytes, "utf8"); + expect(spawnSync("git", ["init", "--quiet"], { cwd: root }).status).toBe(0); + expect(spawnSync("git", ["add", "package-lock.json"], { cwd: root }).status).toBe(0); + expect(spawnSync( + "git", + ["-c", "user.name=Noema Tests", "-c", "user.email=noema@example.invalid", "commit", "--quiet", "-m", "fixture"], + { cwd: root }, + ).status).toBe(0); + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).stdout.trim(); + + expect(() => generateDependencyLicenseInventory({ + cwd: root, + lockPath: "package-lock.json", + outputPath, + expectedCommitSha: head, + readLock: () => { + writeFileSync(lockPath, transientBytes, "utf8"); + const observed = readFileSync(lockPath, "utf8"); + writeFileSync(lockPath, committedBytes, "utf8"); + return observed; + }, + })).toThrow("tracked acquisition input bytes differ from the claimed acquisition commit's pinned Git tree"); + expect(readFileSync(lockPath, "utf8")).toBe(committedBytes); + expect(existsSync(outputPath)).toBe(false); + }); + it("refuses an oversized lockfile before parsing or authenticating its bytes", () => { const root = mkdtempSync(join(tmpdir(), "noema-license-oversized-lock-")); temporaryRoots.push(root); diff --git a/test/deployment-evidence.test.ts b/test/deployment-evidence.test.ts index 424b995d8..dd6f40bed 100644 --- a/test/deployment-evidence.test.ts +++ b/test/deployment-evidence.test.ts @@ -92,6 +92,15 @@ function validInput() { } describe("deployment evidence", () => { + it("builds evidence for a SHA-256 repository commit", () => { + const input = validInput(); + const sha256Commit = "a".repeat(64); + input.identity.commitSha = sha256Commit; + input.releaseEvidence.source.commitSha = sha256Commit; + + expect(buildDeploymentEvidence(input).source.commitSha).toBe(sha256Commit); + }); + it("parses Wrangler structured NDJSON and rejects command failures", () => { const parsed = parseWranglerOutput([ JSON.stringify({ type: "wrangler-session", timestamp: "2026-08-04T00:00:00Z" }), diff --git a/test/release-evidence.test.ts b/test/release-evidence.test.ts index 8a48b1aed..15a4fbb84 100644 --- a/test/release-evidence.test.ts +++ b/test/release-evidence.test.ts @@ -105,6 +105,17 @@ function runEvidence( } describe("signed release evidence", () => { + it("builds release evidence for a SHA-256 repository commit", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-evidence-sha256-")); + try { + const sha256Commit = "a".repeat(64); + const { result } = runEvidence(temp, validSbom(), undefined, sha256Commit); + expect(result.status).toBe(0); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("builds a checksummed buyer-verifiable release manifest", () => { const temp = mkdtempSync(join(tmpdir(), "noema-release-evidence-")); try { diff --git a/test/release-publication-canonical-commit-sha.test.ts b/test/release-publication-canonical-commit-sha.test.ts index 0593146f5..6001bd6e8 100644 --- a/test/release-publication-canonical-commit-sha.test.ts +++ b/test/release-publication-canonical-commit-sha.test.ts @@ -42,6 +42,14 @@ function runReceipt(commitSha: string) { } describe("release publication exact commit identity", () => { + it("accepts a SHA-256 commit identity before release evidence access", () => { + const result = runReceipt("a".repeat(64)); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("release evidence manifest path must identify the exact release asset"); + expect(result.stderr).not.toContain("release commit SHA must be"); + }); + it.each([ "A".repeat(40), `${"a".repeat(39)}A`, @@ -52,7 +60,7 @@ describe("release publication exact commit identity", () => { expect(result.status).toBe(1); expect(result.stderr).toContain( - "release commit SHA must be the canonical lowercase 40-character hexadecimal identity", + "release commit SHA must be a canonical lowercase full hexadecimal Git identity", ); expect(result.stderr).not.toContain("release evidence manifest could not be read safely"); }); diff --git a/test/workflow-readiness.test.ts b/test/workflow-readiness.test.ts index f6258fd18..69bc430ba 100644 --- a/test/workflow-readiness.test.ts +++ b/test/workflow-readiness.test.ts @@ -123,8 +123,8 @@ describe("deployment workflow readiness gates", () => { expect(workflow).toContain("steps.loop.outputs.remaining_open_pull_request_count == '0'"); expect(workflow).toContain('NOEMA_AUDIT_REPORT_ONLY: "1"'); expect(workflow).toContain("npm run readiness:audit"); - expect(workflow).toContain("npm run acquisition:manifest"); - expect(workflow).toContain("npm run acquisition:audit"); + expect(workflow).not.toContain("npm run acquisition:manifest"); + expect(workflow.match(/npm run acquisition:audit/g)).toHaveLength(1); expect(workflow).toContain("name: commercial-readiness-loop-report"); expect(workflow).toContain("name: no-pr-commercial-readiness-evidence"); expect(workflow).toContain("if: always()"); @@ -148,4 +148,4 @@ describe("deployment workflow readiness gates", () => { expect(workflow).toContain("test (100% line+branch coverage gate)"); expect(workflow).toContain("docstring coverage (100% gate)"); }); -}); +}); \ No newline at end of file