diff --git a/CHANGELOG.md b/CHANGELOG.md index 437fbcb39..4d69dff52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,15 @@ # Changelog ## Unreleased +- `writeAcquisitionPrivateFile`의 기존 대상 사전-교체 검증 read(`existingDescriptor` open)에 `O_NONBLOCK`을 추가해 fail-closed를 강화한다. 이 open은 이미 필수 filesystem capability로 `O_NONBLOCK`을 검증했지만 실제로는 사용하지 않아, 로컬 권한을 가진 행위자가 사전 `lstatSync` 정규 파일 확인과 이 open 사이에 대상 경로를 FIFO로 교체하면 writer가 나타날 때까지 무한정 블로킹해 writer lease를 계속 점유할 수 있었다. `O_NONBLOCK`은 정규 파일에는 영향이 없고, FIFO에서는 open이 즉시 반환되어 이어지는 descriptor 타입 검증이 그대로 fail-closed로 거부한다. 회귀 테스트(`test/acquisition-private-output-existing-target-nonblocking.test.ts`)와 기존 open-flags 계약 테스트 갱신으로 고정했다. +- `readStableFile`의 close-후 재검증 단계(`afterClosePath` lookup 실패)와 `writeAcquisitionPrivateFile`의 cleanup-시점 `O_NONBLOCK` 소실 분기에 대한 fail-closed 회귀 테스트를 추가해 `scripts/lib/acquisition-data-room-integrity.mjs`/`scripts/lib/acquisition-private-output.mjs`의 100% coverage 게이트를 복구한다. 동작 변화는 없다. - Noema reviewer의 strict changed-file evidence를 historical 12-file prefix에서 canonical 80-file CodeGraph scope와 일치시켰다. 13–80 file PR은 선택된 모든 current-head file context를 유지하고 81개 이상은 기존처럼 실패-폐쇄하며, local CodeGraph fallback의 `HOME`·`TEMP`·`TMP`·`TMPDIR`은 ambient host path를 상속하지 않고 실행마다 새 private temporary directory로 격리한다. - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. - 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - 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. +- revenue/transfer acquisition evidence의 `source_documents`를 임의 문자열 label 대신 stable retained artifact의 `{path, sha256}` binding으로 검증한다. Digest 일치는 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한은 계속 별도 buyer evidence로 요구한다. - 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는 유지한다. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9f287d5e7..a57fd95ed 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -57,7 +57,7 @@ Each arrow is a separate authority. Success at an earlier stage cannot fabricate | Credential/security coverage truth | protected main | protected `src/index.ts`, `docs/TEST_STRATEGY.md` and coverage contracts | exact configured 100% statement/branch/function/line gates; no broad credential/security V8-ignore contract | current protected-main CI remains observation-scoped | Implemented on protected main | | Patch-validator image supply chain | issue #66 + protected implementation | `Dockerfile.patch-validator`, image workflow, validator runtime/profile, SBOM/scanner/receipt validators | exact build/runtime/smoke/SBOM/vulnerability/receipt/final-head verification | protected-main operational receipt and later publication/signing/activation evidence | Source/runtime/supply-chain implementation is integrated on protected main; later operational/publication authority remains separate | | Licensing/IP authority | licensing/IP contract | rights/evidence validators | duplicate-key/UTF-8/exact-artifact and rights-metadata tests | owner/legal grant and transfer evidence | Technical controls exist; legal authority external | -| Release/acquisition readiness | release/provenance/acquisition contracts | release verification and evidence scripts | exact-source package/SBOM/provenance/readiness tests | immutable release/deployment/customer/revenue/legal evidence | Incomplete; no readiness claim from docs alone | +| Release/acquisition readiness | release/provenance/acquisition contracts | release verification and evidence scripts, digest-bound revenue/transfer source documents | exact-source package/SBOM/provenance/readiness and retained-source byte-integrity tests | immutable release/deployment/customer/revenue/legal authority | Technical byte binding implemented; commercial/legal authenticity remains external | ## 3. Live governance traceability diff --git a/docs/acquisition-readiness-2b.md b/docs/acquisition-readiness-2b.md index d42159839..d8e349f43 100644 --- a/docs/acquisition-readiness-2b.md +++ b/docs/acquisition-readiness-2b.md @@ -141,13 +141,13 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 - security evidence: `artifacts/security/security-validation-evidence.json` (`npm run security:evidence`로 단독 검증) - production pilot log: `docs/pilot-readiness-log.md` 또는 `NOEMA_PILOT_LOG_PATH` - saleable readiness evidence: `artifacts/saleable-readiness//goal-audit.json` -- revenue/transfer evidence는 `owner`, `source_documents`, 최근 `updated_at`을 포함해야 한다. +- revenue/transfer evidence는 `owner`, 최근 `updated_at`, 그리고 1~32개의 retained `{path, sha256}` 레코드로 구성된 `source_documents`를 포함해야 한다. `path`는 canonical repository-relative evidence 경로여야 하고 `sha256`은 그 보존 파일의 64-hex SHA-256이어야 한다. 이 digest 검증은 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한을 대신하지 않는다. - `updated_at`은 기본 45일 이내 증빙이어야 하며, 필요 시 `NOEMA_ACQUISITION_EVIDENCE_MAX_AGE_DAYS`로 조정한다. - Strategic pipeline route는 `buyer_due_diligence_qna`에 구매자별 보안/운영 실사 Q&A 로그 경로를 1개 이상 포함해야 한다. - production pilot log는 production HTTPS `NOEMA URL`, `증빙 출처: production`, KPI threshold, trace sample, support channel, 계약/매출 증빙 경로가 있는 완료 항목 1건 이상을 요구한다. - 작성 템플릿은 `docs/evidence-templates/revenue-evidence.example.json`, `docs/evidence-templates/transfer-evidence.example.json`에 둔다. 템플릿은 `artifacts/acquisition/*.json`으로 복사한 뒤 placeholder를 실제 owner/source/evidence 값으로 교체해야 한다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 `npm run acquisition:audit`에서 evidence로 인정하지 않는다. -예시는 다음과 같다. +예시는 형식 설명용이다. 실제 제출에서는 예시 digest를 해당 retained bytes의 SHA-256으로 교체하고, `updated_at`도 제출 시점의 freshness window(기본 45일) 안에 있는 실제 증빙 갱신일로 반드시 교체해야 한다. ```json { @@ -160,11 +160,17 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 "crm:noema-enterprise-security-qna" ], "customer_concentration_top1": 0.5, - "updated_at": "2026-07-02", + "updated_at": "2026-09-01", "owner": "finance", "source_documents": [ - "crm:noema-arr-report", - "contracts/noema-paid-customers.pdf" + { + "path": "artifacts/acquisition/source-records/noema-arr-report.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "path": "artifacts/acquisition/source-records/noema-paid-customers.pdf", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } ] } ``` @@ -178,11 +184,17 @@ Product Design 기준으로 구매자와 파일럿 고객이 제품 가치를 "secrets_rotation_plan": "pass", "owner_transfer_plan": "pass", "privacy_review": "pass", - "updated_at": "2026-07-02", + "updated_at": "2026-09-01", "owner": "legal", "source_documents": [ - "docs/buyer-due-diligence-index.md", - "legal/noema-transfer-review.pdf" + { + "path": "legal/noema-transfer-review.pdf", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + { + "path": "legal/noema-ip-assignment-register.pdf", + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } ] } ``` diff --git a/docs/buyer-due-diligence-index.md b/docs/buyer-due-diligence-index.md index 19752bfde..1dbcc1011 100644 --- a/docs/buyer-due-diligence-index.md +++ b/docs/buyer-due-diligence-index.md @@ -86,7 +86,7 @@ Production 파일럿 로그는 `npm run acquisition:audit`에서도 직접 검 ## Commercial -`artifacts/acquisition/revenue-evidence.json`에는 `owner`, `source_documents`, 기본 45일 이내 `updated_at`이 있어야 한다. +`artifacts/acquisition/revenue-evidence.json`에는 `owner`, 기본 45일 이내 `updated_at`, 그리고 1~32개의 retained source binding으로 구성된 `source_documents`가 있어야 한다. 각 항목은 canonical repository-relative `path`와 그 보존 파일 bytes의 64-hex `sha256`을 담는 `{path, sha256}` 레코드여야 하며 placeholder나 template 경로는 인정하지 않는다. SHA-256 일치는 byte integrity일 뿐 CRM·계약·지급·법률 기록의 진실성 또는 승인 권한은 별도 authoritative evidence다. 작성 템플릿은 `docs/evidence-templates/revenue-evidence.example.json`이다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 evidence로 인정하지 않는다. | 항목 | Evidence | 상태 | @@ -100,7 +100,7 @@ Production 파일럿 로그는 `npm run acquisition:audit`에서도 직접 검 ## Transfer -`artifacts/acquisition/transfer-evidence.json`에는 `owner`, `source_documents`, 기본 45일 이내 `updated_at`이 있어야 한다. +`artifacts/acquisition/transfer-evidence.json`에는 `owner`, 기본 45일 이내 `updated_at`, 그리고 1~32개의 retained source binding으로 구성된 `source_documents`가 있어야 한다. 각 항목은 canonical repository-relative `path`와 그 보존 파일 bytes의 lowercase/uppercase 64-hex `sha256`을 담는 `{path, sha256}` 레코드여야 하며 placeholder나 template 경로는 인정하지 않는다. SHA-256 일치는 보존 bytes의 무결성만 증명하고, 법률·IP·계정 이전 기록의 진실성이나 승인 권한은 별도 authoritative evidence로 확인해야 한다. 작성 템플릿은 `docs/evidence-templates/transfer-evidence.example.json`이다. `replace-with-*`, `.example.json`, `docs/evidence-templates/` 값은 evidence로 인정하지 않는다. | 항목 | Evidence | 상태 | diff --git a/docs/evidence-templates/revenue-evidence.example.json b/docs/evidence-templates/revenue-evidence.example.json index 20ac49d5d..c993e7bc1 100644 --- a/docs/evidence-templates/revenue-evidence.example.json +++ b/docs/evidence-templates/revenue-evidence.example.json @@ -11,7 +11,13 @@ "updated_at": "replace-with-YYYY-MM-DD", "owner": "replace-with-finance-or-sales-owner", "source_documents": [ - "replace-with-crm-arr-report", - "replace-with-contract-or-loi-path" + { + "path": "replace-with-retained-crm-arr-report-path", + "sha256": "replace-with-retained-crm-arr-report-sha256" + }, + { + "path": "replace-with-retained-contract-or-loi-path", + "sha256": "replace-with-retained-contract-or-loi-sha256" + } ] } diff --git a/docs/evidence-templates/transfer-evidence.example.json b/docs/evidence-templates/transfer-evidence.example.json index 61b15506f..da9754670 100644 --- a/docs/evidence-templates/transfer-evidence.example.json +++ b/docs/evidence-templates/transfer-evidence.example.json @@ -9,8 +9,14 @@ "updated_at": "replace-with-YYYY-MM-DD", "owner": "replace-with-legal-or-security-owner", "source_documents": [ - "replace-with-license-review-path", - "replace-with-transfer-runbook-or-approval-path" + { + "path": "replace-with-retained-license-review-path", + "sha256": "replace-with-retained-license-review-sha256" + }, + { + "path": "replace-with-retained-transfer-approval-path", + "sha256": "replace-with-retained-transfer-approval-sha256" + } ], "licensing_ip": { "owner_legal_decision": { @@ -61,4 +67,4 @@ ] } } -} \ No newline at end of file +} diff --git a/scripts/acquisition-readiness-audit.mjs b/scripts/acquisition-readiness-audit.mjs index c9d8e9493..b8d2374bf 100644 --- a/scripts/acquisition-readiness-audit.mjs +++ b/scripts/acquisition-readiness-audit.mjs @@ -17,6 +17,7 @@ import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evi const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); const isoDateOrTimestampRegex = /^(\d{4}-\d{2}-\d{2})(?:T(?:[01]\d|2[0-3]):\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/; const MAX_ISO_UTC_OFFSET_MS = 14 * 60 * 60 * 1000; +const MAX_SOURCE_DOCUMENTS = 32; const now = new Date().toISOString(); const configuredOutputDir = process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR; if (configuredOutputDir) { @@ -198,8 +199,15 @@ function validateEvidenceMetadata(value) { } else if (isPlaceholderEvidence(value.owner)) { failures.push("owner cannot be a placeholder"); } - const sourceDocuments = validateEvidenceRefs(value.source_documents, "source_documents"); - failures.push(...sourceDocuments.failures); + if (!Array.isArray(value.source_documents) || value.source_documents.length === 0) { + failures.push("source_documents must contain at least one retained artifact binding"); + } else if (value.source_documents.length > MAX_SOURCE_DOCUMENTS) { + failures.push(`source_documents must contain at most ${MAX_SOURCE_DOCUMENTS} artifact bindings`); + } else { + value.source_documents.forEach((document, index) => { + validateDigestBoundArtifact(document, `source_documents[${index}]`, failures); + }); + } if (!updatedAt || Number.isNaN(updatedAtMs)) { failures.push("updated_at must be an ISO date or timestamp"); } else if (updatedAtMs > futureBoundaryMs) { @@ -864,4 +872,4 @@ if (!output.passed) { } process.exit(1); } -} \ No newline at end of file +} diff --git a/scripts/lib/acquisition-data-room-integrity.mjs b/scripts/lib/acquisition-data-room-integrity.mjs index 89140c7a3..07b2f7abc 100644 --- a/scripts/lib/acquisition-data-room-integrity.mjs +++ b/scripts/lib/acquisition-data-room-integrity.mjs @@ -184,6 +184,7 @@ function isSafeRegularMetadata(metadata, maximumBytes) { && typeof metadata.isSymbolicLink === "function" && metadata.isFile() && !metadata.isSymbolicLink() + && (metadata.nlink === undefined || metadata.nlink === 1) && Number.isSafeInteger(metadata.size) && metadata.size >= 0 && metadata.size <= maximumBytes, @@ -203,13 +204,16 @@ function sameIdentity(left, right) { } /** - * Read a bounded regular file through O_NOFOLLOW and require path/descriptor - * identity to remain stable before and after the complete read. The returned - * bytes are suitable for hashing or fatal UTF-8 decoding; unsafe evidence is - * represented as null rather than partially trusted data. + * Read a bounded single-link regular file through O_NOFOLLOW and require path/descriptor + * identity to remain stable before, during, and after the complete read and descriptor + * close. The returned bytes are suitable for hashing or fatal UTF-8 decoding; unsafe + * evidence is represented as null rather than partially trusted data. Injectable test + * metadata may omit nlink; real filesystem metadata must report exactly one link. */ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES, fileSystem = defaultFileSystem) { let descriptor = null; + let opened = null; + let result = null; try { if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { return null; @@ -224,7 +228,7 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES return null; } descriptor = fileSystem.openSync(path, readOnly | noFollow); - const opened = fileSystem.fstatSync(descriptor); + opened = fileSystem.fstatSync(descriptor); if (!isSafeRegularMetadata(opened, maximumBytes) || !sameIdentity(before, opened)) { return null; } @@ -248,7 +252,7 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES if (!sameIdentity(opened, afterDescriptor) || !sameIdentity(opened, afterPath)) { return null; } - return bytes; + result = bytes; } catch { return null; } finally { @@ -256,11 +260,24 @@ export function readStableFile(path, maximumBytes = MAX_DATA_ROOM_EVIDENCE_BYTES try { fileSystem.closeSync(descriptor); } catch { - // A failed close cannot make evidence more trustworthy; the read result - // is already bounded and callers remain fail-closed on validation. + result = null; + } + } + if (result !== null && opened !== null) { + try { + const afterClosePath = fileSystem.lstatSync(path); + if ( + !isSafeRegularMetadata(afterClosePath, maximumBytes) + || !sameIdentity(opened, afterClosePath) + ) { + result = null; + } + } catch { + result = null; } } } + return result; } function canonicalRelativePath(rootDir, candidate) { diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index dd4224f10..931cf6d5f 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -91,10 +91,63 @@ function cleanupIdentityMatchedPath(path, expectedMetadata, fileSystem) { fileSystem.unlinkSync(path); } } catch { - // Preserve the original write/validation error. Cleanup authority requires - // unchanged real-directory parent traversal plus the same safe single-link - // inode at deletion time; an unsafe parent, replaced pathname, or unsafe - // multi-link/non-file object is never unlinked. + // Lock and staging cleanup is best-effort only. Final evidence paths use + // descriptor-bound neutralization below so cleanup can never unlink a + // concurrent replacement after a pathname identity check. + } +} + +function neutralizeIdentityMatchedPath(path, expectedMetadata, fileSystem) { + if ( + !safeOutputMetadata(expectedMetadata) + || typeof fileSystem.openSync !== "function" + || typeof fileSystem.fstatSync !== "function" + || typeof fileSystem.ftruncateSync !== "function" + || typeof fileSystem.closeSync !== "function" + ) { + return; + } + + const writeOnly = fileSystem.constants?.O_WRONLY; + const noFollow = fileSystem.constants?.O_NOFOLLOW; + const nonBlocking = fileSystem.constants?.O_NONBLOCK; + if ( + !Number.isInteger(writeOnly) + || !Number.isInteger(noFollow) + || !Number.isInteger(nonBlocking) + ) { + return; + } + + let descriptor = null; + try { + assertAcquisitionPrivatePathParents(path, fileSystem); + descriptor = fileSystem.openSync(path, writeOnly | noFollow | nonBlocking); + const opened = fileSystem.fstatSync(descriptor); + const retained = fileSystem.lstatSync(path, { throwIfNoEntry: false }) ?? null; + assertAcquisitionPrivatePathParents(path, fileSystem); + if ( + safeOutputMetadata(opened) + && safeOutputMetadata(retained) + && sameOutputIdentity(expectedMetadata, opened) + && sameOutputIdentity(opened, retained) + ) { + fileSystem.ftruncateSync(descriptor, 0); + } + } catch { + // Preserve the original write/validation failure. The cleanup descriptor is + // bound before the final pathname check; if the pathname is concurrently + // replaced, only the writer-owned inode can be truncated and the replacement + // remains untouched. O_NONBLOCK also prevents special-file replacements from + // stalling best-effort cleanup before descriptor type validation can run. + } finally { + if (descriptor !== null) { + try { + fileSystem.closeSync(descriptor); + } catch { + // Cleanup close failure does not replace the original operation error. + } + } } } @@ -237,7 +290,7 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { closeError = error; } if (!accepted || closeFailed) { - cleanupIdentityMatchedPath(path, createdMetadata, fileSystem); + neutralizeIdentityMatchedPath(path, createdMetadata, fileSystem); } } if (closeFailed && !operationFailed) { @@ -249,8 +302,13 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { * Write one UTF-8 acquisition evidence file without following a pre-existing * symbolic link or silently switching filesystem objects during the write. * Existing regular files must have a single hard link and are version-checked - * through a read-only no-follow descriptor without mutating their bytes or - * metadata before replacement commits. Replacement bytes are written completely + * through a read-only no-follow non-blocking descriptor without mutating their + * bytes or metadata before replacement commits. The non-blocking open flag + * keeps a locally authorized actor from wedging the writer lease indefinitely + * by racing the pre-open regular-file check with a FIFO substitution: opening + * a FIFO for read-only without O_NONBLOCK blocks until a writer appears, but + * with O_NONBLOCK the open returns immediately and the subsequent descriptor + * type check then fails closed instead of hanging. Replacement bytes are written completely * to an owner-only, exclusive sibling file and atomically renamed over the * unchanged verified target only after the write succeeds. A same-target writer * lease is held from the first target inspection through replacement acceptance, @@ -263,20 +321,21 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { * against its pre-rename identity, mode, size, and mtime before acceptance. POSIX * rename may itself advance ctime, so ctime remains an exact guard before rename * but is not compared across the rename operation. If the writer-owned inode - * changes at the final handoff, the operation fails closed and removes it only - * when the target pathname still names that exact safe single-link inode. A - * failed or stale replacement therefore cannot truncate, chmod, partially - * overwrite, or silently clobber a concurrent update to trusted prior evidence. - * A safe existing target may itself be read-only because replacement authority - * comes from the containing directory; verification never requires write access - * to the old inode. Newly created targets use O_EXCL directly and remove their - * identity-matched leaf only while parent traversal still resolves through real - * directories and the created metadata remains safe single-link deletion - * authority. Existing parent components are required to be real directories, - * never symbolic links or non-directory objects, and the configured output path - * must already be lexically canonical before and immediately after each - * leaf/staging open and again before a new file is accepted or an existing target - * is atomically replaced. + * changes at the final handoff, the operation fails closed and neutralizes only + * the writer-owned inode through a no-follow descriptor; it never unlinks a + * concurrent replacement after a pathname check. A failed or stale replacement + * therefore cannot truncate, chmod, partially overwrite, or silently clobber a + * concurrent update to trusted prior evidence. A safe existing target may itself + * be read-only because replacement authority comes from the containing directory; + * verification never requires write access to the old inode. Newly created + * targets use O_EXCL directly; failed publication leaves an identity-bound + * non-authoritative leaf (truncated when the writer inode can still be proven) + * for operator inspection rather than deleting by pathname. Existing parent + * components are required to be real directories, never symbolic links or + * non-directory objects, and the configured output path must already be + * lexically canonical before and immediately after each leaf/staging open and + * again before a new file is accepted or an existing target is atomically + * replaced. */ export function writeAcquisitionPrivateFile( path, @@ -292,8 +351,11 @@ export function writeAcquisitionPrivateFile( const create = fileSystem.constants?.O_CREAT; const exclusive = fileSystem.constants?.O_EXCL; const noFollow = fileSystem.constants?.O_NOFOLLOW; - if (![readOnly, writeOnly, create, exclusive, noFollow].every(Number.isInteger)) { - throw new Error("acquisition output requires no-follow filesystem support"); + const nonBlocking = fileSystem.constants?.O_NONBLOCK; + if (![readOnly, writeOnly, create, exclusive, noFollow, nonBlocking].every(Number.isInteger)) { + throw new Error( + "acquisition output requires no-follow filesystem support; non-blocking filesystem support is required for cleanup", + ); } assertAcquisitionPrivatePathParents(path, fileSystem); @@ -326,7 +388,7 @@ export function writeAcquisitionPrivateFile( throw new Error("acquisition output replacement requires atomic rename filesystem support"); } - const existingDescriptor = fileSystem.openSync(path, readOnly | noFollow); + const existingDescriptor = fileSystem.openSync(path, readOnly | noFollow | nonBlocking); try { assertAcquisitionPrivatePathParents(path, fileSystem); const opened = fileSystem.fstatSync(existingDescriptor); @@ -403,7 +465,7 @@ export function writeAcquisitionPrivateFile( if (staged && stagedMetadata) { cleanupIdentityMatchedPath(tempPath, stagedMetadata, fileSystem); } else if (replacementCommitted && !replacementAccepted && stagedMetadata) { - cleanupIdentityMatchedPath(path, stagedMetadata, fileSystem); + neutralizeIdentityMatchedPath(path, stagedMetadata, fileSystem); } } } finally { diff --git a/test/acquisition-artifact-rights-json.test.ts b/test/acquisition-artifact-rights-json.test.ts index 7048a586f..bc6c76b17 100644 --- a/test/acquisition-artifact-rights-json.test.ts +++ b/test/acquisition-artifact-rights-json.test.ts @@ -88,7 +88,11 @@ describe("acquisition artifact-rights JSON evidence", () => { "artifacts/acquisition/transfer-evidence.json", `${JSON.stringify({ owner: "Acquisition counsel", - source_documents: ["legal/review-record.pdf"], + source_documents: [digestArtifact( + root, + "artifacts/acquisition/transfer-source.json", + '{"source":"test-counsel-record"}\n', + )], updated_at: new Date().toISOString(), license_review: "pass", third_party_review: "pass", diff --git a/test/acquisition-data-room-integrity-branches.test.ts b/test/acquisition-data-room-integrity-branches.test.ts index 4903ad75e..356294ef8 100644 --- a/test/acquisition-data-room-integrity-branches.test.ts +++ b/test/acquisition-data-room-integrity-branches.test.ts @@ -224,6 +224,17 @@ describe("acquisition data-room integrity defensive branches", () => { throw new Error("post-read path lookup failed"); }); expect(readStableFile("ignored", 8, afterPath)).toBeNull(); + + const afterClose = fileSystemFor(stable, stable); + afterClose.lstatSync + .mockReturnValueOnce(stable) + .mockReturnValueOnce(stable) + .mockImplementationOnce(() => { + throw new Error("post-close path lookup failed"); + }); + expect(readStableFile("ignored", 8, afterClose)).toBeNull(); + expect(afterClose.closeSync).toHaveBeenCalledWith(7); + expect(afterClose.lstatSync).toHaveBeenCalledTimes(3); }); it.each([ diff --git a/test/acquisition-evidence-iso-date.test.ts b/test/acquisition-evidence-iso-date.test.ts index ad9e0e767..a981173da 100644 --- a/test/acquisition-evidence-iso-date.test.ts +++ b/test/acquisition-evidence-iso-date.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -43,6 +44,8 @@ function prepareAuditRoot(prefix: string): string { } function runAuditWithRevenueTimestamp(root: string, updatedAt: string, nowMs?: number) { + const sourceBytes = '{"source":"test-ledger"}\n'; + writeFixture(root, "artifacts/acquisition/revenue-source.json", sourceBytes); const revenuePath = writeFixture(root, "revenue.json", JSON.stringify({ arr_krw: 300_000_000, gross_margin: 0.75, @@ -52,7 +55,10 @@ function runAuditWithRevenueTimestamp(root: string, updatedAt: string, nowMs?: n customer_concentration_top1: 0.5, updated_at: updatedAt, owner: "finance", - source_documents: ["crm:noema-arr-report"], + source_documents: [{ + path: "artifacts/acquisition/revenue-source.json", + sha256: createHash("sha256").update(sourceBytes).digest("hex"), + }], })); const outputDir = join(root, "audit-output"); const inheritedEnvironment = Object.fromEntries( diff --git a/test/acquisition-private-output-atomic-coverage.test.ts b/test/acquisition-private-output-atomic-coverage.test.ts index 9d2da8e39..3885a5887 100644 --- a/test/acquisition-private-output-atomic-coverage.test.ts +++ b/test/acquisition-private-output-atomic-coverage.test.ts @@ -44,7 +44,14 @@ function existingFileSystem({ let outputRead = 0; let fstatRead = 0; return { - constants: { O_RDONLY: 0, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 0, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 16, + }, lstatSync: vi.fn((path: string) => { if (path === "output") { return outputReads[outputRead++] ?? null; diff --git a/test/acquisition-private-output-atomic-replace.test.ts b/test/acquisition-private-output-atomic-replace.test.ts index 6465a4d45..87b1f8aa1 100644 --- a/test/acquisition-private-output-atomic-replace.test.ts +++ b/test/acquisition-private-output-atomic-replace.test.ts @@ -52,7 +52,7 @@ describe.skipIf(process.platform === "win32")( } }); - it("fails closed if the staged inode changes after atomic rename", () => { + it("neutralizes the writer-owned replacement if its version changes after atomic rename", () => { const root = mkdtempSync(join(tmpdir(), "noema-private-post-rename-")); const output = join(root, "evidence.json"); try { @@ -89,7 +89,8 @@ describe.skipIf(process.platform === "win32")( mutatingFileSystem as never, )).toThrow("acquisition output path changed during atomic replacement"); expect(renameObserved).toBe(true); - expect(lstatSync(output, { throwIfNoEntry: false })).toBeUndefined(); + expect(lstatSync(output, { throwIfNoEntry: false })).toBeDefined(); + expect(readFileSync(output, "utf8")).toBe(""); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/test/acquisition-private-output-close-cleanup.test.ts b/test/acquisition-private-output-close-cleanup.test.ts index 013715f0f..1767a704c 100644 --- a/test/acquisition-private-output-close-cleanup.test.ts +++ b/test/acquisition-private-output-close-cleanup.test.ts @@ -31,7 +31,14 @@ function newFileSystem({ writeFails = false } = {}) { let outputReads = 0; let descriptorReads = 0; return { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, + }, lstatSync: vi.fn((path: string) => { if (path === "output") { outputReads += 1; @@ -58,19 +65,21 @@ function newFileSystem({ writeFails = false } = {}) { } describe("acquisition private output close failure cleanup", () => { - it("removes an identity-matched new output when close fails after a successful write", () => { + it("neutralizes an identity-matched new output when close fails after a successful write", () => { const fileSystem = newFileSystem(); expect(() => writeAcquisitionPrivateFile("output", "replacement\n", fileSystem as never)) .toThrow("close failed"); - expect(fileSystem.unlinkSync).toHaveBeenCalledWith("output"); + expect(fileSystem.ftruncateSync).toHaveBeenCalled(); + expect(fileSystem.unlinkSync).not.toHaveBeenCalledWith("output"); }); - it("preserves the original write error while still cleaning up when close also fails", () => { + it("preserves the original write error while neutralizing when close also fails", () => { const fileSystem = newFileSystem({ writeFails: true }); expect(() => writeAcquisitionPrivateFile("output", "replacement\n", fileSystem as never)) .toThrow("write failed"); - expect(fileSystem.unlinkSync).toHaveBeenCalledWith("output"); + expect(fileSystem.ftruncateSync).toHaveBeenCalled(); + expect(fileSystem.unlinkSync).not.toHaveBeenCalledWith("output"); }); }); diff --git a/test/acquisition-private-output-existing-target-metadata.test.ts b/test/acquisition-private-output-existing-target-metadata.test.ts index 56320fa95..4ad63837f 100644 --- a/test/acquisition-private-output-existing-target-metadata.test.ts +++ b/test/acquisition-private-output-existing-target-metadata.test.ts @@ -7,6 +7,7 @@ const constants = { O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8, + O_NONBLOCK: 16, }; function directoryMetadata() { diff --git a/test/acquisition-private-output-existing-target-nonblocking.test.ts b/test/acquisition-private-output-existing-target-nonblocking.test.ts new file mode 100644 index 000000000..a6427c41e --- /dev/null +++ b/test/acquisition-private-output-existing-target-nonblocking.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +const constants = { + O_RDONLY: 0, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 16, +}; + +function directoryMetadata() { + return { + isDirectory: () => true, + isSymbolicLink: () => false, + }; +} + +function fileMetadata(ino: number) { + return { + dev: 1, + ino, + nlink: 1, + isFile: () => true, + isSymbolicLink: () => false, + }; +} + +function fifoMetadata() { + return { + dev: 1, + ino: 99, + nlink: 1, + isFile: () => false, + isSymbolicLink: () => false, + }; +} + +describe("acquisition private-output existing-target open is non-blocking", () => { + it("opens the pre-replacement verification read with O_NONBLOCK", () => { + const targetPath = "/tmp/noema-acquisition/report.json"; + const existing = fileMetadata(10); + const io = { + constants, + lstatSync: vi.fn((path: string) => (path === targetPath ? existing : directoryMetadata())), + openSync: vi.fn(() => 41), + fstatSync: vi.fn(() => existing), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(() => { + throw new Error("stop after existing-target verification"); + }), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile(targetPath, "replacement", io)).toThrow(); + + const existingTargetOpen = io.openSync.mock.calls.find(([path]) => path === targetPath); + expect(existingTargetOpen).toBeDefined(); + const [, flags] = existingTargetOpen as [string, number]; + expect(flags & constants.O_NONBLOCK).toBe(constants.O_NONBLOCK); + }); + + it("fails closed instead of hanging when the target is replaced with a FIFO before the verification open", () => { + // A locally authorized actor can race the pre-open lstat check (which still + // observed a regular file) with a substitution of the target path for a + // FIFO. Without O_NONBLOCK, a read-only open of a FIFO blocks until a + // writer appears -- wedging this call, and the writer lease it holds, + // indefinitely. With O_NONBLOCK the open returns immediately and the + // descriptor-type check below fails closed instead. + const targetPath = "/tmp/noema-acquisition/report.json"; + const existing = fileMetadata(10); + const fifo = fifoMetadata(); + const io = { + constants, + lstatSync: vi.fn((path: string) => (path === targetPath ? existing : directoryMetadata())), + openSync: vi.fn(() => 41), + fstatSync: vi.fn(() => fifo), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile(targetPath, "replacement", io)).toThrow( + "acquisition output path changed before writing", + ); + expect(io.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/test/acquisition-private-output-filesystem-capability.test.ts b/test/acquisition-private-output-filesystem-capability.test.ts new file mode 100644 index 000000000..91543c1cd --- /dev/null +++ b/test/acquisition-private-output-filesystem-capability.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +function fileMetadata() { + return { + dev: 1, + ino: 2, + mode: 0o100600, + size: 5, + mtimeMs: 1, + ctimeMs: 1, + nlink: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + }; +} + +function directoryMetadata() { + return { + ...fileMetadata(), + isFile: () => false, + isDirectory: () => true, + }; +} + +describe("acquisition private output filesystem capability", () => { + it("rejects adapters without non-blocking cleanup support before output creation", () => { + let outputReads = 0; + const openSync = vi.fn(() => 17); + const fileSystem = { + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + }, + lstatSync: vi.fn((path: string) => { + if (path === "output") { + outputReads += 1; + return outputReads === 1 ? null : fileMetadata(); + } + return directoryMetadata(); + }), + openSync, + fstatSync: vi.fn(() => fileMetadata()), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("non-blocking filesystem support"); + expect(openSync).not.toHaveBeenCalled(); + }); +}); diff --git a/test/acquisition-private-output-new-file-failure-cleanup.test.ts b/test/acquisition-private-output-new-file-failure-cleanup.test.ts index 650c97b88..a947ea153 100644 --- a/test/acquisition-private-output-new-file-failure-cleanup.test.ts +++ b/test/acquisition-private-output-new-file-failure-cleanup.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { closeSync, constants, @@ -8,6 +9,7 @@ import { lstatSync, mkdtempSync, openSync, + readFileSync, rmSync, unlinkSync, writeFileSync as fsWriteFileSync, @@ -19,7 +21,7 @@ import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private- describe("acquisition private output new-file failure cleanup", () => { it.skipIf(process.platform === "win32")( - "removes the identity-matched partial leaf when a new private write fails", + "neutralizes the identity-matched partial leaf when a new private write fails", () => { const directory = mkdtempSync(join(tmpdir(), "noema-private-new-failure-")); const output = join(directory, "evidence.json"); @@ -41,7 +43,8 @@ describe("acquisition private output new-file failure cleanup", () => { try { expect(() => writeAcquisitionPrivateFile(output, "complete\n", fileSystem as never)) .toThrow("simulated acquisition write failure"); - expect(existsSync(output)).toBe(false); + expect(existsSync(output)).toBe(true); + expect(readFileSync(output, "utf8")).toBe(""); } finally { rmSync(directory, { recursive: true, force: true }); } @@ -76,4 +79,105 @@ describe("acquisition private output new-file failure cleanup", () => { } }, ); + + it.skipIf(process.platform === "win32")( + "preserves a replacement installed after failed-output cleanup observes the writer inode", + () => { + const directory = mkdtempSync(join(tmpdir(), "noema-private-new-cleanup-race-")); + const output = join(directory, "evidence.json"); + let replaced = false; + const fileSystem = { + constants, + lstatSync(path: Parameters[0], options?: Parameters[1]) { + const metadata = lstatSync(path, options as never); + if (String(path) === output && metadata && !replaced) { + unlinkSync(output); + fsWriteFileSync(output, "concurrent-evidence\n", { encoding: "utf8", mode: 0o600 }); + replaced = true; + } + return metadata; + }, + openSync, + fstatSync, + fchmodSync, + ftruncateSync, + closeSync, + unlinkSync, + writeFileSync(descriptor: number) { + fsWriteFileSync(descriptor, "partial\n", { encoding: "utf8" }); + throw new Error("simulated acquisition write failure"); + }, + }; + + try { + expect(() => writeAcquisitionPrivateFile(output, "complete\n", fileSystem as never)) + .toThrow("simulated acquisition write failure"); + expect(replaced).toBe(true); + expect(readFileSync(output, "utf8")).toBe("concurrent-evidence\n"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "returns promptly when failed output is replaced by a FIFO before cleanup", + () => { + const moduleUrl = new URL("../scripts/lib/acquisition-private-output.mjs", import.meta.url).href; + const childScript = ` + import { execFileSync } from "node:child_process"; + import { + closeSync, constants, fchmodSync, fstatSync, ftruncateSync, + lstatSync, mkdtempSync, openSync, rmSync, unlinkSync, + writeFileSync, + } from "node:fs"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { writeAcquisitionPrivateFile } from ${JSON.stringify(moduleUrl)}; + + const directory = mkdtempSync(join(tmpdir(), "noema-private-new-fifo-race-")); + const output = join(directory, "evidence.json"); + const fileSystem = { + constants, + lstatSync, + openSync, + fstatSync, + fchmodSync, + ftruncateSync, + closeSync, + unlinkSync, + writeFileSync(descriptor, contents, options) { + writeFileSync(descriptor, contents, options); + unlinkSync(output); + execFileSync("mkfifo", [output]); + throw new Error("simulated acquisition write failure"); + }, + }; + + try { + writeAcquisitionPrivateFile(output, "complete\\n", fileSystem); + process.exitCode = 2; + } catch (error) { + if (error?.message !== "simulated acquisition write failure") { + console.error(error); + process.exitCode = 3; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + `; + + const mkfifoProbe = spawnSync("mkfifo", ["--help"], { encoding: "utf8" }); + if (mkfifoProbe.error?.code === "ENOENT") return; + + const child = spawnSync( + process.execPath, + ["--input-type=module", "--eval", childScript], + { encoding: "utf8", timeout: 1_000 }, + ); + + expect(child.error && "code" in child.error ? child.error.code : undefined).not.toBe("ETIMEDOUT"); + expect(child.status, child.stderr).toBe(0); + }, + ); }); diff --git a/test/acquisition-private-output-parent-race.test.ts b/test/acquisition-private-output-parent-race.test.ts index 70584ce72..a7f842da7 100644 --- a/test/acquisition-private-output-parent-race.test.ts +++ b/test/acquisition-private-output-parent-race.test.ts @@ -27,7 +27,14 @@ describe("acquisition private output parent integrity", () => { it("fails closed without path cleanup when a parent becomes a symbolic link after exclusive leaf open", () => { let parentBecameSymbolicLink = false; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, + }, lstatSync: vi.fn((path: string) => { if (path === "output") { return parentBecameSymbolicLink ? fileMetadata() : null; diff --git a/test/acquisition-private-output-staging-parent-race.test.ts b/test/acquisition-private-output-staging-parent-race.test.ts index 491138aff..82c424728 100644 --- a/test/acquisition-private-output-staging-parent-race.test.ts +++ b/test/acquisition-private-output-staging-parent-race.test.ts @@ -23,6 +23,15 @@ function directoryMetadata({ symbolicLink = false } = {}) { }; } +const adapterConstants = { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, +}; + describe("acquisition private output staging parent integrity", () => { it("never path-unlinks a staged inode after parent authority is lost", () => { let openCount = 0; @@ -30,7 +39,7 @@ describe("acquisition private output staging parent integrity", () => { const existing = fileMetadata(2); const staged = fileMetadata(4); const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { return existing; @@ -74,7 +83,7 @@ describe("acquisition private output staging parent integrity", () => { const existing = fileMetadata(2); const unsafeStaged = { ...fileMetadata(4), nlink: 2 }; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { return existing; @@ -111,7 +120,7 @@ describe("acquisition private output staging parent integrity", () => { const staged = fileMetadata(4); const hardLinkedStaged = { ...staged, nlink: 2 }; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { return existing; diff --git a/test/acquisition-private-output-version-race.test.ts b/test/acquisition-private-output-version-race.test.ts index d0674fec0..46dcd6a2e 100644 --- a/test/acquisition-private-output-version-race.test.ts +++ b/test/acquisition-private-output-version-race.test.ts @@ -25,6 +25,15 @@ function parentMetadata() { }; } +const adapterConstants = { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, +}; + function replacementFileSystem({ opened = fileMetadata(), currentTarget = fileMetadata(), @@ -60,7 +69,7 @@ function replacementFileSystem({ return staged; }); return { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync, openSync: vi.fn(() => 17), fstatSync, @@ -102,7 +111,7 @@ describe("acquisition private output replacement version authority", () => { let targetReads = 0; let descriptorReads = 0; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { targetReads += 1; @@ -147,7 +156,7 @@ describe("acquisition private output replacement version authority", () => { let outputReads = 0; let descriptorReads = 0; const fileSystem = { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: adapterConstants, lstatSync: vi.fn((path: string) => { if (path === "output") { outputReads += 1; @@ -173,6 +182,7 @@ describe("acquisition private output replacement version authority", () => { expect(() => writeAcquisitionPrivateFile("output", "replacement\n", fileSystem as never)) .toThrow("changed while writing"); - expect(fileSystem.unlinkSync).toHaveBeenCalledWith("output"); + expect(fileSystem.ftruncateSync).toHaveBeenCalledTimes(2); + expect(fileSystem.unlinkSync).not.toHaveBeenCalledWith("output"); }); }); diff --git a/test/acquisition-private-output.test.ts b/test/acquisition-private-output.test.ts index 76c9a5128..7414c90f8 100644 --- a/test/acquisition-private-output.test.ts +++ b/test/acquisition-private-output.test.ts @@ -71,7 +71,14 @@ function mockFileSystem({ }); const fstat = vi.fn(() => descriptorValues[descriptorReads++] ?? afterDescriptor); return { - constants: { O_RDONLY: 16, O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + constants: { + O_RDONLY: 16, + O_WRONLY: 1, + O_CREAT: 2, + O_EXCL: 4, + O_NOFOLLOW: 8, + O_NONBLOCK: 32, + }, lstatSync: lstat, openSync: vi.fn(() => 17), fstatSync: fstat, @@ -221,11 +228,14 @@ describe("acquisition private output", () => { expect(fileSystem.closeSync).toHaveBeenCalledWith(17); }); - it("opens an existing file read-only without truncation and verifies its descriptor identity first", () => { + it("opens an existing file read-only, non-blocking, without truncation and verifies its descriptor identity first", () => { const before = metadata(); const fileSystem = mockFileSystem({ before }); writeAcquisitionPrivateFile("output", "value", fileSystem as never); - expect(fileSystem.openSync).toHaveBeenCalledWith("output", 16 | 8); + // O_NONBLOCK (32) keeps this verification open from hanging if a locally + // authorized actor races the pre-open regular-file check with a FIFO + // substitution -- see acquisition-private-output-existing-target-nonblocking.test.ts. + expect(fileSystem.openSync).toHaveBeenCalledWith("output", 16 | 8 | 32); expect(fileSystem.ftruncateSync).toHaveBeenCalledOnce(); }); @@ -263,4 +273,19 @@ describe("acquisition private output", () => { .toThrow("write failed"); expect(fileSystem.closeSync).toHaveBeenCalledWith(17); }); + + it("skips best-effort content neutralization when non-blocking support disappears mid-cleanup", () => { + const fileSystem = mockFileSystem({ writeError: new Error("write failed") }); + fileSystem.writeFileSync.mockImplementation(() => { + delete (fileSystem.constants as { O_NONBLOCK?: number }).O_NONBLOCK; + throw new Error("write failed"); + }); + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("write failed"); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + // The write's own descriptor open is the only one: the neutralization + // cleanup's re-open must never run once O_NONBLOCK is no longer an + // integer, even though the earlier top-level gate saw it as valid. + expect(fileSystem.openSync).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/acquisition-readiness-audit.test.ts b/test/acquisition-readiness-audit.test.ts index c9ba6ea7e..a73700c13 100644 --- a/test/acquisition-readiness-audit.test.ts +++ b/test/acquisition-readiness-audit.test.ts @@ -32,6 +32,12 @@ function writeFixture(root: string, relativePath: string, content: string): stri return path; } +function writeSourceDocument(root: string, relativePath = "artifacts/acquisition/source-record.json") { + const content = '{"source":"authenticated-test-fixture"}\n'; + writeFixture(root, relativePath, content); + return { path: relativePath, sha256: createHash("sha256").update(content).digest("hex") }; +} + function prepareAuditRoot(prefix: string): string { const root = mkdtempSync(join(tmpdir(), prefix)); writeFixture( @@ -203,7 +209,7 @@ function writePassingTransfer(root: string, path: string) { privacy_review: "pass", updated_at: today(), owner: "legal", - source_documents: ["legal/transfer-review.pdf"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/transfer-source.json")], licensing_ip: passingLicensingIp(root), })); } @@ -216,6 +222,7 @@ function writePassingSaleable(path: string) { } function writeArrRevenue(path: string, overrides: Record = {}) { + const root = dirname(path); writeFileSync(path, JSON.stringify({ arr_krw: 300_000_000, gross_margin: 0.75, @@ -225,7 +232,7 @@ function writeArrRevenue(path: string, overrides: Record = {}) customer_concentration_top1: 0.5, updated_at: today(), owner: "finance", - source_documents: ["crm:noema-arr-report"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/revenue-source.json")], ...overrides, })); } @@ -398,14 +405,14 @@ describe("acquisition-readiness-audit", () => { ); expect(revenueCheck.details.metadataFailures).toContain("owner cannot be a placeholder"); expect(revenueCheck.details.metadataFailures).toContain( - "source_documents must reference reviewed evidence, not placeholders or templates", + "source_documents[0] artifact binding required", ); expect(revenueCheck.details.buyerQnaFailures).toContain( "buyer_due_diligence_qna must reference reviewed evidence, not placeholders or templates", ); expect(transferCheck.details.metadataFailures).toContain("owner cannot be a placeholder"); expect(transferCheck.details.metadataFailures).toContain( - "source_documents must reference reviewed evidence, not placeholders or templates", + "source_documents[0] artifact binding required", ); expect(transferCheck.details.licensingIpFailures).toContain( "licensing_ip evidence object required", @@ -463,7 +470,7 @@ describe("acquisition-readiness-audit", () => { customer_concentration_top1: 1, updated_at: today(), owner: "sales", - source_documents: ["crm:noema-enterprise-pipeline"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/pipeline-source.json")], })); writePassingTransfer(root, paths.transferPath); writePassingSaleable(paths.saleablePath); @@ -484,7 +491,7 @@ describe("acquisition-readiness-audit", () => { buyer_due_diligence_qna: ["crm:noema-enterprise-security-qna"], updated_at: today(), owner: "sales", - source_documents: ["crm:noema-enterprise-pipeline"], + source_documents: [writeSourceDocument(root, "artifacts/acquisition/pipeline-source.json")], })); const withQna = runAudit(root, passingEnv(paths)); diff --git a/test/acquisition-retained-artifact-hardlink.test.ts b/test/acquisition-retained-artifact-hardlink.test.ts new file mode 100644 index 000000000..df8da4bb9 --- /dev/null +++ b/test/acquisition-retained-artifact-hardlink.test.ts @@ -0,0 +1,84 @@ +import { + closeSync, + constants, + fstatSync, + linkSync, + lstatSync, + mkdtempSync, + openSync, + readSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readStableFile } from "../scripts/lib/acquisition-data-room-integrity.mjs"; + +describe("acquisition retained artifact link authority", () => { + it("rejects a retained evidence path that hardlinks another filesystem object", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-hardlink-")); + const originalPath = join(root, "authoritative-source.json"); + const retainedPath = join(root, "retained-evidence.json"); + const bytes = "{\"source\":\"authenticated-record\"}\n"; + + try { + writeFileSync(originalPath, bytes, "utf8"); + linkSync(originalPath, retainedPath); + + expect(readStableFile(retainedPath, 1024)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects retained evidence when descriptor close reports failure", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-close-")); + const retainedPath = join(root, "retained-evidence.json"); + + try { + writeFileSync(retainedPath, "{\"source\":\"authenticated-record\"}\n", "utf8"); + const fileSystem = { + closeSync(descriptor: number) { + closeSync(descriptor); + throw new Error("simulated close completion failure"); + }, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + }; + + expect(readStableFile(retainedPath, 1024, fileSystem)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects retained evidence when the path is replaced after descriptor close", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-post-close-replace-")); + const retainedPath = join(root, "retained-evidence.json"); + + try { + writeFileSync(retainedPath, "{\"source\":\"authenticated-record\"}\n", "utf8"); + const fileSystem = { + closeSync(descriptor: number) { + closeSync(descriptor); + unlinkSync(retainedPath); + writeFileSync(retainedPath, "{\"source\":\"replacement-record\"}\n", "utf8"); + }, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + }; + + expect(readStableFile(retainedPath, 1024, fileSystem)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/test/acquisition-revenue-metric-domain.test.ts b/test/acquisition-revenue-metric-domain.test.ts index 035c5c204..0cdc656e0 100644 --- a/test/acquisition-revenue-metric-domain.test.ts +++ b/test/acquisition-revenue-metric-domain.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -37,6 +38,7 @@ function runRevenueAudit(revenue: Record) { } function passingRevenue(overrides: Record = {}) { + const sourceBytes = readFileSync("README.md"); return { arr_krw: 300_000_000, gross_margin: 0.75, @@ -46,7 +48,10 @@ function passingRevenue(overrides: Record = {}) { customer_concentration_top1: 0.5, updated_at: new Date().toISOString(), owner: "finance", - source_documents: ["crm:noema-arr-report"], + source_documents: [{ + path: "README.md", + sha256: createHash("sha256").update(sourceBytes).digest("hex"), + }], ...overrides, }; } @@ -74,4 +79,38 @@ describe("acquisition revenue metric authority", () => { expect(revenueCheck.pass).toBe(true); expect(revenueCheck.details.metricFailures).toEqual([]); }); + + it("rejects an arbitrary source-system label without retained bytes", () => { + const { revenueCheck } = runRevenueAudit(passingRevenue({ + source_documents: ["crm:noema-arr-report"], + })); + + expect(revenueCheck.pass).toBe(false); + expect(revenueCheck.details.metadataFailures).toContain( + "source_documents[0] artifact binding required", + ); + }); + + it("rejects retained source bytes whose digest does not match", () => { + const { revenueCheck } = runRevenueAudit(passingRevenue({ + source_documents: [{ path: "README.md", sha256: "0".repeat(64) }], + })); + + expect(revenueCheck.pass).toBe(false); + expect(revenueCheck.details.metadataFailures).toContain( + "source_documents[0].sha256 does not match retained artifact bytes", + ); + }); + + it("bounds the retained source-document set", () => { + const binding = passingRevenue().source_documents[0]; + const { revenueCheck } = runRevenueAudit(passingRevenue({ + source_documents: Array.from({ length: 33 }, () => binding), + })); + + expect(revenueCheck.pass).toBe(false); + expect(revenueCheck.details.metadataFailures).toContain( + "source_documents must contain at most 32 artifact bindings", + ); + }); }); diff --git a/test/acquisition-review-regressions.test.ts b/test/acquisition-review-regressions.test.ts index c68b35a50..813c2fe89 100644 --- a/test/acquisition-review-regressions.test.ts +++ b/test/acquisition-review-regressions.test.ts @@ -120,7 +120,7 @@ describe("acquisition review regressions", () => { } }); - it("fails closed on invalid read bounds but tolerates a close failure after a stable empty read", () => { + it("fails closed on invalid read bounds and on close failure after a stable empty read", () => { const metadata = { dev: 1, ino: 2, @@ -143,7 +143,7 @@ describe("acquisition review regressions", () => { expect(readStableFile("unused", 0, fileSystem)).toBeNull(); expect(fileSystem.lstatSync).not.toHaveBeenCalled(); - expect(readStableFile("empty", 16, fileSystem)).toEqual(Buffer.alloc(0)); + expect(readStableFile("empty", 16, fileSystem)).toBeNull(); expect(fileSystem.closeSync).toHaveBeenCalledWith(7); }); diff --git a/test/acquisition-source-only-license.test.ts b/test/acquisition-source-only-license.test.ts index 0e56f6db7..a5782f7ed 100644 --- a/test/acquisition-source-only-license.test.ts +++ b/test/acquisition-source-only-license.test.ts @@ -112,12 +112,18 @@ function writeSourceOnlyTransferEvidence(root: string, packagePrivate = true): s }, }; + const sourceDocument = digestArtifact( + root, + "legal/review-record.pdf", + "Acquisition counsel source-only review record.\n", + ); + return writeFixture( root, "artifacts/acquisition/transfer-evidence.json", `${JSON.stringify({ owner: "Acquisition counsel", - source_documents: ["legal/review-record.pdf"], + source_documents: [sourceDocument], updated_at: new Date().toISOString(), license_review: "pass", third_party_review: "pass", diff --git a/test/acquisition-transfer-rights.test.ts b/test/acquisition-transfer-rights.test.ts index 6e1d566b6..ff0082087 100644 --- a/test/acquisition-transfer-rights.test.ts +++ b/test/acquisition-transfer-rights.test.ts @@ -93,12 +93,17 @@ function writeTransferEvidence( root: string, licensingIp?: Record, ): string { + const sourceDocument = writeDigestArtifact( + root, + "artifacts/acquisition/transfer-source.json", + '{"source":"test-counsel-record"}\n', + ); return writeFixture( root, "artifacts/acquisition/transfer-evidence.json", `${JSON.stringify({ owner: "Acquisition counsel", - source_documents: ["legal/review-record.pdf"], + source_documents: [sourceDocument], updated_at: new Date().toISOString(), license_review: "pass", third_party_review: "pass", diff --git a/test/actions-runner-assignment-write-io-boundary.test.ts b/test/actions-runner-assignment-write-io-boundary.test.ts index 5ef27861f..912ec1719 100644 --- a/test/actions-runner-assignment-write-io-boundary.test.ts +++ b/test/actions-runner-assignment-write-io-boundary.test.ts @@ -7,6 +7,7 @@ const constants = { O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8, + O_NONBLOCK: 16, }; function directoryMetadata() {