diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 335e6b26e..dbe1744c4 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -43,7 +43,7 @@ jobs: echo 'meson=1.7.2' } > "${RUNNER_TEMP}/linux-runtime-toolchain.txt" TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)" - SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/stage-runtime.mjs') }}" + SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}" echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}" echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" @@ -126,6 +126,7 @@ jobs: export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64" export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources" + export LIBPLACEBO_SOURCE_RECORD="${RUNNER_TEMP}/libplacebo-source-record.json" test -f "${RUNTIME_ROOT}/runtime-manifest.json" test -d "${SOURCE_INPUT_ROOT}/archives" @@ -147,23 +148,27 @@ jobs: "${SOURCE_BUNDLE_ROOT}/tooling" cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/" - cp -a "${SOURCE_INPUT_ROOT}/git/." "${SOURCE_BUNDLE_ROOT}/git/" cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/" cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/" cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json" + node tools/packaging/prepare-linux-runtime-source-snapshot.cjs prepare \ + --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ + --checkout "${SOURCE_INPUT_ROOT}/git/libplacebo" \ + --output "${SOURCE_BUNDLE_ROOT}/git/libplacebo" \ + --record-output "${LIBPLACEBO_SOURCE_RECORD}" cp \ tools/embedded-mpv/build-linux-runtime.cjs \ tools/embedded-mpv/build-linux-runtime.mjs \ tools/embedded-mpv/generate-linux-runtime-notices.cjs \ tools/embedded-mpv/linux-runtime-manifest.cjs \ tools/embedded-mpv/stage-runtime.mjs \ + tools/packaging/prepare-linux-runtime-source-snapshot.cjs \ "${SOURCE_BUNDLE_ROOT}/tooling/" test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt" test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json" git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt" git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch" node <<'NODE' - const childProcess = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); @@ -195,56 +200,15 @@ jobs: ); } - const libplaceboCheckout = path.join( - process.env.SOURCE_BUNDLE_ROOT, - 'git', - 'libplacebo' + const libplacebo = JSON.parse( + fs.readFileSync(process.env.LIBPLACEBO_SOURCE_RECORD, 'utf8') ); - const gitOutput = (...args) => - childProcess.execFileSync('git', ['-C', libplaceboCheckout, ...args], { - encoding: 'utf8', - }).trim(); - const sourceGitCommit = gitOutput('rev-parse', 'HEAD'); - if (sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit) { - throw new Error('Bundled libplacebo checkout commit does not match the runtime manifest.'); - } - const sourceSubmodules = gitOutput('submodule', 'status', '--recursive') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); if ( - JSON.stringify(sourceSubmodules) !== + libplacebo.sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit || + JSON.stringify(libplacebo.sourceSubmodules) !== JSON.stringify(manifest.packages.libplacebo.sourceSubmodules) ) { - throw new Error('Bundled libplacebo submodules do not match the runtime manifest.'); - } - const assertCleanCheckout = (checkoutPath, label) => { - const status = childProcess.execFileSync( - 'git', - [ - '-C', - checkoutPath, - 'status', - '--porcelain=v1', - '--untracked-files=all', - '--ignore-submodules=none', - ], - { encoding: 'utf8' } - ); - if (status.trim()) { - throw new Error(`${label} checkout contains dirty or untracked files.`); - } - }; - assertCleanCheckout(libplaceboCheckout, 'libplacebo'); - for (const submoduleRecord of sourceSubmodules) { - const match = submoduleRecord.match(/^[+-U]?[a-f0-9]{40,64}\s+([^\s]+)/); - if (!match) { - throw new Error(`Invalid libplacebo submodule record: ${submoduleRecord}`); - } - assertCleanCheckout( - path.join(libplaceboCheckout, match[1]), - `libplacebo submodule ${match[1]}` - ); + throw new Error('Prepared libplacebo source identity does not match the runtime manifest.'); } const notices = JSON.parse( @@ -271,10 +235,7 @@ jobs: repositoryRevision, sourcePackages: manifest.packages, archives, - libplacebo: { - sourceGitCommit, - sourceSubmodules, - }, + libplacebo, legal: { manifest: 'notices/embedded-mpv-notices.json', noticeFile: notices.noticeFile, @@ -290,6 +251,8 @@ jobs: cd "${SOURCE_BUNDLE_ROOT}/archives" sha256sum * > "../metadata/archive-sha256.txt" ) + node tools/packaging/prepare-linux-runtime-source-snapshot.cjs assert-vcs-free \ + --directory "${SOURCE_BUNDLE_ROOT}" mkdir -p dist/compliance rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 58160a06e..d40f64bb2 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -214,13 +214,36 @@ test('Linux CI builds one cached source runtime and packages three isolated prof complianceStep, /new Set\(expectedArchiveHashes\)\.size[\s\S]*archives\.length !== expectedArchiveHashes\.length/ ); - assert.match( + assert.match(complianceStep, /prepare-linux-runtime-source-snapshot\.cjs/); + assert.doesNotMatch( complianceStep, - /status[\s\S]*--porcelain=v1[\s\S]*--untracked-files=all[\s\S]*--ignore-submodules=none/ + /cp -a "\$\{SOURCE_INPUT_ROOT\}\/git\/\."/ + ); + assert.ok( + complianceStep.indexOf('prepare-linux-runtime-source-snapshot.cjs') < + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) ); assert.match( complianceStep, - /for \(const submoduleRecord of sourceSubmodules\)/ + /libplacebo-source-record\.json[\s\S]*sourceGitCommit[\s\S]*sourceSubmodules/ + ); + assert.match( + complianceStep, + /prepare-linux-runtime-source-snapshot\.cjs assert-vcs-free/ + ); + assert.ok( + complianceStep.indexOf( + 'prepare-linux-runtime-source-snapshot.cjs assert-vcs-free' + ) < + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) + ); + assert.match( + buildWorkflow, + /hashFiles\([^\n]*prepare-linux-runtime-source-snapshot\.cjs/ ); const buildStep = workflowStep('Build and stage pinned LGPL Linux runtime'); diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.cjs b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs new file mode 100644 index 000000000..5fcb54634 --- /dev/null +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node + +'use strict'; + +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); + +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; + +function gitOutput(checkoutPath, ...args) { + try { + return childProcess + .execFileSync('git', ['-C', checkoutPath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + .trim(); + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String(error.stderr).trim() + : ''; + throw new Error( + `Unable to inspect source checkout with git ${args.join(' ')}${stderr ? `: ${stderr}` : '.'}` + ); + } +} + +function assertExpectedGitRecord(expected) { + if ( + expected === null || + typeof expected !== 'object' || + typeof expected.sourceGitCommit !== 'string' || + !GIT_COMMIT_PATTERN.test(expected.sourceGitCommit) || + !Array.isArray(expected.sourceSubmodules) || + expected.sourceSubmodules.some( + (record) => typeof record !== 'string' || record.length === 0 + ) + ) { + throw new Error( + 'Expected source identity must contain one commit and a submodule record array.' + ); + } +} + +function assertCleanCheckout(checkoutPath, label) { + const status = gitOutput( + checkoutPath, + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--ignore-submodules=none' + ); + if (status) { + throw new Error(`${label} checkout contains dirty or untracked files.`); + } +} + +function sourceSubmoduleIdentity(record) { + const match = record.match(/^([a-f0-9]{40,64})\s+([^\s]+)(?:\s|$)/); + if (!match) { + throw new Error(`Invalid source submodule record: ${record}`); + } + const submodulePath = match[2]; + if ( + path.isAbsolute(submodulePath) || + submodulePath + .split('/') + .some((part) => part === '' || part === '.' || part === '..') + ) { + throw new Error(`Unsafe source submodule path: ${submodulePath}`); + } + return { + commit: match[1], + path: submodulePath, + }; +} + +function inspectCleanGitSource(checkoutPath, expected) { + assertExpectedGitRecord(expected); + const sourceGitCommit = gitOutput(checkoutPath, 'rev-parse', 'HEAD'); + if (sourceGitCommit !== expected.sourceGitCommit) { + throw new Error( + 'Source checkout commit does not match the runtime manifest.' + ); + } + const submoduleOutput = gitOutput( + checkoutPath, + 'submodule', + 'status', + '--recursive' + ); + const sourceSubmodules = submoduleOutput + ? submoduleOutput + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + : []; + if (!isDeepStrictEqual(sourceSubmodules, expected.sourceSubmodules)) { + throw new Error( + 'Source checkout submodules do not match the runtime manifest.' + ); + } + + assertCleanCheckout(checkoutPath, 'Source'); + for (const submoduleRecord of sourceSubmodules) { + const submodule = sourceSubmoduleIdentity(submoduleRecord); + const submoduleCheckout = path.join( + checkoutPath, + ...submodule.path.split('/') + ); + if ( + gitOutput(submoduleCheckout, 'rev-parse', 'HEAD') !== + submodule.commit + ) { + throw new Error( + `Source submodule ${submodule.path} commit does not match its recorded identity.` + ); + } + assertCleanCheckout( + submoduleCheckout, + `Source submodule ${submodule.path}` + ); + } + return { + sourceGitCommit, + sourceSubmodules, + }; +} + +function gitMetadataEntries(rootPath) { + const entries = []; + function visit(directoryPath, relativeDirectory = '') { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directoryPath, entry.name); + if (entry.name === '.git') { + entries.push(relativePath); + } else if (entry.isDirectory()) { + visit(absolutePath, relativePath); + } + } + } + visit(rootPath); + return entries.sort(); +} + +function assertNoGitMetadata(rootPath) { + const entries = gitMetadataEntries(rootPath); + if (entries.length > 0) { + throw new Error( + `Prepared source snapshot must not contain VCS metadata: ${entries.join(', ')}` + ); + } +} + +function lstatIfExists(candidatePath) { + try { + return fs.lstatSync(candidatePath); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw error; + } +} + +function copyWorkingTreeWithoutGitMetadata(checkoutPath, outputPath) { + const outputParent = path.dirname(outputPath); + const temporaryPath = fs.mkdtempSync( + path.join(outputParent, `.${path.basename(outputPath)}-`) + ); + try { + fs.cpSync(checkoutPath, temporaryPath, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + filter: (sourcePath) => path.basename(sourcePath) !== '.git', + }); + assertNoGitMetadata(temporaryPath); + if (lstatIfExists(outputPath)) { + throw new Error( + `Prepared source snapshot output must not already exist: ${outputPath}` + ); + } + fs.renameSync(temporaryPath, outputPath); + } catch (error) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw error; + } +} + +function prepareLinuxRuntimeSourceSnapshot({ + checkoutPath, + outputPath, + expected, +}) { + const checkoutStat = fs.lstatSync(checkoutPath); + if (!checkoutStat.isDirectory() || checkoutStat.isSymbolicLink()) { + throw new Error('Source checkout must be a real directory.'); + } + const checkoutRoot = fs.realpathSync(checkoutPath); + const outputRoot = path.resolve(outputPath); + const outputParent = path.dirname(outputRoot); + const outputParentStat = fs.lstatSync(outputParent); + if (!outputParentStat.isDirectory() || outputParentStat.isSymbolicLink()) { + throw new Error( + 'Prepared source snapshot parent must be a real directory.' + ); + } + if (lstatIfExists(outputRoot)) { + throw new Error( + `Prepared source snapshot output must not already exist: ${outputRoot}` + ); + } + const realOutputRoot = path.join( + fs.realpathSync(outputParent), + path.basename(outputRoot) + ); + const isInside = (root, candidate) => { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); + }; + if ( + isInside(checkoutRoot, realOutputRoot) || + isInside(realOutputRoot, checkoutRoot) + ) { + throw new Error( + 'Prepared source snapshot and checkout must be separate trees.' + ); + } + const record = inspectCleanGitSource(checkoutRoot, expected); + copyWorkingTreeWithoutGitMetadata(checkoutRoot, realOutputRoot); + assertNoGitMetadata(realOutputRoot); + return record; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const name = tokens[index]; + const value = tokens[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid command-line argument: ${name ?? ''}`); + } + options[name.slice(2)] = value; + } + return { command, options }; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command === 'prepare' && + options['runtime-manifest'] && + options.checkout && + options.output && + options['record-output'] + ) { + const runtimeManifest = readJson(options['runtime-manifest']); + const sourcePackage = runtimeManifest?.packages?.libplacebo; + const record = prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: options.checkout, + outputPath: options.output, + expected: { + sourceGitCommit: sourcePackage?.sourceGitCommit, + sourceSubmodules: sourcePackage?.sourceSubmodules, + }, + }); + writeJson(options['record-output'], record); + return; + } + if (command === 'assert-vcs-free' && options.directory) { + assertNoGitMetadata(options.directory); + return; + } + throw new Error( + 'Usage: prepare-linux-runtime-source-snapshot.cjs prepare --runtime-manifest --checkout --output --record-output | assert-vcs-free --directory ' + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { + assertNoGitMetadata, + inspectCleanGitSource, + prepareLinuxRuntimeSourceSnapshot, +}; diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs new file mode 100644 index 000000000..c767efa5e --- /dev/null +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const helperPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'prepare-linux-runtime-source-snapshot.cjs' +); + +async function loadHelper() { + if (!fs.existsSync(helperPath)) { + return null; + } + return import(pathToFileURL(helperPath).href); +} + +function runGit(cwd, args, env = {}) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + }); + assert.equal( + result.status, + 0, + `git ${args.join(' ')} failed:\n${result.stderr}` + ); + return result.stdout.trim(); +} + +function createEquivalentCheckouts(root) { + const origin = path.join(root, 'origin'); + fs.mkdirSync(origin); + runGit(origin, ['init', '--quiet']); + fs.mkdirSync(path.join(origin, 'src')); + fs.writeFileSync(path.join(origin, 'src', 'renderer.c'), 'int main() {}\n'); + fs.writeFileSync(path.join(origin, 'LICENSE'), 'license\n'); + fs.symlinkSync('../LICENSE', path.join(origin, 'src', 'license-link')); + runGit(origin, ['add', '.']); + runGit(origin, ['commit', '--quiet', '-m', 'source fixture'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }); + const commit = runGit(origin, ['rev-parse', 'HEAD']); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + runGit(root, ['clone', '--quiet', origin, first]); + runGit(root, ['clone', '--quiet', origin, second]); + + fs.appendFileSync( + path.join(first, '.git', 'config'), + '\n[fixture]\n\tvalue = first\n' + ); + fs.appendFileSync( + path.join(second, '.git', 'config'), + '\n[fixture]\n\tvalue = second\n' + ); + fs.writeFileSync(path.join(first, '.git', 'fixture-cache'), 'one'); + fs.writeFileSync(path.join(second, '.git', 'fixture-cache'), 'two'); + const differentTime = new Date('2030-01-01T00:00:00Z'); + fs.utimesSync( + path.join(second, '.git', 'index'), + differentTime, + differentTime + ); + + return { commit, first, second }; +} + +function createCheckoutWithSubmodule(root) { + const submoduleOrigin = path.join(root, 'submodule-origin'); + fs.mkdirSync(submoduleOrigin); + runGit(submoduleOrigin, ['init', '--quiet']); + fs.writeFileSync( + path.join(submoduleOrigin, 'submodule-source.c'), + 'int submodule_source;\n' + ); + runGit(submoduleOrigin, ['add', '.']); + runGit(submoduleOrigin, ['commit', '--quiet', '-m', 'submodule source'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }); + + const checkout = path.join(root, 'checkout-with-submodule'); + fs.mkdirSync(checkout); + runGit(checkout, ['init', '--quiet']); + fs.writeFileSync(path.join(checkout, 'README'), 'source tree\n'); + runGit(checkout, ['add', 'README']); + runGit(checkout, [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + submoduleOrigin, + '3rdparty/example', + ]); + runGit(checkout, ['commit', '--quiet', '-m', 'source with submodule'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-02T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-02T00:00:00Z', + }); + return { + checkout, + expected: { + sourceGitCommit: runGit(checkout, ['rev-parse', 'HEAD']), + sourceSubmodules: runGit(checkout, [ + 'submodule', + 'status', + '--recursive', + ]) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + }, + }; +} + +function snapshotRecords(root) { + const records = []; + function visit(directory, relativeDirectory = '') { + for (const entry of fs + .readdirSync(directory, { withFileTypes: true }) + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + )) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directory, entry.name); + const stat = fs.lstatSync(absolutePath); + if (entry.isDirectory()) { + records.push({ + path: `${relativePath}/`, + mode: stat.mode & 0o777, + }); + visit(absolutePath, relativePath); + } else if (entry.isSymbolicLink()) { + records.push({ + path: relativePath, + mode: stat.mode & 0o777, + symlink: fs.readlinkSync(absolutePath), + }); + } else { + records.push({ + path: relativePath, + mode: stat.mode & 0o777, + sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex'), + }); + } + } + } + visit(root); + return records; +} + +test('prepares identical VCS-free snapshots from equivalent clean checkouts', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-snapshot-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first, second } = createEquivalentCheckouts(root); + const firstOutput = path.join(root, 'snapshot-first'); + const secondOutput = path.join(root, 'snapshot-second'); + const expected = { + sourceGitCommit: commit, + sourceSubmodules: [], + }; + + const firstRecord = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: firstOutput, + expected, + }); + const secondRecord = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: second, + outputPath: secondOutput, + expected, + }); + + assert.deepEqual(firstRecord, expected); + assert.deepEqual(secondRecord, expected); + assert.deepEqual( + snapshotRecords(firstOutput), + snapshotRecords(secondOutput) + ); + assert.equal( + snapshotRecords(firstOutput).some(({ path: recordPath }) => + recordPath.split('/').includes('.git') + ), + false + ); + assert.equal( + fs.readlinkSync(path.join(firstOutput, 'src', 'license-link')), + '../LICENSE' + ); +}); + +test('validates checkout cleanliness and exact commit/submodule identities before copying', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-identity-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first, second } = createEquivalentCheckouts(root); + + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: path.join(root, 'wrong-commit'), + expected: { + sourceGitCommit: '0'.repeat(40), + sourceSubmodules: [], + }, + }), + /commit does not match/ + ); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: path.join(root, 'wrong-submodules'), + expected: { + sourceGitCommit: commit, + sourceSubmodules: [`${'1'.repeat(40)} 3rdparty/example`], + }, + }), + /submodules do not match/ + ); + + fs.writeFileSync(path.join(second, 'untracked-build-output'), 'dirty'); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: second, + outputPath: path.join(root, 'dirty'), + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /dirty or untracked files/ + ); + + const existingOutput = path.join(root, 'existing-output'); + fs.mkdirSync(existingOutput); + fs.writeFileSync( + path.join(existingOutput, 'owned-by-someone-else'), + 'keep' + ); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: existingOutput, + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /output.*must not already exist/i + ); + assert.equal( + fs.readFileSync( + path.join(existingOutput, 'owned-by-someone-else'), + 'utf8' + ), + 'keep' + ); + + const existingSymlink = path.join(root, 'existing-symlink'); + fs.symlinkSync('missing-target', existingSymlink); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: existingSymlink, + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /output.*must not already exist/i + ); + assert.equal(fs.readlinkSync(existingSymlink), 'missing-target'); +}); + +test('preserves recursive submodule sources while stripping every nested .git link', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-submodule-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { checkout, expected } = createCheckoutWithSubmodule(root); + const output = path.join(root, 'submodule-snapshot'); + + assert.deepEqual( + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: checkout, + outputPath: output, + expected, + }), + expected + ); + assert.equal( + fs.readFileSync( + path.join(output, '3rdparty', 'example', 'submodule-source.c'), + 'utf8' + ), + 'int submodule_source;\n' + ); + assert.doesNotThrow(() => helper.assertNoGitMetadata(output)); +}); + +test('fails closed when a prepared snapshot contains any nested .git entry', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-vcs-entry-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'nested', '.git'), { recursive: true }); + fs.writeFileSync(path.join(root, 'nested', '.git', 'index'), 'metadata'); + + assert.throws( + () => helper.assertNoGitMetadata(root), + /must not contain VCS metadata.*nested\/\.git/i + ); +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index e31fca041..16b430425 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -26,6 +26,8 @@ "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs", "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs", @@ -41,14 +43,17 @@ "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, "lint": { "inputs": [ "default", + "{workspaceRoot}/.github/workflows/build-and-make.yaml", "{workspaceRoot}/.github/workflows/publish-snap.yaml", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs", "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", "{workspaceRoot}/tools/packaging/release-snap-assets.cjs" ],