fix(deps): make update and advisory checks complete

- resolve FFmpeg against complete cross-platform stable provider tuples
- report Cargo lock drift through isolated resolver metadata
- gate vulnerabilities and document upstream informational advisories
This commit is contained in:
NimBold
2026-09-05 07:42:31 +03:30
parent ddde763133
commit 6440d8ad40
4 changed files with 283 additions and 46 deletions
+12
View File
@@ -9,6 +9,18 @@ permissions:
contents: read
jobs:
rust-security:
name: Rust advisory audit
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install locked cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked
- name: Reject vulnerable resolved dependencies
working-directory: src-tauri
run: cargo audit
frontend:
name: Frontend checks
runs-on: ubuntu-22.04
+148 -45
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -9,6 +10,7 @@ const repoRoot = path.resolve(__dirname, '..');
const userAgent = 'firelink-update-check';
const fetchRetryDelaysMs = [250, 1_000];
const fetchTimeoutMs = 30_000;
const cargoOutputLimit = 64 * 1024 * 1024;
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
function httpResponseError(response, url) {
@@ -158,32 +160,39 @@ async function latestFfmpegStable() {
async function latestMartinRiedlMacArm64Release() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const releaseSection = html.split('Download Release Build')[1] || '';
const match =
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
return match?.[1];
const card = releaseSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
const version =
card.match(/<b>Release:\s*<\/b>\s*([0-9.]+)/)?.[1] ||
card.match(/Release:\s*([0-9.]+)/)?.[1];
const relativeUrl = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
if (!version || !relativeUrl) return undefined;
const url = new URL(relativeUrl, 'https://ffmpeg.martin-riedl.de').href;
const checksum = await fetchText(`${url}.sha256`);
const sha256 = checksum.match(/\b([0-9a-f]{64})\b/i)?.[1]?.toLowerCase();
return sha256 ? { version, url, sha256 } : undefined;
}
async function latestMartinRiedlMacArm64Snapshot() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
const card = snapshotSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
const match =
card.match(/<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
card.match(/Release:\s*([A-Za-z0-9.-]+)/);
const url = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
return match?.[1]
? { version: match[1], url: url ? new URL(url, 'https://ffmpeg.martin-riedl.de').href : undefined }
: undefined;
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function latestBtbnFfmpegN81Build() {
async function latestBtbnFfmpegStableBuild(stableVersion) {
if (!/^\d+\.\d+\.\d+$/.test(stableVersion)) {
throw new Error(`unsupported FFmpeg stable version: ${stableVersion}`);
}
const stableSeries = stableVersion.split('.').slice(0, 2).join('.');
const versionPattern = escapeRegExp(stableVersion);
const seriesPattern = escapeRegExp(stableSeries);
const assetPattern = new RegExp(
`^ffmpeg-n(${versionPattern}-\\d+-g[0-9a-f]+)-(win64|linux64)-gpl-${seriesPattern}\\.(?:zip|tar\\.xz)$`
);
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
if (!Array.isArray(releases)) throw new Error('BtbN releases response is not an array');
for (const release of releases) {
if (release.tag_name === 'latest') continue;
const assets = (release.assets || [])
.map(asset => {
const match = asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(win64|linux64)-gpl-8\.1\.(?:zip|tar\.xz)$/);
const match = asset.name.match(assetPattern);
if (!match) return undefined;
return {
target: match[2] === 'win64' ? 'windows' : 'linux',
@@ -210,6 +219,94 @@ async function latestBtbnFfmpegN81Build() {
return undefined;
}
function cargoPackages(metadata) {
if (!metadata || !Array.isArray(metadata.packages)) {
throw new Error('Cargo metadata contained no package list');
}
return metadata.packages.map(pkg => ({
name: pkg.name,
version: pkg.version,
source: pkg.source || 'path',
}));
}
function diffCargoMetadata(currentMetadata, updatedMetadata) {
const current = cargoPackages(currentMetadata);
const updated = cargoPackages(updatedMetadata);
const groups = new Map();
for (const [side, packages] of [['current', current], ['updated', updated]]) {
for (const pkg of packages) {
const key = `${pkg.name}\0${pkg.source}`;
const group = groups.get(key) || { name: pkg.name, source: pkg.source, current: [], updated: [] };
group[side].push(pkg.version);
groups.set(key, group);
}
}
const changes = [];
for (const group of groups.values()) {
const oldVersions = [...group.current];
const newVersions = [...group.updated];
for (let index = oldVersions.length - 1; index >= 0; index -= 1) {
const unchangedIndex = newVersions.indexOf(oldVersions[index]);
if (unchangedIndex >= 0) {
oldVersions.splice(index, 1);
newVersions.splice(unchangedIndex, 1);
}
}
oldVersions.sort(compareVersions);
newVersions.sort(compareVersions);
for (let index = 0; index < Math.min(oldVersions.length, newVersions.length); index += 1) {
changes.push({
name: group.name,
version: oldVersions[index],
latest: newVersions[index],
source: group.source,
});
}
}
return changes.sort((left, right) => left.name.localeCompare(right.name));
}
function cargoMetadata(manifestPath) {
return JSON.parse(execFileSync('cargo', [
'metadata', '--format-version', '1', '--locked', '--manifest-path', manifestPath,
], { encoding: 'utf8', maxBuffer: cargoOutputLimit, stdio: ['ignore', 'pipe', 'pipe'] }));
}
function cargoCompatibleUpdates() {
const sourceDir = path.join(repoRoot, 'src-tauri');
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-cargo-update-'));
try {
fs.copyFileSync(path.join(sourceDir, 'Cargo.toml'), path.join(temporaryRoot, 'Cargo.toml'));
fs.copyFileSync(path.join(sourceDir, 'Cargo.lock'), path.join(temporaryRoot, 'Cargo.lock'));
fs.mkdirSync(path.join(temporaryRoot, 'src'));
fs.writeFileSync(path.join(temporaryRoot, 'src', 'lib.rs'), '');
const manifestPath = path.join(temporaryRoot, 'Cargo.toml');
const current = cargoMetadata(path.join(sourceDir, 'Cargo.toml'));
execFileSync('cargo', ['update', '--manifest-path', manifestPath], {
encoding: 'utf8',
maxBuffer: cargoOutputLimit,
stdio: ['ignore', 'pipe', 'pipe'],
});
return diffCargoMetadata(current, cargoMetadata(manifestPath));
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
function printCargoReport(updates) {
if (!updates.length) {
console.log('Rust Cargo: current');
return 0;
}
console.log(`Rust Cargo: ${updates.length} compatible locked package update(s)`);
for (const update of updates) {
console.log(` ${update.name}: ${update.version} -> ${update.latest}`);
}
return updates.length;
}
function printNpmReport(label, outdated) {
const entries = Object.entries(outdated);
if (!entries.length) {
@@ -301,7 +398,9 @@ async function main() {
'Browser extension npm',
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
);
outdatedCount += printCargoReport(cargoCompatibleUpdates());
const ffmpegStablePromise = latestFfmpegStable();
const providerChecks = [
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
['Deno latest release', () => githubLatest('denoland/deno')],
@@ -309,7 +408,7 @@ async function main() {
[
'FFmpeg stable release',
async () => {
const version = await latestFfmpegStable();
const version = await ffmpegStablePromise;
if (!version) throw new Error('FFmpeg release provider response has no usable version');
return version;
},
@@ -317,17 +416,10 @@ async function main() {
[
'Martin Riedl macOS release',
async () => {
const version = await latestMartinRiedlMacArm64Release();
if (!version) throw new Error('Martin Riedl macOS release provider response has no usable version');
return version;
},
],
[
'Martin Riedl macOS snapshot',
async () => {
const build = await latestMartinRiedlMacArm64Snapshot();
if (!build?.version || !build.url) {
throw new Error('Martin Riedl FFmpeg provider response has no complete macOS arm64 snapshot');
const build = await latestMartinRiedlMacArm64Release();
const stableVersion = await ffmpegStablePromise;
if (!build?.version || !build.url || !build.sha256 || build.version !== stableVersion) {
throw new Error('Martin Riedl FFmpeg provider response has no complete matching macOS arm64 stable build');
}
return build;
},
@@ -335,7 +427,7 @@ async function main() {
[
'BtbN FFmpeg Windows/Linux build',
async () => {
const build = await latestBtbnFfmpegN81Build();
const build = await latestBtbnFfmpegStableBuild(await ffmpegStablePromise);
if (
!build?.version ||
!build.urls?.windows ||
@@ -365,8 +457,8 @@ async function main() {
const deno = providerValue(1);
const aria2 = providerValue(2);
const ffmpeg = providerValue(3);
const martinRiedlMacArm64Snapshot = providerValue(5);
const btbnFfmpegN81Build = providerValue(6);
const martinRiedlMacArm64Release = providerValue(4);
const btbnFfmpegStableBuild = providerValue(5);
const latestByEngine = {
'yt-dlp': ytDlp?.tag_name,
deno: deno?.tag_name,
@@ -377,17 +469,18 @@ async function main() {
const latestUrlsByTargetEngine = {};
const latestHashesByTargetEngine = {};
const latestHashesByUrl = providerAssetHashes({ ytDlp, deno, aria2 });
if (btbnFfmpegN81Build?.version && btbnFfmpegN81Build.urls?.windows && btbnFfmpegN81Build.urls?.linux) {
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.version;
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.version;
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.urls.windows;
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.urls.linux;
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegN81Build.hashes?.windows;
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegN81Build.hashes?.linux;
if (btbnFfmpegStableBuild?.version && btbnFfmpegStableBuild.urls?.windows && btbnFfmpegStableBuild.urls?.linux) {
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.version;
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.version;
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.urls.windows;
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.urls.linux;
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.hashes?.windows;
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.hashes?.linux;
}
if (martinRiedlMacArm64Snapshot?.version && martinRiedlMacArm64Snapshot.url) {
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.version;
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Snapshot.url;
if (martinRiedlMacArm64Release?.version && martinRiedlMacArm64Release.url) {
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.version;
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.url;
latestHashesByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.sha256;
}
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
@@ -396,8 +489,8 @@ async function main() {
console.log(` ${engine}: ${displayVersion(version)}`);
}
console.log('\nlatest engine provider builds:');
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${displayVersion(btbnFfmpegN81Build?.version)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${displayVersion(martinRiedlMacArm64Snapshot?.version)}`);
console.log(` BtbN FFmpeg stable Windows/Linux: ${displayVersion(btbnFfmpegStableBuild?.version)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 stable: ${displayVersion(martinRiedlMacArm64Release?.version)}`);
const targetSpecificEngines = new Set(['ffmpeg']);
const engineCheckFailures = [];
@@ -456,4 +549,14 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
});
}
export { checkRows, fetchJson, fetchText, fetchWithContext, npmExecutable, providerAssetHashes };
export {
checkRows,
diffCargoMetadata,
fetchJson,
fetchText,
fetchWithContext,
latestBtbnFfmpegStableBuild,
latestMartinRiedlMacArm64Release,
npmExecutable,
providerAssetHashes,
};
+100 -1
View File
@@ -1,7 +1,16 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { checkRows, fetchJson, fetchText, npmExecutable, providerAssetHashes } from './check-updates.js';
import {
checkRows,
diffCargoMetadata,
fetchJson,
fetchText,
latestBtbnFfmpegStableBuild,
latestMartinRiedlMacArm64Release,
npmExecutable,
providerAssetHashes,
} from './check-updates.js';
async function withMockFetch(mockFetch, callback) {
const originalFetch = globalThis.fetch;
@@ -117,3 +126,93 @@ test('npm executable selection uses the Windows command shim when needed', () =>
assert.equal(npmExecutable('darwin'), 'npm');
assert.equal(npmExecutable('linux'), 'npm');
});
test('selects a complete BtbN build for the current stable series', async () => {
const digest = value => `sha256:${value.repeat(64)}`;
const release = {
tag_name: 'autobuild-test',
assets: [
{
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
browser_download_url: 'https://example.test/windows.zip',
digest: digest('a'),
},
{
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-linux64-gpl-9.0.tar.xz',
browser_download_url: 'https://example.test/linux.tar.xz',
digest: digest('b'),
},
{
name: 'ffmpeg-n8.1.2-50-g1a748fe2cd-win64-gpl-8.1.zip',
browser_download_url: 'https://example.test/old.zip',
digest: digest('c'),
},
],
};
const result = await withMockFetch(
async () => new Response(JSON.stringify([release]), { status: 200 }),
() => latestBtbnFfmpegStableBuild('9.0.1'),
);
assert.equal(result.version, '9.0.1-11-ge47273f4d9');
assert.equal(result.urls.windows, 'https://example.test/windows.zip');
assert.equal(result.hashes.linux, 'b'.repeat(64));
});
test('rejects an incomplete BtbN stable target tuple', async () => {
const release = {
tag_name: 'autobuild-test',
assets: [{
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
browser_download_url: 'https://example.test/windows.zip',
digest: `sha256:${'a'.repeat(64)}`,
}],
};
const result = await withMockFetch(
async () => new Response(JSON.stringify([release]), { status: 200 }),
() => latestBtbnFfmpegStableBuild('9.0.1'),
);
assert.equal(result, undefined);
});
test('requires a complete Martin Riedl stable artifact and digest', async () => {
const html = `
<h2>Download Release Build</h2>
<div><h3>macOS (Apple Silicon/arm64)</h3>
<p><b>Release: </b>9.0.1</p>
<a href="/download/macos/arm64/build/ffmpeg.zip">FFmpeg (ZIP)</a></div>`;
const result = await withMockFetch(
async url => new Response(
String(url).endsWith('.sha256') ? `${'d'.repeat(64)} ffmpeg.zip\n` : html,
{ status: 200 },
),
() => latestMartinRiedlMacArm64Release(),
);
assert.deepEqual(result, {
version: '9.0.1',
url: 'https://ffmpeg.martin-riedl.de/download/macos/arm64/build/ffmpeg.zip',
sha256: 'd'.repeat(64),
});
});
test('reports compatible Cargo resolution drift from structured metadata', () => {
const metadata = versions => ({
packages: Object.entries(versions).map(([name, version]) => ({
name,
version,
source: 'registry+https://github.com/rust-lang/crates.io-index',
})),
});
assert.deepEqual(
diffCargoMetadata(metadata({ indexmap: '2.14.1', serde: '1.0.229' }), metadata({
indexmap: '2.14.2',
serde: '1.0.229',
})),
[{
name: 'indexmap',
version: '2.14.1',
latest: '2.14.2',
source: 'registry+https://github.com/rust-lang/crates.io-index',
}],
);
});
+23
View File
@@ -0,0 +1,23 @@
# Rust dependency advisory policy
`cargo audit` is a required CI gate. Vulnerability advisories must be resolved;
the gate must not be bypassed with a broad ignore list.
As of 2026-09-05, Cargo reports no vulnerability advisories. It does report
the following informational warnings, which remain visible in CI output:
- `RUSTSEC-2024-0411` through `RUSTSEC-2024-0420` (GTK3 bindings) and
`RUSTSEC-2024-0370` (`proc-macro-error`) are Linux-only dependencies reached
through Tauri/Wry's GTK3 and tray integration.
- `RUSTSEC-2024-0429` (`glib` 0.18.5 iterator unsoundness) is in that same
Linux Tauri/Wry GTK3 graph. Firelink does not directly use
`glib::VariantStrIter`, but this remains an upstream risk rather than a
Firelink-level remediation.
- `RUSTSEC-2025-0075`, `RUSTSEC-2025-0080`, `RUSTSEC-2025-0081`,
`RUSTSEC-2025-0098`, and `RUSTSEC-2025-0100` are unmaintained UNIC crates
reached through `tauri-utils -> urlpattern`.
Review these paths with every Tauri/Wry update and no later than 2026-12-05.
Remove this acknowledgement when the upstream graph no longer contains the
affected packages. Do not add these advisory IDs to Cargo's ignore list: a
future severity change must remain visible.