From 7517c4a2a1c3040e93133b1651ebb6f9fd602109 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:35:29 +0200 Subject: [PATCH 1/2] fix: transport exact pre-push evidence --- scripts/ci-prepush-lowend.mjs | 19 +++++ scripts/hooks/pre-push.mjs | 14 ++++ scripts/signing/signing-core.d.mts | 28 ++++++- scripts/signing/signing-core.mjs | 112 +++++++++++++++++++++++++++- scripts/signing/verify-outgoing.mjs | 21 ++++-- tests/unit/signing.test.ts | 53 +++++++++++++ 6 files changed, 235 insertions(+), 12 deletions(-) diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 20ba9d4bb..ba8a2facb 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -1,5 +1,24 @@ import process from 'node:process'; import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs'; +import { parseSerializedPrePushUpdates, resolvePushEvidence } from './signing/signing-core.mjs'; + +const serializedUpdates = process.env.WORLD_SCRIPT_PREPUSH_UPDATES; +if (serializedUpdates) { + let evidence; + try { + evidence = resolvePushEvidence(parseSerializedPrePushUpdates(serializedUpdates), process.cwd()); + } catch (error) { + console.error(`[local-lowend] outgoing evidence capture failed closed: ${error.message}`); + process.exit(1); + } + if (evidence.evidenceState !== 'RESOLVED') { + console.error(`[local-lowend] outgoing evidence is invalid: ${evidence.reason}`); + process.exit(1); + } + console.log( + `[local-lowend] outgoing evidence resolved: ${evidence.updates.length} update(s), ${evidence.changedFiles.length} changed path(s)`, + ); +} 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..8dff3de12 100644 --- a/scripts/hooks/pre-push.mjs +++ b/scripts/hooks/pre-push.mjs @@ -1,6 +1,20 @@ import process from 'node:process'; +import { parsePrePushInput, serializePrePushUpdates } from '../signing/signing-core.mjs'; import { runNodeScript } from './shared.mjs'; +let input = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) input += chunk; +try { + const updates = parsePrePushInput(input); + process.env.WORLD_SCRIPT_PREPUSH_UPDATES = serializePrePushUpdates(updates); +} catch (error) { + console.error( + `pre-push evidence capture failed closed: ${error instanceof Error ? error.message : 'invalid input'}`, + ); + process.exit(1); +} + if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0) process.exit(1); process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs')); diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index 715a9d42c..fb232da67 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -71,6 +71,27 @@ 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 parsePrePushInput(input: string): RefUpdate[]; +export function serializePrePushUpdates(updates: RefUpdate[]): string; +export function parseSerializedPrePushUpdates(serialized: string): RefUpdate[]; +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; + 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 +113,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 +135,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..dab72c23f 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -245,6 +245,99 @@ export function parseRefUpdate(line) { return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] }; } +// QNBS-v3: parse the hook stream once so signing and admission consume identical push evidence. +export function parsePrePushInput(input) { + if (typeof input !== 'string') throw new Error('pre-push input must be text'); + const lines = input.split(/\r?\n/).filter((line) => line.length > 0); + if (lines.length === 0) throw new Error('pre-push input is empty'); + const updates = lines.map(parseRefUpdate); + if (updates.some((update) => !update)) throw new Error('invalid pre-push ref-update input'); + return updates; +} + +export function serializePrePushUpdates(updates) { + if (!Array.isArray(updates) || updates.length === 0) + throw new Error('pre-push updates are empty'); + const lines = updates.map((update) => { + const fields = [update.localRef, update.localSha, update.remoteRef, update.remoteSha]; + if (fields.some((field) => typeof field !== 'string' || field.length === 0)) + throw new Error('pre-push update contains an invalid field'); + return fields.join(' '); + }); + return JSON.stringify(lines); +} + +export function parseSerializedPrePushUpdates(serialized) { + if (typeof serialized !== 'string' || serialized.length === 0) + throw new Error('serialized pre-push input is missing'); + let lines; + try { + lines = JSON.parse(serialized); + } catch { + throw new Error('serialized pre-push input is not valid JSON'); + } + if (!Array.isArray(lines) || lines.some((line) => typeof line !== 'string')) + throw new Error('serialized pre-push input must contain text lines'); + return parsePrePushInput(lines.join('\n')); +} + +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 = Array.isArray(input) ? input : parsePrePushInput(input); + if (updates.length === 0 || updates.some((update) => !update)) + throw new Error('pre-push updates are empty or invalid'); + const commitExists = + dependencies.commitExists ?? + ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{commit}`], { 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 ( + (!isSha(update.localSha) && !isZeroSha(update.localSha)) || + (!isSha(update.remoteSha) && !isZeroSha(update.remoteSha)) + ) + throw new Error(`invalid SHA in update for ${update.remoteRef}`); + if (isZeroSha(update.localSha)) { + evidenceUpdates.push({ ...update, disposition: 'DELETED' }); + continue; + } + if (!commitExists(update.localSha)) + throw new Error(`local outgoing object is unavailable for ${update.localRef}`); + if (update.remoteRef.startsWith('refs/tags/')) { + evidenceUpdates.push({ ...update, disposition: 'TAG' }); + continue; + } + if (!update.remoteRef.startsWith('refs/heads/')) + throw new Error(`unsupported outgoing ref ${update.remoteRef}`); + const base = isZeroSha(update.remoteSha) ? EMPTY_TREE : update.remoteSha; + if (!isZeroSha(base) && !commitExists(base)) + throw new Error(`remote base object 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,14 +432,25 @@ 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' }; + let updates; + try { + updates = + Array.isArray(input) && input.every((item) => typeof item === 'string') + ? parsePrePushInput(input.join('\n')) + : input; + if (!Array.isArray(updates) || updates.length === 0 || updates.some((update) => !update)) + throw new Error('invalid pre-push ref-update input'); + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : 'invalid pre-push ref-update input', + }; + } const reports = []; for (const update of updates) { if (isZeroSha(update.localSha)) continue; diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs index a499b9eca..45d23e795 100644 --- a/scripts/signing/verify-outgoing.mjs +++ b/scripts/signing/verify-outgoing.mjs @@ -1,6 +1,10 @@ #!/usr/bin/env node import process from 'node:process'; -import { verifyOutgoingUpdates } from './signing-core.mjs'; +import { + parsePrePushInput, + parseSerializedPrePushUpdates, + verifyOutgoingUpdates, +} from './signing-core.mjs'; const remote = process.argv[2]; if (!remote) { @@ -9,12 +13,17 @@ if (!remote) { ); 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 updates; + const serialized = process.env.WORLD_SCRIPT_PREPUSH_UPDATES; + if (serialized) updates = parseSerializedPrePushUpdates(serialized); + else { + let input = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) input += chunk; + updates = parsePrePushInput(input); + } + const result = verifyOutgoingUpdates(updates, 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..afe8ea9f9 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -6,10 +6,14 @@ import { hasCommitSignature, isGitHubCompatibleEmail, outgoingBaseShas, + parsePrePushInput, parseRefUpdate, + parseSerializedPrePushUpdates, pushCommitShas, pushEventRange, + resolvePushEvidence, selectIntroducedCommits, + serializePrePushUpdates, verifyOutgoingUpdates, } from '../../scripts/signing/signing-core.mjs'; import { @@ -41,6 +45,55 @@ describe('local signing controls', () => { expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toBeNull(); }); + it('round-trips one canonical structured update stream for both consumers', () => { + const updates = parsePrePushInput( + `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}\n`, + ); + expect(parseSerializedPrePushUpdates(serializePrePushUpdates(updates))).toEqual(updates); + }); + + it('resolves committed paths independently of a clean or dirty worktree', () => { + const update = parseRefUpdate( + `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`, + )!; + const result = resolvePushEvidence([update], process.cwd(), { + commitExists: () => true, + changedFilesBetween: () => ['src/with\nnewline.ts', '世界 file.ts', 'with\t tab.ts'], + }); + expect(result).toMatchObject({ evidenceState: 'RESOLVED' }); + expect(result.changedFiles).toEqual(['src/with\nnewline.ts', '世界 file.ts', 'with\t tab.ts']); + }); + + it('handles new branches, deletions, multiple refs, and invalid input explicitly', () => { + const zero = '0'.repeat(40); + const branch = parseRefUpdate(`refs/heads/new ${'a'.repeat(40)} refs/heads/new ${zero}`)!; + const deletion = parseRefUpdate(`refs/heads/old ${zero} refs/heads/old ${'b'.repeat(40)}`)!; + const result = resolvePushEvidence([branch, deletion], process.cwd(), { + commitExists: () => true, + changedFilesBetween: (base) => + base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' ? ['new.ts'] : [], + }); + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.updates.map(({ disposition }) => disposition)).toEqual(['NEW_BRANCH', 'DELETED']); + expect(result.changedFiles).toEqual(['new.ts']); + expect(resolvePushEvidence('malformed').evidenceState).toBe('INVALID'); + }); + + it('fails closed for missing objects and Git path-resolution failures', () => { + 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: () => true, + changedFilesBetween: () => { + throw new Error('git diff failed'); + }, + }).evidenceState, + ).toBe('INVALID'); + }); + it('checks only commits introduced beyond the remote tracking base', () => { const base = 'a'.repeat(40); const introduced = 'b'.repeat(40); From 3a4e5f9417f6fd350c33e899a5fd60dfe82c7b61 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:45:31 +0200 Subject: [PATCH 2/2] fix: close S3a evidence review findings --- README.md | 8 ++++---- scripts/signing/signing-core.mjs | 8 ++++++-- tests/unit/signing.test.ts | 10 +++++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ceaf3c849..42975d34a 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 + 6958+ 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 (6958+ 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 (6958+ 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 +- **6958+ 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/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index dab72c23f..55dc244d4 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -289,7 +289,11 @@ function changedFilesBetween(base, head, cwd) { export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {}) { try { - const updates = Array.isArray(input) ? input : parsePrePushInput(input); + const updates = Array.isArray(input) + ? input.every((item) => typeof item === 'string') + ? parsePrePushInput(input.join('\n')) + : input + : parsePrePushInput(input); if (updates.length === 0 || updates.some((update) => !update)) throw new Error('pre-push updates are empty or invalid'); const commitExists = @@ -318,7 +322,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { if (!update.remoteRef.startsWith('refs/heads/')) throw new Error(`unsupported outgoing ref ${update.remoteRef}`); const base = isZeroSha(update.remoteSha) ? EMPTY_TREE : update.remoteSha; - if (!isZeroSha(base) && !commitExists(base)) + if (!isZeroSha(base) && base !== EMPTY_TREE && !commitExists(base)) throw new Error(`remote base object is unavailable for ${update.remoteRef}`); for (const path of resolveFiles(base, update.localSha)) changedFiles.add(path); evidenceUpdates.push({ diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index afe8ea9f9..96a2c7f94 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -69,7 +69,7 @@ describe('local signing controls', () => { const branch = parseRefUpdate(`refs/heads/new ${'a'.repeat(40)} refs/heads/new ${zero}`)!; const deletion = parseRefUpdate(`refs/heads/old ${zero} refs/heads/old ${'b'.repeat(40)}`)!; const result = resolvePushEvidence([branch, deletion], process.cwd(), { - commitExists: () => true, + commitExists: (sha) => sha !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904', changedFilesBetween: (base) => base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' ? ['new.ts'] : [], }); @@ -77,6 +77,14 @@ describe('local signing controls', () => { expect(result.updates.map(({ disposition }) => disposition)).toEqual(['NEW_BRANCH', 'DELETED']); expect(result.changedFiles).toEqual(['new.ts']); expect(resolvePushEvidence('malformed').evidenceState).toBe('INVALID'); + const localSha = 'a'.repeat(40); + const line = `refs/heads/new ${localSha} refs/heads/new ${zero}`; + const legacyResult = resolvePushEvidence([line], process.cwd(), { + commitExists: (sha) => sha !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + changedFilesBetween: (base, head) => + base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' && head === localSha ? ['new.ts'] : [], + }); + expect(legacyResult).toMatchObject({ evidenceState: 'RESOLVED', changedFiles: ['new.ts'] }); }); it('fails closed for missing objects and Git path-resolution failures', () => {