From b14ce2452bc587c94f2678260e04faf92da4d24a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:15:09 +0200 Subject: [PATCH] fix(packaging): add verified source mirror fallback (#1325) --- .github/workflows/build-and-make.yaml | 1 + tools/embedded-mpv/README.md | 10 ++ tools/embedded-mpv/build-macos-runtime.mjs | 78 +++++++--- tools/embedded-mpv/download-pinned-source.mjs | 63 ++++++++ .../download-pinned-source.test.mjs | 136 ++++++++++++++++++ tools/packaging/project.json | 10 +- 6 files changed, 273 insertions(+), 25 deletions(-) create mode 100644 tools/embedded-mpv/download-pinned-source.mjs create mode 100644 tools/embedded-mpv/download-pinned-source.test.mjs diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 3638e3359..4b216c820 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -483,6 +483,7 @@ jobs: const safeDeploymentTarget = deploymentTarget.replace(/[^A-Za-z0-9_.-]/g, '-'); sourceHashFiles.push( 'tools/embedded-mpv/build-macos-runtime.mjs', + 'tools/embedded-mpv/download-pinned-source.mjs', 'tools/embedded-mpv/stage-macos-runtime.mjs' ); try { diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index f7c8b07f6..688f174a5 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -82,6 +82,16 @@ pnpm embedded-mpv:build-runtime:linux -- /tmp/linux-prefix pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/linux-prefix ``` +The macOS builder verifies every downloaded archive against its pinned +SHA-256 digest before extraction. FreeType uses its official SourceForge +distribution as the primary source and the official Savannah distribution as +a fallback; a failed or mismatched download is discarded before the next +mirror is attempted. The runtime manifest records the selected URL for a new +download and the complete ordered candidate list for every archive, so +fallback use remains visible in the source provenance. Changes to the +downloader participate in the runtime cache key, so cached native artifacts +cannot outlive source-acquisition policy changes. + The Linux builder runs only on Linux x64. It requires the tool versions and system development interfaces declared in `build-linux-runtime.cjs`, including Meson 1.6 or newer, gperf 3.1 or newer, Ninja, CMake, NASM, pkg-config, diff --git a/tools/embedded-mpv/build-macos-runtime.mjs b/tools/embedded-mpv/build-macos-runtime.mjs index f15e2777b..325f366ab 100644 --- a/tools/embedded-mpv/build-macos-runtime.mjs +++ b/tools/embedded-mpv/build-macos-runtime.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { spawnSync } from 'child_process'; +import { downloadPinnedSource } from './download-pinned-source.mjs'; const rawArgs = process.argv.slice(2); const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; @@ -17,31 +17,44 @@ const sourcePackages = [ { id: 'freetype', version: '2.13.3', - url: 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + url: 'https://downloads.sourceforge.net/project/freetype/freetype2/2.13.3/freetype-2.13.3.tar.xz', + mirrors: [ + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + ], + expectedSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', license: 'FreeType License or GPL-2.0-or-later', }, { id: 'fribidi', version: '1.0.16', url: 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + expectedSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', license: 'LGPL-2.1-or-later', }, { id: 'harfbuzz', version: '8.5.0', url: 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + expectedSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', license: 'MIT', }, { id: 'libass', version: '0.17.3', url: 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + expectedSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', license: 'ISC', }, { id: 'ffmpeg', version: '8.1', url: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + expectedSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', license: 'LGPL-compatible configuration', }, { @@ -55,6 +68,8 @@ const sourcePackages = [ id: 'mpv', version: '0.41.0', url: 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + expectedSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', license: 'LGPL-compatible configuration with -Dgpl=false', }, ]; @@ -220,12 +235,6 @@ function sourcePathFor(packageId) { return path.join(sourceRoot, packageId); } -function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - hash.update(fs.readFileSync(filePath)); - return hash.digest('hex'); -} - function runCapture(command, commandArgs, options = {}) { const result = spawnSync(command, commandArgs, { cwd: options.cwd ?? workspaceRoot, @@ -309,18 +318,27 @@ function downloadSources() { } const archivePath = archivePathFor(sourcePackage); - if (!fs.existsSync(archivePath)) { - run('curl', [ - '-fL', - '--retry', - '3', - '--retry-delay', - '5', - '-o', - archivePath, - sourcePackage.url, - ]); - } + const { sourceSha256, sourceUrl, sourceUrls } = downloadPinnedSource({ + archivePath, + expectedSha256: sourcePackage.expectedSha256, + urls: [sourcePackage.url, ...(sourcePackage.mirrors ?? [])], + download: ({ destinationPath, url }) => + run('curl', [ + '--fail', + '--location', + '--retry', + '3', + '--retry-all-errors', + '--connect-timeout', + '30', + '--proto', + '=https', + '--tlsv1.2', + '--output', + destinationPath, + url, + ]), + }); const packageSourcePath = sourcePathFor(sourcePackage.id); fs.rmSync(packageSourcePath, { recursive: true, force: true }); @@ -333,7 +351,9 @@ function downloadSources() { '--strip-components', '1', ]); - sourcePackage.sha256 = sha256File(archivePath); + sourcePackage.sha256 = sourceSha256; + sourcePackage.sourceUrl = sourceUrl; + sourcePackage.sourceUrls = sourceUrls; } } @@ -493,7 +513,13 @@ function sourceMetadata(packageId) { const sourcePackage = packageById.get(packageId); return { version: sourcePackage.version, - sourceUrl: sourcePackage.url ?? sourcePackage.gitUrl, + sourceUrl: + sourcePackage.sourceUrl ?? + sourcePackage.url ?? + sourcePackage.gitUrl, + ...(sourcePackage.sourceUrls + ? { sourceUrls: sourcePackage.sourceUrls } + : {}), ...(sourcePackage.tag ? { sourceTag: sourcePackage.tag } : {}), ...(sourcePackage.sha256 ? { sourceSha256: sourcePackage.sha256 } @@ -523,7 +549,13 @@ function writeManifest() { sourcePackage.id, { version: sourcePackage.version, - sourceUrl: sourcePackage.url ?? sourcePackage.gitUrl, + sourceUrl: + sourcePackage.sourceUrl ?? + sourcePackage.url ?? + sourcePackage.gitUrl, + ...(sourcePackage.sourceUrls + ? { sourceUrls: sourcePackage.sourceUrls } + : {}), ...(sourcePackage.tag ? { sourceTag: sourcePackage.tag } : {}), diff --git a/tools/embedded-mpv/download-pinned-source.mjs b/tools/embedded-mpv/download-pinned-source.mjs new file mode 100644 index 000000000..e50f9776e --- /dev/null +++ b/tools/embedded-mpv/download-pinned-source.mjs @@ -0,0 +1,63 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +} + +function checksumFailure(expectedSha256, actualSha256) { + return `SHA-256 mismatch: expected ${expectedSha256}, received ${actualSha256}`; +} + +export function downloadPinnedSource({ + archivePath, + download, + expectedSha256, + urls, +}) { + const partialPath = `${archivePath}.partial`; + const failures = []; + const sourceUrls = [...urls]; + + if (fs.existsSync(archivePath)) { + const actualSha256 = sha256File(archivePath); + if (actualSha256 === expectedSha256) { + return { + sourceSha256: actualSha256, + sourceUrl: null, + sourceUrls, + }; + } + failures.push( + `cached archive: ${checksumFailure( + expectedSha256, + actualSha256 + )}` + ); + fs.rmSync(archivePath, { force: true }); + } + + for (const url of sourceUrls) { + fs.rmSync(partialPath, { force: true }); + try { + download({ destinationPath: partialPath, url }); + const actualSha256 = sha256File(partialPath); + if (actualSha256 !== expectedSha256) { + throw new Error( + checksumFailure(expectedSha256, actualSha256) + ); + } + fs.renameSync(partialPath, archivePath); + return { sourceSha256: actualSha256, sourceUrl: url, sourceUrls }; + } catch (error) { + failures.push(`${url}: ${error.message}`); + fs.rmSync(partialPath, { force: true }); + } + } + + throw new Error( + `Unable to download a verified source archive:\n${failures.join('\n')}` + ); +} diff --git a/tools/embedded-mpv/download-pinned-source.test.mjs b/tools/embedded-mpv/download-pinned-source.test.mjs new file mode 100644 index 000000000..42c0e5bac --- /dev/null +++ b/tools/embedded-mpv/download-pinned-source.test.mjs @@ -0,0 +1,136 @@ +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 test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const downloaderPath = path.join(currentDir, 'download-pinned-source.mjs'); +const macosBuilderPath = path.join(currentDir, 'build-macos-runtime.mjs'); +const workspaceRoot = path.resolve(currentDir, '..', '..'); +const buildWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'build-and-make.yaml' +); +const { downloadPinnedSource } = await import(pathToFileURL(downloaderPath)); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function withTemporaryDirectory(run) { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-pinned-source-test-') + ); + try { + return run(temporaryDirectory); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +} + +test('provides the pinned source archive downloader', () => { + assert.equal(fs.existsSync(downloaderPath), true); +}); + +test('pins official FreeType mirrors in the macOS runtime builder', () => { + const builderSource = fs.readFileSync(macosBuilderPath, 'utf8'); + const sourceForgeUrl = + 'https://downloads.sourceforge.net/project/freetype/freetype2/2.13.3/freetype-2.13.3.tar.xz'; + const savannahUrl = + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz'; + + assert.match(builderSource, /downloadPinnedSource/); + assert.ok(builderSource.indexOf(sourceForgeUrl) >= 0); + assert.ok( + builderSource.indexOf(savannahUrl) > + builderSource.indexOf(sourceForgeUrl) + ); + assert.match( + builderSource, + /0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289/ + ); + assert.match( + builderSource, + /const\s*\{\s*sourceSha256,\s*sourceUrl,\s*sourceUrls\s*\}\s*=\s*downloadPinnedSource/ + ); + assert.match(builderSource, /sourcePackage\.sourceUrl = sourceUrl/); + assert.match(builderSource, /sourcePackage\.sourceUrls = sourceUrls/); + assert.match( + builderSource, + /sourceUrl:\s*sourcePackage\.sourceUrl\s*\?\?\s*sourcePackage\.url/ + ); + assert.match( + builderSource, + /sourcePackage\.sourceUrls\s*\?\s*\{\s*sourceUrls:\s*sourcePackage\.sourceUrls\s*\}/ + ); +}); + +test('includes the pinned downloader in the macOS runtime cache key', () => { + assert.match( + fs.readFileSync(buildWorkflowPath, 'utf8'), + /tools\/embedded-mpv\/download-pinned-source\.mjs/ + ); +}); + +test('uses the next mirror when the primary source is unavailable', () => { + withTemporaryDirectory((temporaryDirectory) => { + const archive = Buffer.from('verified source archive'); + const archivePath = path.join(temporaryDirectory, 'source.tar.xz'); + const urls = [ + 'https://primary.example/source.tar.xz', + 'https://fallback.example/source.tar.xz', + ]; + const attempts = []; + + const result = downloadPinnedSource({ + archivePath, + expectedSha256: sha256(archive), + urls, + download: ({ destinationPath, url }) => { + attempts.push(url); + if (url === urls[0]) { + throw new Error('primary unavailable'); + } + fs.writeFileSync(destinationPath, archive); + }, + }); + + assert.deepEqual(attempts, urls); + assert.deepEqual(fs.readFileSync(archivePath), archive); + assert.deepEqual(result, { + sourceSha256: sha256(archive), + sourceUrl: urls[1], + sourceUrls: urls, + }); + assert.equal(fs.existsSync(`${archivePath}.partial`), false); + }); +}); + +test('rejects mirrors whose archive does not match the pinned checksum', () => { + withTemporaryDirectory((temporaryDirectory) => { + const archivePath = path.join(temporaryDirectory, 'source.tar.xz'); + const urls = [ + 'https://primary.example/source.tar.xz', + 'https://fallback.example/source.tar.xz', + ]; + + assert.throws( + () => + downloadPinnedSource({ + archivePath, + expectedSha256: sha256('expected archive'), + urls, + download: ({ destinationPath }) => + fs.writeFileSync(destinationPath, 'corrupt archive'), + }), + /Unable to download a verified source archive.*SHA-256 mismatch/is + ); + assert.equal(fs.existsSync(archivePath), false); + assert.equal(fs.existsSync(`${archivePath}.partial`), false); + }); +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 709602181..0b84f2ac3 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -37,9 +37,12 @@ "{workspaceRoot}/tools/packaging/release-snap-source-binding.cjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/build-macos-runtime.mjs", "{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/download-pinned-source.mjs", + "{workspaceRoot}/tools/embedded-mpv/download-pinned-source.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", @@ -52,7 +55,7 @@ "{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/flatpak-launcher-validation.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-after-pack.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/release-snap-assets.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/flatpak-launcher-validation.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-after-pack.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/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/download-pinned-source.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, @@ -70,14 +73,17 @@ "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", "{workspaceRoot}/tools/packaging/release-snap-assets.test.mjs", "{workspaceRoot}/tools/packaging/release-snap-source-binding.cjs", + "{workspaceRoot}/tools/embedded-mpv/build-macos-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/download-pinned-source.mjs", + "{workspaceRoot}/tools/embedded-mpv/download-pinned-source.test.mjs", "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], - "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/linux-source-archive-contract.cjs\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" + "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-macos-runtime.mjs\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/download-pinned-source.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/linux-source-archive-contract.cjs\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"]