diff --git a/README.md b/README.md index ceaf3c849..fe5022aef 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6954+ tests / 575 files + 6959+ tests / 575 files Codecov Coverage License MIT CI Status @@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (6954+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6959+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -550,7 +550,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (6954+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6959+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **6954+ unit tests** across **575 test files** — CI is authoritative for pass/fail +- **6959+ unit tests** across **575 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 20ba9d4bb..16c7c5b2c 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -1,5 +1,21 @@ import process from 'node:process'; import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs'; +import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs'; + +const evidenceIndex = process.argv.indexOf('--prepush-evidence-file'); +if (evidenceIndex >= 0) { + try { + const evidence = resolvePushEvidence( + readPrePushEvidenceFile(process.argv[evidenceIndex + 1]), + process.cwd(), + ); + if (evidence.evidenceState !== 'RESOLVED') + throw new Error(evidence.reason ?? 'invalid evidence'); + } catch (error) { + console.error(`[local-lowend] outgoing evidence rejected: ${error.message}`); + process.exit(1); + } +} const checks = [ ['toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])], diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs index 5c8f3aa39..c97c0a1b5 100644 --- a/scripts/hooks/pre-push.mjs +++ b/scripts/hooks/pre-push.mjs @@ -1,6 +1,49 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import process from 'node:process'; +import { normalizePrePushUpdates, writePrePushEvidenceFile } from '../signing/signing-core.mjs'; import { runNodeScript } from './shared.mjs'; -if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0) - process.exit(1); -process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs')); +let input = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) input += chunk; + +let evidenceDir; +let evidenceFile; +let exitCode = 1; +try { + const updates = normalizePrePushUpdates(input); + evidenceDir = await mkdtemp(join(tmpdir(), 'worldscript-prepush-')); + evidenceFile = join(evidenceDir, 'evidence.json'); + writePrePushEvidenceFile(evidenceFile, updates); + const childArgs = [...process.argv.slice(2), '--prepush-evidence-file', evidenceFile]; + exitCode = runNodeScript('scripts/signing/verify-outgoing.mjs', childArgs); + if (exitCode === 0) exitCode = runNodeScript('scripts/ci-prepush-lowend.mjs', childArgs); +} catch (error) { + console.error( + `pre-push evidence capture failed closed: ${error instanceof Error ? error.message : 'invalid input'}`, + ); +} finally { + if (evidenceFile) { + try { + await rm(evidenceFile, { force: true }); + } catch (error) { + console.error( + `pre-push evidence cleanup failed: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + if (exitCode === 0) exitCode = 1; + } + } + if (evidenceDir) { + try { + await rm(evidenceDir, { recursive: true, force: true }); + } catch (error) { + console.error( + `pre-push evidence directory cleanup failed: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + if (exitCode === 0) exitCode = 1; + } + } +} +process.exit(exitCode); diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index 715a9d42c..8ccec839d 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -71,6 +71,33 @@ export function getSigningConfig(cwd?: string): SigningConfig; export function hasCommitSignature(commitText: string): boolean; export function isGitHubCompatibleEmail(email: string): boolean; export function parseRefUpdate(line: string): RefUpdate | null; +export function normalizePrePushUpdates(input: string | string[] | RefUpdate[]): RefUpdate[]; +export function serializePrePushEvidence(input: string | string[] | RefUpdate[]): string; +export function parsePrePushEvidence(serialized: string): RefUpdate[]; +export function readPrePushEvidenceFile(file: string): RefUpdate[]; +export function writePrePushEvidenceFile( + file: string, + input: string | string[] | RefUpdate[], +): void; +export interface PushEvidenceUpdate extends RefUpdate { + base?: string; + disposition: 'DELETED' | 'TAG' | 'NEW_BRANCH' | 'UPDATED'; +} +export interface PushEvidence { + updates: PushEvidenceUpdate[]; + changedFiles: string[]; + evidenceState: 'RESOLVED' | 'INVALID'; + reason?: string; +} +export function resolvePushEvidence( + input: string | string[] | RefUpdate[], + cwd?: string, + dependencies?: { + commitExists?: (sha: string) => boolean; + objectExists?: (sha: string) => boolean; + changedFilesBetween?: (base: string, head: string) => string[]; + }, +): PushEvidence; export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[]; export function runSigningProbe(cwd?: string): { ok: boolean; reason?: string; commit?: string }; export function verifyCommitObject(sha: string, cwd?: string): VerificationResult; @@ -92,7 +119,10 @@ export function pushCommitShas( export function parseAnnotatedTag( sha: string, cwd?: string, -): { objectType: 'commit'; target: string } | { objectType: 'tag'; target: string; targetType: string } | null; +): + | { objectType: 'commit'; target: string } + | { objectType: 'tag'; target: string; targetType: string } + | null; export function verifyTagObject(sha: string, cwd?: string): VerificationResult; export function remoteTrackingBases(remote: string, remoteRef: string, cwd?: string): string[]; export function outgoingBaseShas(update: RefUpdate, fallbackBases: string[]): string[]; @@ -111,7 +141,7 @@ export function safeConfigSummary(cwd?: string): { unsafeOverrides: string[]; }; export function verifyOutgoingUpdates( - lines: string[], + input: string | string[] | RefUpdate[], remote: string, cwd?: string, dependencies?: { diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index 64b28b5b8..7122ed82b 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; @@ -245,6 +245,138 @@ export function parseRefUpdate(line) { return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] }; } +function isRefUpdate(value) { + return ( + value && + typeof value === 'object' && + typeof value.localRef === 'string' && + typeof value.localSha === 'string' && + typeof value.remoteRef === 'string' && + typeof value.remoteSha === 'string' + ); +} + +function validatePrePushUpdate(update) { + if (!isRefUpdate(update)) throw new Error('pre-push update array contains an invalid record'); + if ( + (!isSha(update.localSha) && !isZeroSha(update.localSha)) || + (!isSha(update.remoteSha) && !isZeroSha(update.remoteSha)) + ) + throw new Error(`invalid SHA in update for ${update.remoteRef}`); + if (!update.remoteRef.startsWith('refs/heads/') && !update.remoteRef.startsWith('refs/tags/')) + throw new Error(`unsupported outgoing ref ${update.remoteRef}`); + return update; +} + +function validatedPrePushUpdates(input) { + return normalizePrePushUpdates(input).map(validatePrePushUpdate); +} + +// QNBS-v3: normalize every public input form through one fail-closed parser. +export function normalizePrePushUpdates(input) { + if (typeof input === 'string') { + if (input === '') return []; + const lines = input.split(/\r?\n/).filter((line) => line.length > 0); + if (lines.length === 0) throw new Error('invalid pre-push ref-update input'); + const updates = lines.map(parseRefUpdate); + if (updates.some((update) => !update)) throw new Error('invalid pre-push ref-update input'); + return updates; + } + if (!Array.isArray(input)) throw new Error('pre-push input must be text or an update array'); + if (input.length === 0) return []; + if (input.every((item) => typeof item === 'string')) + return normalizePrePushUpdates(input.join('\n')); + if (input.every(isRefUpdate)) return input; + throw new Error('pre-push update array contains an invalid record'); +} + +export function serializePrePushEvidence(input) { + return JSON.stringify({ version: 1, updates: validatedPrePushUpdates(input) }); +} + +export function parsePrePushEvidence(serialized) { + if (typeof serialized !== 'string' || serialized.length === 0) + throw new Error('pre-push evidence artifact is empty'); + let document; + try { + document = JSON.parse(serialized); + } catch { + throw new Error('pre-push evidence artifact is not valid JSON'); + } + if (document?.version !== 1 || !Array.isArray(document.updates)) + throw new Error('pre-push evidence artifact has an unsupported shape'); + return normalizePrePushUpdates(document.updates); +} + +export function readPrePushEvidenceFile(file) { + if (typeof file !== 'string' || file.length === 0) + throw new Error('pre-push evidence file is missing'); + return parsePrePushEvidence(readFileSync(file, 'utf8')); +} + +export function writePrePushEvidenceFile(file, input) { + if (typeof file !== 'string' || file.length === 0) + throw new Error('pre-push evidence file is missing'); + writeFileSync(file, serializePrePushEvidence(input), { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); +} + +function changedFilesBetween(base, head, cwd) { + const result = runGit(['diff', '--no-renames', '--name-only', '-z', base, head, '--'], { cwd }); + if (result.status !== 0) throw new Error('cannot resolve changed paths for outgoing ref'); + return result.stdout.split('\0').filter((path) => path.length > 0); +} + +export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {}) { + try { + const updates = validatedPrePushUpdates(input); + const commitExists = + dependencies.commitExists ?? + ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{commit}`], { cwd }).status === 0); + const objectExists = + dependencies.objectExists ?? + ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{object}`], { cwd }).status === 0); + const resolveFiles = + dependencies.changedFilesBetween ?? ((base, head) => changedFilesBetween(base, head, cwd)); + const changedFiles = new Set(); + const evidenceUpdates = []; + for (const update of updates) { + if (isZeroSha(update.localSha)) { + evidenceUpdates.push({ ...update, disposition: 'DELETED' }); + continue; + } + if (update.remoteRef.startsWith('refs/tags/')) { + if (!objectExists(update.localSha)) + throw new Error(`local outgoing object is unavailable for ${update.localRef}`); + evidenceUpdates.push({ ...update, disposition: 'TAG' }); + continue; + } + if (!commitExists(update.localSha)) + throw new Error(`local outgoing commit is unavailable for ${update.localRef}`); + const base = isZeroSha(update.remoteSha) ? EMPTY_TREE : update.remoteSha; + if (base !== EMPTY_TREE && !commitExists(base)) + throw new Error(`remote base commit is unavailable for ${update.remoteRef}`); + for (const path of resolveFiles(base, update.localSha)) changedFiles.add(path); + evidenceUpdates.push({ + ...update, + base, + disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED', + }); + } + return { updates: evidenceUpdates, changedFiles: [...changedFiles], evidenceState: 'RESOLVED' }; + } catch (error) { + return { + updates: [], + changedFiles: [], + evidenceState: 'INVALID', + reason: error instanceof Error ? error.message : 'invalid push evidence', + }; + } +} + function refSha(ref, cwd) { const sha = gitOutput(['rev-parse', '--verify', `${ref}^{commit}`], { cwd }); return isSha(sha) ? sha : null; @@ -339,36 +471,40 @@ export function classifyTagVerification({ : commitVerification; } -export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd(), dependencies = {}) { +export function verifyOutgoingUpdates(input, remote, cwd = process.cwd(), dependencies = {}) { const verifyCommit = dependencies.verifyCommitObject ?? ((sha) => verifyCommitObject(sha, cwd)); const verifyTag = dependencies.verifyTagObject ?? ((sha) => verifyTagObject(sha, cwd)); const getIntroducedCommits = dependencies.introducedCommits ?? ((update) => introducedCommits(update, remote, cwd)); - const updates = lines.map(parseRefUpdate); - if (updates.some((update) => !update)) - return { ok: false, reason: 'invalid pre-push ref-update input' }; const reports = []; - for (const update of updates) { - if (isZeroSha(update.localSha)) continue; - if (!isSha(update.localSha)) - return { ok: false, reason: `invalid outgoing SHA for ${update.remoteRef}` }; - if (update.remoteRef.startsWith('refs/tags/')) { - const verification = verifyTag(update.localSha); - reports.push({ sha: update.localSha, subject: update.remoteRef, verification }); - if (!verification.ok) - return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` }; - continue; - } - const commits = getIntroducedCommits(update); - for (const sha of commits) { - const verification = verifyCommit(sha); - const report = { sha, subject: commitSubject(sha, cwd), verification }; - reports.push(report); - if (!verification.ok) - return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` }; + try { + const updates = validatedPrePushUpdates(input); + for (const update of updates) { + if (isZeroSha(update.localSha)) continue; + if (update.remoteRef.startsWith('refs/tags/')) { + const verification = verifyTag(update.localSha); + reports.push({ sha: update.localSha, subject: update.remoteRef, verification }); + if (!verification.ok) + return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` }; + continue; + } + const commits = getIntroducedCommits(update); + for (const sha of commits) { + const verification = verifyCommit(sha); + const report = { sha, subject: commitSubject(sha, cwd), verification }; + reports.push(report); + if (!verification.ok) + return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` }; + } } + return { ok: true, reports }; + } catch (error) { + return { + ok: false, + reports, + reason: error instanceof Error ? error.message : 'invalid pre-push ref-update input', + }; } - return { ok: true, reports }; } export function safeConfigSummary(cwd = process.cwd()) { diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs index a499b9eca..7d3946281 100644 --- a/scripts/signing/verify-outgoing.mjs +++ b/scripts/signing/verify-outgoing.mjs @@ -1,20 +1,27 @@ #!/usr/bin/env node import process from 'node:process'; -import { verifyOutgoingUpdates } from './signing-core.mjs'; +import { readPrePushEvidenceFile, verifyOutgoingUpdates } from './signing-core.mjs'; -const remote = process.argv[2]; +const args = process.argv.slice(2); +const remote = args[0]; +const evidenceIndex = args.indexOf('--prepush-evidence-file'); +const evidenceFile = evidenceIndex >= 0 ? args[evidenceIndex + 1] : null; if (!remote) { console.error( 'pre-push signing check requires the remote name. Run "pnpm run hooks:install" to refresh the installed hook.', ); process.exit(1); } -let input = ''; -process.stdin.setEncoding('utf8'); -for await (const chunk of process.stdin) input += chunk; -const lines = input.split(/\r?\n/).filter(Boolean); try { - const result = verifyOutgoingUpdates(lines, remote); + let input; + if (evidenceIndex >= 0) input = readPrePushEvidenceFile(evidenceFile); + else { + let stream = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) stream += chunk; + input = stream; + } + const result = verifyOutgoingUpdates(input, remote); if (!result.ok) { console.error(`pre-push signing check rejected the update: ${result.reason}`); process.exit(1); diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index c14f30c7c..18c0d3a03 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { classifyCommitObject, @@ -5,12 +8,18 @@ import { getSigningConfig, hasCommitSignature, isGitHubCompatibleEmail, + normalizePrePushUpdates, outgoingBaseShas, + parsePrePushEvidence, parseRefUpdate, pushCommitShas, pushEventRange, + readPrePushEvidenceFile, + resolvePushEvidence, selectIntroducedCommits, + serializePrePushEvidence, verifyOutgoingUpdates, + writePrePushEvidenceFile, } from '../../scripts/signing/signing-core.mjs'; import { hasCompleteCommitRange, @@ -107,6 +116,54 @@ describe('local signing controls', () => { }, ), ).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.0' }] }); + expect( + verifyOutgoingUpdates( + [`refs/tags/v1.0.1 ${commit} refs/tags/v1.0.1 ${remote}`], + 'origin', + process.cwd(), + { verifyTagObject: () => ({ ok: true, reason: 'tag and target commit verified' }) }, + ), + ).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.1' }] }); + let tagVerificationCalled = false; + expect( + verifyOutgoingUpdates( + [ + { + localRef: 'refs/tags/v1.0.2', + localSha: commit, + remoteRef: 'refs/tags/v1.0.2', + remoteSha: 'invalid-remote-sha', + }, + ], + 'origin', + process.cwd(), + { + verifyTagObject: () => { + tagVerificationCalled = true; + return { ok: true, reason: 'tag and target commit verified' }; + }, + }, + ), + ).toMatchObject({ ok: false }); + expect(tagVerificationCalled).toBe(false); + expect( + verifyOutgoingUpdates( + [ + { + localRef: 'refs/heads/main', + localSha: commit, + remoteRef: 'refs/heads/main', + remoteSha: 'invalid-remote-sha', + }, + ], + 'origin', + process.cwd(), + { + introducedCommits: () => [commit], + verifyCommitObject: () => ({ ok: true, reason: 'Git-native signature verified' }), + }, + ), + ).toMatchObject({ ok: false }); expect( verifyOutgoingUpdates( [`refs/heads/main ${commit} refs/heads/main ${remote}`], @@ -123,6 +180,132 @@ describe('local signing controls', () => { ).toMatchObject({ ok: true, reports: [{ sha: commit }] }); }); + // QNBS-v3: lock the pre-push evidence contract against malformed or unresolved input. + it('normalizes raw, line-array, structured, and empty public inputs', () => { + const zero = '0'.repeat(40); + const line = `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`; + const structured = parseRefUpdate(line)!; + expect(normalizePrePushUpdates('')).toEqual([]); + expect(normalizePrePushUpdates(line)).toEqual([structured]); + expect(normalizePrePushUpdates([line])).toEqual([structured]); + expect(normalizePrePushUpdates([structured])).toEqual([structured]); + expect( + verifyOutgoingUpdates('', 'origin', process.cwd(), { + introducedCommits: () => { + throw new Error('no-op must not enumerate commits'); + }, + }), + ).toMatchObject({ ok: true, reports: [] }); + expect( + normalizePrePushUpdates(`refs/heads/deleted ${zero} refs/heads/deleted ${'c'.repeat(40)}`), + ).toHaveLength(1); + expect(() => normalizePrePushUpdates('malformed')).toThrow('invalid pre-push ref-update input'); + expect(() => normalizePrePushUpdates('\n')).toThrow('invalid pre-push ref-update input'); + }); + + it('resolves branch, new-branch, deletion, tag, and multi-ref evidence losslessly', () => { + const zero = '0'.repeat(40); + const updates = [ + parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`)!, + parseRefUpdate(`refs/heads/new ${'c'.repeat(40)} refs/heads/new ${zero}`)!, + parseRefUpdate(`refs/heads/deleted ${zero} refs/heads/deleted ${'d'.repeat(40)}`)!, + parseRefUpdate(`refs/tags/v1 ${'e'.repeat(40)} refs/tags/v1 ${zero}`)!, + ]; + const result = resolvePushEvidence(updates, process.cwd(), { + commitExists: (sha) => sha !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + objectExists: () => true, + changedFilesBetween: (base) => + base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + ? ['new\nfile.ts'] + : ['src/with\t tab.ts', '世界 file.ts', 'src/with\t tab.ts'], + }); + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.updates.map(({ disposition }) => disposition)).toEqual([ + 'UPDATED', + 'NEW_BRANCH', + 'DELETED', + 'TAG', + ]); + expect(result.changedFiles).toEqual(['src/with\t tab.ts', '世界 file.ts', 'new\nfile.ts']); + }); + + it('fails closed for missing objects, bases, Git failures, and unsupported evidence', () => { + const update = parseRefUpdate( + `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`, + )!; + expect(resolvePushEvidence([update]).evidenceState).toBe('INVALID'); + expect( + resolvePushEvidence([update], process.cwd(), { + commitExists: (sha) => sha === 'a'.repeat(40), + }).evidenceState, + ).toBe('INVALID'); + expect( + resolvePushEvidence([update], process.cwd(), { + commitExists: () => true, + changedFilesBetween: () => { + throw new Error('git diff failed'); + }, + }).evidenceState, + ).toBe('INVALID'); + expect( + resolvePushEvidence( + [ + parseRefUpdate( + `refs/remotes/origin/x ${'a'.repeat(40)} refs/remotes/origin/x ${'b'.repeat(40)}`, + )!, + ], + process.cwd(), + { commitExists: () => true }, + ).evidenceState, + ).toBe('INVALID'); + expect( + resolvePushEvidence([ + parseRefUpdate( + `refs/remotes/origin/x ${'0'.repeat(40)} refs/remotes/origin/x ${'b'.repeat(40)}`, + )!, + ]).evidenceState, + ).toBe('INVALID'); + }); + + it('round-trips the same immutable artifact for both consumers', () => { + let dir: string; + try { + dir = mkdtempSync(join(tmpdir(), 'worldscript-s3a-test-')); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('read-only file system')) + throw error; + dir = mkdtempSync(join(process.cwd(), '.worldscript-s3a-test-')); + } + const file = join(dir, 'evidence.json'); + const line = `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`; + try { + writePrePushEvidenceFile(file, [line]); + expect(statSync(file).mode & 0o777).toBe(0o600); + expect(() => writePrePushEvidenceFile(file, [line])).toThrow(); + const serialized = readFileSync(file, 'utf8'); + expect(parsePrePushEvidence(serialized)).toEqual(normalizePrePushUpdates([line])); + expect(readPrePushEvidenceFile(file)).toEqual(normalizePrePushUpdates([line])); + expect(() => readPrePushEvidenceFile(join(dir, 'missing.json'))).toThrow(); + writeFileSync(file, '{"version":1,"updates":', { flag: 'w' }); + expect(() => readPrePushEvidenceFile(file)).toThrow('not valid JSON'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + expect(() => readPrePushEvidenceFile(file)).toThrow(); + }); + + it('keeps large multi-ref evidence in the artifact contract, not process environment', () => { + const updates = Array.from({ length: 200 }, (_, index) => ({ + localRef: `refs/heads/feature-${index}`, + localSha: index.toString(16).padStart(40, '0'), + remoteRef: `refs/heads/feature-${index}`, + remoteSha: '0'.repeat(40), + })); + const artifact = serializePrePushEvidence(updates); + expect(artifact.length).toBeGreaterThan(10_000); + expect(parsePrePushEvidence(artifact)).toEqual(updates); + }); + it('derives an exact push range and rejects an unverified earlier commit', () => { const before = '0'.repeat(40); const after = 'f'.repeat(40);