fix(tools): verify pinned archives before atomic install

Pin Streamlink and FFmpeg archives to verified manifests, stage replacements before promotion, and expose managed tool status with repair and reset controls. Keep updater checks exclusive until their underlying operation settles and restore default electron-builder certificate environment support.
This commit is contained in:
Sucukdeluxe
2026-08-12 00:59:45 +02:00
parent 421da7be77
commit d32f42746d
17 changed files with 822 additions and 89 deletions
+211
View File
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createManagedToolInstaller, type ExternalToolManifest } from './managed-tools';
let directory: string;
function sha256(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
function manifest(): ExternalToolManifest {
return {
id: 'streamlink',
version: '8.4.0',
sourceUrl: 'https://example.invalid/streamlink-8.4.0.zip',
archiveName: 'streamlink-8.4.0.zip',
sha256: sha256('verified archive'),
executables: ['streamlink.exe']
};
}
function writeExistingInstallation(installPath: string, contents = 'old executable'): void {
fs.mkdirSync(path.join(installPath, 'bin'), { recursive: true });
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), contents);
}
function createInstaller(options: {
archiveContents?: string;
extract?: (archivePath: string, destinationPath: string) => Promise<void>;
rename?: (sourcePath: string, destinationPath: string) => void;
} = {}) {
const installPath = path.join(directory, 'installed');
const tempPath = path.join(directory, 'temporary');
const download = vi.fn(async (_sourceUrl: string, archivePath: string) => {
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
fs.writeFileSync(archivePath, options.archiveContents ?? 'verified archive');
});
const extract = options.extract ?? (async (_archivePath: string, destinationPath: string) => {
fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), 'new executable');
});
return {
installPath,
installer: createManagedToolInstaller({
installationDirectory: installPath,
temporaryDirectory: tempPath,
download,
extract,
rename: options.rename
}),
download
};
}
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'twitch-vod-manager-managed-tools-'));
});
afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});
describe('managed tool installer', () => {
it('retains the working installation when the downloaded archive hash is corrupted', async () => {
const { installer, installPath, download } = createInstaller({ archiveContents: 'corrupted archive' });
writeExistingInstallation(installPath);
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('archive-hash-mismatch');
expect(download).toHaveBeenCalledTimes(1);
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
});
it('retains the working installation when extraction is interrupted', async () => {
const { installer, installPath } = createInstaller({
extract: async () => { throw new Error('interrupted extraction'); }
});
writeExistingInstallation(installPath);
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('extract-failed');
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
});
it('retains the working installation when a staged archive lacks the required executable', async () => {
const { installer, installPath } = createInstaller({
extract: async (_archivePath, destinationPath) => {
fs.mkdirSync(destinationPath, { recursive: true });
fs.writeFileSync(path.join(destinationPath, 'readme.txt'), 'missing executable');
}
});
writeExistingInstallation(installPath);
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('required-executable-missing');
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
});
it('restores the previous tool when promotion of the staged installation fails', async () => {
const installPath = path.join(directory, 'installed');
let failedPromotion = false;
const { installer } = createInstaller({
rename: (sourcePath, destinationPath) => {
if (!failedPromotion && sourcePath.includes('.stage-') && destinationPath === installPath) {
failedPromotion = true;
throw new Error('promotion interrupted');
}
fs.renameSync(sourcePath, destinationPath);
}
});
writeExistingInstallation(installPath);
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('promotion-failed');
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
});
it('reports promotion failure when the previous installation cannot be restored', async () => {
const installPath = path.join(directory, 'installed');
const { installer } = createInstaller({
rename: (sourcePath, destinationPath) => {
if (sourcePath.includes('.stage-') && destinationPath === installPath) {
throw new Error('promotion interrupted');
}
if (sourcePath.includes('.backup-') && destinationPath === installPath) {
throw new Error('rollback interrupted');
}
fs.renameSync(sourcePath, destinationPath);
}
});
writeExistingInstallation(installPath);
const result = await installer.repair(manifest());
expect(result.success).toBe(false);
expect(result.error).toBe('promotion-failed');
});
it('reports a verified manifest version only after a staged install is promoted', async () => {
const { installer, installPath } = createInstaller();
const result = await installer.repair(manifest());
expect(result.success).toBe(true);
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
expect(result.status).toMatchObject({
id: 'streamlink',
version: '8.4.0',
state: 'verified',
verified: true
});
});
it('marks a mismatched installed version as unverified', () => {
const { installer, installPath } = createInstaller();
writeExistingInstallation(installPath);
fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({
id: 'streamlink',
version: '8.3.0',
sourceUrl: 'https://example.invalid/streamlink-8.3.0.zip',
archiveName: 'streamlink-8.3.0.zip',
sha256: sha256('old archive')
}));
expect(installer.status(manifest())).toMatchObject({
version: '8.3.0',
state: 'unverified',
verified: false
});
});
it('shares one repair operation for concurrent requests', async () => {
let releaseDownload: (() => void) | undefined;
const downloadGate = new Promise<void>((resolve) => { releaseDownload = resolve; });
const { installer, download } = createInstaller({
extract: async (_archivePath, destinationPath) => {
fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), 'new executable');
}
});
download.mockImplementationOnce(async (_sourceUrl: string, archivePath: string) => {
await downloadGate;
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
fs.writeFileSync(archivePath, 'verified archive');
});
const first = installer.repair(manifest());
const second = installer.repair(manifest());
await Promise.resolve();
expect(download).toHaveBeenCalledTimes(1);
releaseDownload?.();
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.success).toBe(true);
expect(secondResult.success).toBe(true);
expect(download).toHaveBeenCalledTimes(1);
});
});
+249
View File
@@ -0,0 +1,249 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
export type ManagedToolId = 'streamlink' | 'ffmpeg';
export interface ExternalToolManifest {
id: ManagedToolId;
version: string;
sourceUrl: string;
archiveName: string;
sha256: string;
executables: string[];
}
export interface ManagedToolStatus {
id: ManagedToolId;
version: string;
sourceUrl: string;
archiveName: string;
state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt';
verified: boolean;
}
export interface ManagedToolInstallResult {
success: boolean;
status: ManagedToolStatus;
error?: 'download-failed' | 'archive-hash-mismatch' | 'extract-failed' | 'required-executable-missing' | 'promotion-failed';
}
interface ManagedToolRecord {
id: ManagedToolId;
version: string;
sourceUrl: string;
archiveName: string;
sha256: string;
}
export interface ManagedToolInstallerOptions {
installationDirectory: string;
temporaryDirectory: string;
download(sourceUrl: string, archivePath: string): Promise<void>;
extract(archivePath: string, destinationPath: string): Promise<void>;
rename?(sourcePath: string, destinationPath: string): void;
}
const RECORD_FILE_NAME = '.tool-manifest.json';
function findFileRecursive(rootDir: string, fileName: string): string | null {
try {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(rootDir, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
return entryPath;
}
if (entry.isDirectory()) {
const nested = findFileRecursive(entryPath, fileName);
if (nested) return nested;
}
}
} catch {
return null;
}
return null;
}
function sha256File(filePath: string): string {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function isRecord(value: unknown): value is ManagedToolRecord {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record.id === 'string'
&& typeof record.version === 'string'
&& typeof record.sourceUrl === 'string'
&& typeof record.archiveName === 'string'
&& typeof record.sha256 === 'string';
}
function matchesManifest(record: ManagedToolRecord, manifest: ExternalToolManifest): boolean {
return record.id === manifest.id
&& record.version === manifest.version
&& record.sourceUrl === manifest.sourceUrl
&& record.archiveName === manifest.archiveName
&& record.sha256 === manifest.sha256;
}
export class ManagedToolInstaller {
private readonly inFlight = new Map<ManagedToolId, Promise<ManagedToolInstallResult>>();
constructor(private readonly options: ManagedToolInstallerOptions) {}
status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus {
const base: Omit<ManagedToolStatus, 'state' | 'verified'> = {
id: manifest.id,
version: manifest.version,
sourceUrl: manifest.sourceUrl,
archiveName: manifest.archiveName
};
if (includeInstallState && this.inFlight.has(manifest.id)) {
return { ...base, state: 'installing', verified: false };
}
if (!fs.existsSync(this.options.installationDirectory)) {
return { ...base, state: 'missing', verified: false };
}
let record: ManagedToolRecord | null = null;
try {
const parsed = JSON.parse(fs.readFileSync(path.join(this.options.installationDirectory, RECORD_FILE_NAME), 'utf8'));
if (!isRecord(parsed)) {
return { ...base, state: 'corrupt', verified: false };
}
record = parsed;
} catch {
const hasAnyExecutable = manifest.executables.some((name) => findFileRecursive(this.options.installationDirectory, name));
return { ...base, state: hasAnyExecutable ? 'unverified' : 'corrupt', verified: false };
}
const recordBase = { ...base, version: record.version };
if (!matchesManifest(record, manifest)) {
return { ...recordBase, state: 'unverified', verified: false };
}
const allExecutablesPresent = manifest.executables.every((name) => findFileRecursive(this.options.installationDirectory, name));
return allExecutablesPresent
? { ...recordBase, state: 'verified', verified: true }
: { ...recordBase, state: 'corrupt', verified: false };
}
install(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
const currentStatus = this.status(manifest);
if (currentStatus.verified) {
return Promise.resolve({ success: true, status: currentStatus });
}
return this.start(manifest);
}
repair(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
return this.start(manifest);
}
reset(manifest: ExternalToolManifest): ManagedToolStatus {
if (this.inFlight.has(manifest.id)) {
return this.status(manifest);
}
fs.rmSync(this.options.installationDirectory, { recursive: true, force: true });
return this.status(manifest);
}
private start(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
const existing = this.inFlight.get(manifest.id);
if (existing) return existing;
const operation = this.installOnce(manifest);
this.inFlight.set(manifest.id, operation);
void operation.then(() => {
if (this.inFlight.get(manifest.id) === operation) {
this.inFlight.delete(manifest.id);
}
});
return operation;
}
private async installOnce(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
const uniqueSuffix = crypto.randomUUID();
const archivePath = path.join(this.options.temporaryDirectory, `${manifest.archiveName}.${uniqueSuffix}`);
const stagingDirectory = `${this.options.installationDirectory}.stage-${uniqueSuffix}`;
let error: ManagedToolInstallResult['error'] | undefined;
try {
fs.mkdirSync(this.options.temporaryDirectory, { recursive: true });
await this.options.download(manifest.sourceUrl, archivePath);
if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) {
error = 'archive-hash-mismatch';
return { success: false, error, status: this.status(manifest, false) };
}
try {
await this.options.extract(archivePath, stagingDirectory);
} catch {
error = 'extract-failed';
return { success: false, error, status: this.status(manifest, false) };
}
if (!manifest.executables.every((name) => findFileRecursive(stagingDirectory, name))) {
error = 'required-executable-missing';
return { success: false, error, status: this.status(manifest, false) };
}
const record: ManagedToolRecord = {
id: manifest.id,
version: manifest.version,
sourceUrl: manifest.sourceUrl,
archiveName: manifest.archiveName,
sha256: manifest.sha256
};
fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record));
try {
this.promote(stagingDirectory);
} catch {
error = 'promotion-failed';
return { success: false, error, status: this.status(manifest, false) };
}
return { success: true, status: this.status(manifest, false) };
} catch {
error = 'download-failed';
return { success: false, error, status: this.status(manifest, false) };
} finally {
fs.rmSync(archivePath, { force: true });
fs.rmSync(stagingDirectory, { recursive: true, force: true });
}
}
private promote(stagingDirectory: string): void {
const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`;
const rename = this.options.rename ?? fs.renameSync;
let previousMoved = false;
try {
if (fs.existsSync(this.options.installationDirectory)) {
rename(this.options.installationDirectory, backupDirectory);
previousMoved = true;
}
rename(stagingDirectory, this.options.installationDirectory);
if (previousMoved) {
try {
fs.rmSync(backupDirectory, { recursive: true, force: true });
} catch {}
}
} catch (error) {
if (previousMoved && fs.existsSync(backupDirectory) && !fs.existsSync(this.options.installationDirectory)) {
try {
rename(backupDirectory, this.options.installationDirectory);
} catch {}
}
throw error;
}
}
}
export function createManagedToolInstaller(options: ManagedToolInstallerOptions): ManagedToolInstaller {
return new ManagedToolInstaller(options);
}
+20
View File
@@ -0,0 +1,20 @@
import type { ExternalToolManifest } from './managed-tools';
export const APPLICATION_TOOL_MANIFEST: Record<'streamlink' | 'ffmpeg', ExternalToolManifest> = {
streamlink: {
id: 'streamlink',
version: '8.4.0',
sourceUrl: 'https://github.com/streamlink/windows-builds/releases/download/8.4.0-1/streamlink-8.4.0-1-py314-x86_64.zip',
archiveName: 'streamlink-8.4.0-1-py314-x86_64.zip',
sha256: 'a8d3bd2b409e6d1b1f7a0e2a5c0cbfba619775e475da3f31285af08d680fb71c',
executables: ['streamlink.exe']
},
ffmpeg: {
id: 'ffmpeg',
version: '8.1.2',
sourceUrl: 'https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip',
archiveName: 'ffmpeg-8.1.2-essentials_build.zip',
sha256: 'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec',
executables: ['ffmpeg.exe', 'ffprobe.exe']
}
};
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { createUpdateCheckCoordinator } from './update-check-operation';
function deferred<T>() {
let resolve: (value: T) => void = () => {};
let reject: (reason?: unknown) => void = () => {};
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe('update check coordinator', () => {
it('keeps a timed-out check exclusive until the underlying operation settles', async () => {
vi.useFakeTimers();
const coordinator = createUpdateCheckCoordinator();
const firstCheck = deferred<void>();
const firstFactory = vi.fn(() => firstCheck.promise);
const secondFactory = vi.fn(() => Promise.resolve());
const first = coordinator.run(firstFactory, 100);
await vi.advanceTimersByTimeAsync(100);
expect(await first).toEqual({ state: 'timed-out' });
expect(coordinator.inProgress).toBe(true);
expect(await coordinator.run(secondFactory, 100)).toEqual({ state: 'in-progress' });
expect(secondFactory).not.toHaveBeenCalled();
firstCheck.resolve();
await firstCheck.promise;
await Promise.resolve();
await Promise.resolve();
expect(coordinator.inProgress).toBe(false);
expect(await coordinator.run(secondFactory, 100)).toEqual({ state: 'completed' });
expect(secondFactory).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('releases the operation only after its rejection settles', async () => {
const coordinator = createUpdateCheckCoordinator();
const failedCheck = deferred<void>();
const attempt = coordinator.run(() => failedCheck.promise, 1000);
failedCheck.reject(new Error('network failed'));
await expect(attempt).resolves.toMatchObject({ state: 'failed' });
expect(coordinator.inProgress).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
export type UpdateCheckOperationResult =
| { state: 'completed' }
| { state: 'failed'; error: unknown }
| { state: 'timed-out' }
| { state: 'in-progress' };
export class UpdateCheckCoordinator {
private activeOperation: Promise<{ state: 'completed' } | { state: 'failed'; error: unknown }> | null = null;
get inProgress(): boolean {
return this.activeOperation !== null;
}
run(operation: () => Promise<void>, timeoutMs: number): Promise<UpdateCheckOperationResult> {
if (this.activeOperation) {
return Promise.resolve({ state: 'in-progress' });
}
const tracked = Promise.resolve()
.then(operation)
.then(
() => ({ state: 'completed' as const }),
(error) => ({ state: 'failed' as const, error })
);
this.activeOperation = tracked;
void tracked.then(() => {
if (this.activeOperation === tracked) {
this.activeOperation = null;
}
});
return new Promise<UpdateCheckOperationResult>((resolve) => {
let pending = true;
const timeout = setTimeout(() => {
if (!pending) return;
pending = false;
resolve({ state: 'timed-out' });
}, timeoutMs);
void tracked.then((result) => {
if (!pending) return;
pending = false;
clearTimeout(timeout);
resolve(result);
});
});
}
}
export function createUpdateCheckCoordinator(): UpdateCheckCoordinator {
return new UpdateCheckCoordinator();
}