diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16a63e3..b3a2992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,8 +141,39 @@ jobs: mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2 mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib - - name: Provision locked engines + - name: Fingerprint engine toolchain + id: engine-toolchain if: runner.os != 'macOS' + env: + FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }} + FIRELINK_TARGET_TRIPLE: ${{ matrix.target }} + run: node scripts/engine-toolchain-fingerprint.js + - name: Restore verified engine payload cache + id: engine-cache + if: runner.os != 'macOS' + # v4.2.0 pinned to an immutable commit; this cache is an optimization, + # and a miss always falls back to source provisioning below. + uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 + with: + path: src-tauri/provisioned-engines/${{ matrix.target }} + # The target, toolchain fingerprint, lockfiles, provisioning code, + # payload validators, and runner package lists all invalidate the key. + key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }} + - name: Validate restored engine payload + id: engine-cache-validation + if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true' + continue-on-error: true + env: + FIRELINK_TARGET_TRIPLE: ${{ matrix.target }} + FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }} + run: | + node scripts/stage-engines.js + node scripts/verify-binaries.js --staged + - name: Provision locked engines + if: >- + runner.os != 'macOS' && + (steps.engine-cache.outputs.cache-hit != 'true' || + steps.engine-cache-validation.outcome != 'success') env: FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }} run: node scripts/provision-engines.js --target ${{ matrix.target }} @@ -164,3 +195,13 @@ jobs: env: FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist run: node scripts/smoke-aria2-transfers.js + - name: Save verified engine payload cache + if: >- + runner.os != 'macOS' && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + steps.engine-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 + with: + path: src-tauri/provisioned-engines/${{ matrix.target }} + key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 098e724..225dd57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,8 +115,37 @@ jobs: mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2 mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib - - name: Provision locked engines + - name: Fingerprint engine toolchain + id: engine-toolchain if: runner.os != 'macOS' + env: + FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }} + FIRELINK_TARGET_TRIPLE: ${{ matrix.target }} + run: node scripts/engine-toolchain-fingerprint.js + - name: Restore verified engine payload cache + id: engine-cache + if: runner.os != 'macOS' + # v4.2.0 pinned to an immutable commit; release jobs never write cache + # entries, so only trusted CI pushes can populate the shared payload. + uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 + with: + path: src-tauri/provisioned-engines/${{ matrix.target }} + key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }} + - name: Validate restored engine payload + id: engine-cache-validation + if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true' + continue-on-error: true + env: + FIRELINK_TARGET_TRIPLE: ${{ matrix.target }} + FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }} + run: | + node scripts/stage-engines.js + node scripts/verify-binaries.js --staged + - name: Provision locked engines + if: >- + runner.os != 'macOS' && + (steps.engine-cache.outputs.cache-hit != 'true' || + steps.engine-cache-validation.outcome != 'success') env: FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }} run: node scripts/provision-engines.js --target ${{ matrix.target }} @@ -338,8 +367,12 @@ jobs: rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip" - name: Generate checksums run: | + set -euo pipefail cd release-assets - find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + checksum_tmp="$RUNNER_TEMP/Firelink-SHA256SUMS" + trap 'rm -f "$checksum_tmp"' EXIT + find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > "$checksum_tmp" + mv "$checksum_tmp" SHA256SUMS - uses: softprops/action-gh-release@v3 with: files: release-assets/** diff --git a/scripts/engine-cache-workflow.node-test.js b/scripts/engine-cache-workflow.node-test.js new file mode 100644 index 0000000..bf114d4 --- /dev/null +++ b/scripts/engine-cache-workflow.node-test.js @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const repositoryRoot = path.resolve(import.meta.dirname, '..'); +const ciWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github', 'workflows', 'ci.yml'), + 'utf8', +); +const releaseWorkflow = fs.readFileSync( + path.join(repositoryRoot, '.github', 'workflows', 'release.yml'), + 'utf8', +); +const cacheActionSha = '1bd1e32a3bdc45362d1e726936510720a7c30a57'; + +function assertSafeEngineCacheWorkflow(workflow) { + assert.match(workflow, new RegExp(`actions/cache/restore@${cacheActionSha}`)); + assert.match(workflow, /key: firelink-engine-payload-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.fingerprint \}\}/); + assert.match(workflow, /engine-sources\.lock\.json/); + assert.match(workflow, /scripts\/aria2\/\*\*/); + assert.match(workflow, /scripts\/engine-\*\.js/); + assert.match(workflow, /scripts\/verify-binaries\.js/); + assert.doesNotMatch(workflow, /restore-keys:/); + + const restore = workflow.indexOf('actions/cache/restore@'); + const validation = workflow.indexOf('id: engine-cache-validation'); + const provision = workflow.indexOf('node scripts/provision-engines.js'); + assert.ok(restore >= 0 && restore < validation && validation < provision); + assert.match(workflow, /continue-on-error: true/); + assert.match(workflow, /FIRELINK_TARGET_TRIPLE: \$\{\{ matrix\.target \}\}/); +} + +test('CI and release restore only exact, validated engine payload caches', () => { + assertSafeEngineCacheWorkflow(ciWorkflow); + assertSafeEngineCacheWorkflow(releaseWorkflow); +}); + +test('only trusted main pushes save the shared engine cache', () => { + assert.match(ciWorkflow, new RegExp(`actions/cache/save@${cacheActionSha}`)); + const save = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified engine payload cache')); + assert.match(save, /github\.event_name == 'push'/); + assert.match(save, /github\.ref == 'refs\/heads\/main'/); + assert.doesNotMatch(releaseWorkflow, /actions\/cache\/save@/); +}); diff --git a/scripts/engine-payload-manifest.js b/scripts/engine-payload-manifest.js new file mode 100644 index 0000000..9297c51 --- /dev/null +++ b/scripts/engine-payload-manifest.js @@ -0,0 +1,118 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { collectRegularFiles, sha256 } from './engine-payload-integrity.js'; + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, canonicalize(value[key])]), + ); + } + return value; +} + +export function buildPayloadProvenance(targetSources) { + if (!targetSources || typeof targetSources !== 'object') { + throw new Error('Engine source lock is missing the target provenance.'); + } + + return Object.fromEntries( + Object.entries(targetSources).map(([name, source]) => [ + name, + { + version: source.version, + url: source.url || source.sourceUrl, + sha256: source.sha256 || source.sourceSha256, + ...(source.buildFromSource === true + ? { + patchSha256: source.patchSha256, + allocationTelemetry: source.allocationTelemetry === true, + } + : {}), + ...(name === 'aria2c' && source.firelinkRouteContract + ? { firelinkRouteContract: source.firelinkRouteContract } + : {}), + }, + ]), + ); +} + +export function assertPayloadManifestProvenance(manifest, targetSources, target) { + if (manifest?.schemaVersion !== 1) { + throw new Error(`Unsupported engine payload manifest schema for ${target}.`); + } + if (manifest.target !== target) { + throw new Error(`Engine payload manifest target mismatch for ${target}.`); + } + + const expected = buildPayloadProvenance(targetSources); + if (JSON.stringify(canonicalize(manifest.generatedFrom)) + !== JSON.stringify(canonicalize(expected))) { + throw new Error(`Engine payload manifest provenance mismatch for ${target}.`); + } +} + +function resolveManifestFile(root, relative) { + if (typeof relative !== 'string' || relative.length === 0) { + throw new Error('Engine payload manifest contains an invalid file path.'); + } + + const resolvedRoot = path.resolve(root); + const candidate = path.resolve(resolvedRoot, relative); + const relativeToRoot = path.relative(resolvedRoot, candidate); + if ( + relativeToRoot === '..' + || relativeToRoot.startsWith(`..${path.sep}`) + || path.isAbsolute(relativeToRoot) + ) { + throw new Error(`Engine payload manifest escapes its root: ${relative}.`); + } + return candidate; +} + +export function readAndValidatePayloadManifest(root, targetSources, target) { + const manifestPath = path.join(root, 'payload-manifest.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Engine payload manifest is missing for ${target}.`); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new Error(`Engine payload manifest is invalid for ${target}: ${error.message}`); + } + + assertPayloadManifestProvenance(manifest, targetSources, target); + if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) { + throw new Error(`Engine payload manifest files are invalid for ${target}.`); + } + + const expectedFiles = Object.keys(manifest.files).sort(); + const resolvedFiles = new Map( + expectedFiles.map(relative => [relative, resolveManifestFile(root, relative)]), + ); + const actualFiles = collectRegularFiles(root, { + ignoredNames: ['payload-manifest.json'], + }).map(file => path.relative(root, file).split(path.sep).join('/')).sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`Engine payload files do not match the manifest for ${target}.`); + } + + for (const relative of expectedFiles) { + const expected = manifest.files[relative]; + if (!/^[a-f0-9]{64}$/.test(expected)) { + throw new Error(`Engine payload manifest checksum is invalid: ${relative}.`); + } + const file = resolvedFiles.get(relative); + if (!fs.statSync(file).isFile() || sha256(file) !== expected) { + throw new Error(`Engine payload manifest checksum mismatch: ${relative}.`); + } + } + + return manifest; +} diff --git a/scripts/engine-payload-manifest.node-test.js b/scripts/engine-payload-manifest.node-test.js new file mode 100644 index 0000000..7fc5b18 --- /dev/null +++ b/scripts/engine-payload-manifest.node-test.js @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + assertPayloadManifestProvenance, + buildPayloadProvenance, + readAndValidatePayloadManifest, +} from './engine-payload-manifest.js'; +import { sha256 } from './engine-payload-integrity.js'; + +const TARGET = 'x86_64-unknown-linux-gnu'; +const SOURCES = { + 'yt-dlp': { + version: '2026.08.19', + url: 'https://example.invalid/yt-dlp.zip', + sha256: 'a'.repeat(64), + }, + deno: { + version: '2.9.6', + url: 'https://example.invalid/deno.zip', + sha256: 'b'.repeat(64), + }, + ffmpeg: { + version: '9.0.1', + url: 'https://example.invalid/ffmpeg.tar.xz', + sha256: 'c'.repeat(64), + }, + aria2c: { + version: '1.37.0-firelink-native-dns-v1', + url: 'https://example.invalid/aria2.tar.xz', + sha256: 'd'.repeat(64), + buildFromSource: true, + patchSha256: 'e'.repeat(64), + allocationTelemetry: true, + firelinkRouteContract: { + revision: 'firelink-native-dns-v1', + dnsResolver: 'native-async', + networkTargetPolicy: 'firelink-v1', + networkTargetPolicyDigest: 'sha256:test', + }, + }, +}; + +function createPayload() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-manifest-')); + const file = path.join(root, 'aria2c'); + fs.writeFileSync(file, 'verified engine'); + const manifest = { + schemaVersion: 1, + target: TARGET, + generatedFrom: buildPayloadProvenance(SOURCES), + files: { aria2c: sha256(file) }, + }; + fs.writeFileSync(path.join(root, 'payload-manifest.json'), `${JSON.stringify(manifest)}\n`); + return { root, manifest }; +} + +test('payload manifest validation binds files and source provenance', () => { + const { root, manifest } = createPayload(); + try { + assert.deepEqual(readAndValidatePayloadManifest(root, SOURCES, TARGET), manifest); + assert.doesNotThrow(() => assertPayloadManifestProvenance(manifest, SOURCES, TARGET)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('payload manifest validation rejects changed provenance and path traversal', () => { + const { root, manifest } = createPayload(); + try { + const changed = { ...manifest, generatedFrom: { ...manifest.generatedFrom } }; + changed.generatedFrom.aria2c = { + ...changed.generatedFrom.aria2c, + patchSha256: 'f'.repeat(64), + }; + fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(changed)); + assert.throws( + () => readAndValidatePayloadManifest(root, SOURCES, TARGET), + /provenance mismatch/, + ); + + const traversal = { + ...manifest, + files: { '../outside': '0'.repeat(64) }, + }; + fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(traversal)); + assert.throws( + () => readAndValidatePayloadManifest(root, SOURCES, TARGET), + /escapes its root|files do not match/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/engine-toolchain-fingerprint.js b/scripts/engine-toolchain-fingerprint.js new file mode 100644 index 0000000..02d63eb --- /dev/null +++ b/scripts/engine-toolchain-fingerprint.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const WINDOWS_PACKAGES = [ + 'autoconf', + 'automake', + 'libtool', + 'gettext-devel', + 'pkgconf', + 'make', + 'patch', + 'binutils', + 'mingw-w64-x86_64-gcc', + 'mingw-w64-x86_64-binutils', + 'mingw-w64-x86_64-pkgconf', + 'mingw-w64-x86_64-openssl', + 'mingw-w64-x86_64-libssh2', + 'mingw-w64-x86_64-c-ares', + 'mingw-w64-x86_64-expat', + 'mingw-w64-x86_64-sqlite3', + 'mingw-w64-x86_64-zlib', +]; + +const LINUX_PACKAGES = [ + 'gcc', + 'g++', + 'make', + 'patch', + 'binutils', + 'autoconf', + 'automake', + 'libtool', + 'gettext', + 'autopoint', + 'pkg-config', + 'libssl-dev', + 'libssh2-1-dev', + 'libgcrypt20-dev', + 'libc-ares-dev', + 'libexpat1-dev', + 'libsqlite3-dev', + 'zlib1g-dev', +]; + +function run(command, args) { + try { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).replaceAll('\r\n', '\n').trim(); + } catch (error) { + const detail = error.stderr?.toString().trim() || error.message; + throw new Error(`Could not fingerprint the engine toolchain with ${command}: ${detail}`); + } +} + +function writeOutput(name, value) { + const line = `${name}=${value}\n`; + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, line); + } else { + process.stdout.write(line); + } +} + +function main() { + const target = process.env.FIRELINK_TARGET_TRIPLE; + if (!target) throw new Error('FIRELINK_TARGET_TRIPLE is required.'); + + const records = [`target=${target}`]; + if (process.platform === 'win32') { + const msysRoot = process.env.FIRELINK_MSYS2_ROOT; + if (!msysRoot) throw new Error('FIRELINK_MSYS2_ROOT is required on Windows.'); + const bash = path.join(msysRoot, 'usr', 'bin', 'bash.exe'); + const packages = WINDOWS_PACKAGES.join(' '); + records.push(`msys2-packages=${run(bash, ['-lc', `pacman -Q ${packages}`])}`); + } else if (process.platform === 'linux') { + records.push(`debian-packages=${run('dpkg-query', [ + '-W', + '-f=${binary:Package}=${Version}\\n', + ...LINUX_PACKAGES, + ])}`); + for (const [command, args] of [ + ['gcc', ['--version']], + ['make', ['--version']], + ['autoconf', ['--version']], + ['automake', ['--version']], + ['pkg-config', ['--version']], + ]) { + records.push(`${command}=${run(command, args).split('\n', 1)[0]}`); + } + } else { + throw new Error(`Unsupported engine toolchain host: ${process.platform}`); + } + + const fingerprint = crypto.createHash('sha256').update(records.join('\n')).digest('hex'); + writeOutput('fingerprint', fingerprint); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index 672dea8..ea5b9bb 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -5,6 +5,7 @@ import { execFile } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { collectRegularFiles, sha256 } from './engine-payload-integrity.js'; +import { buildPayloadProvenance } from './engine-payload-manifest.js'; import { downloadEngineArchive } from './engine-download.js'; import { promoteDirectory, @@ -138,20 +139,7 @@ function writePayloadManifest() { const manifest = { schemaVersion: 1, target, - generatedFrom: Object.fromEntries( - Object.entries(targetSources).map(([name, source]) => [ - name, - { - version: source.version, - url: source.url || source.sourceUrl, - sha256: source.sha256 || source.sourceSha256, - ...(source.buildFromSource ? { patchSha256: source.patchSha256, allocationTelemetry: true } : {}), - ...(name === 'aria2c' && source.firelinkRouteContract - ? { firelinkRouteContract: source.firelinkRouteContract } - : {}) - } - ]) - ), + generatedFrom: buildPayloadProvenance(targetSources), files: Object.fromEntries( files.map(file => [ path.relative(payloadDestination, file).split(path.sep).join('/'), diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js index 30dbc20..bdb7297 100644 --- a/scripts/stage-engines.js +++ b/scripts/stage-engines.js @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js'; +import { sha256, treeDigest } from './engine-payload-integrity.js'; +import { readAndValidatePayloadManifest } from './engine-payload-manifest.js'; import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js'; import { assertSafeOutputRoot, @@ -15,6 +16,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries'); const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8')); +const sourceLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')); const target = resolveTargetTriple(); const outputRoot = assertSafeOutputRoot(resolveOutputRoot(), [ @@ -78,38 +80,18 @@ if (targetLock) { } } } else { - const manifestPath = path.join(source, 'payload-manifest.json'); - if (!fs.existsSync(manifestPath)) { - console.error(`No committed lock or payload manifest exists for ${target}.`); + const sourceTargetLock = sourceLock.targets?.[target]; + if (!sourceTargetLock) { + console.error(`No source lock exists for the provisioned engine target ${target}.`); process.exit(1); } - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - if (manifest.target !== target) { - console.error(`Payload manifest target mismatch: ${manifest.target}`); - process.exit(1); - } - if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) { - try { + try { + const manifest = readAndValidatePayloadManifest(source, sourceTargetLock, target); + if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) { assertAria2RouteSource(manifest.generatedFrom.aria2c, target); - } catch (error) { - console.error(error.message); - process.exit(1); } - } - for (const [relative, expected] of Object.entries(manifest.files || {})) { - const file = path.join(source, relative); - if (!fs.existsSync(file) || sha256(file) !== expected) { - console.error(`Payload manifest mismatch: ${relative}`); - process.exit(1); - } - } - const actualFiles = collectRegularFiles(source, { - ignoredNames: ['payload-manifest.json'], - }).map(file => path.relative(source, file).split(path.sep).join('/')); - const expectedFiles = Object.keys(manifest.files || {}).sort(); - actualFiles.sort(); - if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { - console.error(`Payload contains files not covered by manifest for ${target}.`); + } catch (error) { + console.error(error.message); process.exit(1); } } diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index a62db43..772b21c 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -13,7 +13,9 @@ import { ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS, assertAria2Baseline, assertAria2AllocationCapabilities, + assertAria2RouteSource, } from './aria2-route-contract.js'; +import { readAndValidatePayloadManifest } from './engine-payload-manifest.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -48,6 +50,10 @@ const ext = isWindows ? '.exe' : ''; const suffix = `-${targetTriple}${ext}`; const scriptsDir = __dirname; +const repoRoot = path.resolve(__dirname, '..'); +const sourceLock = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8') +); const searchRoot = argValue('--search-root'); function findEngineRoot(root) { const expected = `yt-dlp-${targetTriple}${ext}`; @@ -97,6 +103,7 @@ const binariesDir = configuredRoot : path.join(scriptsDir, '..', 'src-tauri', 'binaries'); const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']; const stagedVerification = process.argv.includes('--staged'); +const sourceTargetLock = sourceLock.targets?.[targetTriple]; const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar']; const FORBIDDEN_STDERR = [ @@ -117,6 +124,23 @@ function ok(msg) { console.log(`[OK] ${msg}`); } +if (sourceTargetLock) { + try { + const manifest = readAndValidatePayloadManifest(binariesDir, sourceTargetLock, targetTriple); + if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) { + assertAria2RouteSource(manifest.generatedFrom.aria2c, targetTriple); + } + ok('Payload manifest provenance and checksums'); + } catch (error) { + fail(error.message); + } +} + +if (exitCode !== 0) { + console.error('\nAborting: engine payload integrity checks failed.'); + process.exit(1); +} + function rejectSymlinks(root, label) { if (!fs.existsSync(root)) { return;