diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index da7a9b5ec..13b947036 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -43,17 +43,19 @@ 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/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') }}" echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}" - echo "key=linux-frame-copy-runtime-v4-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" + echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" - - name: Restore pinned Linux runtime and source compliance bundle + - name: Restore pinned Linux runtime and immutable source inputs id: linux-runtime-cache uses: actions/cache@v4 with: path: | - vendor/embedded-mpv/linux-x64 - dist/compliance/linux-frame-copy-runtime-sources.tar.xz + vendor/embedded-mpv/linux-x64/include + vendor/embedded-mpv/linux-x64/lib + vendor/embedded-mpv/linux-x64/runtime-manifest.json + dist/linux-frame-copy-runtime-source-inputs key: ${{ steps.linux-runtime-cache-key.outputs.key }} - name: Install pinned Linux runtime build dependencies @@ -99,30 +101,67 @@ jobs: node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}" node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}" + export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" + rm -rf "${SOURCE_INPUT_ROOT}" + mkdir -p \ + "${SOURCE_INPUT_ROOT}/archives" \ + "${SOURCE_INPUT_ROOT}/git" + + git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ + submodule foreach --recursive git clean -ffdqx + git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ + clean -ffdqx + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/" + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo" + node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \ + --runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \ + --source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \ + --output-root "${SOURCE_INPUT_ROOT}/license-inputs" + + - name: Generate Linux runtime notices and assemble source compliance + shell: bash + run: | + set -euo pipefail + + 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" - rm -rf "${SOURCE_BUNDLE_ROOT}" + + test -f "${RUNTIME_ROOT}/runtime-manifest.json" + test -d "${SOURCE_INPUT_ROOT}/archives" + test -d "${SOURCE_INPUT_ROOT}/git/libplacebo" + test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json" + + rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}" + node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \ + --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ + --license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \ + --output-root "${RUNTIME_ROOT}/notices" + mkdir -p \ "${SOURCE_BUNDLE_ROOT}/archives" \ "${SOURCE_BUNDLE_ROOT}/git" \ + "${SOURCE_BUNDLE_ROOT}/license-inputs" \ "${SOURCE_BUNDLE_ROOT}/metadata" \ "${SOURCE_BUNDLE_ROOT}/notices" \ "${SOURCE_BUNDLE_ROOT}/tooling" - cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/" - cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_BUNDLE_ROOT}/git/libplacebo" - cp "${RUNTIME_PREFIX}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json" + 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" 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 \ "${SOURCE_BUNDLE_ROOT}/tooling/" - find "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/hwdata" \ - -maxdepth 1 -type f -iname 'license*' \ - -exec cp '{}' "${SOURCE_BUNDLE_ROOT}/notices/hwdata-LICENSE" ';' - test -f "${SOURCE_BUNDLE_ROOT}/notices/hwdata-LICENSE" + 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 -- tools/embedded-mpv > "${SOURCE_BUNDLE_ROOT}/metadata/local-embedded-mpv-changes.patch" + git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch" node <<'NODE' const childProcess = require('node:child_process'); const crypto = require('node:crypto'); @@ -130,7 +169,7 @@ jobs: const path = require('node:path'); const manifest = JSON.parse( - fs.readFileSync(path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'), 'utf8') + fs.readFileSync(path.join(process.env.RUNTIME_ROOT, 'runtime-manifest.json'), 'utf8') ); const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives'); const archives = fs.readdirSync(archivesDirectory).sort().map((name) => { @@ -140,13 +179,20 @@ jobs: sha256: crypto.createHash('sha256').update(contents).digest('hex'), }; }); - const archiveHashes = new Set(archives.map(({ sha256 }) => sha256)); - for (const [packageName, source] of Object.entries(manifest.packages)) { - if (source.sourceSha256 && !archiveHashes.has(source.sourceSha256)) { - throw new Error( - `Source bundle is missing ${packageName} archive ${source.sourceSha256}.` - ); - } + const expectedArchiveHashes = Object.values(manifest.packages) + .map(({ sourceSha256 }) => sourceSha256) + .filter(Boolean) + .sort(); + const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort(); + if ( + new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || + new Set(actualArchiveHashes).size !== actualArchiveHashes.length || + archives.length !== expectedArchiveHashes.length || + JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes) + ) { + throw new Error( + 'Source bundle archives must match the exact unique pinned archive hash set.' + ); } const libplaceboCheckout = path.join( @@ -172,18 +218,68 @@ jobs: ) { 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]}` + ); + } + const notices = JSON.parse( + fs.readFileSync( + path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + const repositoryRevision = fs + .readFileSync( + path.join( + process.env.SOURCE_BUNDLE_ROOT, + 'metadata', + 'iptvnator-git-revision.txt' + ), + 'utf8' + ) + .trim(); fs.writeFileSync( path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'), `${JSON.stringify( { - schemaVersion: 1, + schemaVersion: 2, + repositoryRevision, sourcePackages: manifest.packages, archives, libplacebo: { sourceGitCommit, sourceSubmodules, }, + legal: { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: notices.noticeFile, + packages: notices.packages, + }, }, null, 2 @@ -196,9 +292,15 @@ jobs: ) mkdir -p dist/compliance + rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz tar \ --create \ --xz \ + --sort=name \ + --mtime='UTC 1970-01-01' \ + --owner=0 \ + --group=0 \ + --numeric-owner \ --file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ --directory "${SOURCE_BUNDLE_ROOT}" \ . diff --git a/.gitignore b/.gitignore index ea63467e3..c2263e287 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,5 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json vendor/embedded-mpv/*/bin/ vendor/embedded-mpv/*/include/ vendor/embedded-mpv/*/lib/ +vendor/embedded-mpv/*/notices/ vendor/embedded-mpv/*/runtime-manifest.json diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.cjs b/tools/embedded-mpv/generate-linux-runtime-notices.cjs new file mode 100644 index 000000000..ec2a44dc1 --- /dev/null +++ b/tools/embedded-mpv/generate-linux-runtime-notices.cjs @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs'); + +const LICENSE_INPUT_MANIFEST = 'linux-runtime-license-inputs.json'; +const NOTICE_MANIFEST = 'embedded-mpv-notices.json'; +const THIRD_PARTY_NOTICES = 'THIRD_PARTY_NOTICES.txt'; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0); +const NOTICE_SOURCE_PACKAGES = Object.freeze( + [...SOURCE_PACKAGES].sort(({ id: left }, { id: right }) => + compareText(left, right) + ) +); + +const LICENSE_PATHS_BY_PACKAGE = Object.freeze({ + freetype: Object.freeze(['LICENSE.TXT', 'docs/FTL.TXT']), + fribidi: Object.freeze(['COPYING']), + harfbuzz: Object.freeze(['COPYING']), + expat: Object.freeze(['COPYING']), + fontconfig: Object.freeze(['COPYING']), + libass: Object.freeze(['COPYING']), + openssl: Object.freeze(['LICENSE.txt']), + ffmpeg: Object.freeze(['LICENSE.md', 'COPYING.LGPLv2.1']), + libplacebo: Object.freeze([ + 'LICENSE', + '3rdparty/Vulkan-Headers/LICENSE.md', + '3rdparty/fast_float/LICENSE-APACHE', + '3rdparty/fast_float/LICENSE-BOOST', + '3rdparty/fast_float/LICENSE-MIT', + '3rdparty/glad/LICENSE', + '3rdparty/jinja/LICENSE.txt', + '3rdparty/markupsafe/LICENSE.txt', + 'demos/3rdparty/nuklear/LICENSE', + ]), + hwdata: Object.freeze(['LICENSE', 'COPYING']), + 'libdisplay-info': Object.freeze(['LICENSE']), + mpv: Object.freeze(['Copyright', 'LICENSE.LGPL']), +}); + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactFields(value, fields) { + return ( + isObject(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()) + ); +} + +function isPathInside(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function assertDirectoryWithoutSymlinks(directoryPath, label) { + let stat; + try { + stat = fs.lstatSync(directoryPath); + } catch { + throw new Error(`Missing ${label}: ${directoryPath}`); + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${directoryPath}`); + } + return fs.realpathSync(directoryPath); +} + +function assertRegularFileInside(root, relativePath, label) { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + path.isAbsolute(relativePath) || + relativePath.split(/[\\/]/).some((part) => part === '..' || part === '') + ) { + throw new Error( + `${label} has an unsafe relative path: ${relativePath}` + ); + } + const realRoot = assertDirectoryWithoutSymlinks(root, `${label} root`); + const candidate = path.resolve(root, ...relativePath.split('/')); + if (!isPathInside(path.resolve(root), candidate)) { + throw new Error(`${label} resolves outside ${root}: ${relativePath}`); + } + + let cursor = path.resolve(root); + const parts = path.relative(cursor, candidate).split(path.sep); + for (const [index, part] of parts.entries()) { + cursor = path.join(cursor, part); + let stat; + try { + stat = fs.lstatSync(cursor); + } catch { + throw new Error(`Missing ${label}: ${cursor}`); + } + if (stat.isSymbolicLink()) { + throw new Error(`${label} must not use a symbolic link: ${cursor}`); + } + if (index < parts.length - 1 && !stat.isDirectory()) { + throw new Error(`${label} parent must be a directory: ${cursor}`); + } + if (index === parts.length - 1 && !stat.isFile()) { + throw new Error(`${label} must be a regular file: ${cursor}`); + } + } + + const realCandidate = fs.realpathSync(candidate); + if (!isPathInside(realRoot, realCandidate)) { + throw new Error( + `${label} resolves outside its trusted root: ${relativePath}` + ); + } + return realCandidate; +} + +function sourcePackageMetadata(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { sourceGitCommit: sourcePackage.expectedGitCommit }), + license: sourcePackage.license, + }; +} + +function validateRuntimeManifestPackages(runtimeManifest) { + if ( + !isObject(runtimeManifest) || + runtimeManifest.platform !== 'linux' || + runtimeManifest.arch !== 'x64' || + !isObject(runtimeManifest.packages) || + !isDeepStrictEqual( + Object.keys(runtimeManifest.packages).sort(), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ) + ) { + throw new Error( + 'Linux runtime notice generation requires the exact pinned linux-x64 package manifest.' + ); + } + for (const sourcePackage of SOURCE_PACKAGES) { + const actual = runtimeManifest.packages[sourcePackage.id]; + const expected = sourcePackageMetadata(sourcePackage); + for (const [field, expectedValue] of Object.entries(expected)) { + if (!isDeepStrictEqual(actual?.[field], expectedValue)) { + throw new Error( + `Linux runtime package ${sourcePackage.id}.${field} does not match its immutable pin.` + ); + } + } + if ( + sourcePackage.sourceKind === 'git' && + (!Array.isArray(actual.sourceSubmodules) || + actual.sourceSubmodules.length === 0) + ) { + throw new Error( + `Linux runtime package ${sourcePackage.id} must record pinned submodules.` + ); + } + const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]; + if (!Array.isArray(licensePaths) || licensePaths.length === 0) { + throw new Error( + `Linux runtime package ${sourcePackage.id} has no pinned upstream license files.` + ); + } + } +} + +function fileRecord(filePath, relativePath) { + const contents = fs.readFileSync(filePath); + return { + path: relativePath.split(path.sep).join('/'), + size: contents.length, + sha256: sha256(contents), + }; +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o644, + }); +} + +function replaceDirectory(outputRoot, writer) { + const resolvedOutputRoot = path.resolve(outputRoot); + const temporaryRoot = `${resolvedOutputRoot}.tmp-${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}`; + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + fs.mkdirSync(temporaryRoot, { recursive: true }); + try { + const result = writer(temporaryRoot); + fs.rmSync(resolvedOutputRoot, { recursive: true, force: true }); + fs.renameSync(temporaryRoot, resolvedOutputRoot); + return result; + } catch (error) { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + throw error; + } +} + +function expectedLicensePath(packageId, sourceRelativePath) { + return path.posix.join('licenses', packageId, sourceRelativePath); +} + +function collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot, + runtimeManifest, +}) { + validateRuntimeManifestPackages(runtimeManifest); + assertDirectoryWithoutSymlinks(sourceRoot, 'Linux runtime source root'); + + return replaceDirectory(outputRoot, (temporaryRoot) => { + const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage) => { + const files = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map( + (sourceRelativePath) => { + const sourcePath = assertRegularFileInside( + path.join(sourceRoot, sourcePackage.id), + sourceRelativePath, + `${sourcePackage.id} upstream license` + ); + const relativeOutputPath = expectedLicensePath( + sourcePackage.id, + sourceRelativePath + ); + const destinationPath = path.join( + temporaryRoot, + ...relativeOutputPath.split('/') + ); + fs.mkdirSync(path.dirname(destinationPath), { + recursive: true, + }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o644); + return { + sourcePath: sourceRelativePath, + ...fileRecord(destinationPath, relativeOutputPath), + }; + } + ); + return { + id: sourcePackage.id, + ...sourcePackageMetadata(sourcePackage), + files, + }; + }); + const manifest = { + schemaVersion: 1, + origin: 'pinned-linux-runtime-license-inputs', + platform: 'linux', + arch: 'x64', + packages, + }; + writeJson(path.join(temporaryRoot, LICENSE_INPUT_MANIFEST), manifest); + return manifest; + }); +} + +function listFilesRecursively(root) { + const files = []; + function visit(directoryPath) { + for (const entry of fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name))) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isSymbolicLink()) { + throw new Error( + `Linux runtime legal files must not use symbolic links: ${entryPath}` + ); + } + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.isFile()) { + files.push( + path.relative(root, entryPath).split(path.sep).join('/') + ); + } else { + throw new Error( + `Linux runtime legal input must be a regular file: ${entryPath}` + ); + } + } + } + visit(root); + return files; +} + +function readJsonFileInside(root, relativePath, label) { + const filePath = assertRegularFileInside(root, relativePath, label); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error( + `Invalid JSON in ${label}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function validateLicenseInputManifest(inputRoot, runtimeManifest) { + validateRuntimeManifestPackages(runtimeManifest); + const inputManifest = readJsonFileInside( + inputRoot, + LICENSE_INPUT_MANIFEST, + 'Linux runtime license input manifest' + ); + if ( + !hasExactFields(inputManifest, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'packages', + ]) || + inputManifest.schemaVersion !== 1 || + inputManifest.origin !== 'pinned-linux-runtime-license-inputs' || + inputManifest.platform !== 'linux' || + inputManifest.arch !== 'x64' || + !Array.isArray(inputManifest.packages) || + inputManifest.packages.length !== NOTICE_SOURCE_PACKAGES.length + ) { + throw new Error('Invalid Linux runtime license input manifest.'); + } + + const expectedPaths = new Set([LICENSE_INPUT_MANIFEST]); + for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) { + const packageRecord = inputManifest.packages[index]; + const expectedMetadata = sourcePackageMetadata(sourcePackage); + if ( + !hasExactFields(packageRecord, [ + 'id', + ...Object.keys(expectedMetadata), + 'files', + ]) || + packageRecord.id !== sourcePackage.id || + !Object.entries(expectedMetadata).every(([field, value]) => + isDeepStrictEqual(packageRecord[field], value) + ) || + !Array.isArray(packageRecord.files) || + packageRecord.files.length !== + LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].length + ) { + throw new Error( + `Invalid cached license inputs for ${sourcePackage.id}.` + ); + } + for (const [fileIndex, sourceRelativePath] of LICENSE_PATHS_BY_PACKAGE[ + sourcePackage.id + ].entries()) { + const record = packageRecord.files[fileIndex]; + const expectedPath = expectedLicensePath( + sourcePackage.id, + sourceRelativePath + ); + if ( + !hasExactFields(record, [ + 'sourcePath', + 'path', + 'size', + 'sha256', + ]) || + record.sourcePath !== sourceRelativePath || + record.path !== expectedPath || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error( + `Invalid cached license file record for ${sourcePackage.id}.` + ); + } + const inputPath = assertRegularFileInside( + inputRoot, + record.path, + `${sourcePackage.id} license input` + ); + const actual = fileRecord(inputPath, record.path); + if (actual.size !== record.size) { + throw new Error( + `Size mismatch for cached license input ${record.path}.` + ); + } + if (actual.sha256 !== record.sha256) { + throw new Error( + `SHA-256 mismatch for cached license input ${record.path}.` + ); + } + expectedPaths.add(record.path); + } + } + for (const actualPath of listFilesRecursively(inputRoot)) { + if (!expectedPaths.has(actualPath)) { + throw new Error( + `Found undeclared license input ${actualPath} in ${inputRoot}.` + ); + } + } + return inputManifest; +} + +function noticePackageRecord(sourcePackage, inputPackage) { + return { + id: sourcePackage.id, + ...sourcePackageMetadata(sourcePackage), + files: inputPackage.files.map( + ({ path: filePath, size, sha256: hash }) => ({ + path: filePath, + size, + sha256: hash, + }) + ), + }; +} + +function createThirdPartyNotices(packages) { + const lines = [ + 'IPTVnator Linux Embedded MPV Third-Party Notices', + '================================================', + '', + 'This file identifies the pinned upstream source packages used by the bundled Linux Embedded MPV runtime.', + 'The complete verbatim upstream license files are included at the paths and SHA-256 digests listed below.', + 'The exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources.tar.xz.', + '', + ]; + for (const packageRecord of packages) { + lines.push( + `${packageRecord.id} ${packageRecord.version}`, + `License: ${packageRecord.license}`, + `Source: ${packageRecord.sourceUrl}`, + 'Included upstream license files:' + ); + for (const file of packageRecord.files) { + lines.push(`- ${file.path} (SHA-256 ${file.sha256})`); + } + lines.push(''); + } + return `${lines.join('\n')}\n`; +} + +function generateLinuxRuntimeNotices({ + licenseInputRoot, + outputRoot, + runtimeManifest, +}) { + const inputManifest = validateLicenseInputManifest( + licenseInputRoot, + runtimeManifest + ); + return replaceDirectory(outputRoot, (temporaryRoot) => { + const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage, index) => { + const inputPackage = inputManifest.packages[index]; + for (const file of inputPackage.files) { + const sourcePath = assertRegularFileInside( + licenseInputRoot, + file.path, + `${sourcePackage.id} cached license` + ); + const destinationPath = path.join( + temporaryRoot, + ...file.path.split('/') + ); + fs.mkdirSync(path.dirname(destinationPath), { + recursive: true, + }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o644); + } + return noticePackageRecord(sourcePackage, inputPackage); + }); + const noticeContents = Buffer.from(createThirdPartyNotices(packages)); + const noticePath = path.join(temporaryRoot, THIRD_PARTY_NOTICES); + fs.writeFileSync(noticePath, noticeContents, { mode: 0o644 }); + const noticeFile = fileRecord(noticePath, THIRD_PARTY_NOTICES); + const licenseTotalBytes = packages + .flatMap(({ files }) => files) + .reduce((total, file) => total + file.size, 0); + const manifest = { + schemaVersion: 1, + origin: 'pinned-linux-runtime-upstream-licenses', + platform: 'linux', + arch: 'x64', + noticeFile, + packages, + totalBytes: noticeFile.size + licenseTotalBytes, + }; + writeJson(path.join(temporaryRoot, NOTICE_MANIFEST), manifest); + const errors = validateLinuxRuntimeNotices( + temporaryRoot, + runtimeManifest + ); + if (errors.length > 0) { + throw new Error( + [ + 'Generated Linux runtime notices are invalid.', + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + return manifest; + }); +} + +function assertValidLinuxRuntimeNotices( + root, + runtimeManifest, + { allowUnrelatedFiles = false } = {} +) { + validateRuntimeManifestPackages(runtimeManifest); + assertDirectoryWithoutSymlinks(root, 'Linux runtime notices root'); + const manifest = readJsonFileInside( + root, + NOTICE_MANIFEST, + 'Linux runtime notices manifest' + ); + if ( + !hasExactFields(manifest, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'noticeFile', + 'packages', + 'totalBytes', + ]) || + manifest.schemaVersion !== 1 || + manifest.origin !== 'pinned-linux-runtime-upstream-licenses' || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + !Array.isArray(manifest.packages) || + manifest.packages.length !== NOTICE_SOURCE_PACKAGES.length + ) { + throw new Error('Invalid Linux runtime notices manifest.'); + } + + const expectedFiles = new Set([NOTICE_MANIFEST, THIRD_PARTY_NOTICES]); + let licenseTotalBytes = 0; + for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) { + const packageRecord = manifest.packages[index]; + const expectedMetadata = sourcePackageMetadata(sourcePackage); + const expectedPaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map( + (sourceRelativePath) => + expectedLicensePath(sourcePackage.id, sourceRelativePath) + ); + if ( + !hasExactFields(packageRecord, [ + 'id', + ...Object.keys(expectedMetadata), + 'files', + ]) || + packageRecord.id !== sourcePackage.id || + !Object.entries(expectedMetadata).every(([field, value]) => + isDeepStrictEqual(packageRecord[field], value) + ) || + !Array.isArray(packageRecord.files) || + !isDeepStrictEqual( + packageRecord.files.map(({ path: filePath }) => filePath), + expectedPaths + ) + ) { + throw new Error( + `Invalid packaged notice record for ${sourcePackage.id}.` + ); + } + for (const record of packageRecord.files) { + if ( + !hasExactFields(record, ['path', 'size', 'sha256']) || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error( + `Invalid packaged notice file for ${sourcePackage.id}.` + ); + } + const filePath = assertRegularFileInside( + root, + record.path, + `${sourcePackage.id} packaged license` + ); + const actual = fileRecord(filePath, record.path); + if (actual.size !== record.size) { + throw new Error( + `Size mismatch for packaged license ${record.path}.` + ); + } + if (actual.sha256 !== record.sha256) { + throw new Error( + `SHA-256 mismatch for packaged license ${record.path}.` + ); + } + licenseTotalBytes += record.size; + expectedFiles.add(record.path); + } + } + + if ( + !hasExactFields(manifest.noticeFile, ['path', 'size', 'sha256']) || + manifest.noticeFile.path !== THIRD_PARTY_NOTICES || + !Number.isSafeInteger(manifest.noticeFile.size) || + manifest.noticeFile.size <= 0 || + !SHA256_PATTERN.test(manifest.noticeFile.sha256) + ) { + throw new Error('Invalid aggregate third-party notice file record.'); + } + const noticePath = assertRegularFileInside( + root, + THIRD_PARTY_NOTICES, + 'aggregate third-party notices' + ); + const actualNotice = fileRecord(noticePath, THIRD_PARTY_NOTICES); + if ( + actualNotice.size !== manifest.noticeFile.size || + actualNotice.sha256 !== manifest.noticeFile.sha256 + ) { + throw new Error( + 'Aggregate THIRD_PARTY_NOTICES.txt size or SHA-256 mismatch.' + ); + } + const expectedNoticeContents = createThirdPartyNotices(manifest.packages); + if (fs.readFileSync(noticePath, 'utf8') !== expectedNoticeContents) { + throw new Error( + 'Aggregate THIRD_PARTY_NOTICES.txt does not match the exact notice index.' + ); + } + if (manifest.totalBytes !== actualNotice.size + licenseTotalBytes) { + throw new Error( + 'Linux runtime notices totalBytes does not match declared legal files.' + ); + } + const actualPaths = allowUnrelatedFiles + ? [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + ...listFilesRecursively(path.join(root, 'licenses')).map( + (relativePath) => path.posix.join('licenses', relativePath) + ), + ] + : listFilesRecursively(root); + for (const actualPath of actualPaths) { + if (!expectedFiles.has(actualPath)) { + throw new Error( + `Found undeclared packaged legal file ${actualPath} in ${root}.` + ); + } + } + return manifest; +} + +function validateLinuxRuntimeNotices(root, runtimeManifest, options) { + try { + assertValidLinuxRuntimeNotices(root, runtimeManifest, options); + return []; + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } +} + +function parseArguments(argv) { + const args = argv[0] === '--' ? argv.slice(1) : [...argv]; + const mode = args.shift(); + if (!['collect', 'generate'].includes(mode)) { + throw new Error( + 'Usage: generate-linux-runtime-notices.cjs --runtime-manifest --output-root [--source-root |--license-input-root ]' + ); + } + const values = {}; + while (args.length > 0) { + const key = args.shift(); + const value = args.shift(); + if (!key?.startsWith('--') || !value) { + throw new Error(`Invalid Linux runtime notices argument: ${key}`); + } + const name = key.slice(2); + if (Object.hasOwn(values, name)) { + throw new Error(`Duplicate Linux runtime notices argument: ${key}`); + } + values[name] = value; + } + const required = [ + 'runtime-manifest', + 'output-root', + mode === 'collect' ? 'source-root' : 'license-input-root', + ]; + for (const name of required) { + if (!values[name]) { + throw new Error(`Missing --${name}.`); + } + } + return { mode, values }; +} + +function main(argv = process.argv.slice(2)) { + const { mode, values } = parseArguments(argv); + const runtimeManifest = JSON.parse( + fs.readFileSync(path.resolve(values['runtime-manifest']), 'utf8') + ); + if (mode === 'collect') { + collectLinuxRuntimeLicenseInputs({ + sourceRoot: path.resolve(values['source-root']), + outputRoot: path.resolve(values['output-root']), + runtimeManifest, + }); + } else { + generateLinuxRuntimeNotices({ + licenseInputRoot: path.resolve(values['license-input-root']), + outputRoot: path.resolve(values['output-root']), + runtimeManifest, + }); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} + +module.exports = { + LICENSE_INPUT_MANIFEST, + LICENSE_PATHS_BY_PACKAGE, + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, + validateLinuxRuntimeNotices, +}; diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs new file mode 100644 index 000000000..42c11966e --- /dev/null +++ b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs'); +const { + LICENSE_PATHS_BY_PACKAGE, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, + validateLinuxRuntimeNotices, +} = require('./generate-linux-runtime-notices.cjs'); +const generatorScript = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'generate-linux-runtime-notices.cjs' +); + +function runtimeManifest() { + return { + platform: 'linux', + arch: 'x64', + packages: Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [ + `${'a'.repeat(40)} 3rdparty/example`, + ], + }), + license: sourcePackage.license, + ...(sourcePackage.buildInput + ? { buildInput: sourcePackage.buildInput } + : {}), + }, + ]) + ), + }; +} + +function createSourceFixture() { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-sources-') + ); + for (const sourcePackage of SOURCE_PACKAGES) { + const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]; + assert.ok( + Array.isArray(licensePaths) && licensePaths.length > 0, + `missing test mapping for ${sourcePackage.id}` + ); + for (const relativePath of licensePaths) { + const filePath = path.join(root, sourcePackage.id, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + `verbatim upstream ${sourcePackage.id} ${relativePath}\n` + ); + } + } + return root; +} + +function fileTree(root) { + const files = []; + function visit(directoryPath) { + for (const entry of fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name))) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else { + files.push({ + path: path + .relative(root, entryPath) + .split(path.sep) + .join('/'), + contents: fs.readFileSync(entryPath), + }); + } + } + } + visit(root); + return files; +} + +test('collects verbatim pinned licenses and generates deterministic exact notices', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-notices-') + ); + const inputRoot = path.join(fixtureRoot, 'inputs'); + const firstOutput = path.join(fixtureRoot, 'first'); + const secondOutput = path.join(fixtureRoot, 'second'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const manifest = runtimeManifest(); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + const first = generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: firstOutput, + runtimeManifest: manifest, + }); + const second = generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: secondOutput, + runtimeManifest: manifest, + }); + + assert.deepEqual(fileTree(firstOutput), fileTree(secondOutput)); + assert.deepEqual(first, second); + assert.equal(first.schemaVersion, 1); + assert.equal(first.origin, 'pinned-linux-runtime-upstream-licenses'); + assert.deepEqual( + first.packages.map(({ id }) => id), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ); + assert.ok(first.packages.every(({ files }) => files.length >= 1)); + assert.deepEqual(LICENSE_PATHS_BY_PACKAGE.libplacebo, [ + 'LICENSE', + '3rdparty/Vulkan-Headers/LICENSE.md', + '3rdparty/fast_float/LICENSE-APACHE', + '3rdparty/fast_float/LICENSE-BOOST', + '3rdparty/fast_float/LICENSE-MIT', + '3rdparty/glad/LICENSE', + '3rdparty/jinja/LICENSE.txt', + '3rdparty/markupsafe/LICENSE.txt', + 'demos/3rdparty/nuklear/LICENSE', + ]); + assert.deepEqual(validateLinuxRuntimeNotices(firstOutput, manifest), []); + + const noticeContents = fs.readFileSync( + path.join(firstOutput, 'THIRD_PARTY_NOTICES.txt') + ); + assert.equal(first.noticeFile.path, 'THIRD_PARTY_NOTICES.txt'); + assert.equal(first.noticeFile.size, noticeContents.length); + assert.equal( + first.noticeFile.sha256, + crypto.createHash('sha256').update(noticeContents).digest('hex') + ); + assert.match( + noticeContents.toString('utf8'), + /exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources\.tar\.xz/ + ); + + for (const packageRecord of first.packages) { + for (const fileRecord of packageRecord.files) { + const outputContents = fs.readFileSync( + path.join(firstOutput, fileRecord.path) + ); + const sourcePath = fileRecord.path.slice( + `licenses/${packageRecord.id}/`.length + ); + assert.ok( + LICENSE_PATHS_BY_PACKAGE[packageRecord.id].includes(sourcePath) + ); + assert.deepEqual( + outputContents, + fs.readFileSync( + path.join(sourceRoot, packageRecord.id, sourcePath) + ) + ); + } + } +}); + +test('rejects symlinked license sources and path escapes', (t) => { + const sourceRoot = createSourceFixture(); + const outputRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-inputs-') + ); + const outsidePath = path.join(outputRoot, 'outside-license'); + fs.writeFileSync(outsidePath, 'outside\n'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(outputRoot, { recursive: true, force: true }); + }); + + const freetypeLicense = path.join( + sourceRoot, + 'freetype', + LICENSE_PATHS_BY_PACKAGE.freetype[0] + ); + fs.rmSync(freetypeLicense); + fs.symlinkSync(outsidePath, freetypeLicense); + + assert.throws( + () => + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: path.join(outputRoot, 'collected'), + runtimeManifest: runtimeManifest(), + }), + /symbolic link|outside/i + ); +}); + +test('rejects missing, tampered, and undeclared cached license inputs', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-cache-') + ); + const inputRoot = path.join(fixtureRoot, 'inputs'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const manifest = runtimeManifest(); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + const collectedManifest = JSON.parse( + fs.readFileSync( + path.join(inputRoot, 'linux-runtime-license-inputs.json'), + 'utf8' + ) + ); + const firstLicensePath = path.join( + inputRoot, + collectedManifest.packages[0].files[0].path + ); + fs.appendFileSync(firstLicensePath, 'tampered\n'); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'tampered-output'), + runtimeManifest: manifest, + }), + /(?:Size|SHA-256) mismatch/ + ); + + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + fs.writeFileSync(path.join(inputRoot, 'licenses', 'undeclared.txt'), 'x'); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'extra-output'), + runtimeManifest: manifest, + }), + /undeclared license input/ + ); + + fs.rmSync(path.join(inputRoot, 'licenses', 'undeclared.txt')); + fs.rmSync( + path.join(inputRoot, collectedManifest.packages[0].files[0].path) + ); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'missing-output'), + runtimeManifest: manifest, + }), + /Missing .*license input/ + ); +}); + +test('CLI collects immutable inputs and regenerates the packaged notice bundle', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-notices-cli-') + ); + const manifestPath = path.join(fixtureRoot, 'runtime-manifest.json'); + const inputRoot = path.join(fixtureRoot, 'inputs'); + const outputRoot = path.join(fixtureRoot, 'notices'); + fs.writeFileSync( + manifestPath, + `${JSON.stringify(runtimeManifest(), null, 2)}\n` + ); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + for (const args of [ + [ + 'collect', + '--runtime-manifest', + manifestPath, + '--source-root', + sourceRoot, + '--output-root', + inputRoot, + ], + [ + 'generate', + '--runtime-manifest', + manifestPath, + '--license-input-root', + inputRoot, + '--output-root', + outputRoot, + ], + ]) { + const result = spawnSync(process.execPath, [generatorScript, ...args], { + encoding: 'utf8', + }); + assert.equal( + result.status, + 0, + [result.stdout, result.stderr].filter(Boolean).join('\n') + ); + } + assert.deepEqual( + validateLinuxRuntimeNotices(outputRoot, runtimeManifest()), + [] + ); +}); diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index b3c63fa90..c2a22d06e 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -66,6 +66,15 @@ test('configures portable and flatpak passes without mixing targets', () => { assert.deepEqual(configuredTargets(flatpak), [ { target: 'flatpak', arch: ['x64'] }, ]); + assert.deepEqual(portable.snap.plugs, [ + 'default', + { + 'shared-memory': { + interface: 'shared-memory', + private: true, + }, + }, + ]); }); test('configures a separate marker-only foreign DEB pass without libmpv metadata', () => { @@ -173,6 +182,60 @@ test('Linux CI builds one cached source runtime and packages three isolated prof buildWorkflow, /key: \$\{\{ steps\.linux-runtime-cache-key\.outputs\.key \}\}/ ); + const cacheStep = workflowStep( + 'Restore pinned Linux runtime and immutable source inputs' + ); + assert.match(cacheStep, /dist\/linux-frame-copy-runtime-source-inputs/); + assert.doesNotMatch(cacheStep, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.doesNotMatch(cacheStep, /THIRD_PARTY_NOTICES/); + + const complianceStep = workflowStep( + 'Generate Linux runtime notices and assemble source compliance' + ); + assert.doesNotMatch(complianceStep, /^\s+if:/m); + assert.match( + complianceStep, + /generate-linux-runtime-notices\.cjs generate/ + ); + assert.match(complianceStep, /git rev-parse HEAD/); + assert.match(complianceStep, /git diff --binary HEAD/); + assert.match( + complianceStep, + /tar[\s\S]*linux-frame-copy-runtime-sources\.tar\.xz/ + ); + assert.match(complianceStep, /source-index\.json/); + assert.match(complianceStep, /THIRD_PARTY_NOTICES\.txt/); + assert.match(complianceStep, /embedded-mpv-notices\.json/); + assert.match( + complianceStep, + /SOURCE_INPUT_ROOT.*license-inputs[\s\S]*SOURCE_BUNDLE_ROOT.*license-inputs/ + ); + assert.match( + complianceStep, + /new Set\(expectedArchiveHashes\)\.size[\s\S]*archives\.length !== expectedArchiveHashes\.length/ + ); + assert.match( + complianceStep, + /status[\s\S]*--porcelain=v1[\s\S]*--untracked-files=all[\s\S]*--ignore-submodules=none/ + ); + assert.match( + complianceStep, + /for \(const submoduleRecord of sourceSubmodules\)/ + ); + + const buildStep = workflowStep('Build and stage pinned LGPL Linux runtime'); + assert.match(buildStep, /generate-linux-runtime-notices\.cjs collect/); + assert.match( + buildStep, + /linux-frame-copy-runtime-source-inputs[\s\S]*archives[\s\S]*git\/libplacebo/ + ); + assert.ok( + buildStep.indexOf('git clean -ffdqx') < + buildStep.indexOf( + 'cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo"' + ) + ); + assert.doesNotMatch(buildStep, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.match( buildWorkflow, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index bd4936c29..7e2cdc79b 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -54,10 +54,23 @@ function copyEmbeddedMpvNativeOutput( fs.rmSync(destinationDir, { recursive: true, force: true }); fs.cpSync(sourceDir, destinationDir, { recursive: true }); + const resolvedPreparationOptions = + platform === 'linux' && preparationOptions + ? { + ...preparationOptions, + noticeSourceDir: path.join( + projectDir, + 'vendor', + 'embedded-mpv', + 'linux-x64', + 'notices' + ), + } + : preparationOptions; return preparePackagedFrameCopyArtifacts( destinationDir, platform, - preparationOptions + resolvedPreparationOptions ); } diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index f8a46418d..7b969270a 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -25,6 +25,11 @@ const { const { preparePackagedFrameCopyArtifacts, } = require('./embedded-mpv-frame-copy-files.cjs'); +const { + LICENSE_PATHS_BY_PACKAGE, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); const { resolveLinuxFrameCopyPackagingContext, } = require('./electron-after-pack.cjs'); @@ -170,6 +175,33 @@ function createBuildManifest(runtimeContents) { }; } +function createNoticeFixture(fixtureRoot, sourceRuntime) { + const sourceRoot = join(fixtureRoot, 'upstream-license-sources'); + for (const sourcePackage of SOURCE_PACKAGES) { + for (const relativePath of LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]) { + const sourcePath = join(sourceRoot, sourcePackage.id, relativePath); + fs.mkdirSync(dirname(sourcePath), { recursive: true }); + fs.writeFileSync( + sourcePath, + `verbatim ${sourcePackage.id} ${relativePath}\n` + ); + } + } + const licenseInputRoot = join(fixtureRoot, 'license-inputs'); + const noticeSourceDir = join(fixtureRoot, 'generated-notices'); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: licenseInputRoot, + runtimeManifest: sourceRuntime, + }); + generateLinuxRuntimeNotices({ + licenseInputRoot, + outputRoot: noticeSourceDir, + runtimeManifest: sourceRuntime, + }); + return noticeSourceDir; +} + function createNativeFixture({ runtimeContents = { 'libavcodec.so.61': 'libavcodec-runtime', @@ -205,8 +237,19 @@ function createNativeFixture({ for (const [name, contents] of Object.entries(runtimeContents)) { fs.writeFileSync(join(nativeDir, 'lib', name), contents); } + const noticeSourceDir = createNoticeFixture( + fixtureRoot, + buildManifest.sourceRuntime + ); + fs.cpSync(noticeSourceDir, nativeDir, { recursive: true }); fs.writeFileSync(join(appOutDir, 'iptvnator.bin'), 'electron'); - return { fixtureRoot, resourceDir, appOutDir, nativeDir }; + return { + fixtureRoot, + resourceDir, + appOutDir, + nativeDir, + noticeSourceDir, + }; } function readManifest(nativeDir) { @@ -462,6 +505,13 @@ test('prepares a normalized system profile with no private runtime', (t) => { fs.existsSync(join(fixture.nativeDir, 'embedded-mpv-unavailable.txt')), false ); + for (const legalPath of [ + 'embedded-mpv-notices.json', + 'THIRD_PARTY_NOTICES.txt', + 'licenses', + ]) { + assert.equal(fs.existsSync(join(fixture.nativeDir, legalPath)), false); + } assert.deepEqual( validatePackagedEmbeddedMpv( fixture.resourceDir, @@ -482,6 +532,16 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); } }); + for (const legalPath of [ + 'embedded-mpv-notices.json', + 'THIRD_PARTY_NOTICES.txt', + 'licenses', + ]) { + fs.rmSync(join(portable.nativeDir, legalPath), { + recursive: true, + force: true, + }); + } const portableManifest = preparePackagedFrameCopyArtifacts( portable.nativeDir, @@ -489,6 +549,7 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( { profile: 'portable', targetNames: ['AppImage', 'SNAP'], + noticeSourceDir: portable.noticeSourceDir, } ); const flatpakManifest = preparePackagedFrameCopyArtifacts( @@ -526,6 +587,20 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( '--disable-gpl' ) ); + const notices = JSON.parse( + fs.readFileSync( + join(portable.nativeDir, 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + assert.equal(notices.schemaVersion, 1); + assert.equal(notices.origin, 'pinned-linux-runtime-upstream-licenses'); + assert.equal(notices.noticeFile.path, 'THIRD_PARTY_NOTICES.txt'); + assert.deepEqual( + notices.packages.map(({ id }) => id), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ); + assert.ok(notices.packages.every(({ files }) => files.length >= 1)); assert.equal(flatpakManifest.profile, 'flatpak'); assert.equal(flatpakManifest.runtimeMode, 'bundled'); assert.equal(flatpakManifest.origin, 'bundled-lgpl-frame-copy'); @@ -551,6 +626,70 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( ); }); +test('rejects missing, tampered, undeclared, and symlinked bundled legal files', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ); + const options = pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }); + const notices = JSON.parse( + fs.readFileSync( + join(fixture.nativeDir, 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + const licensePath = join( + fixture.nativeDir, + notices.packages[0].files[0].path + ); + const originalContents = fs.readFileSync(licensePath); + + fs.appendFileSync(licensePath, 'tampered\n'); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /(?:Size|SHA-256) mismatch.*packaged license/i + ); + + fs.writeFileSync(licensePath, originalContents); + fs.rmSync(licensePath); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /Missing .*packaged license/i + ); + + fs.writeFileSync(licensePath, originalContents); + const undeclaredPath = join(fixture.nativeDir, 'licenses', 'stale.txt'); + fs.writeFileSync(undeclaredPath, 'stale\n'); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /undeclared packaged legal file.*stale\.txt/i + ); + + fs.rmSync(undeclaredPath); + fs.rmSync(licensePath); + fs.symlinkSync( + join(fixture.nativeDir, 'THIRD_PARTY_NOTICES.txt'), + licensePath + ); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /packaged license.*symbolic link/i + ); +}); + test('rejects invalid build provenance and incomplete bundled runtime input', (t) => { const extra = createNativeFixture(); const missing = createNativeFixture(); diff --git a/tools/packaging/embedded-mpv-frame-copy-files.cjs b/tools/packaging/embedded-mpv-frame-copy-files.cjs index a537f3ef2..2cbe77109 100644 --- a/tools/packaging/embedded-mpv-frame-copy-files.cjs +++ b/tools/packaging/embedded-mpv-frame-copy-files.cjs @@ -7,6 +7,11 @@ const { isDeepStrictEqual } = require('node:util'); const { validateLinuxRuntimeManifest, } = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + validateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); const { LINUX_SYSTEM_PACKAGE_DEPENDENCIES, resolveLinuxFrameCopyProfile, @@ -19,6 +24,7 @@ const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node'; const EMBEDDED_MPV_ADDON = 'embedded_mpv.node'; const RUNTIME_MANIFEST = 'embedded-mpv-runtime.json'; const UNAVAILABLE_MARKER = 'embedded-mpv-unavailable.txt'; +const LICENSES_DIRECTORY = 'licenses'; const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; const EXPECTED_ARTIFACTS = Object.freeze({ addon: EMBEDDED_MPV_ADDON, @@ -302,6 +308,70 @@ function writeManifest(nativeDir, manifest) { return manifest; } +function removeLinuxRuntimeNotices(nativeDir) { + for (const relativePath of [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + LICENSES_DIRECTORY, + 'notices', + ]) { + fs.rmSync(path.join(nativeDir, relativePath), { + recursive: true, + force: true, + }); + } +} + +function prepareBundledLinuxRuntimeNotices( + nativeDir, + sourceRuntime, + noticeSourceDir +) { + if (noticeSourceDir) { + const sourceErrors = validateLinuxRuntimeNotices( + noticeSourceDir, + sourceRuntime + ); + if (sourceErrors.length > 0) { + throw new Error( + [ + `Invalid Linux runtime notice source at ${noticeSourceDir}.`, + ...sourceErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + removeLinuxRuntimeNotices(nativeDir); + for (const relativePath of [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + LICENSES_DIRECTORY, + ]) { + fs.cpSync( + path.join(noticeSourceDir, relativePath), + path.join(nativeDir, relativePath), + { + recursive: true, + force: true, + } + ); + } + } + + const packagedErrors = validateLinuxRuntimeNotices( + nativeDir, + sourceRuntime, + { allowUnrelatedFiles: true } + ); + if (packagedErrors.length > 0) { + throw new Error( + [ + `Invalid packaged Linux runtime notices at ${nativeDir}.`, + ...packagedErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } +} + function createPackagedManifest(buildManifest, profile, targetNames) { const bundled = profile.runtimeMode === 'bundled'; const runtimeFiles = bundled @@ -390,6 +460,7 @@ function prepareNativeViewOnlyLinuxArtifacts(nativeDir) { fs.rmSync(path.join(nativeDir, fileName), { force: true }); } fs.rmSync(path.join(nativeDir, 'lib'), { recursive: true, force: true }); + removeLinuxRuntimeNotices(nativeDir); return writeManifest(nativeDir, { schemaVersion: 1, @@ -448,6 +519,13 @@ function prepareLinuxFrameCopyArtifacts(nativeDir, options = {}) { recursive: true, force: true, }); + removeLinuxRuntimeNotices(nativeDir); + } else { + prepareBundledLinuxRuntimeNotices( + nativeDir, + buildManifest.sourceRuntime, + options.noticeSourceDir + ); } return writeManifest( @@ -481,6 +559,7 @@ function removeStaleFrameCopyArtifacts(nativeDir) { ]) { fs.rmSync(path.join(nativeDir, fileName), { force: true }); } + removeLinuxRuntimeNotices(nativeDir); } module.exports = { diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index c22849bc3..5320c20ca 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -12,6 +12,11 @@ const { const { validateLinuxRuntimeManifest, } = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + validateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); const { LINUX_SYSTEM_PACKAGE_DEPENDENCIES, resolveLinuxFrameCopyProfile, @@ -54,6 +59,11 @@ const linuxFrameCopyProcessIsolation = Object.freeze({ const linuxNativeViewFallback = 'process-isolated mpv --wid'; const versionedLinuxLibmpvPattern = /^libmpv\.so\.\d+(?:\.\d+)*$/; const anyLinuxLibmpvPattern = /^libmpv\.so(?:\.|$)/; +const linuxRuntimeLegalPaths = Object.freeze([ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + 'licenses', +]); function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -887,6 +897,14 @@ function validateNativeViewOnlyLinuxPackage( manifestPath, errors ) { + for (const legalPath of linuxRuntimeLegalPaths) { + const packagedLegalPath = path.join(nativeDir, legalPath); + if (pathExistsByLstat(packagedLegalPath)) { + errors.push( + `Linux native-view-only packages must not ship bundled runtime legal files: ${packagedLegalPath}` + ); + } + } for (const artifactName of [ 'iptvnator_mpv_helper', 'iptvnator_mpv_helper.exe', @@ -1037,6 +1055,14 @@ function validatePackagedManifestContract( } function validateSystemLinuxRuntime(nativeDir, manifest, errors) { + for (const legalPath of linuxRuntimeLegalPaths) { + const packagedLegalPath = path.join(nativeDir, legalPath); + if (pathExistsByLstat(packagedLegalPath)) { + errors.push( + `Linux system frame-copy packages must not ship bundled runtime legal files: ${packagedLegalPath}` + ); + } + } if ( !isDeepStrictEqual( manifest.packageDependencies, @@ -1101,6 +1127,11 @@ function validateBundledLinuxRuntime(nativeDir, manifest, errors) { (error) => `Invalid packaged Linux source runtime: ${error}` ) ); + errors.push( + ...validateLinuxRuntimeNotices(nativeDir, manifest.sourceRuntime, { + allowUnrelatedFiles: true, + }).map((error) => `Invalid packaged Linux runtime notices: ${error}`) + ); if ( !isDeepStrictEqual( manifest.runtimeFiles, diff --git a/tools/packaging/project.json b/tools/packaging/project.json index c767f6e23..bfba72af0 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -30,18 +30,20 @@ "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.cjs", + "{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs", "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.cjs", "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.test.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs", "{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/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.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/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": { - "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\"" + "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"]