diff --git a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift index 233df0ce..7c36ea3b 100644 --- a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift +++ b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift @@ -307,8 +307,13 @@ func hundredRunLedgerDecodesAndRendersWithinTheNativeGate() throws { let decodeP95 = percentile95(decodeSamples) let renderP95 = percentile95(renderSamples) - #expect(decodeP95 < 25_000, "100-run decoding must stay below 25 ms p95") - #expect(renderP95 < 150_000, "100-run/100-response host rendering must stay below 150 ms p95") + if nativePerformanceGateEnabled() { + #expect(decodeP95 < 25_000, "100-run decoding must stay below 25 ms p95") + #expect( + renderP95 < 150_000, + "100-run/100-response host rendering must stay below 150 ms p95" + ) + } print( "NATIVE_RUN_LEDGER_BENCHMARK_JSON " + "{\"decode_p95_us\":\(decodeP95),\"render_p95_us\":\(renderP95)," @@ -881,6 +886,10 @@ func percentile95(_ values: [UInt64]) -> UInt64 { return ordered[min(max(nearestRank - 1, 0), ordered.count - 1)] } +func nativePerformanceGateEnabled() -> Bool { + ProcessInfo.processInfo.environment["CODEVETTER_NATIVE_PERFORMANCE_GATE"] == "1" +} + func unpackFixturePayload( snapshotCount: Int, nodeCount: Int, diff --git a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift index b6133fe3..4122d442 100644 --- a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift +++ b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift @@ -2738,8 +2738,10 @@ func largeUnpackProjectionDecodesAndRendersWithinTheNativeGate() throws { let decodeP95 = percentile95(decodeSamples) let renderP95 = percentile95(renderSamples) - #expect(decodeP95 < 40_000, "Large Repo Unpack decoding must stay below 40 ms p95") - #expect(renderP95 < 150_000, "Large Repo Unpack rendering must stay below 150 ms p95") + if nativePerformanceGateEnabled() { + #expect(decodeP95 < 40_000, "Large Repo Unpack decoding must stay below 40 ms p95") + #expect(renderP95 < 150_000, "Large Repo Unpack rendering must stay below 150 ms p95") + } print( "NATIVE_UNPACK_BENCHMARK_JSON " + "{\"decode_p95_us\":\(decodeP95),\"render_p95_us\":\(renderP95)," @@ -3103,8 +3105,10 @@ func largeUsageReportDecodesAndRendersWithinTheNativeGate() throws { let decodeP95 = percentile95(decodeSamples) let renderP95 = percentile95(renderSamples) - #expect(decodeP95 < 25_000, "Large usage decoding must stay below 25 ms p95") - #expect(renderP95 < 150_000, "Large usage rendering must stay below 150 ms p95") + if nativePerformanceGateEnabled() { + #expect(decodeP95 < 25_000, "Large usage decoding must stay below 25 ms p95") + #expect(renderP95 < 150_000, "Large usage rendering must stay below 150 ms p95") + } print( "NATIVE_USAGE_BENCHMARK_JSON " + "{\"decode_p95_us\":\(decodeP95),\"render_p95_us\":\(renderP95)," @@ -3355,8 +3359,10 @@ func hundredRowPerformanceReceiptDecodesAndRendersWithinTheNativeGate() throws { let decodeP95 = percentile95(decodeSamples) let renderP95 = percentile95(renderSamples) - #expect(decodeP95 < 25_000, "100-row performance decoding must stay below 25 ms p95") - #expect(renderP95 < 150_000, "100-row performance rendering must stay below 150 ms p95") + if nativePerformanceGateEnabled() { + #expect(decodeP95 < 25_000, "100-row performance decoding must stay below 25 ms p95") + #expect(renderP95 < 150_000, "100-row performance rendering must stay below 150 ms p95") + } print( "NATIVE_PERFORMANCE_BENCHMARK_JSON " + "{\"decode_p95_us\":\(decodeP95),\"render_p95_us\":\(renderP95)," @@ -3647,8 +3653,10 @@ func hundredJourneyTestingReceiptDecodesAndRendersWithinTheNativeGate() throws { let decodeP95 = percentile95(decodeSamples) let renderP95 = percentile95(renderSamples) - #expect(decodeP95 < 25_000, "100-journey receipt decoding must stay below 25 ms p95") - #expect(renderP95 < 150_000, "100-journey receipt rendering must stay below 150 ms p95") + if nativePerformanceGateEnabled() { + #expect(decodeP95 < 25_000, "100-journey receipt decoding must stay below 25 ms p95") + #expect(renderP95 < 150_000, "100-journey receipt rendering must stay below 150 ms p95") + } print( "NATIVE_TESTING_BENCHMARK_JSON " + "{\"decode_p95_us\":\(decodeP95),\"render_p95_us\":\(renderP95)," diff --git a/knip.json b/knip.json index 61c94e41..0b9a9a28 100644 --- a/knip.json +++ b/knip.json @@ -34,13 +34,18 @@ "ccusage" ], "ignoreBinaries": [ + "ditto", "du", "go", "lipo", "lsof", "netstat", + "osascript", + "plutil", "ps", "rustc", + "sips", + "spctl", "swift", "tauri", "xcode-select" diff --git a/scripts/compare-desktop-runtime.mjs b/scripts/compare-desktop-runtime.mjs new file mode 100644 index 00000000..9f066fd7 --- /dev/null +++ b/scripts/compare-desktop-runtime.mjs @@ -0,0 +1,652 @@ +#!/usr/bin/env node + +import { execFile, execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const installedApplication = '/Applications/CodeVetter.app'; +const defaultOutputRoot = join(repositoryRoot, 'artifacts/performance'); +const numericArgumentKeys = { + '--runs': 'runs', + '--settle-ms': 'settleMs', + '--window-timeout-ms': 'windowTimeoutMs', +}; + +const expectedApplications = { + native: { + bundleIdentifier: 'com.codevetter.desktop.native-preview', + executable: 'CodeVetterNative', + performanceMarker: 'Measure what changed.', + markerStrategy: 'native-tree', + }, + tauri: { + bundleIdentifier: 'com.codevetter.desktop', + executable: 'codevetter-desktop', + performanceMarker: 'Choose a workload to measure', + markerStrategy: 'tauri-web-area', + }, +}; + +export function parseArguments(argv) { + const options = { + nativeApp: null, + tauriApp: null, + output: null, + runs: 5, + settleMs: 5_000, + windowTimeoutMs: 20_000, + foregroundApproved: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') continue; + if (argument === '--native-app') { + options.nativeApp = resolve(requiredValue(argv, ++index, argument)); + } else if (argument === '--tauri-app') { + options.tauriApp = resolve(requiredValue(argv, ++index, argument)); + } else if (argument === '--out') { + options.output = resolve(requiredValue(argv, ++index, argument)); + } else if (numericArgumentKeys[argument]) { + options[numericArgumentKeys[argument]] = positiveInteger( + requiredValue(argv, ++index, argument), + argument + ); + } else if (argument === '--foreground') { + options.foregroundApproved = true; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + + if (!options.nativeApp) throw new Error('--native-app is required'); + if (!options.tauriApp) throw new Error('--tauri-app is required'); + if (options.runs < 3) throw new Error('--runs must be at least 3'); + if (!options.foregroundApproved) { + throw new Error( + 'Matched runtime comparison requires --foreground because it launches and controls visible application windows.' + ); + } + return options; +} + +export function assertSafeApplicationPath(applicationPath) { + const resolved = resolve(applicationPath); + if (resolved === installedApplication || resolved.startsWith('/Applications/')) { + throw new Error(`Refusing to launch an installed application: ${resolved}`); + } + if (!resolved.endsWith('.app')) { + throw new Error(`Expected a macOS .app bundle: ${resolved}`); + } + if (!existsSync(resolved) || !statSync(resolved).isDirectory()) { + throw new Error(`Application bundle is missing: ${resolved}`); + } + const canonical = realpathSync(resolved); + if (canonical === installedApplication || canonical.startsWith('/Applications/')) { + throw new Error(`Refusing to follow an application path into /Applications: ${canonical}`); + } + return canonical; +} + +export function assertBundleInfo(kind, info) { + const expected = expectedApplications[kind]; + if (!expected) throw new Error(`Unknown application kind: ${kind}`); + if (info.CFBundleIdentifier !== expected.bundleIdentifier) { + throw new Error( + `${kind} bundle identifier must be ${expected.bundleIdentifier}; received ${info.CFBundleIdentifier ?? 'missing'}` + ); + } + if (info.CFBundleExecutable !== expected.executable) { + throw new Error( + `${kind} executable must be ${expected.executable}; received ${info.CFBundleExecutable ?? 'missing'}` + ); + } +} + +export function parseProcessTable(text) { + return text + .split('\n') + .map((line) => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/); + if (!match) return null; + return { + pid: Number(match[1]), + ppid: Number(match[2]), + rssKiB: Number(match[3]), + command: match[4], + }; + }) + .filter(Boolean); +} + +export function processTree(processes, rootPid) { + const byParent = new Map(); + for (const process of processes) { + const children = byParent.get(process.ppid) ?? []; + children.push(process); + byParent.set(process.ppid, children); + } + + const root = processes.find((process) => process.pid === rootPid); + if (!root) return []; + const result = []; + const pending = [root]; + const seen = new Set(); + while (pending.length > 0) { + const process = pending.shift(); + if (seen.has(process.pid)) continue; + seen.add(process.pid); + result.push(process); + pending.push(...(byParent.get(process.pid) ?? [])); + } + return result; +} + +export function summarize(values) { + if (!Array.isArray(values) || values.length === 0) { + throw new Error('Cannot summarize an empty sample set'); + } + const sorted = [...values].sort((left, right) => left - right); + const median = + sorted.length % 2 === 1 + ? sorted[(sorted.length - 1) / 2] + : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2; + const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1); + return { + samples: values, + minimum: round(sorted[0]), + median: round(median), + average: round(values.reduce((sum, value) => sum + value, 0) / values.length), + p95: round(sorted[p95Index]), + maximum: round(sorted.at(-1)), + }; +} + +export function comparisonOrder(runs) { + return Array.from({ length: runs }, (_, index) => + index % 2 === 0 ? ['native', 'tauri'] : ['tauri', 'native'] + ); +} + +export async function compareDesktopRuntime(options = parseArguments(process.argv.slice(2))) { + const applications = { + native: inspectApplication('native', options.nativeApp), + tauri: inspectApplication('tauri', options.tauriApp), + }; + if (applications.native.path === applications.tauri.path) { + throw new Error('Native and Tauri application paths must be distinct'); + } + + const recordedAt = new Date(); + const runRoot = mkdtempSync(join(ensureDirectory(defaultOutputRoot), 'desktop-runtime-')); + const samples = { native: [], tauri: [] }; + const order = comparisonOrder(options.runs); + + for (let round = 0; round < order.length; round += 1) { + for (const kind of order[round]) { + const stateDirectory = mkdtempSync(join(runRoot, `${round + 1}-${kind}-`)); + process.stderr.write(`Round ${round + 1}/${options.runs}: ${kind}\n`); + samples[kind].push( + await measureApplication({ + kind, + application: applications[kind], + stateDirectory, + settleMs: options.settleMs, + windowTimeoutMs: options.windowTimeoutMs, + round: round + 1, + }) + ); + } + } + + const report = { + schema_version: 'codevetter.desktop-runtime-comparison/v1', + recorded_at: recordedAt.toISOString(), + status: 'measured', + source: sourceIdentity(), + machine: machineIdentity(), + configuration: { + runs_per_application: options.runs, + settle_ms: options.settleMs, + window_timeout_ms: options.windowTimeoutMs, + foreground_approved: options.foregroundApproved, + surface: 'Performance', + order, + launch: 'exact bundle executable; no LaunchServices open command', + isolation: 'unique CODEVETTER_APP_DATA_DIR per sample', + }, + applications, + samples, + summary: { + native: summarizeApplication(samples.native), + tauri: summarizeApplication(samples.tauri), + }, + comparison: comparisonSummary(samples), + claim_boundary: + 'Same-machine Release-bundle comparison of exact-executable launch to first visible window and one settled Performance workspace observation. It is not XCTest first-responsive-frame, scrolling, workload-execution, energy, or long-session evidence.', + limitations: [ + 'The Tauri Performance route is selected through its shipped Command-K palette; the native route is selected by its repository-owned qualification launch argument.', + 'First visible window is observed through macOS accessibility and is not equivalent to XCTest ApplicationFirstFramePresentationResponsive.', + 'RSS is one settled observation of the recursively owned process tree and may miss short-lived descendants between launch and the settled sample.', + 'CODEVETTER_APP_DATA_DIR isolates SQLite and generated artifacts. The incumbent Tauri release still uses its normal WebKit data store and normal release background behavior.', + 'No installed application, updater, deployment, release, or production configuration is touched.', + ], + }; + + const output = + options.output ?? + join(runRoot, `native-tauri-comparison-${recordedAt.toISOString().replaceAll(':', '-')}.json`); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${output}\n`); + return { report, output }; +} + +function inspectApplication(kind, applicationPath) { + const path = assertSafeApplicationPath(applicationPath); + const info = readPlist(join(path, 'Contents/Info.plist')); + assertBundleInfo(kind, info); + const executable = realpathSync(join(path, 'Contents/MacOS', info.CFBundleExecutable)); + if (!statSync(executable).isFile()) + throw new Error(`Application executable is missing: ${executable}`); + return { + path, + bundle_identifier: info.CFBundleIdentifier, + version: info.CFBundleShortVersionString, + build: info.CFBundleVersion, + executable, + executable_bytes: statSync(executable).size, + bundle_kib: Number(run('du', ['-sk', path]).trim().split(/\s+/)[0]), + }; +} + +async function measureApplication({ + kind, + application, + stateDirectory, + settleMs, + windowTimeoutMs, + round, +}) { + const launchArguments = kind === 'native' ? ['--ui-test-section', 'Performance'] : []; + const startedAt = process.hrtime.bigint(); + const child = spawn(application.executable, launchArguments, { + cwd: repositoryRoot, + detached: true, + env: { + ...process.env, + CODEVETTER_APP_DATA_DIR: stateDirectory, + CODEVETTER_RUNTIME_COMPARISON: '1', + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-4_000); + }); + + try { + await childSpawned(child); + const firstVisibleWindowMs = await waitForVisibleWindow(child.pid, startedAt, windowTimeoutMs); + if (kind === 'tauri') await openTauriPerformanceWorkspace(child.pid); + await waitForWindowText( + child.pid, + expectedApplications[kind].performanceMarker, + expectedApplications[kind].markerStrategy, + windowTimeoutMs + ); + await delay(settleMs); + + const table = parseProcessTable(run('ps', ['-axo', 'pid=,ppid=,rss=,command='])); + const ownedTree = processTree(table, child.pid); + if (ownedTree.length === 0) { + throw new Error(`${kind} exited before the settled resource sample`); + } + assertExactProcess(ownedTree[0], application.executable); + return { + round, + pid: child.pid, + first_visible_window_ms: roundNumber(firstVisibleWindowMs), + surface_confirmed: true, + surface_marker: expectedApplications[kind].performanceMarker, + settled_parent_rss_kib: ownedTree[0].rssKiB, + settled_process_tree_rss_kib: ownedTree.reduce((sum, row) => sum + row.rssKiB, 0), + settled_process_count: ownedTree.length, + process_tree: ownedTree.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + rss_kib: row.rssKiB, + executable: row.command.split(/\s+/)[0], + })), + state_directory: stateDirectory, + }; + } catch (error) { + error.message = `${error.message}${stderr ? `\n${stderr}` : ''}`; + throw error; + } finally { + await terminateOwnedProcess(child, application.executable); + } +} + +async function waitForWindowText(pid, expectedText, strategy, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (childExited(pid)) { + throw new Error(`Application process ${pid} exited before the Performance surface appeared`); + } + const found = await windowTextProbe(pid, expectedText, strategy); + if (found === '1') return; + await delay(100); + } + throw new Error( + `Application process ${pid} did not expose the expected Performance marker within ${timeoutMs} ms: ${expectedText}` + ); +} + +function windowTextProbe(pid, expectedText, strategy) { + if (strategy === 'tauri-web-area') { + return appleScript( + [ + 'on run argv', + 'set targetPid to (item 1 of argv) as integer', + 'set expectedText to item 2 of argv', + 'tell application "System Events"', + 'set targetProcess to first process whose unix id is targetPid', + 'if (count of windows of targetProcess) is 0 then return "0"', + 'try', + 'set rootGroup to first UI element of front window of targetProcess whose role is "AXGroup"', + 'set webArea to first UI element of first UI element of first UI element of rootGroup', + 'if (count of UI elements of webArea) < 2 then return "0"', + 'set contentGroup to UI element 2 of webArea', + 'set matches to every UI element of contentGroup whose name is expectedText', + 'return (count of matches) as text', + 'on error', + 'return "0"', + 'end try', + 'end tell', + 'end run', + ], + [String(pid), expectedText] + ); + } + return appleScript( + [ + 'on run argv', + 'set targetPid to (item 1 of argv) as integer', + 'set expectedText to item 2 of argv', + 'tell application "System Events"', + 'set targetProcess to first process whose unix id is targetPid', + 'if (count of windows of targetProcess) is 0 then return "0"', + 'set allItems to entire contents of front window of targetProcess', + 'repeat with uiItem in allItems', + 'try', + 'if ((name of uiItem) as text) contains expectedText then return "1"', + 'end try', + 'try', + 'if ((value of uiItem) as text) contains expectedText then return "1"', + 'end try', + 'end repeat', + 'return "0"', + 'end tell', + 'end run', + ], + [String(pid), expectedText] + ); +} + +async function waitForVisibleWindow(pid, startedAt, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (childExited(pid)) + throw new Error(`Application process ${pid} exited before showing a window`); + const count = Number( + await appleScript( + [ + 'on run argv', + 'set targetPid to (item 1 of argv) as integer', + 'tell application "System Events"', + 'set matches to every process whose unix id is targetPid', + 'if (count of matches) is 0 then return "0"', + 'set targetProcess to item 1 of matches', + 'return (count of windows of targetProcess) as text', + 'end tell', + 'end run', + ], + [String(pid)] + ) + ); + if (count > 0) return elapsedMilliseconds(startedAt); + await delay(25); + } + throw new Error(`Application process ${pid} did not show a window within ${timeoutMs} ms`); +} + +async function openTauriPerformanceWorkspace(pid) { + await appleScript( + [ + 'on run argv', + 'set targetPid to (item 1 of argv) as integer', + 'tell application "System Events"', + 'set targetProcess to first process whose unix id is targetPid', + 'set frontmost of targetProcess to true', + 'delay 0.15', + 'keystroke "k" using command down', + 'delay 0.25', + 'keystroke "Performance"', + 'delay 0.15', + 'key code 36', + 'end tell', + 'end run', + ], + [String(pid)] + ); +} + +async function appleScript(lines, arguments_) { + const args = lines.flatMap((line) => ['-e', line]); + args.push('--', ...arguments_); + const { stdout } = await execFileAsync('osascript', args, { + cwd: repositoryRoot, + encoding: 'utf8', + timeout: 5_000, + }); + return stdout.trim(); +} + +async function terminateOwnedProcess(child, executable) { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + const command = processCommand(child.pid); + if (!command) return; + if (!sameExecutable(command, executable)) { + throw new Error( + `Refusing to terminate PID ${child.pid}; executable identity changed to ${command}` + ); + } + try { + process.kill(-child.pid, 'SIGTERM'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + return; + } + if (await waitForExit(child, 5_000)) return; + const current = processCommand(child.pid); + if (!current || !sameExecutable(current, executable)) return; + process.kill(-child.pid, 'SIGKILL'); + await waitForExit(child, 2_000); +} + +function summarizeApplication(samples) { + return { + first_visible_window_ms: summarize(samples.map((sample) => sample.first_visible_window_ms)), + settled_parent_rss_kib: summarize(samples.map((sample) => sample.settled_parent_rss_kib)), + settled_process_tree_rss_kib: summarize( + samples.map((sample) => sample.settled_process_tree_rss_kib) + ), + settled_process_count: summarize(samples.map((sample) => sample.settled_process_count)), + }; +} + +function comparisonSummary(samples) { + const native = summarizeApplication(samples.native); + const tauri = summarizeApplication(samples.tauri); + return { + first_visible_window_median_delta_ms: round( + native.first_visible_window_ms.median - tauri.first_visible_window_ms.median + ), + first_visible_window_median_ratio: ratio( + native.first_visible_window_ms.median, + tauri.first_visible_window_ms.median + ), + settled_process_tree_rss_median_delta_kib: round( + native.settled_process_tree_rss_kib.median - tauri.settled_process_tree_rss_kib.median + ), + settled_process_tree_rss_median_ratio: ratio( + native.settled_process_tree_rss_kib.median, + tauri.settled_process_tree_rss_kib.median + ), + }; +} + +function sourceIdentity() { + return { + base_sha: run('git', ['rev-parse', 'HEAD']).trim(), + branch: run('git', ['branch', '--show-current']).trim(), + working_tree: run('git', ['status', '--porcelain']).trim() ? 'dirty' : 'clean', + node: process.version, + }; +} + +function machineIdentity() { + return { + platform: process.platform, + architecture: process.arch, + model: safeRun('sysctl', ['-n', 'hw.model']), + logical_cpu: Number(safeRun('sysctl', ['-n', 'hw.logicalcpu'])), + memory_bytes: Number(safeRun('sysctl', ['-n', 'hw.memsize'])), + macos: safeRun('sw_vers', ['-productVersion']), + }; +} + +function readPlist(path) { + return JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', path])); +} + +function assertExactProcess(processRow, executable) { + if (!sameExecutable(processRow.command, executable)) { + throw new Error( + `PID ${processRow.pid} does not belong to the expected executable: ${processRow.command}` + ); + } +} + +function sameExecutable(command, executable) { + return command === executable || command.startsWith(`${executable} `); +} + +function processCommand(pid) { + const value = safeRun('ps', ['-p', String(pid), '-o', 'command=']); + return value || null; +} + +function childExited(pid) { + try { + process.kill(pid, 0); + return false; + } catch (error) { + if (error.code === 'ESRCH') return true; + throw error; + } +} + +function childSpawned(child) { + return new Promise((resolve_, reject) => { + if (child.pid) resolve_(); + else { + child.once('spawn', resolve_); + child.once('error', reject); + } + }); +} + +function waitForExit(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolve_) => { + const timeout = setTimeout(() => { + child.off('exit', onExit); + resolve_(false); + }, timeoutMs); + function onExit() { + clearTimeout(timeout); + resolve_(true); + } + child.once('exit', onExit); + }); +} + +function requiredValue(argv, index, argument) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + return value; +} + +function positiveInteger(value, argument) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${argument} requires a positive integer`); + } + return parsed; +} + +function ensureDirectory(path) { + mkdirSync(path, { recursive: true }); + return path; +} + +function run(command, args) { + return execFileSync(command, args, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function safeRun(command, args) { + try { + return run(command, args).trim(); + } catch { + return 'unavailable'; + } +} + +function elapsedMilliseconds(startedAt) { + return Number(process.hrtime.bigint() - startedAt) / 1_000_000; +} + +function roundNumber(value) { + return Math.round(value * 1_000) / 1_000; +} + +function round(value) { + return Math.round(value * 1_000) / 1_000; +} + +function ratio(numerator, denominator) { + return denominator === 0 ? null : round(numerator / denominator); +} + +function delay(milliseconds) { + return new Promise((resolve_) => setTimeout(resolve_, milliseconds)); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + compareDesktopRuntime().catch((error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/compare-desktop-runtime.test.mjs b/scripts/compare-desktop-runtime.test.mjs new file mode 100644 index 00000000..800fddda --- /dev/null +++ b/scripts/compare-desktop-runtime.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertBundleInfo, + assertSafeApplicationPath, + comparisonOrder, + parseArguments, + parseProcessTable, + processTree, + summarize, +} from './compare-desktop-runtime.mjs'; + +test('argument parsing requires explicit app paths and bounded repeated samples', () => { + assert.throws(() => parseArguments([]), /--native-app is required/); + assert.throws( + () => + parseArguments([ + '--native-app', + '/tmp/Native.app', + '--tauri-app', + '/tmp/Tauri.app', + '--runs', + '2', + ]), + /at least 3/ + ); + assert.throws( + () => parseArguments(['--native-app', '/tmp/Native.app', '--tauri-app', '/tmp/Tauri.app']), + /requires --foreground/ + ); + const options = parseArguments([ + '--native-app', + '/tmp/Native.app', + '--tauri-app', + '/tmp/Tauri.app', + '--runs', + '7', + '--settle-ms', + '2500', + '--out', + '/tmp/runtime.json', + '--foreground', + ]); + assert.equal(options.runs, 7); + assert.equal(options.settleMs, 2500); + assert.equal(options.output, '/tmp/runtime.json'); + assert.equal(options.foregroundApproved, true); +}); + +test('installed and non-bundle application paths fail closed before launch', () => { + assert.throws( + () => assertSafeApplicationPath('/Applications/CodeVetter.app'), + /Refusing to launch/ + ); + assert.throws(() => assertSafeApplicationPath('/tmp/codevetter'), /Expected a macOS/); +}); + +test('bundle identity distinguishes the native preview from the Tauri incumbent', () => { + assert.doesNotThrow(() => + assertBundleInfo('native', { + CFBundleIdentifier: 'com.codevetter.desktop.native-preview', + CFBundleExecutable: 'CodeVetterNative', + }) + ); + assert.doesNotThrow(() => + assertBundleInfo('tauri', { + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleExecutable: 'codevetter-desktop', + }) + ); + assert.throws( + () => + assertBundleInfo('native', { + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleExecutable: 'codevetter-desktop', + }), + /bundle identifier/ + ); +}); + +test('process-table parsing and recursive ownership include only descendants', () => { + const rows = parseProcessTable(` + 10 1 100 /tmp/App + 11 10 50 /tmp/Child + 12 11 25 /tmp/Grandchild --flag + 20 1 999 /tmp/Unrelated +`); + assert.deepEqual( + processTree(rows, 10).map((row) => row.pid), + [10, 11, 12] + ); + assert.deepEqual(processTree(rows, 999), []); +}); + +test('summary uses nearest-rank p95 and preserves acquisition-order samples', () => { + assert.deepEqual(summarize([9, 1, 4, 2, 5]), { + samples: [9, 1, 4, 2, 5], + minimum: 1, + median: 4, + average: 4.2, + p95: 9, + maximum: 9, + }); + assert.deepEqual(summarize([1, 3, 5, 7]), { + samples: [1, 3, 5, 7], + minimum: 1, + median: 4, + average: 4, + p95: 7, + maximum: 7, + }); +}); + +test('launch order alternates to reduce temperature and ordering bias', () => { + assert.deepEqual(comparisonOrder(4), [ + ['native', 'tauri'], + ['tauri', 'native'], + ['native', 'tauri'], + ['tauri', 'native'], + ]); +}); diff --git a/scripts/create-native-notarization-proof.mjs b/scripts/create-native-notarization-proof.mjs new file mode 100644 index 00000000..da1bee58 --- /dev/null +++ b/scripts/create-native-notarization-proof.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isMainModule, parsePathArguments, readJSON } from './native-script-utils.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const proofSchema = 'codevetter.native-notarization-proof/v1'; + +export function parseArguments(argv) { + const paths = { + '--app': 'app', + '--archive': 'archive', + '--qualification': 'qualification', + '--submission': 'submission', + '--out': 'out', + }; + return parsePathArguments(argv, { paths, required: Object.keys(paths) }); +} + +export function buildNativeNotarizationProof({ + submission, + archiveSHA256, + qualification, + stapleValidated, + recordedAt = new Date().toISOString(), +}) { + const archiveQualified = (qualification.archives ?? []).some( + (archive) => archive.sha256 === archiveSHA256 + ); + const accepted = + String(submission.status).toLowerCase() === 'accepted' && + typeof submission.id === 'string' && + submission.id.length > 0; + if (!accepted) throw new Error(`Apple notarization was not accepted: ${submission.status}`); + if (!archiveQualified) throw new Error('The notarized archive is not bound to the qualification'); + if (!stapleValidated) throw new Error('The notarization ticket is not stapled and validated'); + return { + schema_version: proofSchema, + authority: 'apple_notary_service_and_stapler', + recorded_at: recordedAt, + status: 'accepted', + submission_id: submission.id, + archive_sha256: archiveSHA256, + stapled: true, + limitations: [ + 'This proof binds Apple acceptance and a validated ticket to the qualified archive and app.', + 'Publication and replacement of an installed application remain separate actions.', + ], + }; +} + +export function createNativeNotarizationProof(options = parseArguments(process.argv.slice(2))) { + const qualification = readJSON(options.qualification); + const submission = readJSON(options.submission); + const archiveSHA256 = createHash('sha256').update(readFileSync(options.archive)).digest('hex'); + const developerDirectory = execFileSync('xcode-select', ['-p'], { encoding: 'utf8' }).trim(); + const stapler = join(developerDirectory, 'usr/bin/stapler'); + const result = spawnSync(stapler, ['validate', options.app], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const proof = buildNativeNotarizationProof({ + submission, + archiveSHA256, + qualification, + stapleValidated: result.status === 0, + }); + writeFileSync(options.out, `${JSON.stringify(proof, null, 2)}\n`); + process.stdout.write(`${options.out}\n`); + return proof; +} + +if (isMainModule(import.meta.url)) createNativeNotarizationProof(); diff --git a/scripts/create-native-notarization-proof.test.mjs b/scripts/create-native-notarization-proof.test.mjs new file mode 100644 index 00000000..62469033 --- /dev/null +++ b/scripts/create-native-notarization-proof.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildNativeNotarizationProof, + parseArguments, +} from './create-native-notarization-proof.mjs'; + +const input = () => ({ + submission: { id: 'submission-1', status: 'Accepted' }, + archiveSHA256: 'a'.repeat(64), + qualification: { archives: [{ sha256: 'a'.repeat(64) }] }, + stapleValidated: true, + recordedAt: '2026-09-02T00:00:00.000Z', +}); + +test('accepted submission and validated staple produce an archive-bound proof', () => { + const proof = buildNativeNotarizationProof(input()); + assert.equal(proof.status, 'accepted'); + assert.equal(proof.stapled, true); + assert.equal(proof.archive_sha256, 'a'.repeat(64)); +}); + +test('rejected, unbound, and unstapled evidence fail closed', () => { + assert.throws( + () => buildNativeNotarizationProof({ ...input(), submission: { status: 'Invalid' } }), + /not accepted/ + ); + assert.throws( + () => + buildNativeNotarizationProof({ + ...input(), + archiveSHA256: 'b'.repeat(64), + }), + /not bound/ + ); + assert.throws( + () => buildNativeNotarizationProof({ ...input(), stapleValidated: false }), + /not stapled/ + ); +}); + +test('argument parsing requires every local evidence input', () => { + const options = parseArguments([ + '--app', + '/tmp/CodeVetter.app', + '--archive', + '/tmp/CodeVetter.zip', + '--qualification', + '/tmp/qualification.json', + '--submission', + '/tmp/submission.json', + '--out', + '/tmp/notarization.json', + ]); + assert.equal(options.out, '/tmp/notarization.json'); + assert.throws(() => parseArguments([]), /--app is required/); +}); diff --git a/scripts/finalize-native-package-archives.mjs b/scripts/finalize-native-package-archives.mjs new file mode 100644 index 00000000..e3fd3945 --- /dev/null +++ b/scripts/finalize-native-package-archives.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + fileArtifact, + isMainModule, + parsePathArguments, + readJSON, +} from './native-script-utils.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +export function parseArguments(argv) { + return parsePathArguments(argv, { + paths: { '--qualification': 'qualification' }, + required: ['--qualification'], + }); +} + +export function updateArchiveReceipt(qualification, artifacts) { + if (qualification.schema_version !== 'codevetter.native-package-qualification/v1') { + throw new Error('Unsupported native package qualification schema'); + } + const expected = new Set((qualification.archives ?? []).map((item) => item.name)); + if (expected.size !== artifacts.length || artifacts.some((item) => !expected.has(item.name))) { + throw new Error('Final archives do not match the qualified archive identities'); + } + return { + ...qualification, + archives: artifacts, + notarization_ticket_stapled: true, + }; +} + +export function finalizeNativePackageArchives( + options = parseArguments(process.argv.slice(2)), + run = runCommand +) { + const qualification = readJSON(options.qualification); + const directory = dirname(options.qualification); + const app = resolve(qualification.application?.path ?? ''); + if (app !== join(directory, 'CodeVetter.app')) { + throw new Error('The qualification does not bind the staged CodeVetter.app'); + } + const artifacts = []; + for (const archive of qualification.archives ?? []) { + const target = join(directory, archive.name); + const temporary = `${target}.finalizing`; + rmSync(temporary, { recursive: true, force: true }); + if (archive.name.endsWith('.zip')) { + run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', app, temporary]); + } else if (archive.name.endsWith('.dmg')) { + run('hdiutil', [ + 'create', + '-quiet', + '-volname', + 'CodeVetter', + '-srcfolder', + app, + '-format', + 'UDZO', + temporary, + ]); + } else { + throw new Error(`Unsupported native archive: ${archive.name}`); + } + renameSync(temporary, target); + artifacts.push(fileArtifact(target)); + } + const finalized = updateArchiveReceipt(qualification, artifacts); + writeFileSync(options.qualification, `${JSON.stringify(finalized, null, 2)}\n`); + process.stdout.write(`${options.qualification}\n`); + return finalized; +} + +function runCommand(command, arguments_) { + execFileSync(command, arguments_, { cwd: repositoryRoot, stdio: 'inherit' }); +} + +if (isMainModule(import.meta.url)) finalizeNativePackageArchives(); diff --git a/scripts/finalize-native-package-archives.test.mjs b/scripts/finalize-native-package-archives.test.mjs new file mode 100644 index 00000000..1050a4a2 --- /dev/null +++ b/scripts/finalize-native-package-archives.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseArguments, updateArchiveReceipt } from './finalize-native-package-archives.mjs'; + +test('final archive hashes replace only the exact qualified archive identities', () => { + const qualification = { + schema_version: 'codevetter.native-package-qualification/v1', + archives: [ + { name: 'CodeVetter-1.11.0-arm64.zip', sha256: 'old-zip' }, + { name: 'CodeVetter-1.11.0-arm64.dmg', sha256: 'old-dmg' }, + ], + }; + const artifacts = [ + { name: 'CodeVetter-1.11.0-arm64.zip', bytes: 10, sha256: 'new-zip' }, + { name: 'CodeVetter-1.11.0-arm64.dmg', bytes: 20, sha256: 'new-dmg' }, + ]; + const finalized = updateArchiveReceipt(qualification, artifacts); + assert.deepEqual(finalized.archives, artifacts); + assert.equal(finalized.notarization_ticket_stapled, true); + assert.throws(() => updateArchiveReceipt(qualification, artifacts.slice(0, 1)), /do not match/); +}); + +test('argument parsing requires one qualification receipt', () => { + assert.equal( + parseArguments(['--qualification', '/tmp/qualification.json']).qualification, + '/tmp/qualification.json' + ); + assert.throws(() => parseArguments([]), /--qualification is required/); +}); diff --git a/scripts/inspect-native-appcast.mjs b/scripts/inspect-native-appcast.mjs new file mode 100644 index 00000000..686ff578 --- /dev/null +++ b/scripts/inspect-native-appcast.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import { createHash, createPublicKey, verify } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isMainModule, parsePathArguments, readJSON, readPlist } from './native-script-utils.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaVersion = 'codevetter.native-appcast-qualification/v1'; +const qualificationSchema = 'codevetter.native-package-qualification/v1'; + +export function parseArguments(argv) { + return parsePathArguments(argv, { + paths: { + '--app': 'app', + '--appcast': 'appcast', + '--qualification': 'qualification', + '--out': 'out', + }, + required: ['--app', '--appcast', '--qualification'], + }); +} + +export function evaluateNativeAppcast({ xml, info, qualification, archiveBytes, archiveName }) { + if (qualification.schema_version !== qualificationSchema) { + throw new Error('Unsupported native package qualification schema'); + } + const enclosure = parseEnclosure(xml); + const publicKey = canonicalPublicKey(info.SUPublicEDKey); + const archive = (qualification.archives ?? []).find((item) => item.name === archiveName); + const archiveSHA256 = createHash('sha256').update(archiveBytes).digest('hex'); + const signature = Buffer.from(enclosure.signature, 'base64'); + const signatureValid = verify(null, archiveBytes, ed25519PublicKey(publicKey), signature); + const feedURL = new URL(info.SUFeedURL); + const archiveURL = new URL(enclosure.url); + + const checks = [ + check('production_bundle', info.CFBundleIdentifier === 'com.codevetter.desktop'), + check('https_feed', feedURL.protocol === 'https:'), + check('https_archive', archiveURL.protocol === 'https:'), + check('archive_name', basename(archiveURL.pathname) === archiveName), + check( + 'archive_receipt', + archive?.sha256 === archiveSHA256 && archive?.bytes === archiveBytes.length + ), + check('archive_length', enclosure.length === archiveBytes.length), + check('version', enclosure.version === info.CFBundleVersion), + check('short_version', enclosure.shortVersion === info.CFBundleShortVersionString), + check('signature', signature.length === 64 && signatureValid), + ]; + const blockers = checks.filter((item) => !item.passed).map((item) => item.id); + return { + schema_version: schemaVersion, + authority: 'offline_cryptographic_inspection', + status: blockers.length === 0 ? 'qualified' : 'blocked', + qualified: blockers.length === 0, + feed_url: feedURL.toString(), + archive: { + name: archiveName, + url: archiveURL.toString(), + bytes: archiveBytes.length, + sha256: archiveSHA256, + }, + application: { + bundle_identifier: info.CFBundleIdentifier, + version: info.CFBundleShortVersionString, + build: info.CFBundleVersion, + }, + public_key_sha256: createHash('sha256').update(publicKey).digest('hex'), + checks, + blockers, + limitations: [ + 'This receipt verifies the local appcast and archive without publishing either file.', + 'HTTPS reachability and installed updater behavior remain separate release gates.', + ], + }; +} + +export function inspectNativeAppcast(options = parseArguments(process.argv.slice(2))) { + const info = readPlist(join(options.app, 'Contents/Info.plist'), repositoryRoot); + const qualification = readJSON(options.qualification); + const xml = readFileSync(options.appcast, 'utf8'); + const enclosure = parseEnclosure(xml); + const archiveName = basename(new URL(enclosure.url).pathname); + const archivePath = join(dirname(options.appcast), archiveName); + const receipt = evaluateNativeAppcast({ + xml, + info, + qualification, + archiveBytes: readFileSync(archivePath), + archiveName, + }); + const output = `${JSON.stringify(receipt, null, 2)}\n`; + if (options.out) writeFileSync(options.out, output); + process.stdout.write(output); + if (!receipt.qualified) process.exitCode = 1; + return receipt; +} + +function parseEnclosure(xml) { + const tag = xml.match(/]*>/i)?.[0]; + if (!tag) throw new Error('Sparkle appcast enclosure is missing'); + return { + url: requiredAttribute(tag, 'url'), + version: requiredAttribute(tag, 'sparkle:version'), + shortVersion: requiredAttribute(tag, 'sparkle:shortVersionString'), + length: Number(requiredAttribute(tag, 'length')), + signature: requiredAttribute(tag, 'sparkle:edSignature'), + }; +} + +function requiredAttribute(tag, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const value = tag.match(new RegExp(`\\s${escaped}="([^"]+)"`, 'i'))?.[1]; + if (!value) throw new Error(`Sparkle appcast enclosure is missing ${name}`); + return decodeEntities(value); +} + +function decodeEntities(value) { + const entities = { + '&': '&', + '"': '"', + '<': '<', + '>': '>', + }; + return value.replace(/&(amp|quot|lt|gt);/g, (entity) => entities[entity]); +} + +function canonicalPublicKey(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(value)) { + throw new Error('The application does not contain a canonical Sparkle EdDSA public key'); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.length !== 32 || decoded.toString('base64') !== value) { + throw new Error('The application does not contain a canonical Sparkle EdDSA public key'); + } + return decoded; +} + +function ed25519PublicKey(raw) { + const prefix = Buffer.from('302a300506032b6570032100', 'hex'); + return createPublicKey({ key: Buffer.concat([prefix, raw]), format: 'der', type: 'spki' }); +} + +function check(id, passed) { + return { id, passed: passed === true }; +} + +if (isMainModule(import.meta.url)) inspectNativeAppcast(); diff --git a/scripts/inspect-native-appcast.test.mjs b/scripts/inspect-native-appcast.test.mjs new file mode 100644 index 00000000..363e5f05 --- /dev/null +++ b/scripts/inspect-native-appcast.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import test from 'node:test'; + +import { evaluateNativeAppcast, parseArguments } from './inspect-native-appcast.mjs'; + +function fixture() { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const publicDER = publicKey.export({ format: 'der', type: 'spki' }); + const rawPublicKey = publicDER.subarray(-32).toString('base64'); + const archiveBytes = Buffer.from('qualified native archive'); + const signature = sign(null, archiveBytes, privateKey).toString('base64'); + const archiveName = 'CodeVetter-1.11.0-arm64.zip'; + const archiveSHA256 = createHash('sha256').update(archiveBytes).digest('hex'); + return { + xml: ``, + info: { + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleVersion: '11100', + CFBundleShortVersionString: '1.11.0', + SUFeedURL: 'https://github.com/Codevetter/codevetter/releases/latest/download/appcast.xml', + SUPublicEDKey: rawPublicKey, + }, + qualification: { + schema_version: 'codevetter.native-package-qualification/v1', + archives: [{ name: archiveName, bytes: archiveBytes.length, sha256: archiveSHA256 }], + }, + archiveBytes, + archiveName, + }; +} + +test('exact Sparkle archive signature and identities qualify offline', () => { + const receipt = evaluateNativeAppcast(fixture()); + assert.equal(receipt.status, 'qualified'); + assert.equal(receipt.qualified, true); + assert.deepEqual(receipt.blockers, []); +}); + +test('tampered archives and mismatched versions fail closed', () => { + const input = fixture(); + const receipt = evaluateNativeAppcast({ + ...input, + archiveBytes: Buffer.from('tampered native archive'), + info: { ...input.info, CFBundleVersion: '11101' }, + }); + assert.equal(receipt.qualified, false); + assert.deepEqual( + receipt.checks.filter((item) => !item.passed).map((item) => item.id), + ['archive_receipt', 'archive_length', 'version', 'signature'] + ); +}); + +test('appcast attributes are decoded exactly once', () => { + const input = fixture(); + const receipt = evaluateNativeAppcast({ + ...input, + xml: input.xml.replace( + `/${input.archiveName}\"`, + `/${input.archiveName}?label=a&quot;b\"` + ), + }); + + assert.equal(receipt.qualified, true); + assert.match(receipt.archive.url, /label=a"b$/); + assert.doesNotMatch(receipt.archive.url, /%22/); +}); + +test('argument parsing requires the app, appcast, and qualification', () => { + const options = parseArguments([ + '--app', + '/tmp/CodeVetter.app', + '--appcast', + '/tmp/appcast.xml', + '--qualification', + '/tmp/qualification.json', + '--out', + '/tmp/appcast-proof.json', + ]); + assert.equal(options.out, '/tmp/appcast-proof.json'); + assert.throws(() => parseArguments(['--app', '/tmp/CodeVetter.app']), /--appcast is required/); +}); diff --git a/scripts/inspect-native-release-readiness.mjs b/scripts/inspect-native-release-readiness.mjs new file mode 100644 index 00000000..cb76774b --- /dev/null +++ b/scripts/inspect-native-release-readiness.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from 'node:child_process'; +import { realpathSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isMainModule, parsePathArguments, readJSON, readPlist } from './native-script-utils.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const productionBundleIdentifier = 'com.codevetter.desktop'; +const qualificationSchema = 'codevetter.native-package-qualification/v1'; +const notarizationSchema = 'codevetter.native-notarization-proof/v1'; +const installedUpgradeSchema = 'codevetter.native-installed-upgrade-proof/v1'; +const dataContinuitySchema = 'codevetter.native-data-continuity/v1'; +const appcastSchema = 'codevetter.native-appcast-qualification/v1'; +const productionAppDataIdentity = 'com.codevetter.desktop'; + +export function parseArguments(argv) { + return parsePathArguments(argv, { + paths: { + '--app': 'app', + '--qualification': 'qualification', + '--notarization-proof': 'notarizationProof', + '--installed-proof': 'installedProof', + '--appcast-proof': 'appcastProof', + '--out': 'out', + }, + required: ['--app', '--qualification'], + }); +} + +export function evaluateNativeReleaseReadiness(input) { + const qualification = input.qualification; + const app = qualification.application ?? {}; + const archives = qualification.archives ?? []; + const notarization = input.notarizationProof; + const installed = input.installedProof; + const appcastProof = input.appcastProof; + const archiveHashes = new Set(archives.map((archive) => archive.sha256)); + const appVersion = input.info.CFBundleShortVersionString; + const appBuild = input.info.CFBundleVersion; + const requiredCompanions = ['ccusage', 'codevetter', 'codevetter-mcp']; + const packagedCompanions = (qualification.sidecars ?? []).map((sidecar) => sidecar.name).sort(); + const teamIdentifiers = [input.signature, ...input.companionSignatures] + .map((signature) => signature.teamIdentifier) + .filter(Boolean); + const consistentDeveloperTeam = + teamIdentifiers.length === input.companionSignatures.length + 1 && + new Set(teamIdentifiers).size === 1 && + input.signature.developerID === true && + input.companionSignatures.every((signature) => signature.developerID === true); + + const checks = [ + check( + 'qualification', + qualification.schema_version === qualificationSchema && + qualification.status === 'local_package_qualified' && + app.path === input.appPath + ), + check('deep_signature', input.deepSignatureValid), + check('production_bundle', input.info.CFBundleIdentifier === productionBundleIdentifier), + check('host_executable', input.info.CFBundleExecutable === 'CodeVetterNative'), + check('version_identity', app.version === appVersion && app.build === appBuild), + check('packaged_companions', arraysEqual(packagedCompanions, requiredCompanions)), + check('hardened_runtime', input.signature.hardenedRuntime), + check('developer_id_signature', input.signature.developerID), + check('consistent_developer_team', consistentDeveloperTeam), + check( + 'library_validation', + input.entitlements['com.apple.security.cs.disable-library-validation'] !== true + ), + check('execution_authority', input.entitlements['com.apple.security.app-sandbox'] !== true), + check('https_appcast', secureURL(input.info.SUFeedURL)), + check('sparkle_public_key', validSparklePublicKey(input.info.SUPublicEDKey)), + check( + 'sparkle_appcast', + validAppcastProof(appcastProof, { + feedURL: input.info.SUFeedURL, + appVersion, + appBuild, + archiveHashes, + }) + ), + check('gatekeeper', input.gatekeeper.accepted), + check( + 'notarization', + notarization?.schema_version === notarizationSchema && + notarization.status === 'accepted' && + notarization.stapled === true && + archiveHashes.has(notarization.archive_sha256) + ), + check( + 'installed_upgrade', + validInstalledUpgradeProof(installed, { + appVersion, + appBuild, + archiveHashes, + }) + ), + ]; + const blockers = checks.filter((item) => !item.passed).map((item) => blockerFor(item.id)); + return { + schema_version: 'codevetter.native-release-readiness/v1', + authority: 'read_only_inspection', + recorded_at: input.recordedAt ?? new Date().toISOString(), + status: blockers.length === 0 ? 'ready' : 'blocked', + shipping_ready: blockers.length === 0, + application: { + path: input.appPath, + bundle_identifier: input.info.CFBundleIdentifier ?? null, + version: appVersion ?? null, + build: appBuild ?? null, + executable: input.info.CFBundleExecutable ?? null, + signing: input.signature.kind, + team_identifier: input.signature.teamIdentifier ?? null, + }, + updater: { + feed_url: input.info.SUFeedURL ?? null, + public_key_configured: validSparklePublicKey(input.info.SUPublicEDKey), + }, + checks, + gatekeeper: input.gatekeeper, + blockers, + limitations: [ + 'This receipt only inspects supplied local artifacts and proof files.', + 'It never signs, notarizes, installs, publishes, enumerates identities, or reads credentials.', + ], + }; +} + +function validAppcastProof(proof, { feedURL, appVersion, appBuild, archiveHashes }) { + return ( + proof?.schema_version === appcastSchema && + proof.status === 'qualified' && + proof.qualified === true && + proof.feed_url === feedURL && + proof.application?.bundle_identifier === productionBundleIdentifier && + proof.application?.version === appVersion && + proof.application?.build === appBuild && + archiveHashes.has(proof.archive?.sha256) && + proof.checks?.every((item) => item.passed === true) + ); +} + +function validInstalledUpgradeProof(installed, { appVersion, appBuild, archiveHashes }) { + const continuity = installed?.data_continuity; + const before = continuity?.before_sha256; + return ( + installed?.schema_version === installedUpgradeSchema && + installed.status === 'passed' && + installed.bundle_identifier === productionBundleIdentifier && + installed.version === appVersion && + installed.build === appBuild && + archiveHashes.has(installed.archive_sha256) && + installed.upgrade === true && + installed.relaunch === true && + installed.rollback === true && + continuity?.schema_version === dataContinuitySchema && + continuity.app_data_identity === productionAppDataIdentity && + continuity.database_filename === 'codevetter.db' && + Number.isSafeInteger(continuity.preserved_record_count) && + continuity.preserved_record_count > 0 && + canonicalSHA256(before) && + continuity.after_upgrade_sha256 === before && + continuity.after_rollback_sha256 === before + ); +} + +export function inspectNativeReleaseReadiness(options = parseArguments(process.argv.slice(2))) { + const appPath = realpathSync(options.app); + const qualification = readJSON(options.qualification); + const info = readPlist(join(appPath, 'Contents/Info.plist'), repositoryRoot); + const signature = inspectSignature(appPath); + const companionSignatures = (qualification.sidecars ?? []).map((sidecar) => + inspectSignature(join(appPath, 'Contents/MacOS', sidecar.name)) + ); + const receipt = evaluateNativeReleaseReadiness({ + appPath, + qualification, + info, + signature, + companionSignatures, + entitlements: readEntitlements(appPath), + deepSignatureValid: verifyDeepSignature(appPath), + gatekeeper: inspectGatekeeper(appPath), + notarizationProof: options.notarizationProof ? readJSON(options.notarizationProof) : null, + installedProof: options.installedProof ? readJSON(options.installedProof) : null, + appcastProof: options.appcastProof ? readJSON(options.appcastProof) : null, + }); + const output = `${JSON.stringify(receipt, null, 2)}\n`; + if (options.out) writeFileSync(options.out, output); + process.stdout.write(output); + return receipt; +} + +function inspectSignature(path) { + const result = spawnSync('codesign', ['-d', '--verbose=4', path], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + const teamIdentifier = valueFor(output, 'TeamIdentifier'); + const authorities = output + .split('\n') + .filter((line) => line.startsWith('Authority=')) + .map((line) => line.slice('Authority='.length)); + const developerID = authorities.some((authority) => + authority.startsWith('Developer ID Application:') + ); + return { + valid: result.status === 0, + kind: output.includes('Signature=adhoc') ? 'ad_hoc' : developerID ? 'developer_id' : 'other', + developerID, + hardenedRuntime: output.includes('(adhoc,runtime)') || output.includes('(runtime)'), + teamIdentifier: teamIdentifier === 'not set' ? null : teamIdentifier, + }; +} + +function readEntitlements(appPath) { + try { + const plist = execFileSync('codesign', ['-d', '--entitlements', ':-', appPath], { + cwd: repositoryRoot, + encoding: null, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', '-'], { + cwd: repositoryRoot, + encoding: 'utf8', + input: plist, + }) + ); + } catch { + return {}; + } +} + +function verifyDeepSignature(appPath) { + return ( + spawnSync('codesign', ['--verify', '--deep', '--strict', appPath], { + cwd: repositoryRoot, + encoding: 'utf8', + }).status === 0 + ); +} + +function inspectGatekeeper(appPath) { + const result = spawnSync('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const detail = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); + return { accepted: result.status === 0, detail: detail.slice(0, 500) }; +} + +function check(id, passed) { + return { id, passed: passed === true }; +} + +function blockerFor(id) { + const messages = { + qualification: 'The package has not passed the local package qualifier.', + deep_signature: 'The staged application fails deep strict code-signature verification.', + production_bundle: 'The production bundle identifier has not transferred to the native app.', + host_executable: 'The native host executable collides with or differs from CodeVetterNative.', + version_identity: 'The app and package qualification version/build identities differ.', + packaged_companions: + 'The package does not contain exactly codevetter, codevetter-mcp, and ccusage.', + hardened_runtime: 'Hardened Runtime is not enabled in the staged application signature.', + developer_id_signature: 'The application is not signed by a Developer ID Application identity.', + consistent_developer_team: + 'The host and packaged companions do not share one Developer ID team.', + library_validation: 'Library Validation is disabled in the staged application.', + execution_authority: + 'App Sandbox is enabled and would remove required local execution authority.', + https_appcast: 'A production HTTPS Sparkle appcast is not configured.', + sparkle_public_key: 'A production Sparkle EdDSA public key is not configured.', + sparkle_appcast: + 'No offline-verified Sparkle appcast binds the production feed, key, version, and exact archive.', + gatekeeper: 'Gatekeeper does not accept the staged application.', + notarization: 'No archive-bound accepted and stapled notarization proof was supplied.', + installed_upgrade: + 'No archive-bound production-identity upgrade, relaunch, stable-data continuity, and rollback proof was supplied.', + }; + return messages[id]; +} + +function canonicalSHA256(value) { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +} + +function secureURL(value) { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +function validSparklePublicKey(value) { + if (typeof value !== 'string') return false; + const encoded = value.trim(); + if (!/^[A-Za-z0-9+/]{43}=$/.test(encoded)) return false; + try { + const decoded = Buffer.from(encoded, 'base64'); + return decoded.length === 32 && decoded.toString('base64') === encoded; + } catch { + return false; + } +} + +function arraysEqual(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function valueFor(output, key) { + return output + .split('\n') + .find((line) => line.startsWith(`${key}=`)) + ?.slice(key.length + 1); +} + +if (isMainModule(import.meta.url)) inspectNativeReleaseReadiness(); diff --git a/scripts/inspect-native-release-readiness.test.mjs b/scripts/inspect-native-release-readiness.test.mjs new file mode 100644 index 00000000..9819856f --- /dev/null +++ b/scripts/inspect-native-release-readiness.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + evaluateNativeReleaseReadiness, + parseArguments, +} from './inspect-native-release-readiness.mjs'; + +function input(overrides = {}) { + return { + appPath: '/fixture/CodeVetter.app', + recordedAt: '2026-09-02T00:00:00.000Z', + qualification: { + schema_version: 'codevetter.native-package-qualification/v1', + status: 'local_package_qualified', + application: { path: '/fixture/CodeVetter.app', version: '1.11.0', build: '11100' }, + sidecars: [{ name: 'codevetter' }, { name: 'codevetter-mcp' }, { name: 'ccusage' }], + archives: [{ sha256: 'archive-sha' }], + }, + info: { + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleExecutable: 'CodeVetterNative', + CFBundleShortVersionString: '1.11.0', + CFBundleVersion: '11100', + SUFeedURL: 'https://updates.example.test/appcast.xml', + SUPublicEDKey: Buffer.alloc(32, 7).toString('base64'), + }, + signature: { + kind: 'developer_id', + developerID: true, + hardenedRuntime: true, + teamIdentifier: 'TEAM123', + }, + companionSignatures: [ + { developerID: true, teamIdentifier: 'TEAM123' }, + { developerID: true, teamIdentifier: 'TEAM123' }, + { developerID: true, teamIdentifier: 'TEAM123' }, + ], + entitlements: {}, + deepSignatureValid: true, + gatekeeper: { accepted: true, detail: 'accepted' }, + notarizationProof: { + schema_version: 'codevetter.native-notarization-proof/v1', + status: 'accepted', + stapled: true, + archive_sha256: 'archive-sha', + }, + appcastProof: { + schema_version: 'codevetter.native-appcast-qualification/v1', + status: 'qualified', + qualified: true, + feed_url: 'https://updates.example.test/appcast.xml', + application: { + bundle_identifier: 'com.codevetter.desktop', + version: '1.11.0', + build: '11100', + }, + archive: { sha256: 'archive-sha' }, + checks: [{ id: 'signature', passed: true }], + }, + installedProof: { + schema_version: 'codevetter.native-installed-upgrade-proof/v1', + status: 'passed', + bundle_identifier: 'com.codevetter.desktop', + version: '1.11.0', + build: '11100', + archive_sha256: 'archive-sha', + upgrade: true, + relaunch: true, + rollback: true, + data_continuity: { + schema_version: 'codevetter.native-data-continuity/v1', + app_data_identity: 'com.codevetter.desktop', + database_filename: 'codevetter.db', + preserved_record_count: 3, + before_sha256: 'a'.repeat(64), + after_upgrade_sha256: 'a'.repeat(64), + after_rollback_sha256: 'a'.repeat(64), + }, + }, + ...overrides, + }; +} + +test('production evidence passes only when every release boundary is proven', () => { + const receipt = evaluateNativeReleaseReadiness(input()); + assert.equal(receipt.status, 'ready'); + assert.equal(receipt.shipping_ready, true); + assert.deepEqual(receipt.blockers, []); +}); + +test('preview package fails closed on identity, signing, updater, and release proof', () => { + const receipt = evaluateNativeReleaseReadiness( + input({ + info: { + CFBundleIdentifier: 'com.codevetter.desktop.native-preview', + CFBundleExecutable: 'CodeVetterNative', + CFBundleShortVersionString: '1.11.0', + CFBundleVersion: '11100', + }, + signature: { + kind: 'ad_hoc', + developerID: false, + hardenedRuntime: true, + teamIdentifier: null, + }, + companionSignatures: [ + { developerID: false, teamIdentifier: null }, + { developerID: false, teamIdentifier: null }, + { developerID: false, teamIdentifier: null }, + ], + entitlements: { 'com.apple.security.cs.disable-library-validation': true }, + gatekeeper: { accepted: false, detail: 'rejected' }, + notarizationProof: null, + appcastProof: null, + installedProof: null, + }) + ); + assert.equal(receipt.status, 'blocked'); + assert.equal(receipt.shipping_ready, false); + assert.equal(receipt.blockers.length, 10); +}); + +test('proof files must bind to the qualified archive, installed version, and build', () => { + const receipt = evaluateNativeReleaseReadiness( + input({ + notarizationProof: { + schema_version: 'codevetter.native-notarization-proof/v1', + status: 'accepted', + stapled: true, + archive_sha256: 'another-archive', + }, + installedProof: { + ...input().installedProof, + version: '1.10.0', + archive_sha256: 'another-archive', + }, + }) + ); + assert.deepEqual( + receipt.checks.filter((check) => !check.passed).map((check) => check.id), + ['notarization', 'installed_upgrade'] + ); +}); + +test('installed proof requires a stable non-empty data fingerprint through rollback', () => { + const malformedProofs = [ + { + ...input().installedProof, + data_continuity: { + ...input().installedProof.data_continuity, + preserved_record_count: 0, + }, + }, + { + ...input().installedProof, + data_continuity: { + ...input().installedProof.data_continuity, + app_data_identity: 'com.codevetter.desktop.native-preview', + }, + }, + { + ...input().installedProof, + data_continuity: { + ...input().installedProof.data_continuity, + after_upgrade_sha256: 'b'.repeat(64), + }, + }, + { + ...input().installedProof, + data_continuity: { + ...input().installedProof.data_continuity, + after_rollback_sha256: 'b'.repeat(64), + }, + }, + ]; + + for (const installedProof of malformedProofs) { + const receipt = evaluateNativeReleaseReadiness(input({ installedProof })); + assert.equal(receipt.checks.find((check) => check.id === 'installed_upgrade')?.passed, false); + } +}); + +test('package qualification must bind to the exact inspected application', () => { + const receipt = evaluateNativeReleaseReadiness( + input({ + qualification: { + ...input().qualification, + application: { + ...input().qualification.application, + path: '/fixture/Another.app', + }, + }, + }) + ); + assert.deepEqual( + receipt.checks.filter((check) => !check.passed).map((check) => check.id), + ['qualification'] + ); +}); + +test('Sparkle public key must be canonical base64 for exactly 32 bytes', () => { + const malformed = evaluateNativeReleaseReadiness( + input({ + info: { + ...input().info, + SUPublicEDKey: `${Buffer.alloc(32, 7).toString('base64')}ignored`, + }, + }) + ); + assert.equal(malformed.checks.find((check) => check.id === 'sparkle_public_key')?.passed, false); +}); + +test('appcast proof must bind the exact feed, version, build, and qualified archive', () => { + const malformed = evaluateNativeReleaseReadiness( + input({ + appcastProof: { + ...input().appcastProof, + application: { ...input().appcastProof.application, build: '11101' }, + archive: { sha256: 'another-archive' }, + }, + }) + ); + assert.equal(malformed.checks.find((check) => check.id === 'sparkle_appcast')?.passed, false); +}); + +test('argument parsing requires an app and qualification without reading credentials', () => { + const options = parseArguments([ + '--app', + '/tmp/CodeVetter.app', + '--qualification', + '/tmp/qualification.json', + '--notarization-proof', + '/tmp/notary.json', + '--installed-proof', + '/tmp/installed.json', + '--appcast-proof', + '/tmp/appcast.json', + '--out', + '/tmp/readiness.json', + ]); + assert.equal(options.app, '/tmp/CodeVetter.app'); + assert.equal(options.qualification, '/tmp/qualification.json'); + assert.equal(options.appcastProof, '/tmp/appcast.json'); + assert.throws(() => parseArguments(['--app', '/tmp/CodeVetter.app']), /qualification/); +}); diff --git a/scripts/native-script-utils.mjs b/scripts/native-script-utils.mjs new file mode 100644 index 00000000..462632f9 --- /dev/null +++ b/scripts/native-script-utils.mjs @@ -0,0 +1,51 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function parsePathArguments(argv, { paths, required = [] }) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') continue; + const property = paths[argument]; + if (!property) throw new Error(`Unknown argument: ${argument}`); + const value = argv[++index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + options[property] = resolve(value); + } + for (const argument of required) { + const property = paths[argument]; + if (!options[property]) throw new Error(`${argument} is required`); + } + return options; +} + +export function readJSON(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +export function readPlist(path, cwd) { + return JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', path], { + cwd, + encoding: 'utf8', + }) + ); +} + +export function fileArtifact(path) { + const bytes = readFileSync(path); + return { + name: basename(path), + bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +export function isMainModule(importMetaURL) { + return Boolean( + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(importMetaURL)) + ); +} diff --git a/scripts/owner-review-gallery.test.mjs b/scripts/owner-review-gallery.test.mjs new file mode 100644 index 00000000..496f51b8 --- /dev/null +++ b/scripts/owner-review-gallery.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const reviewRoot = resolve('evidence/design/native-acceptance-2026-09-01'); + +test('owner-review gallery contains every exact manifest render once', () => { + const manifest = JSON.parse(readFileSync(resolve(reviewRoot, 'owner-review-manifest.json'))); + const gallery = readFileSync(resolve(reviewRoot, 'gallery.html'), 'utf8'); + const galleryPaths = [...gallery.matchAll(/data-render="([^"]+)"/g)].map((match) => match[1]); + const manifestPaths = manifest.entries.map((entry) => entry.path); + + assert.equal(new Set(galleryPaths).size, galleryPaths.length); + assert.deepEqual(galleryPaths.toSorted(), manifestPaths.toSorted()); + for (const path of manifestPaths) { + assert.match(gallery, new RegExp(`href="${path}"`)); + assert.match(gallery, new RegExp(`src="${path}"`)); + } + assert.doesNotMatch(gallery, /https?:\/\//); +}); diff --git a/scripts/qualify-native-data-continuity.mjs b/scripts/qualify-native-data-continuity.mjs new file mode 100644 index 00000000..ad4c4a24 --- /dev/null +++ b/scripts/qualify-native-data-continuity.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { basename, dirname, resolve } from 'node:path'; +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const snapshotSchema = 'codevetter.native-data-snapshot/v1'; +const continuitySchema = 'codevetter.native-data-continuity/v1'; +const productionAppDataIdentity = 'com.codevetter.desktop'; +const sqlite = '/usr/bin/sqlite3'; +const pathArguments = new Map([ + ['--database', 'database'], + ['--baseline', 'baseline'], + ['--before', 'before'], + ['--after-upgrade', 'afterUpgrade'], + ['--after-rollback', 'afterRollback'], + ['--out', 'out'], +]); + +const durableIdentityTables = [ + ['cc_projects', 'id'], + ['cc_sessions', 'id'], + ['local_reviews', 'id'], + ['local_review_findings', 'id'], + ['trex_watchers', 'repo_path'], + ['trex_pr_runs', 'id'], + ['trex_preview_runs', 'id'], + ['repo_projects', 'id'], + ['repo_unpacked_reports', 'id'], + ['preferences', 'key'], + ['workspaces', 'id'], + ['agent_tasks', 'id'], + ['local_check_runs', 'run_id'], + ['managed_work_runs', 'id'], +]; + +export function parseArguments(argv) { + const operation = argv[0]; + if (!['capture', 'compare'].includes(operation)) { + throw new Error('Expected capture or compare'); + } + const options = { operation }; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') continue; + const pathKey = pathArguments.get(argument); + if (pathKey) { + options[pathKey] = resolve(requiredValue(argv, ++index, argument)); + continue; + } + if (argument === '--phase') { + options.phase = requiredValue(argv, ++index, argument); + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + validateArguments(options); + return options; +} + +function validateArguments(options) { + if (!options.out) throw new Error('--out is required'); + if (options.operation === 'capture') validateCaptureArguments(options); + else if (!options.before || !options.afterUpgrade || !options.afterRollback) { + throw new Error('compare requires --before, --after-upgrade, and --after-rollback'); + } +} + +function validateCaptureArguments(options) { + if (!options.database) throw new Error('--database is required'); + if (!['before', 'after_upgrade', 'after_rollback'].includes(options.phase)) { + throw new Error('--phase must be before, after_upgrade, or after_rollback'); + } + if (options.phase === 'before' && options.baseline) { + throw new Error('The before phase must not receive --baseline'); + } + if (options.phase !== 'before' && !options.baseline) { + throw new Error(`${options.phase} requires --baseline`); + } +} + +export function captureDataSnapshot({ + databasePath, + phase, + baseline = null, + recordedAt = new Date().toISOString(), + probeNonce = randomBytes(32).toString('hex'), + query = queryDatabase, +}) { + if (!['before', 'after_upgrade', 'after_rollback'].includes(phase)) { + throw new Error(`Unsupported data-continuity phase: ${phase}`); + } + const database = realpathSync(databasePath); + if (basename(database) !== 'codevetter.db') { + throw new Error('The continuity probe only accepts codevetter.db'); + } + if (basename(dirname(database)) !== productionAppDataIdentity) { + throw new Error(`The database parent must be ${productionAppDataIdentity}`); + } + if (phase === 'before' && baseline) throw new Error('Before capture cannot use a baseline'); + if (phase !== 'before') validateBaseline(baseline); + + const nonce = baseline?.probe_nonce ?? probeNonce; + if (!canonicalSHA256(nonce)) throw new Error('The probe nonce must be 32-byte lowercase hex'); + const rows = query(database); + const currentHashes = new Set( + rows.records.map(({ table, identity }) => recordHash(nonce, table, identity)) + ); + const expectedHashes = baseline?.record_hashes ?? [...currentHashes].sort(); + const preservedHashes = expectedHashes.filter((hash) => currentHashes.has(hash)).sort(); + const tableCounts = Object.fromEntries( + durableIdentityTables.map(([table]) => [table, rows.tableCounts[table] ?? 0]) + ); + + return { + schema_version: snapshotSchema, + authority: 'read_only_identity_probe', + recorded_at: recordedAt, + phase, + app_data_identity: productionAppDataIdentity, + database_filename: 'codevetter.db', + database_integrity: rows.integrity, + probe_nonce: nonce, + baseline_fingerprint_sha256: baseline?.fingerprint_sha256 ?? fingerprint(expectedHashes), + fingerprint_sha256: fingerprint(preservedHashes), + observed_record_count: currentHashes.size, + preserved_record_count: preservedHashes.length, + missing_record_count: expectedHashes.length - preservedHashes.length, + new_record_count: Math.max(0, currentHashes.size - preservedHashes.length), + table_counts: tableCounts, + record_hashes: preservedHashes, + limitations: [ + 'Hashes cover durable record identities only; no user content or setting values are read.', + 'The probe is read-only and does not launch, install, migrate, or roll back an application.', + ], + }; +} + +export function compareDataSnapshots(before, afterUpgrade, afterRollback) { + validateBaseline(before); + validateFollowup(afterUpgrade, 'after_upgrade', before); + validateFollowup(afterRollback, 'after_rollback', before); + if (before.preserved_record_count <= 0) { + throw new Error('Data continuity requires at least one durable baseline record'); + } + return { + schema_version: continuitySchema, + app_data_identity: productionAppDataIdentity, + database_filename: 'codevetter.db', + preserved_record_count: before.preserved_record_count, + before_sha256: before.fingerprint_sha256, + after_upgrade_sha256: afterUpgrade.fingerprint_sha256, + after_rollback_sha256: afterRollback.fingerprint_sha256, + }; +} + +function queryDatabase(database) { + if (!existsSync(sqlite)) throw new Error(`${sqlite} is unavailable`); + const integrity = runSqlite(database, 'PRAGMA query_only=ON; PRAGMA quick_check;').trim(); + if (integrity !== 'ok') throw new Error(`SQLite quick_check failed: ${integrity || 'no result'}`); + const tableRows = runJSON( + database, + "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;" + ); + const available = new Set(tableRows.map((row) => row.name)); + const records = []; + const tableCounts = {}; + for (const [table, key] of durableIdentityTables) { + if (!available.has(table)) continue; + const rows = runJSON( + database, + `SELECT CAST("${key}" AS TEXT) AS identity FROM "${table}" WHERE "${key}" IS NOT NULL ORDER BY 1;` + ); + tableCounts[table] = rows.length; + for (const row of rows) records.push({ table, identity: String(row.identity) }); + } + return { integrity, records, tableCounts }; +} + +function validateBaseline(snapshot) { + if ( + snapshot?.schema_version !== snapshotSchema || + snapshot.phase !== 'before' || + snapshot.authority !== 'read_only_identity_probe' || + snapshot.app_data_identity !== productionAppDataIdentity || + snapshot.database_filename !== 'codevetter.db' || + snapshot.database_integrity !== 'ok' || + !canonicalSHA256(snapshot.probe_nonce) || + !canonicalSHA256(snapshot.fingerprint_sha256) || + !Array.isArray(snapshot.record_hashes) || + snapshot.record_hashes.some((hash) => !canonicalSHA256(hash)) || + snapshot.preserved_record_count !== snapshot.record_hashes.length || + snapshot.missing_record_count !== 0 || + fingerprint(snapshot.record_hashes) !== snapshot.fingerprint_sha256 + ) { + throw new Error('Invalid before data snapshot'); + } +} + +function validateFollowup(snapshot, phase, before) { + if ( + snapshot?.schema_version !== snapshotSchema || + snapshot.phase !== phase || + snapshot.authority !== 'read_only_identity_probe' || + snapshot.app_data_identity !== before.app_data_identity || + snapshot.database_filename !== before.database_filename || + snapshot.database_integrity !== 'ok' || + snapshot.probe_nonce !== before.probe_nonce || + snapshot.baseline_fingerprint_sha256 !== before.fingerprint_sha256 || + snapshot.missing_record_count !== 0 || + snapshot.preserved_record_count !== before.preserved_record_count || + snapshot.fingerprint_sha256 !== before.fingerprint_sha256 || + fingerprint(snapshot.record_hashes) !== snapshot.fingerprint_sha256 + ) { + throw new Error(`${phase} does not preserve the baseline identity fingerprint`); + } +} + +function runJSON(database, sql) { + const output = runSqlite(database, sql, ['-json']); + return output.trim() ? JSON.parse(output) : []; +} + +function runSqlite(database, sql, extraArguments = []) { + const result = spawnSync(sqlite, ['-readonly', ...extraArguments, database, sql], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + throw new Error(`Read-only SQLite probe failed: ${(result.stderr ?? '').trim().slice(0, 500)}`); + } + return result.stdout ?? ''; +} + +function recordHash(nonce, table, identity) { + return sha256(`${nonce}\0${table}\0${identity}`); +} + +function fingerprint(hashes) { + return sha256([...hashes].sort().join('\n')); +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function canonicalSHA256(value) { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +} + +function requiredValue(argv, index, argument) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + return value; +} + +function readJSON(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function run(options = parseArguments(process.argv.slice(2))) { + const receipt = + options.operation === 'capture' + ? captureDataSnapshot({ + databasePath: options.database, + phase: options.phase, + baseline: options.baseline ? readJSON(options.baseline) : null, + }) + : compareDataSnapshots( + readJSON(options.before), + readJSON(options.afterUpgrade), + readJSON(options.afterRollback) + ); + const output = `${JSON.stringify(receipt, null, 2)}\n`; + writeFileSync(options.out, output); + process.stdout.write(output); +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) run(); diff --git a/scripts/qualify-native-data-continuity.test.mjs b/scripts/qualify-native-data-continuity.test.mjs new file mode 100644 index 00000000..c4264595 --- /dev/null +++ b/scripts/qualify-native-data-continuity.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + captureDataSnapshot, + compareDataSnapshots, + parseArguments, +} from './qualify-native-data-continuity.mjs'; + +function withFixture(run) { + const root = mkdtempSync(join(tmpdir(), 'codevetter-data-continuity-')); + const appData = join(root, 'com.codevetter.desktop'); + mkdirSync(appData); + const database = join(appData, 'codevetter.db'); + execFileSync('/usr/bin/sqlite3', [ + database, + `CREATE TABLE cc_projects(id TEXT PRIMARY KEY, display_name TEXT); + CREATE TABLE cc_sessions(id TEXT PRIMARY KEY, project_id TEXT, first_message TEXT); + CREATE TABLE local_reviews(id TEXT PRIMARY KEY, summary_markdown TEXT); + CREATE TABLE preferences(key TEXT PRIMARY KEY, value TEXT); + INSERT INTO cc_projects VALUES ('project-1', 'Private project'); + INSERT INTO cc_sessions VALUES ('session-1', 'project-1', 'private message'); + INSERT INTO local_reviews VALUES ('review-1', 'private review'); + INSERT INTO preferences VALUES ('github_token', 'secret-value');`, + ]); + try { + run({ root, database }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('read-only snapshots preserve incumbent identities while allowing new rows', () => { + withFixture(({ database }) => { + const before = captureDataSnapshot({ + databasePath: database, + phase: 'before', + recordedAt: '2026-09-02T00:00:00.000Z', + probeNonce: 'a'.repeat(64), + }); + execFileSync('/usr/bin/sqlite3', [ + database, + "INSERT INTO cc_sessions VALUES ('session-2', 'project-1', 'new private message');", + ]); + const afterUpgrade = captureDataSnapshot({ + databasePath: database, + phase: 'after_upgrade', + baseline: before, + recordedAt: '2026-09-02T00:01:00.000Z', + }); + execFileSync('/usr/bin/sqlite3', [database, "DELETE FROM cc_sessions WHERE id = 'session-2';"]); + const afterRollback = captureDataSnapshot({ + databasePath: database, + phase: 'after_rollback', + baseline: before, + recordedAt: '2026-09-02T00:02:00.000Z', + }); + + const continuity = compareDataSnapshots(before, afterUpgrade, afterRollback); + assert.equal(continuity.schema_version, 'codevetter.native-data-continuity/v1'); + assert.equal(continuity.preserved_record_count, 4); + assert.equal(continuity.before_sha256, continuity.after_upgrade_sha256); + assert.equal(continuity.before_sha256, continuity.after_rollback_sha256); + assert.equal(afterUpgrade.new_record_count, 1); + assert.equal(JSON.stringify(before).includes('secret-value'), false); + assert.equal(JSON.stringify(before).includes('private message'), false); + }); +}); + +test('comparison fails when an incumbent record disappears', () => { + withFixture(({ database }) => { + const before = captureDataSnapshot({ + databasePath: database, + phase: 'before', + probeNonce: 'b'.repeat(64), + }); + execFileSync('/usr/bin/sqlite3', [ + database, + "DELETE FROM local_reviews WHERE id = 'review-1';", + ]); + const afterUpgrade = captureDataSnapshot({ + databasePath: database, + phase: 'after_upgrade', + baseline: before, + }); + assert.equal(afterUpgrade.missing_record_count, 1); + assert.throws( + () => compareDataSnapshots(before, afterUpgrade, afterUpgrade), + /after_upgrade does not preserve/ + ); + }); +}); + +test('comparison refuses empty baseline evidence', () => { + const query = () => ({ integrity: 'ok', records: [], tableCounts: {} }); + withFixture(({ database }) => { + const before = captureDataSnapshot({ + databasePath: database, + phase: 'before', + probeNonce: 'c'.repeat(64), + query, + }); + const afterUpgrade = captureDataSnapshot({ + databasePath: database, + phase: 'after_upgrade', + baseline: before, + query, + }); + const afterRollback = captureDataSnapshot({ + databasePath: database, + phase: 'after_rollback', + baseline: before, + query, + }); + assert.throws( + () => compareDataSnapshots(before, afterUpgrade, afterRollback), + /at least one durable baseline record/ + ); + }); +}); + +test('argument parsing keeps capture and comparison phases explicit', () => { + const capture = parseArguments([ + 'capture', + '--database', + '/tmp/codevetter.db', + '--phase', + 'after_upgrade', + '--baseline', + '/tmp/before.json', + '--out', + '/tmp/after.json', + ]); + assert.equal(capture.phase, 'after_upgrade'); + assert.throws( + () => + parseArguments([ + 'capture', + '--database', + '/tmp/codevetter.db', + '--phase', + 'after_upgrade', + '--out', + '/tmp/after.json', + ]), + /requires --baseline/ + ); + assert.doesNotThrow(() => + parseArguments([ + 'compare', + '--before', + '/tmp/before.json', + '--after-upgrade', + '/tmp/upgrade.json', + '--after-rollback', + '/tmp/rollback.json', + '--out', + '/tmp/continuity.json', + ]) + ); +}); diff --git a/scripts/qualify-native-installed-upgrade.mjs b/scripts/qualify-native-installed-upgrade.mjs new file mode 100644 index 00000000..fe3d40e2 --- /dev/null +++ b/scripts/qualify-native-installed-upgrade.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +import { execFileSync, spawn } from 'node:child_process'; +import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { captureDataSnapshot, compareDataSnapshots } from './qualify-native-data-continuity.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const productionBundleIdentifier = 'com.codevetter.desktop'; +const proofSchema = 'codevetter.native-installed-upgrade-proof/v1'; +const qualificationSchema = 'codevetter.native-package-qualification/v1'; + +export function parseArguments(argv) { + const options = { foregroundApproved: false, hostedEphemeral: false }; + const valueArguments = new Map([ + ['--incumbent-app', 'incumbentApp'], + ['--native-app', 'nativeApp'], + ['--qualification', 'qualification'], + ['--run-root', 'runRoot'], + ['--out', 'out'], + ]); + const flagArguments = new Map([ + ['--foreground', 'foregroundApproved'], + ['--hosted-ephemeral', 'hostedEphemeral'], + ]); + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') continue; + const valueKey = valueArguments.get(argument); + if (valueKey) { + options[valueKey] = resolve(requiredValue(argv, ++index, argument)); + continue; + } + const flagKey = flagArguments.get(argument); + if (!flagKey) throw new Error(`Unknown argument: ${argument}`); + options[flagKey] = true; + } + for (const required of ['incumbentApp', 'nativeApp', 'qualification', 'runRoot', 'out']) { + if (!options[required]) throw new Error(`--${camelToKebab(required)} is required`); + } + return options; +} + +export function assertHostedUpgradeContext(options, environment = process.env) { + if (!options.foregroundApproved || !options.hostedEphemeral) { + throw new Error('Installed-upgrade qualification requires --foreground --hosted-ephemeral'); + } + if (environment.GITHUB_ACTIONS !== 'true' || !environment.RUNNER_TEMP) { + throw new Error('Installed-upgrade qualification runs only on an isolated GitHub-hosted Mac'); + } + const runnerTemp = resolve(environment.RUNNER_TEMP); + const runRoot = resolve(options.runRoot); + const relation = relative(runnerTemp, runRoot); + if (!relation || relation.startsWith('..') || resolve(runRoot).startsWith('/Applications/')) { + throw new Error('The upgrade run root must be a dedicated child of RUNNER_TEMP'); + } + for (const app of [options.incumbentApp, options.nativeApp]) { + if (resolve(app).startsWith('/Applications/')) { + throw new Error('Installed applications are outside hosted qualification authority'); + } + } + return { runnerTemp, runRoot }; +} + +export function buildInstalledUpgradeProof({ + qualification, + nativeInfo, + continuity, + launches, + rubricPreserved, + recordedAt = new Date().toISOString(), +}) { + if (qualification.schema_version !== qualificationSchema) { + throw new Error('Unsupported native package qualification schema'); + } + const archive = (qualification.archives ?? []).find((item) => item.name.endsWith('.zip')); + if (!archive?.sha256) throw new Error('The native qualification has no ZIP archive identity'); + const requiredLaunches = ['tauri_before', 'native_upgrade', 'native_relaunch', 'tauri_rollback']; + const launchPassed = requiredLaunches.every((kind) => + launches.some((item) => item.kind === kind && item.visible_window === true) + ); + const passed = + nativeInfo.CFBundleIdentifier === productionBundleIdentifier && + launchPassed && + rubricPreserved === true && + continuity.before_sha256 === continuity.after_upgrade_sha256 && + continuity.before_sha256 === continuity.after_rollback_sha256; + return { + schema_version: proofSchema, + authority: 'isolated_hosted_installation', + recorded_at: recordedAt, + status: passed ? 'passed' : 'failed', + bundle_identifier: nativeInfo.CFBundleIdentifier, + version: nativeInfo.CFBundleShortVersionString, + build: nativeInfo.CFBundleVersion, + archive_sha256: archive.sha256, + upgrade: passed, + relaunch: passed, + rollback: passed, + custom_rubric_preserved: rubricPreserved, + launches, + data_continuity: continuity, + limitations: [ + 'The install, relaunch, and rollback occurred only inside RUNNER_TEMP on an isolated hosted Mac.', + 'No application under /Applications and no operator data or credentials were read or changed.', + 'Public release and replacement of the retained Tauri application remain separately authorized actions.', + ], + }; +} + +export async function qualifyInstalledUpgrade( + options = parseArguments(process.argv.slice(2)), + environment = process.env +) { + if (process.platform !== 'darwin') + throw new Error('Installed-upgrade qualification requires macOS'); + const { runRoot } = assertHostedUpgradeContext(options, environment); + const incumbentApp = verifiedApplication(options.incumbentApp, { + bundle: productionBundleIdentifier, + executable: 'codevetter-desktop', + }); + const nativeApp = verifiedApplication(options.nativeApp, { + bundle: productionBundleIdentifier, + executable: 'CodeVetterNative', + }); + const qualification = readJSON(options.qualification); + const installRoot = join(runRoot, 'installation'); + const installedApp = join(installRoot, 'CodeVetter.app'); + const appData = join(runRoot, 'Application Support', productionBundleIdentifier); + const database = join(appData, 'codevetter.db'); + mkdirSync(appData, { recursive: true }); + const launches = []; + + try { + replaceApplication(incumbentApp.path, installedApp, installRoot); + const incumbentCLI = join(installedApp, 'Contents/MacOS/codevetter'); + runCLI(incumbentCLI, appData, [ + 'rubrics', + '--id', + 'hosted-migration-proof', + '--name', + 'Hosted migration proof', + '--focus', + 'Preserve exact verification evidence during shell migration.', + '--check', + 'Keep the custom rubric available after upgrade and rollback.', + '--json', + ]); + launches.push(await launchAndObserve(installedApp, appData, 'tauri_before')); + const before = captureDataSnapshot({ databasePath: database, phase: 'before' }); + + replaceApplication(nativeApp.path, installedApp, installRoot); + launches.push(await launchAndObserve(installedApp, appData, 'native_upgrade')); + launches.push(await launchAndObserve(installedApp, appData, 'native_relaunch')); + const nativeCLI = join(installedApp, 'Contents/MacOS/codevetter'); + const afterNativeRubrics = runCLI(nativeCLI, appData, ['rubrics', '--json']); + const afterUpgrade = captureDataSnapshot({ + databasePath: database, + phase: 'after_upgrade', + baseline: before, + }); + + replaceApplication(incumbentApp.path, installedApp, installRoot); + launches.push(await launchAndObserve(installedApp, appData, 'tauri_rollback')); + const rollbackCLI = join(installedApp, 'Contents/MacOS/codevetter'); + const afterRollbackRubrics = runCLI(rollbackCLI, appData, ['rubrics', '--json']); + const afterRollback = captureDataSnapshot({ + databasePath: database, + phase: 'after_rollback', + baseline: before, + }); + const continuity = compareDataSnapshots(before, afterUpgrade, afterRollback); + const rubricPreserved = [afterNativeRubrics, afterRollbackRubrics].every((receipt) => + receipt.packs?.some((pack) => pack.id === 'hosted-migration-proof' && pack.active === true) + ); + const proof = buildInstalledUpgradeProof({ + qualification, + nativeInfo: nativeApp.info, + continuity, + launches, + rubricPreserved, + }); + writeFileSync(options.out, `${JSON.stringify(proof, null, 2)}\n`); + process.stdout.write(`${options.out}\n`); + if (proof.status !== 'passed') process.exitCode = 1; + return proof; + } finally { + rmSync(runRoot, { recursive: true, force: true }); + } +} + +function verifiedApplication(path, expected) { + const canonical = realpathSync(path); + if (canonical.startsWith('/Applications/') || !statSync(canonical).isDirectory()) { + throw new Error(`Unsafe application path: ${canonical}`); + } + const info = readPlist(join(canonical, 'Contents/Info.plist')); + if ( + info.CFBundleIdentifier !== expected.bundle || + info.CFBundleExecutable !== expected.executable + ) { + throw new Error(`Unexpected application identity: ${canonical}`); + } + return { path: canonical, info }; +} + +function replaceApplication(source, destination, installRoot) { + if (!destination.startsWith(`${installRoot}/`)) + throw new Error('Unsafe hosted install destination'); + rmSync(destination, { recursive: true, force: true }); + mkdirSync(installRoot, { recursive: true }); + execFileSync('ditto', [source, destination], { cwd: repositoryRoot, stdio: 'ignore' }); +} + +function runCLI(command, appData, arguments_) { + const output = execFileSync(command, arguments_, { + cwd: repositoryRoot, + env: { ...process.env, CODEVETTER_APP_DATA_DIR: appData }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(output); +} + +async function launchAndObserve(app, appData, kind) { + const info = readPlist(join(app, 'Contents/Info.plist')); + const executable = join(app, 'Contents/MacOS', info.CFBundleExecutable); + const arguments_ = + info.CFBundleExecutable === 'CodeVetterNative' ? ['--ui-test-section', 'Runs'] : []; + const child = spawn(executable, arguments_, { + cwd: repositoryRoot, + detached: true, + env: { ...process.env, CODEVETTER_APP_DATA_DIR: appData }, + stdio: 'ignore', + }); + try { + await waitForVisibleWindow(child, 25_000); + return { kind, visible_window: true, bundle_identifier: info.CFBundleIdentifier }; + } finally { + await terminateOwnedProcess(child); + } +} + +async function waitForVisibleWindow(child, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Application exited before showing a window`); + const script = `tell application "System Events" to count windows of first process whose unix id is ${child.pid}`; + try { + const count = Number(execFileSync('osascript', ['-e', script], { encoding: 'utf8' }).trim()); + if (count > 0) return; + } catch { + // The process may not have registered with System Events yet. + } + await delay(250); + } + throw new Error(`Application ${child.pid} did not show a visible window within ${timeoutMs} ms`); +} + +async function terminateOwnedProcess(child) { + if (child.exitCode !== null) return; + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + return; + } + const deadline = Date.now() + 3_000; + while (Date.now() < deadline && child.exitCode === null) await delay(100); + if (child.exitCode === null) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + // The owned process group already exited. + } + } +} + +function readPlist(path) { + return JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', path], { + cwd: repositoryRoot, + encoding: 'utf8', + }) + ); +} + +function readJSON(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function delay(milliseconds) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +} + +function camelToKebab(value) { + return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`); +} + +function requiredValue(argv, index, argument) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + return value; +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + qualifyInstalledUpgrade().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/qualify-native-installed-upgrade.test.mjs b/scripts/qualify-native-installed-upgrade.test.mjs new file mode 100644 index 00000000..7043009b --- /dev/null +++ b/scripts/qualify-native-installed-upgrade.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertHostedUpgradeContext, + buildInstalledUpgradeProof, + parseArguments, +} from './qualify-native-installed-upgrade.mjs'; + +function input() { + const continuity = { + schema_version: 'codevetter.native-data-continuity/v1', + app_data_identity: 'com.codevetter.desktop', + database_filename: 'codevetter.db', + preserved_record_count: 4, + before_sha256: 'a'.repeat(64), + after_upgrade_sha256: 'a'.repeat(64), + after_rollback_sha256: 'a'.repeat(64), + }; + return { + qualification: { + schema_version: 'codevetter.native-package-qualification/v1', + archives: [{ name: 'CodeVetter-1.11.0-arm64.zip', sha256: 'b'.repeat(64) }], + }, + nativeInfo: { + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleShortVersionString: '1.11.0', + CFBundleVersion: '11100', + }, + continuity, + launches: ['tauri_before', 'native_upgrade', 'native_relaunch', 'tauri_rollback'].map( + (kind) => ({ kind, visible_window: true }) + ), + rubricPreserved: true, + recordedAt: '2026-09-02T00:00:00.000Z', + }; +} + +test('hosted upgrade proof requires every launch, rubric, and durable identity', () => { + const proof = buildInstalledUpgradeProof(input()); + assert.equal(proof.status, 'passed'); + assert.equal(proof.upgrade, true); + assert.equal(proof.custom_rubric_preserved, true); + + const failed = buildInstalledUpgradeProof({ + ...input(), + launches: input().launches.filter((item) => item.kind !== 'native_relaunch'), + }); + assert.equal(failed.status, 'failed'); +}); + +test('execution is restricted to an explicit GitHub-hosted temporary child', () => { + const options = { + foregroundApproved: true, + hostedEphemeral: true, + runRoot: '/runner/temp/native-upgrade', + incumbentApp: '/runner/temp/incumbent/CodeVetter.app', + nativeApp: '/runner/temp/native/CodeVetter.app', + }; + assert.deepEqual( + assertHostedUpgradeContext(options, { + GITHUB_ACTIONS: 'true', + RUNNER_TEMP: '/runner/temp', + }), + { runnerTemp: '/runner/temp', runRoot: '/runner/temp/native-upgrade' } + ); + assert.throws(() => assertHostedUpgradeContext(options, {}), /only on an isolated/); + assert.throws( + () => + assertHostedUpgradeContext( + { ...options, nativeApp: '/Applications/CodeVetter.app' }, + { GITHUB_ACTIONS: 'true', RUNNER_TEMP: '/runner/temp' } + ), + /outside hosted qualification authority/ + ); +}); + +test('argument parsing keeps hosted and foreground consent explicit', () => { + const options = parseArguments([ + '--incumbent-app', + '/tmp/incumbent.app', + '--native-app', + '/tmp/native.app', + '--qualification', + '/tmp/qualification.json', + '--run-root', + '/tmp/run', + '--out', + '/tmp/proof.json', + '--foreground', + '--hosted-ephemeral', + ]); + assert.equal(options.foregroundApproved, true); + assert.equal(options.hostedEphemeral, true); + assert.throws(() => parseArguments([]), /--incumbent-app is required/); +}); diff --git a/scripts/qualify-native-package.mjs b/scripts/qualify-native-package.mjs new file mode 100644 index 00000000..13fc3790 --- /dev/null +++ b/scripts/qualify-native-package.mjs @@ -0,0 +1,422 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const defaultBuild = join( + repositoryRoot, + 'artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app' +); +const defaultOutputRoot = join(repositoryRoot, 'artifacts/native-package'); +const releaseEntitlements = join( + repositoryRoot, + 'apps/macos/Config/CodeVetter.Release.entitlements' +); + +export function runtimeFiles(sourceDirectory) { + return readdirSync(sourceDirectory) + .filter((name) => name.endsWith('.mjs') && !name.endsWith('.test.mjs')) + .sort(); +} + +export function assertPreviewBundle(info) { + if (info.CFBundleIdentifier !== 'com.codevetter.desktop.native-preview') { + throw new Error( + `Refusing to qualify non-preview bundle identifier: ${info.CFBundleIdentifier ?? 'missing'}` + ); + } + if (info.CFBundleExecutable !== 'CodeVetterNative') { + throw new Error( + `The app executable must remain distinct from the codevetter CLI; received ${info.CFBundleExecutable ?? 'missing'}` + ); + } + if (info.SUFeedURL || info.SUPublicEDKey) { + throw new Error('Preview packages must not contain a Sparkle feed or EdDSA public key'); + } +} + +export function assertProductionBundle(info) { + if (info.CFBundleIdentifier !== 'com.codevetter.desktop') { + throw new Error( + `Production packages require com.codevetter.desktop; received ${info.CFBundleIdentifier ?? 'missing'}` + ); + } + if (info.CFBundleExecutable !== 'CodeVetterNative') { + throw new Error( + `The app executable must remain distinct from the codevetter CLI; received ${info.CFBundleExecutable ?? 'missing'}` + ); + } + if (!isHTTPSURL(info.SUFeedURL)) { + throw new Error('Production packages require an HTTPS Sparkle feed URL'); + } + if (!isCanonicalEdDSAPublicKey(info.SUPublicEDKey)) { + throw new Error('Production packages require a canonical 32-byte Sparkle EdDSA public key'); + } +} + +export function hostTarget(rustVersionText) { + const target = rustVersionText + .split('\n') + .find((line) => line.startsWith('host: ')) + ?.slice('host: '.length); + if (!target) throw new Error('Could not determine the Rust host target'); + return target; +} + +export function assertFrameworkRPath(loadCommands) { + if (!loadCommands.includes('@executable_path/../Frameworks')) { + throw new Error('The native executable cannot resolve bundled frameworks through @rpath'); + } +} + +export function assertNoCoverageInstrumentation(loadCommands) { + const forbidden = ['segname __LLVM_COV', 'sectname __llvm_prf', 'sectname __llvm_cov']; + const present = forbidden.filter((marker) => loadCommands.includes(marker)); + if (present.length > 0) { + throw new Error( + `The native Release executable contains test coverage instrumentation: ${present.join(', ')}` + ); + } +} + +export function assertPackagedCliCapabilities(help) { + const required = [ + 'list|inspect|scan|compare|export|query|query-worker', + '--query-domain ', + '--query-mode ', + '--query-target ', + '--query-direction ', + '--query-depth ', + '--history-selector ', + ]; + const missing = required.filter((capability) => !help.includes(capability)); + if (missing.length > 0) { + throw new Error(`The packaged CLI is missing repository-query parity: ${missing.join(', ')}`); + } +} + +export function parseArguments(argv) { + const options = { + app: defaultBuild, + outputRoot: defaultOutputRoot, + identity: '-', + channel: 'preview', + prepareSidecars: true, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') continue; + if (argument === '--app') options.app = resolve(requiredValue(argv, ++index, argument)); + else if (argument === '--out-root') { + options.outputRoot = resolve(requiredValue(argv, ++index, argument)); + } else if (argument === '--identity') { + options.identity = requiredValue(argv, ++index, argument); + } else if (argument === '--channel') { + options.channel = requiredValue(argv, ++index, argument); + } else if (argument === '--skip-sidecar-build') options.prepareSidecars = false; + else throw new Error(`Unknown argument: ${argument}`); + } + return options; +} + +export function qualifyNativePackage(options = parseArguments(process.argv.slice(2))) { + const sourceApp = resolve(options.app); + if (!existsSync(sourceApp) || !statSync(sourceApp).isDirectory()) { + throw new Error(`Release application is missing: ${sourceApp}`); + } + + const sourceInfo = readPlist(join(sourceApp, 'Contents/Info.plist')); + assertPackageBundle(sourceInfo, options.channel, options.identity); + assertFile(join(sourceApp, 'Contents/Frameworks/Sparkle.framework/Versions/Current/Sparkle')); + const hostLoadCommands = run('otool', [ + '-l', + join(sourceApp, 'Contents/MacOS', sourceInfo.CFBundleExecutable), + ]); + assertFrameworkRPath(hostLoadCommands); + assertNoCoverageInstrumentation(hostLoadCommands); + + if (options.prepareSidecars) prepareSidecars(); + const target = hostTarget(run('rustc', ['-vV'])); + const runDirectory = mkdtempSync(join(ensureDirectory(options.outputRoot), 'qualification-')); + const stagedApp = join(runDirectory, 'CodeVetter.app'); + // `ditto` preserves relative framework symlinks and extended attributes. + // Node's recursive copy rewrites Sparkle symlinks to absolute source paths, + // which invalidates the framework seal as soon as the source build moves. + run('ditto', [sourceApp, stagedApp]); + + const executableDirectory = join(stagedApp, 'Contents/MacOS'); + const sidecars = [ + ['codevetter', `codevetter-${target}`], + ['codevetter-mcp', `codevetter-mcp-${target}`], + ['ccusage', `ccusage-${target}`], + ].map(([destinationName, preparedName]) => { + const source = join(repositoryRoot, 'apps/desktop/src-tauri/binaries', preparedName); + const destination = join(executableDirectory, destinationName); + assertFile(source); + copyFileSync(source, destination); + chmodSync(destination, 0o755); + return destination; + }); + + const runtimeSource = join(repositoryRoot, 'scripts/runtime-failure-capsule'); + const runtimeDestination = join(stagedApp, 'Contents/Resources/runtime-failure-capsule'); + mkdirSync(runtimeDestination, { recursive: true }); + const packagedRuntimeFiles = runtimeFiles(runtimeSource); + for (const name of packagedRuntimeFiles) { + copyFileSync(join(runtimeSource, name), join(runtimeDestination, name)); + } + + signSparkle(stagedApp, options.identity); + for (const executable of sidecars) sign(executable, options.identity); + const appEntitlements = + options.identity === '-' ? writeLocalPreviewEntitlements(runDirectory) : releaseEntitlements; + sign(stagedApp, options.identity, appEntitlements); + run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', stagedApp]); + + const stagedInfo = readPlist(join(stagedApp, 'Contents/Info.plist')); + assertPackageBundle(stagedInfo, options.channel, options.identity); + const cliHelp = run(sidecars[0], ['--help'], { stdio: ['ignore', 'pipe', 'pipe'] }); + assertPackagedCliCapabilities(cliHelp); + const smoke = { + cli: { exit_code: 0, output: cliHelp.trim().slice(0, 500) }, + mcp: capture(sidecars[1], ['--help']), + ccusage: capture(sidecars[2], ['--version']), + runtime: capture(process.execPath, [join(runtimeDestination, 'cli.mjs'), '--help']), + }; + if (!smoke.ccusage.output.includes('20.0.20')) { + throw new Error(`Unexpected bundled ccusage version: ${smoke.ccusage.output}`); + } + + const version = stagedInfo.CFBundleShortVersionString; + const architecture = target.startsWith('aarch64') ? 'arm64' : 'x86_64'; + const archiveStem = `CodeVetter-${version}-${architecture}`; + const zipPath = join(runDirectory, `${archiveStem}.zip`); + const dmgPath = join(runDirectory, `${archiveStem}.dmg`); + run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', stagedApp, zipPath]); + run('hdiutil', [ + 'create', + '-quiet', + '-volname', + 'CodeVetter', + '-srcfolder', + stagedApp, + '-format', + 'UDZO', + dmgPath, + ]); + + const receipt = { + schema_version: 'codevetter.native-package-qualification/v1', + recorded_at: new Date().toISOString(), + status: 'local_package_qualified', + shipping_ready: false, + application: { + path: stagedApp, + bundle_identifier: stagedInfo.CFBundleIdentifier, + executable: stagedInfo.CFBundleExecutable, + version, + build: stagedInfo.CFBundleVersion, + architecture, + hardened_runtime: true, + app_sandbox: false, + coverage_instrumentation: false, + library_validation: options.identity !== '-', + signing: options.identity === '-' ? 'ad_hoc_local' : 'operator_supplied_identity', + }, + updater: { + framework: 'Sparkle', + version: sparkleVersion(stagedApp), + feed_configured: isHTTPSURL(stagedInfo.SUFeedURL), + eddsa_public_key_configured: isCanonicalEdDSAPublicKey(stagedInfo.SUPublicEDKey), + enabled: + options.channel === 'production' && + isHTTPSURL(stagedInfo.SUFeedURL) && + isCanonicalEdDSAPublicKey(stagedInfo.SUPublicEDKey), + }, + sidecars: sidecars.map((path) => artifact(path)), + runtime_files: packagedRuntimeFiles, + smoke, + archives: [artifact(zipPath), artifact(dmgPath)], + blockers: [ + ...(options.channel === 'preview' + ? ['Production bundle identifier transfer requires owner approval.'] + : []), + ...(options.identity === '-' + ? ['Developer ID signing and Apple notarization have not been performed.'] + : ['Apple notarization has not been performed.']), + ...(options.identity === '-' + ? [ + 'The ad-hoc preview disables Library Validation because ad-hoc components have no shared Team ID; production must prove it enabled after Developer ID signing.', + ] + : []), + ...(options.channel === 'preview' + ? ['A production HTTPS Sparkle appcast and real EdDSA public key are not configured.'] + : []), + 'Installed upgrade and rollback proof has not been completed.', + ], + }; + const receiptPath = join(runDirectory, 'qualification.json'); + writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + process.stdout.write(`${receiptPath}\n`); + return { receipt, receiptPath, stagedApp, zipPath, dmgPath }; +} + +function assertPackageBundle(info, channel, identity) { + if (channel === 'preview') return assertPreviewBundle(info); + if (channel !== 'production') throw new Error(`Unsupported native package channel: ${channel}`); + if (!identity || identity === '-') { + throw new Error('Production native packages require a Developer ID signing identity'); + } + assertProductionBundle(info); +} + +function isHTTPSURL(value) { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +function isCanonicalEdDSAPublicKey(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(value)) return false; + const decoded = Buffer.from(value, 'base64'); + return decoded.length === 32 && decoded.toString('base64') === value; +} + +function prepareSidecars() { + for (const [script, args] of [ + ['apps/desktop/scripts/prepare-cli-sidecar.mjs', ['--release']], + ['apps/desktop/scripts/prepare-mcp-sidecar.mjs', ['--release']], + ['apps/desktop/scripts/prepare-ccusage-sidecar.mjs', []], + ]) { + run(process.execPath, [join(repositoryRoot, script), ...args], { stdio: 'inherit' }); + } +} + +function sign(path, identity, entitlements) { + const args = ['--force', '--sign', identity, '--options', 'runtime']; + if (identity === '-') args.push('--timestamp=none'); + if (entitlements) args.push('--entitlements', entitlements); + args.push(path); + run('codesign', args, { stdio: 'inherit' }); +} + +function signSparkle(app, identity) { + const framework = join(app, 'Contents/Frameworks/Sparkle.framework'); + const current = join(framework, 'Versions/Current'); + const components = [ + join(current, 'XPCServices/Installer.xpc'), + join(current, 'XPCServices/Downloader.xpc'), + join(current, 'Updater.app'), + join(current, 'Autoupdate'), + framework, + ]; + for (const component of components) { + const args = [ + '--force', + '--sign', + identity, + '--options', + 'runtime', + '--preserve-metadata=entitlements,flags', + ]; + if (identity === '-') args.push('--timestamp=none'); + args.push(component); + run('codesign', args, { stdio: 'inherit' }); + } +} + +function writeLocalPreviewEntitlements(runDirectory) { + const path = join(runDirectory, 'CodeVetter.LocalPreview.entitlements'); + writeFileSync( + path, + [ + '', + '', + '', + '', + ' com.apple.security.cs.disable-library-validation', + ' ', + '', + '', + '', + ].join('\n') + ); + return path; +} + +function sparkleVersion(app) { + const info = readPlist( + join(app, 'Contents/Frameworks/Sparkle.framework/Versions/Current/Resources/Info.plist') + ); + return info.CFBundleShortVersionString ?? info.CFBundleVersion ?? 'unknown'; +} + +function artifact(path) { + const bytes = readFileSync(path); + return { + name: basename(path), + bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +function capture(command, args) { + try { + const output = run(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + return { exit_code: 0, output: output.slice(0, 500) }; + } catch (error) { + const output = `${error.stdout ?? ''}${error.stderr ?? ''}`.trim(); + const exitCode = Number.isInteger(error.status) ? error.status : 1; + if (exitCode > 2) throw error; + return { exit_code: exitCode, output: output.slice(0, 500) }; + } +} + +function readPlist(path) { + return JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', path])); +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], + }); +} + +function requiredValue(argv, index, argument) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + return value; +} + +function ensureDirectory(path) { + mkdirSync(path, { recursive: true }); + return path.endsWith('/') ? path : `${path}/`; +} + +function assertFile(path) { + if (!existsSync(path) || !statSync(path).isFile() || statSync(path).size === 0) { + throw new Error(`Required file is missing or empty: ${path}`); + } +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) qualifyNativePackage(); diff --git a/scripts/qualify-native-package.test.mjs b/scripts/qualify-native-package.test.mjs new file mode 100644 index 00000000..daf0f6d5 --- /dev/null +++ b/scripts/qualify-native-package.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + assertFrameworkRPath, + assertNoCoverageInstrumentation, + assertPackagedCliCapabilities, + assertPreviewBundle, + assertProductionBundle, + hostTarget, + parseArguments, + runtimeFiles, +} from './qualify-native-package.mjs'; + +test('packaged CLI preserves rich repository-query parity', () => { + const help = [ + 'list|inspect|scan|compare|export|query|query-worker', + '--query-domain ', + '--query-mode ', + '--query-target ', + '--query-direction ', + '--query-depth ', + '--history-selector ', + ].join('\n'); + assert.doesNotThrow(() => assertPackagedCliCapabilities(help)); + assert.throws( + () => assertPackagedCliCapabilities(help.replace('--query-mode ', '')), + /missing repository-query parity/ + ); +}); + +test('production packaging requires the canonical identifier and Sparkle inputs', () => { + const publicKey = Buffer.alloc(32, 7).toString('base64'); + assert.doesNotThrow(() => + assertProductionBundle({ + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleExecutable: 'CodeVetterNative', + SUFeedURL: 'https://github.com/Codevetter/codevetter/releases/latest/download/appcast.xml', + SUPublicEDKey: publicKey, + }) + ); + assert.throws( + () => + assertProductionBundle({ + CFBundleIdentifier: 'com.codevetter.desktop.native-preview', + CFBundleExecutable: 'CodeVetterNative', + SUFeedURL: 'https://updates.example.test/appcast.xml', + SUPublicEDKey: publicKey, + }), + /require com\.codevetter\.desktop/ + ); + assert.throws( + () => + assertProductionBundle({ + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleExecutable: 'CodeVetterNative', + SUFeedURL: 'http://updates.example.test/appcast.xml', + SUPublicEDKey: publicKey, + }), + /HTTPS Sparkle feed/ + ); +}); + +test('native executable must resolve frameworks inside its own bundle', () => { + assert.doesNotThrow(() => + assertFrameworkRPath('path @executable_path/../Frameworks (offset 12)') + ); + assert.throws(() => assertFrameworkRPath('path /usr/lib/swift'), /cannot resolve/); +}); + +test('native Release executable excludes test coverage instrumentation', () => { + assert.doesNotThrow(() => + assertNoCoverageInstrumentation('segname __TEXT\nsectname __text\nsegname __LINKEDIT') + ); + assert.throws( + () => assertNoCoverageInstrumentation('segname __LLVM_COV\nsectname __llvm_covfun'), + /contains test coverage instrumentation/ + ); + assert.throws( + () => assertNoCoverageInstrumentation('segname __DATA\nsectname __llvm_prf_cnts'), + /contains test coverage instrumentation/ + ); +}); + +test('preview packaging fails closed on identity, executable, and updater inputs', () => { + assert.doesNotThrow(() => + assertPreviewBundle({ + CFBundleIdentifier: 'com.codevetter.desktop.native-preview', + CFBundleExecutable: 'CodeVetterNative', + }) + ); + assert.throws( + () => + assertPreviewBundle({ + CFBundleIdentifier: 'com.codevetter.desktop', + CFBundleExecutable: 'CodeVetterNative', + }), + /non-preview/ + ); + assert.throws( + () => + assertPreviewBundle({ + CFBundleIdentifier: 'com.codevetter.desktop.native-preview', + CFBundleExecutable: 'CodeVetter', + }), + /distinct/ + ); + assert.throws( + () => + assertPreviewBundle({ + CFBundleIdentifier: 'com.codevetter.desktop.native-preview', + CFBundleExecutable: 'CodeVetterNative', + SUFeedURL: 'https://updates.example.test/appcast.xml', + }), + /must not contain/ + ); +}); + +test('runtime packaging includes executable modules and excludes tests', () => { + const directory = mkdtempSync(join(tmpdir(), 'codevetter-native-package-')); + writeFileSync(join(directory, 'cli.mjs'), ''); + writeFileSync(join(directory, 'capsule.mjs'), ''); + writeFileSync(join(directory, 'capsule.test.mjs'), ''); + writeFileSync(join(directory, 'README.md'), ''); + assert.deepEqual(runtimeFiles(directory), ['capsule.mjs', 'cli.mjs']); +}); + +test('host and argument parsing preserve explicit operator choices', () => { + assert.equal(hostTarget('rustc 1.90.0\nhost: aarch64-apple-darwin\n'), 'aarch64-apple-darwin'); + const options = parseArguments([ + '--app', + '/tmp/CodeVetter.app', + '--out-root', + '/tmp/native-package', + '--identity', + 'Developer ID Application: Example', + '--channel', + 'production', + '--skip-sidecar-build', + ]); + assert.equal(options.app, '/tmp/CodeVetter.app'); + assert.equal(options.outputRoot, '/tmp/native-package'); + assert.equal(options.identity, 'Developer ID Application: Example'); + assert.equal(options.channel, 'production'); + assert.equal(options.prepareSidecars, false); +}); diff --git a/scripts/render-native-owner-review.mjs b/scripts/render-native-owner-review.mjs new file mode 100644 index 00000000..3c63ec45 --- /dev/null +++ b/scripts/render-native-owner-review.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +import { spawnSync, execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + XCODEBUILDMCP, + nativeCheckCachePath, + nativeCheckEnvironment, +} from './run-native-checks.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const defaultOutputRoot = resolve(repositoryRoot, 'artifacts/native-owner-review'); +const galleryTemplate = resolve( + repositoryRoot, + 'evidence/design/native-acceptance-2026-09-01/gallery.html' +); + +export const nativeOwnerReviewRenders = Object.freeze([ + ['CODEVETTER_USAGE_SCREENSHOT_PATH', 'usage.png'], + ['CODEVETTER_UNPACK_SCREENSHOT_PATH', 'repo-unpack.png'], + ['CODEVETTER_UNPACK_QUERY_DESK_SCREENSHOT_PATH', 'repository-query-desk.png'], + ['CODEVETTER_UNPACK_QUERY_SCREENSHOT_PATH', 'repository-query-evidence-workbench.png'], + ['CODEVETTER_REVIEW_FINDINGS_SCREENSHOT_PATH', 'review-findings.png'], + ['CODEVETTER_REVIEW_FINDINGS_LIGHT_SCREENSHOT_PATH', 'review-findings-light.png'], + ['CODEVETTER_CROSS_REVIEW_SCREENSHOT_PATH', 'review-cross-review.png'], + ['CODEVETTER_CROSS_REVIEW_LIGHT_SCREENSHOT_PATH', 'review-cross-review-light.png'], + ['CODEVETTER_REVIEW_PROOF_MAP_SCREENSHOT_PATH', 'review-proof-map.png'], + ['CODEVETTER_REVIEW_INTENT_SCREENSHOT_PATH', 'review-intent.png'], + ['CODEVETTER_TESTING_SCREENSHOT_PATH', 'testing.png'], + ['CODEVETTER_TESTING_LIGHT_SCREENSHOT_PATH', 'testing-light.png'], + ['CODEVETTER_WARM_SCREENSHOT_PATH', 'testing-warm.png'], + ['CODEVETTER_DIFFERENTIAL_SCREENSHOT_PATH', 'testing-differential.png'], + ['CODEVETTER_SCENARIO_SCREENSHOT_PATH', 'testing-scenario.png'], + ['CODEVETTER_WATCHER_SCREENSHOT_PATH', 'testing-watcher.png'], + ['CODEVETTER_QA_WORKSPACE_SCREENSHOT_PATH', 'qa-journey-workspace.png'], + ['CODEVETTER_PERFORMANCE_SCREENSHOT_PATH', 'performance.png'], + ['CODEVETTER_PERFORMANCE_LIGHT_SCREENSHOT_PATH', 'performance-light.png'], + ['CODEVETTER_RUNS_SCREENSHOT_PATH', 'runs.png'], + ['CODEVETTER_RUNS_LIGHT_SCREENSHOT_PATH', 'runs-light.png'], + ['CODEVETTER_CAPABILITIES_SCREENSHOT_PATH', 'capabilities-mcp.png'], + ['CODEVETTER_SETTINGS_SCREENSHOT_PATH', 'settings.png'], + ['CODEVETTER_RUBRICS_SCREENSHOT_PATH', 'settings-rubrics.png'], + ['CODEVETTER_RUBRICS_LIGHT_SCREENSHOT_PATH', 'settings-rubrics-light.png'], + ['CODEVETTER_HISTORY_ROOTS_SCREENSHOT_PATH', 'settings-history-roots.png'], + ['CODEVETTER_HISTORY_ROOTS_LIGHT_SCREENSHOT_PATH', 'settings-history-roots-light.png'], + ['CODEVETTER_MEMORIES_SCREENSHOT_PATH', 'settings-memories.png'], + ['CODEVETTER_MEMORIES_LIGHT_SCREENSHOT_PATH', 'settings-memories-light.png'], + ['CODEVETTER_AGENT_ISLAND_SETTINGS_SCREENSHOT_PATH', 'settings-agent-island.png'], + ['CODEVETTER_AGENT_ISLAND_SETTINGS_LIGHT_SCREENSHOT_PATH', 'settings-agent-island-light.png'], + ['CODEVETTER_OPS_SETTINGS_SCREENSHOT_PATH', 'settings-ops.png'], + ['CODEVETTER_OPS_SETTINGS_LIGHT_SCREENSHOT_PATH', 'settings-ops-light.png'], + ['CODEVETTER_ONBOARDING_SCREENSHOT_PATH', 'onboarding-purpose.png'], + ['CODEVETTER_ONBOARDING_AGENT_SCREENSHOT_PATH', 'onboarding-agent.png'], +]); + +export function ownerReviewEnvironment(outputRoot = defaultOutputRoot) { + const root = resolve(outputRoot); + return Object.fromEntries( + nativeOwnerReviewRenders.map(([environmentKey, path]) => [environmentKey, join(root, path)]) + ); +} + +export function buildOwnerReviewManifest(entries, renderedAt = new Date()) { + return { + schema_version: 'codevetter.native-owner-review/v1', + rendered_at: renderedAt.toISOString().slice(0, 10), + surface: 'native macOS Evidence Workbench', + scale: 'deterministic offscreen pixels', + owner_acceptance: 'pending', + entries, + }; +} + +export function finalizeOwnerReview(outputRoot = defaultOutputRoot) { + const root = resolve(outputRoot); + const entries = nativeOwnerReviewRenders.map(([, path]) => artifact(join(root, path), path)); + const manifest = buildOwnerReviewManifest(entries); + writeFileSync(join(root, 'owner-review-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + copyFileSync(galleryTemplate, join(root, 'gallery.html')); + return manifest; +} + +export function renderOwnerReview(outputRoot = defaultOutputRoot, spawn = spawnSync) { + if (process.platform !== 'darwin') { + throw new Error('Native owner-review rendering requires macOS.'); + } + const root = resolve(outputRoot); + mkdirSync(root, { recursive: true }); + const cache = nativeCheckCachePath(); + const result = spawn( + '/usr/bin/nice', + [ + '-n', + '10', + 'npx', + '-y', + XCODEBUILDMCP, + 'swift-package', + 'test', + '--package-path', + 'apps/macos/CodeVetterPackage', + '--parallel', + 'false', + ], + { + cwd: repositoryRoot, + env: { + ...nativeCheckEnvironment(process.env, cache), + ...ownerReviewEnvironment(root), + }, + stdio: 'inherit', + } + ); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Native owner-review rendering exited ${result.status}`); + return finalizeOwnerReview(root); +} + +function artifact(path, name) { + const contents = readFileSync(path); + const dimensions = execFileSync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', path], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const width = dimensions.match(/pixelWidth:\s+(\d+)/)?.[1]; + const height = dimensions.match(/pixelHeight:\s+(\d+)/)?.[1]; + if (!width || !height) throw new Error(`Could not read image dimensions: ${path}`); + return { + path: name, + pixels: `${width}x${height}`, + sha256: createHash('sha256').update(contents).digest('hex'), + }; +} + +function parseArguments(argv) { + let operation = 'render'; + let outputRoot = defaultOutputRoot; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (['env', 'finalize', 'render'].includes(argument)) operation = argument; + else if (argument === '--out-root') + outputRoot = resolve(requiredValue(argv, ++index, argument)); + else if (argument !== '--') throw new Error(`Unknown argument: ${argument}`); + } + return { operation, outputRoot }; +} + +function requiredValue(argv, index, argument) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + return value; +} + +function main() { + const { operation, outputRoot } = parseArguments(process.argv.slice(2)); + mkdirSync(outputRoot, { recursive: true }); + if (operation === 'env') { + for (const [key, value] of Object.entries(ownerReviewEnvironment(outputRoot))) { + process.stdout.write(`${key}=${value}\n`); + } + } else if (operation === 'finalize') { + finalizeOwnerReview(outputRoot); + } else { + renderOwnerReview(outputRoot); + } +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) main(); diff --git a/scripts/render-native-owner-review.test.mjs b/scripts/render-native-owner-review.test.mjs new file mode 100644 index 00000000..4888d79b --- /dev/null +++ b/scripts/render-native-owner-review.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + buildOwnerReviewManifest, + nativeOwnerReviewRenders, + ownerReviewEnvironment, +} from './render-native-owner-review.mjs'; + +test('owner-review render contract contains 35 unique environment and image identities', () => { + assert.equal(nativeOwnerReviewRenders.length, 35); + assert.equal(new Set(nativeOwnerReviewRenders.map(([key]) => key)).size, 35); + assert.equal(new Set(nativeOwnerReviewRenders.map(([, path]) => path)).size, 35); +}); + +test('owner-review render contract matches the checked manifest identities', () => { + const manifest = JSON.parse( + readFileSync('evidence/design/native-acceptance-2026-09-01/owner-review-manifest.json') + ); + assert.deepEqual( + nativeOwnerReviewRenders.map(([, path]) => path).toSorted(), + manifest.entries.map((entry) => entry.path).toSorted() + ); +}); + +test('owner-review environment resolves every render under the requested output root', () => { + const environment = ownerReviewEnvironment('/fixture/review'); + assert.equal(Object.keys(environment).length, 35); + assert.equal(environment.CODEVETTER_USAGE_SCREENSHOT_PATH, '/fixture/review/usage.png'); + assert.equal( + environment.CODEVETTER_OPS_SETTINGS_LIGHT_SCREENSHOT_PATH, + '/fixture/review/settings-ops-light.png' + ); +}); + +test('owner-review manifest keeps visual acceptance pending', () => { + const entries = [{ path: 'usage.png', pixels: '2560x1600', sha256: 'a'.repeat(64) }]; + const manifest = buildOwnerReviewManifest(entries, new Date('2026-09-02T07:00:00Z')); + assert.equal(manifest.schema_version, 'codevetter.native-owner-review/v1'); + assert.equal(manifest.rendered_at, '2026-09-02'); + assert.equal(manifest.owner_acceptance, 'pending'); + assert.deepEqual(manifest.entries, entries); +}); diff --git a/scripts/run-biome-sarif.mjs b/scripts/run-biome-sarif.mjs new file mode 100644 index 00000000..3b81be7f --- /dev/null +++ b/scripts/run-biome-sarif.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const outputPath = resolve(process.env.BIOME_SARIF_PATH ?? 'artifacts/tooling/biome.sarif'); +mkdirSync(dirname(outputPath), { recursive: true }); + +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const result = spawnSync( + pnpm, + ['exec', 'biome', 'ci', '--reporter=sarif', `--reporter-file=${outputPath}`, '.'], + { stdio: 'inherit' } +); + +if (result.error) { + console.error(`Unable to run Biome: ${result.error.message}`); + process.exitCode = 1; +} else { + process.exitCode = result.status ?? 1; +} diff --git a/scripts/run-native-checks.mjs b/scripts/run-native-checks.mjs new file mode 100644 index 00000000..b4a15abb --- /dev/null +++ b/scripts/run-native-checks.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +export const XCODEBUILDMCP = 'xcodebuildmcp@2.7.0'; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const nativePerformanceGateTests = [ + 'hundredRunLedgerDecodesAndRendersWithinTheNativeGate', + 'largeUnpackProjectionDecodesAndRendersWithinTheNativeGate', + 'largeUsageReportDecodesAndRendersWithinTheNativeGate', + 'hundredRowPerformanceReceiptDecodesAndRendersWithinTheNativeGate', + 'hundredJourneyTestingReceiptDecodesAndRendersWithinTheNativeGate', +]; + +export function nativeCheckCachePath(root = repositoryRoot) { + return resolve(root, 'artifacts/native-checks/xcodebuildmcp-npm-cache'); +} + +export function parseNativeCheckArguments(arguments_) { + let mode = 'background'; + let foregroundApproved = false; + let desktopIdleApproved = false; + for (const argument of arguments_) { + if (argument === '--') continue; + if (argument === '--background') mode = 'background'; + else if (argument === '--release') mode = 'release'; + else if (argument === '--ui') mode = 'ui'; + else if (argument === '--full') mode = 'full'; + else if (argument === '--foreground') foregroundApproved = true; + else if (argument === '--desktop-idle') desktopIdleApproved = true; + else throw new Error(`Unknown native-check argument: ${argument}`); + } + if ((mode === 'ui' || mode === 'full') && (!foregroundApproved || !desktopIdleApproved)) { + throw new Error( + 'Foreground UI automation requires the just-in-time flags --foreground --desktop-idle because it controls the active macOS desktop.' + ); + } + return { mode, foregroundApproved, desktopIdleApproved }; +} + +export function nativeReleaseBuildSettings(environment = process.env) { + const settings = [ + 'ENABLE_CODE_COVERAGE=NO', + 'CLANG_ENABLE_CODE_COVERAGE=NO', + 'CLANG_COVERAGE_MAPPING=NO', + ]; + const channel = environment.CODEVETTER_NATIVE_CHANNEL ?? 'preview'; + if (channel === 'preview') return settings; + if (channel !== 'production') { + throw new Error(`Unsupported native release channel: ${channel}`); + } + + const bundleIdentifier = environment.CODEVETTER_NATIVE_BUNDLE_IDENTIFIER; + const feedURL = environment.CODEVETTER_NATIVE_SPARKLE_FEED_URL; + const publicKey = environment.CODEVETTER_NATIVE_SPARKLE_PUBLIC_KEY; + if (bundleIdentifier !== 'com.codevetter.desktop') { + throw new Error('Production native builds require com.codevetter.desktop'); + } + if (!isHTTPSURL(feedURL)) { + throw new Error('Production native builds require an HTTPS Sparkle feed URL'); + } + if (!isCanonicalEdDSAPublicKey(publicKey)) { + throw new Error( + 'Production native builds require a canonical 32-byte Sparkle EdDSA public key' + ); + } + return [ + ...settings, + 'CODE_SIGNING_ALLOWED=NO', + 'CODE_SIGNING_REQUIRED=NO', + `PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`, + `INFOPLIST_KEY_SUFeedURL=${feedURL}`, + `INFOPLIST_KEY_SUPublicEDKey=${publicKey}`, + ]; +} + +export function nativeCheckCommands({ mode }, environment = process.env) { + const background = [ + { + label: 'Swift package behavior', + backgroundSafe: true, + arguments: [ + 'swift-package', + 'test', + '--json', + JSON.stringify({ + packagePath: 'apps/macos/CodeVetterPackage', + parallel: false, + }), + ], + }, + ...nativePerformanceGateTests.map((filter) => ({ + label: `Isolated native performance gate: ${filter}`, + backgroundSafe: true, + environment: { CODEVETTER_NATIVE_PERFORMANCE_GATE: '1' }, + arguments: [ + 'swift-package', + 'test', + '--json', + JSON.stringify({ + packagePath: 'apps/macos/CodeVetterPackage', + parallel: false, + filter, + }), + ], + })), + { + label: 'Native macOS application compile', + backgroundSafe: true, + arguments: [ + 'macos', + 'build', + '--workspace-path', + 'apps/macos/CodeVetter.xcworkspace', + '--scheme', + 'CodeVetter', + '--configuration', + 'Debug', + ], + }, + ]; + const ui = { + label: 'Foreground macOS interaction tests', + backgroundSafe: false, + arguments: [ + 'macos', + 'test', + '--json', + JSON.stringify({ + workspacePath: 'apps/macos/CodeVetter.xcworkspace', + scheme: 'CodeVetter', + configuration: 'Debug', + extraArgs: ['-only-testing:CodeVetterUITests'], + }), + ], + }; + const release = { + label: 'Coverage-free native macOS Release compile', + backgroundSafe: true, + arguments: [ + 'macos', + 'build', + '--json', + JSON.stringify({ + workspacePath: 'apps/macos/CodeVetter.xcworkspace', + scheme: 'CodeVetter', + configuration: 'Release', + arch: 'arm64', + derivedDataPath: 'artifacts/native-build/DerivedData', + extraArgs: nativeReleaseBuildSettings(environment), + }), + ], + }; + if (mode === 'background') return background; + if (mode === 'release') return [release]; + if (mode === 'ui') return [ui]; + return [...background, ui]; +} + +export function nativeCheckEnvironment(environment, cache) { + const clean = Object.fromEntries( + Object.entries(environment).filter(([key]) => !key.toLowerCase().startsWith('npm_config_')) + ); + clean.npm_config_cache = cache; + clean.npm_config_update_notifier = 'false'; + return clean; +} + +export function nativeCheckInvocation(command, environment = process.env) { + const lowerPriority = command.backgroundSafe && environment.GITHUB_ACTIONS !== 'true'; + return lowerPriority + ? { + executable: '/usr/bin/nice', + arguments: ['-n', '10', 'npx', '-y', XCODEBUILDMCP, ...command.arguments], + } + : { + executable: 'npx', + arguments: ['-y', XCODEBUILDMCP, ...command.arguments], + }; +} + +export function runNativeChecks(options, spawn = spawnSync) { + if (process.platform !== 'darwin') { + throw new Error('Native CodeVetter checks require macOS.'); + } + const cache = nativeCheckCachePath(); + mkdirSync(cache, { recursive: true }); + for (const command of nativeCheckCommands(options, process.env)) { + process.stdout.write(`\n[native] ${command.label}\n`); + if (!command.backgroundSafe) { + process.stdout.write( + '[native] Foreground lane: CodeVetter and XCUITest may take focus until this command finishes.\n' + ); + } + // Keep local checks polite while the operator works. The isolated hosted + // runner owns its machine and must use normal priority for wall-clock gates. + const { executable, arguments: arguments_ } = nativeCheckInvocation(command); + const result = spawn(executable, arguments_, { + cwd: repositoryRoot, + env: { + ...nativeCheckEnvironment(process.env, cache), + ...command.environment, + }, + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + return result.status ?? 1; + } + } + return 0; +} + +function isHTTPSURL(value) { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +function isCanonicalEdDSAPublicKey(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(value)) return false; + const decoded = Buffer.from(value, 'base64'); + return decoded.length === 32 && decoded.toString('base64') === value; +} + +function isMainModule() { + return process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +} + +if (isMainModule()) { + try { + process.exitCode = runNativeChecks(parseNativeCheckArguments(process.argv.slice(2))); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; + } +}