diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..d0775465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a deterministic release-artifact integrity manifest and verifier that + binds built files to one exact source revision with SHA-256 digests, rejects + symlink/name/path confusion, emits stable non-secret operator failures, and + remains explicitly separate from signed provenance/attestation claims. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON @@ -48,7 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 missing or invalid subject/expiry, or a missing, Boolean, fractional, negative, unsafe, or otherwise invalid token-version claim before user lookup. - Added cross-device regression coverage proving that `logout-all` rejects stale - tokens on bearer, calendar, SSE, and attachment-view transports while the + tokens on bearer, calendar, SSE, and attachment-view URL transports while the replacement token continues through the same authentication boundary. ### Changed diff --git a/docs/doctoring/release-artifact-integrity-manifest.md b/docs/doctoring/release-artifact-integrity-manifest.md new file mode 100644 index 00000000..ddd4f977 --- /dev/null +++ b/docs/doctoring/release-artifact-integrity-manifest.md @@ -0,0 +1,94 @@ +# Release artifact integrity manifest + +## Status + +**Active PR only; not protected-shipped truth.** This control is introduced by ScopeWeave PR #616. Protected `develop` does not contain it until normal reviewed integration completes. + +## Decision and scope + +ScopeWeave release candidates need a deterministic receipt that answers a narrow operator question before a tag or release is created: **do these exact built artifact bytes still match the exact source revision they were produced from?** + +`scripts/release/release_artifact_manifest.mjs` generates an unsigned JSON manifest containing: + +- schema version `scopeweave.release_artifact_manifest.v1`; +- one exact 40-hex Git source revision; +- a sorted set of operator-assigned logical artifact names; +- the byte length and SHA-256 digest of each regular artifact file; and +- a SHA-256 self-digest over the canonical unsigned manifest payload. + +The verifier fails closed when the source revision, artifact set, artifact bytes, lengths, manifest schema, or manifest self-digest differ. Artifact logical names are bounded relative identifiers, duplicate names are rejected, and symlink inputs are rejected so a manifest cannot silently describe a redirected filesystem target. Manifest and artifact verification bind reads to opened file handles and compare their file identity/metadata with the pathname before accepting the result, including a second pathname identity check after artifact hashing. The manifest records logical names only and never records local build-runner paths. + +## What this control proves—and what it does not + +A successful verification is **integrity evidence**, not provenance authentication. It proves that the locally supplied files still hash to the values bound into the supplied manifest and that the manifest names one exact source revision. It does **not** prove who built the files, which workflow produced them, that the named source revision was actually used by a trusted builder, or that the manifest itself was signed by an authorized identity. + +Accordingly, do not use the manifest to claim a SLSA level, signed provenance, SOC 2 compliance, certification, or trustworthy-builder identity. SLSA v1.2 defines provenance as verifiable information about where, when, and how an artifact was produced, and GitHub artifact attestations provide cryptographically verifiable build-provenance/SBOM claims for artifacts produced in GitHub Actions. ScopeWeave's local manifest is intentionally complementary: it provides deterministic byte/source binding that can be checked before the independent provenance gate. + +## Release decision contract + +A release operator should proceed only when all of the following are true on the **same integrated protected revision**: + +1. normal protected-branch review, required checks, security gates, coverage/docstrings, migration/recovery, accessibility, compatibility, package/build, and operational acceptance are passing for that revision; +2. release artifacts are built from that exact protected revision; +3. an artifact manifest is generated for every artifact that will be distributed; +4. the manifest verifies against the unchanged artifacts and the same exact source revision; +5. applicable GitHub/SLSA artifact provenance and SBOM attestations are generated and independently verified; and +6. source/artifact hashes recorded in the release evidence match the assets that are actually published. + +A failed manifest verification is a **stop-release** signal. Rebuild the artifact set or regenerate the manifest from the correct unchanged artifacts; do not edit digest fields by hand to make verification pass. + +## Operator examples + +Generate a manifest from built artifacts and redirect the deterministic JSON to an evidence file: + +```bash +npm run ops:release-manifest -- generate \ + --source-revision "$(git rev-parse HEAD)" \ + --artifact browser/scopeweave-static.tar=dist/scopeweave-static.tar \ + --artifact server/scopeweave-server.tar=dist/scopeweave-server.tar \ + > release-artifact-manifest.json +``` + +Verify the same bytes before publication: + +```bash +npm run ops:release-manifest -- verify \ + --source-revision "$(git rev-parse HEAD)" \ + --manifest release-artifact-manifest.json \ + --artifact browser/scopeweave-static.tar=dist/scopeweave-static.tar \ + --artifact server/scopeweave-server.tar=dist/scopeweave-server.tar +``` + +Success prints a small machine-readable receipt containing `ok`, `source_revision`, `artifact_count`, and `manifest_sha256`. Deterministic failures return exit code `2` with a stable `error` and an operator-oriented `action`; local filesystem paths and raw filesystem exception text are not emitted. An `artifact_changed_during_read` result directs the operator to rebuild the release artifacts rather than retrying against a potentially replaced pathname. + +## Threat and failure notes + +- **Artifact replacement after build:** digest verification fails. +- **Artifact pathname replacement during verification:** the verifier hashes the opened handle, compares its identity with the live pathname after open and again after hashing, and fails closed with `artifact_changed_during_read` if the pathname no longer resolves to that same file. +- **Manifest pathname replacement during verification:** manifest JSON is read from the opened handle, and pathname identity is checked after open and after read; replacement fails closed as `manifest_file_invalid`. +- **Source/artifact mix-up:** exact source-revision comparison fails. +- **Manifest field tampering:** the manifest self-digest fails unless the attacker can also replace the entire unsigned manifest; signed provenance remains the authority for authenticity. +- **Symlink substitution:** direct symlink inputs are rejected with `O_NOFOLLOW` where the platform exposes it, in addition to file-type and identity checks. +- **Missing/extra artifacts:** verification requires an identical named artifact set. +- **Runner-path disclosure:** only logical artifact names are serialized; error envelopes use stable codes rather than raw filesystem messages. +- **Maliciously large manifest input:** verify-mode manifest JSON is bounded to 1 MiB before parsing. + +## Verification evidence required for PR #616 + +The repository regression history now includes multiple real RED→GREEN stages rather than relying on assertion-only success: + +- the initial contract was introduced RED before the production module existed and then implemented; +- predecessor RED `ec720063c5bf540671e3969289295e87e8ba0a19` demonstrated that manifest self-digest verification depended on nested artifact-object key insertion order; its production fix canonicalized the digest payload; +- predecessor RED `d3018a44d55a8ce6fadda67a6b53315ab289904a` registered the artifact-path replacement regression in normal unit/coverage execution and failed Server Tests run `33072327214`, `unit-and-api` job `98517475339`, because the artifact-open seam was not invoked (`0 !== 1`); +- production repair `ea9472e90bdb9de1365f69e385aeb98bdedb0178` wires the artifact-open seam through verification, checks live pathname identity around the opened handle, and maps a detected replacement to `rebuild_release_artifacts`; and +- hosted Server Tests run `33073885033`, `unit-and-api` job `98522928574`, is GREEN and explicitly records `release artifact pathname replacement regression passed`; cloud E2E, Fuzz, Dependency Review, OSV, Security Scan, and SAST are also run-level GREEN on the same pull-request event. + +That hosted GREEN is **behavioral evidence, not exact-head merge authority**: Server Tests checked out synthetic merge `623d5765181ae52d133313f4bf942141932aad9d`, not immutable contributor head `ea9472e90bdb9de1365f69e385aeb98bdedb0178`. PR #523 owns the ScopeWeave exact-head Server Tests/100%-owned-coverage control and `ContextualWisdomLab/.github#1222` owns the reusable central SAST/Security exact-head defect. PR #616 must remain Draft until those controls protect and regenerate authoritative evidence on one unchanged exact contributor head, all public exports retain beginner-readable JSDoc, valid current-head review findings are zero, and the live independent-approval requirement is satisfied. + +## References (APA 7th) + +GitHub. (2026). *Using artifact attestations to establish provenance for builds*. GitHub Docs. https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations + +Supply-chain Levels for Software Artifacts. (2025). *SLSA specification (Version 1.2)*. The Linux Foundation. https://slsa.dev/spec/v1.2/ + +Supply-chain Levels for Software Artifacts. (2025). *Provenance (SLSA specification Version 1.2)*. The Linux Foundation. https://slsa.dev/spec/v1.2/provenance diff --git a/package.json b/package.json index 8cefdc74..f62c13d6 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", + "ops:release-manifest": "node scripts/release/release_artifact_manifest.mjs", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/release-artifact-manifest.test.mjs && node tests/unit/release-artifact-path-race.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=scripts/release/release_artifact_manifest.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/release-artifact-manifest.test.mjs && node tests/unit/release-artifact-path-race.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/scripts/release/release_artifact_manifest.mjs b/scripts/release/release_artifact_manifest.mjs new file mode 100644 index 00000000..8183b36d --- /dev/null +++ b/scripts/release/release_artifact_manifest.mjs @@ -0,0 +1,479 @@ +import { constants as fsConstants } from 'node:fs'; +import { createHash, timingSafeEqual } from 'node:crypto'; +import { lstat, open } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const SCHEMA_VERSION = 'scopeweave.release_artifact_manifest.v1'; +const MAX_ARTIFACTS = 256; +const MAX_ARTIFACT_NAME_LENGTH = 240; +const MAX_MANIFEST_BYTES = 1024 * 1024; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const SOURCE_REVISION = /^[0-9a-f]{40}$/; +const ARTIFACT_NAME_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._@+-]*$/; + +/** + * Stable, non-secret failure raised by the release-artifact manifest boundary. + * Callers should branch on `code` rather than exposing filesystem error text. + */ +export class ReleaseArtifactManifestError extends Error { + /** + * @param {string} code Stable machine-readable failure code. + */ + constructor(code) { + super(code); + this.name = 'ReleaseArtifactManifestError'; + this.code = code; + } +} + +function fail(code) { + throw new ReleaseArtifactManifestError(code); +} + +function compareArtifactNames(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function validateSourceRevision(value) { + if (typeof value !== 'string' || !SOURCE_REVISION.test(value)) { + fail('source_revision_invalid'); + } + return value; +} + +function validateArtifactName(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_ARTIFACT_NAME_LENGTH + || value.includes('\\') + || value.startsWith('/') + || value.endsWith('/') + || value.includes('\u0000') + ) { + fail('artifact_name_invalid'); + } + + const segments = value.split('/'); + if ( + segments.some((segment) => ( + segment === '' + || segment === '.' + || segment === '..' + || !ARTIFACT_NAME_SEGMENT.test(segment) + )) + ) { + fail('artifact_name_invalid'); + } + return value; +} + +function validateArtifactInputs(artifacts) { + if (!Array.isArray(artifacts) || artifacts.length === 0 || artifacts.length > MAX_ARTIFACTS) { + fail('artifact_set_invalid'); + } + + const names = new Set(); + return artifacts.map((artifact) => { + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) { + fail('artifact_input_invalid'); + } + const name = validateArtifactName(artifact.name); + if (names.has(name)) { + fail('artifact_name_duplicate'); + } + names.add(name); + if (typeof artifact.path !== 'string' || artifact.path.length === 0 || artifact.path.includes('\u0000')) { + fail('artifact_path_invalid'); + } + return { name, path: artifact.path }; + }); +} + +function stableStatMatches(left, right) { + return left.dev === right.dev + && left.ino === right.ino + && left.mode === right.mode + && left.size === right.size + && left.mtimeNs === right.mtimeNs + && left.ctimeNs === right.ctimeNs; +} + +async function statWithoutPathDisclosure(path, errorCode) { + try { + return await lstat(path, { bigint: true }); + } catch { + fail(errorCode); + } +} + +async function openRegularFile(path, options = {}) { + const { + symlinkCode = 'artifact_symlink_not_allowed', + invalidCode = 'artifact_not_regular_file', + unreadableCode = 'artifact_unreadable', + changedCode = 'artifact_changed_during_read', + } = options; + + const before = await statWithoutPathDisclosure(path, unreadableCode); + if (before.isSymbolicLink()) fail(symlinkCode); + if (!before.isFile()) fail(invalidCode); + + let handle; + try { + const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0; + handle = await open(path, fsConstants.O_RDONLY | noFollow); + } catch (error) { + if (error?.code === 'ELOOP') fail(symlinkCode); + fail(unreadableCode); + } + + try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile()) fail(invalidCode); + if (!stableStatMatches(before, opened)) fail(changedCode); + return { handle, opened }; + } catch (error) { + await handle.close().catch(() => {}); + if (error instanceof ReleaseArtifactManifestError) throw error; + fail(unreadableCode); + } +} + +async function hashArtifactFile(path, options = {}) { + const { afterOpen } = options; + const { handle, opened } = await openRegularFile(path); + const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); + if (opened.size > maxSafe) { + await handle.close().catch(() => {}); + fail('artifact_size_unsupported'); + } + + const digest = createHash('sha256'); + try { + if (afterOpen !== undefined) { + if (typeof afterOpen !== 'function') fail('artifact_changed_during_read'); + try { + await afterOpen(); + } catch { + fail('artifact_changed_during_read'); + } + } + + const pathAfterOpen = await statWithoutPathDisclosure(path, 'artifact_changed_during_read'); + if (!stableStatMatches(opened, pathAfterOpen)) fail('artifact_changed_during_read'); + + const stream = handle.createReadStream({ autoClose: false }); + for await (const chunk of stream) { + digest.update(chunk); + } + const after = await handle.stat({ bigint: true }); + if (!stableStatMatches(opened, after)) fail('artifact_changed_during_read'); + const pathAfterRead = await statWithoutPathDisclosure(path, 'artifact_changed_during_read'); + if (!stableStatMatches(after, pathAfterRead)) fail('artifact_changed_during_read'); + return { + byte_length: Number(after.size), + digest: { sha256: digest.digest('hex') }, + }; + } catch (error) { + if (error instanceof ReleaseArtifactManifestError) throw error; + fail('artifact_unreadable'); + } finally { + await handle.close().catch(() => {}); + } +} + +function payloadForDigest(manifest) { + return { + schema_version: manifest.schema_version, + source_revision: manifest.source_revision, + artifacts: manifest.artifacts.map((entry) => ({ + name: entry.name, + byte_length: entry.byte_length, + digest: { sha256: entry.digest.sha256 }, + })), + }; +} + +function digestPayload(payload) { + return createHash('sha256').update(JSON.stringify(payload), 'utf8').digest('hex'); +} + +function exactKeys(value, expected) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function validateManifestShape(manifest) { + if (!exactKeys(manifest, ['schema_version', 'source_revision', 'artifacts', 'manifest_digest'])) { + fail('manifest_schema_invalid'); + } + if (manifest.schema_version !== SCHEMA_VERSION) fail('manifest_schema_invalid'); + if (typeof manifest.source_revision !== 'string' || !SOURCE_REVISION.test(manifest.source_revision)) { + fail('manifest_schema_invalid'); + } + if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0 || manifest.artifacts.length > MAX_ARTIFACTS) { + fail('manifest_schema_invalid'); + } + if (!exactKeys(manifest.manifest_digest, ['sha256']) || !SHA256_HEX.test(manifest.manifest_digest.sha256)) { + fail('manifest_schema_invalid'); + } + + let previousName = null; + for (const entry of manifest.artifacts) { + if (!exactKeys(entry, ['name', 'byte_length', 'digest'])) fail('manifest_schema_invalid'); + try { + validateArtifactName(entry.name); + } catch { + fail('manifest_schema_invalid'); + } + if ( + !Number.isSafeInteger(entry.byte_length) + || entry.byte_length < 0 + || !exactKeys(entry.digest, ['sha256']) + || !SHA256_HEX.test(entry.digest.sha256) + ) { + fail('manifest_schema_invalid'); + } + if (previousName !== null && compareArtifactNames(previousName, entry.name) >= 0) { + fail('manifest_schema_invalid'); + } + previousName = entry.name; + } + + const expectedDigest = digestPayload(payloadForDigest(manifest)); + const actualBuffer = Buffer.from(manifest.manifest_digest.sha256, 'hex'); + const expectedBuffer = Buffer.from(expectedDigest, 'hex'); + if (!timingSafeEqual(actualBuffer, expectedBuffer)) fail('manifest_digest_mismatch'); + return manifest; +} + +/** + * Hash built release artifacts and bind their logical identities to one exact Git revision. + * The result is deterministic unsigned integrity metadata; it is not a provenance attestation. + * + * @param {{sourceRevision: string, artifacts: Array<{name: string, path: string}>}} input Build inputs. + * @returns {Promise} Canonical manifest with a self-digest over its unsigned payload. + */ +export async function buildReleaseArtifactManifest({ sourceRevision, artifacts }) { + const revision = validateSourceRevision(sourceRevision); + const inputs = validateArtifactInputs(artifacts); + const entries = []; + + for (const artifact of inputs) { + const evidence = await hashArtifactFile(artifact.path); + entries.push({ name: artifact.name, ...evidence }); + } + entries.sort((left, right) => compareArtifactNames(left.name, right.name)); + + const payload = { + schema_version: SCHEMA_VERSION, + source_revision: revision, + artifacts: entries, + }; + return { + ...payload, + manifest_digest: { sha256: digestPayload(payloadForDigest(payload)) }, + }; +} + +/** + * Verify a manifest, its exact source revision, and the current bytes of every declared artifact. + * Verification fails closed when the manifest set and supplied artifact set are not identical. + * `afterArtifactOpen` is an optional deterministic test seam invoked after each artifact is opened. + * + * @param {{manifest: object, sourceRevision: string, artifacts: Array<{name: string, path: string}>, afterArtifactOpen?: Function}} input Verification inputs. + * @returns {Promise<{ok: true, source_revision: string, artifact_count: number, manifest_sha256: string}>} Stable verification receipt. + */ +export async function verifyReleaseArtifactManifest({ manifest, sourceRevision, artifacts, afterArtifactOpen }) { + const revision = validateSourceRevision(sourceRevision); + const checkedManifest = validateManifestShape(manifest); + if (checkedManifest.source_revision !== revision) fail('source_revision_mismatch'); + + const inputs = validateArtifactInputs(artifacts) + .sort((left, right) => compareArtifactNames(left.name, right.name)); + if (inputs.length !== checkedManifest.artifacts.length) fail('artifact_set_mismatch'); + + for (let index = 0; index < inputs.length; index += 1) { + const input = inputs[index]; + const expected = checkedManifest.artifacts[index]; + if (input.name !== expected.name) fail('artifact_set_mismatch'); + const actual = await hashArtifactFile(input.path, { afterOpen: afterArtifactOpen }); + if (actual.digest.sha256 !== expected.digest.sha256) fail('artifact_digest_mismatch'); + if (actual.byte_length !== expected.byte_length) fail('artifact_size_mismatch'); + } + + return { + ok: true, + source_revision: revision, + artifact_count: inputs.length, + manifest_sha256: checkedManifest.manifest_digest.sha256, + }; +} + +function parseArtifactArgument(value, cwd) { + if (typeof value !== 'string') fail('cli_usage_invalid'); + const separator = value.indexOf('='); + if (separator <= 0 || separator === value.length - 1) fail('cli_usage_invalid'); + const name = value.slice(0, separator); + const localPath = value.slice(separator + 1); + validateArtifactName(name); + return { name, path: resolve(cwd, localPath) }; +} + +function parseCliArguments(argv, cwd) { + if (!Array.isArray(argv) || (argv[0] !== 'generate' && argv[0] !== 'verify')) { + fail('cli_usage_invalid'); + } + const command = argv[0]; + let sourceRevision = null; + let manifestPath = null; + const artifacts = []; + + for (let index = 1; index < argv.length; index += 1) { + const flag = argv[index]; + const value = argv[index + 1]; + if (flag === '--source-revision' && sourceRevision === null && value !== undefined) { + sourceRevision = value; + index += 1; + continue; + } + if (flag === '--manifest' && manifestPath === null && value !== undefined) { + manifestPath = resolve(cwd, value); + index += 1; + continue; + } + if (flag === '--artifact' && value !== undefined) { + artifacts.push(parseArtifactArgument(value, cwd)); + index += 1; + continue; + } + fail('cli_usage_invalid'); + } + + if (sourceRevision === null || artifacts.length === 0) fail('cli_usage_invalid'); + validateSourceRevision(sourceRevision); + if (command === 'generate' && manifestPath !== null) fail('cli_usage_invalid'); + if (command === 'verify' && manifestPath === null) fail('cli_usage_invalid'); + return { command, sourceRevision, manifestPath, artifacts }; +} + +async function readManifest(path, options = {}) { + const { afterOpen } = options; + const { handle, opened } = await openRegularFile(path, { + symlinkCode: 'manifest_file_invalid', + invalidCode: 'manifest_file_invalid', + unreadableCode: 'manifest_file_invalid', + changedCode: 'manifest_file_invalid', + }); + + try { + if (opened.size > BigInt(MAX_MANIFEST_BYTES)) fail('manifest_file_invalid'); + + if (afterOpen !== undefined) { + if (typeof afterOpen !== 'function') fail('manifest_file_invalid'); + try { + await afterOpen(); + } catch { + fail('manifest_file_invalid'); + } + } + + const pathAfterOpen = await statWithoutPathDisclosure(path, 'manifest_file_invalid'); + if (!stableStatMatches(opened, pathAfterOpen)) fail('manifest_file_invalid'); + + let text; + try { + text = await handle.readFile({ encoding: 'utf8' }); + } catch { + fail('manifest_file_invalid'); + } + + const afterRead = await handle.stat({ bigint: true }); + if (!stableStatMatches(opened, afterRead)) fail('manifest_file_invalid'); + const pathAfterRead = await statWithoutPathDisclosure(path, 'manifest_file_invalid'); + if (!stableStatMatches(afterRead, pathAfterRead)) fail('manifest_file_invalid'); + if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) fail('manifest_file_invalid'); + + try { + return JSON.parse(text); + } catch { + fail('manifest_json_invalid'); + } + } finally { + await handle.close().catch(() => {}); + } +} + +function errorAction(code) { + if (code === 'manifest_json_invalid' || code === 'manifest_schema_invalid' || code === 'manifest_digest_mismatch') { + return 'regenerate_release_manifest'; + } + if ( + code === 'artifact_changed_during_read' + || code === 'artifact_digest_mismatch' + || code === 'artifact_size_mismatch' + || code === 'source_revision_mismatch' + ) { + return 'rebuild_release_artifacts'; + } + if (code === 'unexpected_release_manifest_error') return 'inspect_release_manifest_tool'; + return 'fix_release_manifest_input'; +} + +/** + * Run the release-manifest operator CLI without leaking local build paths in machine-readable errors. + * `generate` prints a manifest; `verify` prints a verification receipt. The caller owns redirection/storage. + * `afterManifestOpen` and `afterArtifactOpen` are optional deterministic test seams invoked after opening the corresponding file. + * + * @param {{argv?: string[], cwd?: string, stdout?: {write: Function}, stderr?: {write: Function}, afterManifestOpen?: Function, afterArtifactOpen?: Function}} options Runtime adapters. + * @returns {Promise} Process-style exit code: 0 success, 2 deterministic validation failure. + */ +export async function runReleaseArtifactManifestCli(options = {}) { + const { + argv = process.argv.slice(2), + cwd = process.cwd(), + stdout = process.stdout, + stderr = process.stderr, + afterManifestOpen, + afterArtifactOpen, + } = options; + + try { + const parsed = parseCliArguments(argv, cwd); + if (parsed.command === 'generate') { + const manifest = await buildReleaseArtifactManifest({ + sourceRevision: parsed.sourceRevision, + artifacts: parsed.artifacts, + }); + stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + return 0; + } + + const manifest = await readManifest(parsed.manifestPath, { afterOpen: afterManifestOpen }); + const verification = await verifyReleaseArtifactManifest({ + manifest, + sourceRevision: parsed.sourceRevision, + artifacts: parsed.artifacts, + afterArtifactOpen, + }); + stdout.write(`${JSON.stringify(verification)}\n`); + return 0; + } catch (error) { + const code = error instanceof ReleaseArtifactManifestError + ? error.code + : 'unexpected_release_manifest_error'; + stderr.write(`${JSON.stringify({ ok: false, error: code, action: errorAction(code) })}\n`); + return 2; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) { + process.exitCode = await runReleaseArtifactManifestCli(); +} diff --git a/tests/unit/release-artifact-manifest.test.mjs b/tests/unit/release-artifact-manifest.test.mjs new file mode 100644 index 00000000..05836b1e --- /dev/null +++ b/tests/unit/release-artifact-manifest.test.mjs @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ReleaseArtifactManifestError, + buildReleaseArtifactManifest, + runReleaseArtifactManifestCli, + verifyReleaseArtifactManifest, +} from '../../scripts/release/release_artifact_manifest.mjs'; + +const SOURCE_REVISION = '0123456789abcdef0123456789abcdef01234567'; + +async function withTempDir(run) { + const dir = await mkdtemp(join(tmpdir(), 'scopeweave-release-manifest-')); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function captureStream() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + return true; + }, + value() { + return value; + }, + }; +} + +async function expectManifestError(promise, code) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof ReleaseArtifactManifestError); + assert.equal(error.code, code); + return true; + }); +} + +await withTempDir(async (dir) => { + const browserPath = join(dir, 'browser.tar'); + const serverPath = join(dir, 'server.tar'); + await writeFile(browserPath, Buffer.from('browser-artifact\n')); + await writeFile(serverPath, Buffer.from('server-artifact\n')); + + const artifactInputs = [ + { name: 'server/server.tar', path: serverPath }, + { name: 'browser/browser.tar', path: browserPath }, + ]; + + const first = await buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }); + const second = await buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [...artifactInputs].reverse(), + }); + + assert.deepEqual(first, second, 'manifest must be deterministic regardless of input order'); + assert.equal(first.schema_version, 'scopeweave.release_artifact_manifest.v1'); + assert.equal(first.source_revision, SOURCE_REVISION); + assert.deepEqual(first.artifacts.map((entry) => entry.name), [ + 'browser/browser.tar', + 'server/server.tar', + ]); + for (const entry of first.artifacts) { + assert.match(entry.digest.sha256, /^[0-9a-f]{64}$/); + assert.ok(Number.isSafeInteger(entry.byte_length)); + assert.ok(entry.byte_length > 0); + } + assert.match(first.manifest_digest.sha256, /^[0-9a-f]{64}$/); + + const verified = await verifyReleaseArtifactManifest({ + manifest: first, + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }); + assert.deepEqual(verified, { + ok: true, + source_revision: SOURCE_REVISION, + artifact_count: 2, + manifest_sha256: first.manifest_digest.sha256, + }); + + const reorderedArtifactFields = { + ...first, + artifacts: first.artifacts.map((entry) => ({ + digest: { sha256: entry.digest.sha256 }, + byte_length: entry.byte_length, + name: entry.name, + })), + }; + const reorderedVerified = await verifyReleaseArtifactManifest({ + manifest: reorderedArtifactFields, + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }); + assert.deepEqual( + reorderedVerified, + verified, + 'canonical manifest digest must not depend on JSON object key insertion order', + ); + + await writeFile(browserPath, Buffer.from('tampered-browser-artifact\n')); + await expectManifestError( + verifyReleaseArtifactManifest({ + manifest: first, + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }), + 'artifact_digest_mismatch', + ); + + await expectManifestError( + verifyReleaseArtifactManifest({ + manifest: first, + sourceRevision: '89abcdef0123456789abcdef0123456789abcdef', + artifacts: artifactInputs, + }), + 'source_revision_mismatch', + ); +}); + +await withTempDir(async (dir) => { + const targetPath = join(dir, 'target.bin'); + const linkPath = join(dir, 'link.bin'); + await writeFile(targetPath, 'artifact'); + await symlink(targetPath, linkPath); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [{ name: 'artifact.bin', path: linkPath }], + }), + 'artifact_symlink_not_allowed', + ); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [ + { name: 'artifact.bin', path: targetPath }, + { name: 'artifact.bin', path: targetPath }, + ], + }), + 'artifact_name_duplicate', + ); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [{ name: '../artifact.bin', path: targetPath }], + }), + 'artifact_name_invalid', + ); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: 'not-a-git-sha', + artifacts: [{ name: 'artifact.bin', path: targetPath }], + }), + 'source_revision_invalid', + ); +}); + +await withTempDir(async (dir) => { + const artifactPath = join(dir, 'scopeweave-server.tar'); + const manifestPath = join(dir, 'release-manifest.json'); + await writeFile(artifactPath, 'server-release-artifact'); + + const generateOut = captureStream(); + const generateErr = captureStream(); + const generateCode = await runReleaseArtifactManifestCli({ + argv: [ + 'generate', + '--source-revision', + SOURCE_REVISION, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: generateOut, + stderr: generateErr, + }); + assert.equal(generateCode, 0); + assert.equal(generateErr.value(), ''); + const generated = JSON.parse(generateOut.value()); + assert.equal(generated.source_revision, SOURCE_REVISION); + assert.equal(generated.artifacts.length, 1); + assert.equal(generated.artifacts[0].name, 'server/scopeweave-server.tar'); + assert.ok(!generateOut.value().includes(dir), 'manifest must not disclose build-runner paths'); + await writeFile(manifestPath, `${JSON.stringify(generated)}\n`); + + const verifyOut = captureStream(); + const verifyErr = captureStream(); + const verifyCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: verifyOut, + stderr: verifyErr, + }); + assert.equal(verifyCode, 0); + assert.equal(verifyErr.value(), ''); + const verification = JSON.parse(verifyOut.value()); + assert.equal(verification.ok, true); + assert.equal(verification.source_revision, SOURCE_REVISION); + assert.equal(verification.artifact_count, 1); + + let replacementHookCalls = 0; + const replacedManifestOut = captureStream(); + const replacedManifestErr = captureStream(); + const replacedManifestCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: replacedManifestOut, + stderr: replacedManifestErr, + afterManifestOpen: async () => { + replacementHookCalls += 1; + await rename(manifestPath, `${manifestPath}.opened`); + await writeFile(manifestPath, '{"replacement":true}\n'); + }, + }); + assert.equal(replacementHookCalls, 1, 'manifest replacement regression must run after the verified file is opened'); + assert.equal(replacedManifestCode, 2); + assert.equal(replacedManifestOut.value(), ''); + assert.deepEqual(JSON.parse(replacedManifestErr.value()), { + ok: false, + error: 'manifest_file_invalid', + action: 'fix_release_manifest_input', + }); + assert.ok(!replacedManifestErr.value().includes(dir), 'manifest replacement errors must not disclose build-runner paths'); + + await writeFile(manifestPath, `${JSON.stringify(generated)}\n`); + const malformedOut = captureStream(); + const malformedErr = captureStream(); + const malformedCode = await runReleaseArtifactManifestCli({ + argv: ['generate', '--source-revision', SOURCE_REVISION, '--artifact', `../bad=${artifactPath}`], + cwd: dir, + stdout: malformedOut, + stderr: malformedErr, + }); + assert.equal(malformedCode, 2); + assert.equal(malformedOut.value(), ''); + assert.deepEqual(JSON.parse(malformedErr.value()), { + ok: false, + error: 'artifact_name_invalid', + action: 'fix_release_manifest_input', + }); + assert.ok(!malformedErr.value().includes(dir), 'operator errors must not disclose build-runner paths'); + + await writeFile(manifestPath, '{bad json'); + const badManifestOut = captureStream(); + const badManifestErr = captureStream(); + const badManifestCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: badManifestOut, + stderr: badManifestErr, + }); + assert.equal(badManifestCode, 2); + assert.equal(badManifestOut.value(), ''); + assert.deepEqual(JSON.parse(badManifestErr.value()), { + ok: false, + error: 'manifest_json_invalid', + action: 'regenerate_release_manifest', + }); +}); + +console.log('release artifact manifest tests passed'); diff --git a/tests/unit/release-artifact-path-race.test.mjs b/tests/unit/release-artifact-path-race.test.mjs new file mode 100644 index 00000000..ebe3442e --- /dev/null +++ b/tests/unit/release-artifact-path-race.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildReleaseArtifactManifest, + runReleaseArtifactManifestCli, +} from '../../scripts/release/release_artifact_manifest.mjs'; + +const SOURCE_REVISION = '0123456789abcdef0123456789abcdef01234567'; + +function captureStream() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + return true; + }, + value() { + return value; + }, + }; +} + +const dir = await mkdtemp(join(tmpdir(), 'scopeweave-release-artifact-race-')); +try { + const artifactPath = join(dir, 'scopeweave-server.tar'); + const openedArtifactPath = join(dir, 'scopeweave-server.opened.tar'); + const manifestPath = join(dir, 'release-manifest.json'); + await writeFile(artifactPath, 'server-release-artifact'); + + const manifest = await buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [{ name: 'server/scopeweave-server.tar', path: artifactPath }], + }); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + + let artifactOpenHookCalls = 0; + const stdout = captureStream(); + const stderr = captureStream(); + const exitCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout, + stderr, + afterArtifactOpen: async () => { + artifactOpenHookCalls += 1; + await rename(artifactPath, openedArtifactPath); + await writeFile(artifactPath, 'replacement-release-artifact'); + }, + }); + + assert.equal( + artifactOpenHookCalls, + 1, + 'artifact replacement regression must run after the verified artifact is opened', + ); + assert.equal(exitCode, 2, 'verification must fail closed when the artifact pathname is replaced after open'); + assert.equal(stdout.value(), ''); + assert.deepEqual(JSON.parse(stderr.value()), { + ok: false, + error: 'artifact_changed_during_read', + action: 'rebuild_release_artifacts', + }); + assert.ok(!stderr.value().includes(dir), 'artifact replacement errors must not disclose build-runner paths'); +} finally { + await rm(dir, { recursive: true, force: true }); +} + +console.log('release artifact pathname replacement regression passed');