diff --git a/src/main.ts b/src/main.ts index 6349e75..85d6beb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7977,9 +7977,9 @@ registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () = return await runPreflight(autoFix); }); -ipcMain.handle('get-managed-tool-status', (event) => { +ipcMain.handle('get-managed-tool-status', async (event) => { if (!isTrustedRendererEvent(event)) return null; - return getManagedToolStatuses(); + return await getManagedToolStatuses(); }); ipcMain.handle('repair-managed-tools', async (event) => { @@ -7987,9 +7987,9 @@ ipcMain.handle('repair-managed-tools', async (event) => { return await repairManagedTools(); }); -ipcMain.handle('reset-managed-tools', (event) => { +ipcMain.handle('reset-managed-tools', async (event) => { if (!isTrustedRendererEvent(event)) return null; - return resetManagedTools(); + return await resetManagedTools(); }); registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => { diff --git a/src/main/domain/managed-tools.test.ts b/src/main/domain/managed-tools.test.ts index 7bbac7a..1d81dd8 100644 --- a/src/main/domain/managed-tools.test.ts +++ b/src/main/domain/managed-tools.test.ts @@ -1,4 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fileSystemSpies = vi.hoisted(() => ({ + readFileSync: vi.fn() +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + fileSystemSpies.readFileSync.mockImplementation(actual.readFileSync); + return { ...actual, readFileSync: fileSystemSpies.readFileSync }; +}); + import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -187,7 +198,7 @@ describe('managed tool installer', () => { }); }); - it('marks a mismatched installed version as unverified', () => { + it('marks a mismatched installed version as unverified', async () => { const { installer, installPath } = createInstaller(); writeExistingInstallation(installPath); fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({ @@ -198,7 +209,7 @@ describe('managed tool installer', () => { sha256: sha256('old archive') })); - expect(installer.status(manifest())).toMatchObject({ + await expect(installer.status(manifest())).resolves.toMatchObject({ version: '8.3.0', state: 'unverified', verified: false @@ -222,8 +233,7 @@ describe('managed tool installer', () => { const first = installer.repair(manifest()); const second = installer.repair(manifest()); - await Promise.resolve(); - expect(download).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(download).toHaveBeenCalledTimes(1)); releaseDownload?.(); const [firstResult, secondResult] = await Promise.all([first, second]); @@ -233,7 +243,7 @@ describe('managed tool installer', () => { expect(download).toHaveBeenCalledTimes(1); }); - it('restores a valid previous tool at the active path after interruption between backup and promotion', () => { + it('restores a valid previous tool at the active path after interruption between backup and promotion', async () => { const { installer, installPath } = createInstaller(); const backupPath = `${installPath}.backup-interrupted`; const stagingPath = `${installPath}.stage-interrupted`; @@ -241,14 +251,14 @@ describe('managed tool installer', () => { writeVerifiedInstallation(stagingPath, 'new executable'); writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created'); - expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true }); + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true }); expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable'); expect(fs.existsSync(backupPath)).toBe(false); expect(fs.existsSync(stagingPath)).toBe(false); expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false); }); - it('keeps a valid promoted tool after interruption between promotion and journal cleanup', () => { + it('keeps a valid promoted tool after interruption between promotion and journal cleanup', async () => { const { installer, installPath } = createInstaller(); const backupPath = `${installPath}.backup-interrupted`; const stagingPath = `${installPath}.stage-interrupted`; @@ -256,7 +266,7 @@ describe('managed tool installer', () => { writeVerifiedInstallation(backupPath, 'old executable'); writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created'); - expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true }); + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true }); expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable'); expect(fs.existsSync(backupPath)).toBe(false); expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false); @@ -313,11 +323,61 @@ describe('managed tool installer', () => { expect(installed.success).toBe(true); fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'changed executable'); - expect(installer.status(manifest())).toMatchObject({ state: 'corrupt', verified: false }); + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'corrupt', verified: false }); const repaired = await installer.repair(manifest()); expect(repaired.success).toBe(true); expect(repaired.status).toMatchObject({ state: 'verified', verified: true }); expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable'); }); + + it('promotes a verified staged first install after interruption before promotion', async () => { + const { installer, installPath, download } = createInstaller(); + const stagingPath = `${installPath}.stage-interrupted`; + writeVerifiedInstallation(stagingPath, 'first executable'); + writeInterruptedPromotionJournal(installPath, `${installPath}.backup-interrupted`, stagingPath, 'staged'); + + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true }); + expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('first executable'); + expect(fs.existsSync(stagingPath)).toBe(false); + expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false); + + const result = await installer.install(manifest()); + expect(result.success).toBe(true); + expect(download).not.toHaveBeenCalled(); + }); + + it('clears a staged first-install journal after promotion before its phase update', async () => { + const { installer, installPath, download } = createInstaller(); + writeVerifiedInstallation(installPath, 'first executable'); + writeInterruptedPromotionJournal(installPath, `${installPath}.backup-interrupted`, `${installPath}.stage-interrupted`, 'staged'); + + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true }); + expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('first executable'); + expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false); + + const result = await installer.install(manifest()); + expect(result.success).toBe(true); + expect(download).not.toHaveBeenCalled(); + }); + + it('streams multi-megabyte executable verification without readFileSync and detects corruption', async () => { + const multiMegabyteExecutable = Buffer.alloc(3 * 1024 * 1024, 0x5a); + const { installer, installPath } = createInstaller({ + extract: async (_archivePath, destinationPath) => { + fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), multiMegabyteExecutable); + } + }); + + expect((await installer.repair(manifest())).success).toBe(true); + fileSystemSpies.readFileSync.mockClear(); + + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'verified', verified: true }); + expect(fileSystemSpies.readFileSync).not.toHaveBeenCalled(); + + fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), Buffer.alloc(3 * 1024 * 1024, 0x2a)); + await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'corrupt', verified: false }); + expect(fileSystemSpies.readFileSync).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/domain/managed-tools.ts b/src/main/domain/managed-tools.ts index 5e2dc17..fe5b916 100644 --- a/src/main/domain/managed-tools.ts +++ b/src/main/domain/managed-tools.ts @@ -92,8 +92,16 @@ function findFileRecursive(rootDir: string, fileName: string): string | null { return null; } -function sha256File(filePath: string): string { - return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +function sha256File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => { + hash.update(chunk); + }); + stream.once('error', reject); + stream.once('end', () => resolve(hash.digest('hex'))); + }); } function isRecord(value: unknown): value is ManagedToolRecord { @@ -146,20 +154,20 @@ export class ManagedToolInstaller { constructor(private readonly options: ManagedToolInstallerOptions) {} - status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus { + async status(manifest: ExternalToolManifest, includeInstallState = true): Promise { if (includeInstallState && this.inFlight.has(manifest.id)) { return this.createStatus(manifest, 'installing', false); } - const recovery = this.recover(manifest, []); + const recovery = await this.recover(manifest, []); if (!recovery.success) { return this.createStatus(manifest, 'corrupt', false); } - return this.statusFromInspection(manifest); + return await this.statusFromInspection(manifest); } - install(manifest: ExternalToolManifest): Promise { - const currentStatus = this.status(manifest); + async install(manifest: ExternalToolManifest): Promise { + const currentStatus = await this.status(manifest); if (currentStatus.verified) { return Promise.resolve({ success: true, status: currentStatus, diagnostics: [] }); } @@ -170,19 +178,19 @@ export class ManagedToolInstaller { return this.start(manifest); } - reset(manifest: ExternalToolManifest): ManagedToolStatus { + async reset(manifest: ExternalToolManifest): Promise { if (this.inFlight.has(manifest.id)) { - return this.status(manifest); + return await this.status(manifest); } const diagnostics: string[] = []; - const journal = this.readJournal(); + const journal = await this.readJournal(); if (isJournal(journal, this.options.installationDirectory)) { this.cleanup(journal.backupDirectory, true, diagnostics); this.cleanup(journal.stagingDirectory, true, diagnostics); } this.cleanup(this.options.installationDirectory, true, diagnostics); this.cleanup(this.journalPath(), false, diagnostics); - return this.status(manifest); + return await this.status(manifest); } private start(manifest: ExternalToolManifest): Promise { @@ -192,7 +200,7 @@ export class ManagedToolInstaller { let operation!: Promise; operation = Promise.resolve() .then(() => this.installOnce(manifest)) - .catch((error: unknown) => this.failure(manifest, 'download-failed', this.errorText(error), [])) + .catch(async (error: unknown) => await this.failure(manifest, 'download-failed', this.errorText(error), [])) .finally(() => { if (this.inFlight.get(manifest.id) === operation) { this.inFlight.delete(manifest.id); @@ -209,29 +217,29 @@ export class ManagedToolInstaller { const diagnostics: string[] = []; try { - const recovery = this.recover(manifest, diagnostics); + const recovery = await this.recover(manifest, diagnostics); if (!recovery.success) { - return this.failure(manifest, recovery.error!, recovery.detail, diagnostics); + return await this.failure(manifest, recovery.error!, recovery.detail, diagnostics); } fs.mkdirSync(this.options.temporaryDirectory, { recursive: true }); await this.options.download(manifest.sourceUrl, archivePath); - if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) { - return this.failure(manifest, 'archive-hash-mismatch', undefined, diagnostics); + if ((await sha256File(archivePath)).toLowerCase() !== manifest.sha256.toLowerCase()) { + return await this.failure(manifest, 'archive-hash-mismatch', undefined, diagnostics); } try { await this.options.extract(archivePath, stagingDirectory); } catch (error) { - return this.failure(manifest, 'extract-failed', this.errorText(error), diagnostics); + return await this.failure(manifest, 'extract-failed', this.errorText(error), diagnostics); } const executablePaths = this.executablePaths(stagingDirectory, manifest.executables); if (!executablePaths) { - return this.failure(manifest, 'required-executable-missing', undefined, diagnostics); + return await this.failure(manifest, 'required-executable-missing', undefined, diagnostics); } - const executableHashes = Object.fromEntries(executablePaths.map(([name, executablePath]) => [name, sha256File(executablePath)])); + const executableHashes = Object.fromEntries(await Promise.all(executablePaths.map(async ([name, executablePath]) => [name, await sha256File(executablePath)] as const))); const record: ManagedToolRecord = { id: manifest.id, version: manifest.version, @@ -242,21 +250,21 @@ export class ManagedToolInstaller { }; fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record)); - const promotion = this.promote(manifest, stagingDirectory, diagnostics); + const promotion = await this.promote(manifest, stagingDirectory, diagnostics); if (!promotion.success) { - return this.failure(manifest, promotion.error!, promotion.detail, diagnostics); + return await this.failure(manifest, promotion.error!, promotion.detail, diagnostics); } - return { success: true, status: this.statusFromInspection(manifest), diagnostics }; + return { success: true, status: await this.statusFromInspection(manifest), diagnostics }; } catch (error) { - return this.failure(manifest, 'download-failed', this.errorText(error), diagnostics); + return await this.failure(manifest, 'download-failed', this.errorText(error), diagnostics); } finally { this.cleanup(archivePath, false, diagnostics); this.cleanup(stagingDirectory, true, diagnostics); } } - private promote(manifest: ExternalToolManifest, stagingDirectory: string, diagnostics: string[]): RecoveryResult { + private async promote(manifest: ExternalToolManifest, stagingDirectory: string, diagnostics: string[]): Promise { const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`; const journal: InstallationJournal = { installationDirectory: this.options.installationDirectory, @@ -279,26 +287,26 @@ export class ManagedToolInstaller { this.cleanup(this.journalPath(), false, diagnostics); return { success: true }; } catch (error) { - const recovery = this.recover(manifest, diagnostics); + const recovery = await this.recover(manifest, diagnostics); return recovery.success ? { success: false, error: 'promotion-failed', detail: this.errorText(error) } : recovery; } } - private recover(manifest: ExternalToolManifest, diagnostics: string[]): RecoveryResult { - const journal = this.readJournal(); + private async recover(manifest: ExternalToolManifest, diagnostics: string[]): Promise { + const journal = await this.readJournal(); if (journal === undefined) { return { success: true }; } if (!isJournal(journal, this.options.installationDirectory)) { return { success: false, error: 'recovery-journal-invalid', detail: 'Ungültiges Installationsjournal.' }; } - return this.recoverJournal(manifest, journal, diagnostics); + return await this.recoverJournal(manifest, journal, diagnostics); } - private recoverJournal(manifest: ExternalToolManifest, journal: InstallationJournal, diagnostics: string[]): RecoveryResult { - const active = this.inspectInstallation(this.options.installationDirectory, manifest); + private async recoverJournal(manifest: ExternalToolManifest, journal: InstallationJournal, diagnostics: string[]): Promise { + const active = await this.inspectInstallation(this.options.installationDirectory, manifest); if (active.strictValid || (journal.phase === 'staged' && active.layoutUsable)) { this.cleanup(journal.backupDirectory, true, diagnostics); this.cleanup(journal.stagingDirectory, true, diagnostics); @@ -306,36 +314,48 @@ export class ManagedToolInstaller { return { success: true }; } - const backup = this.inspectInstallation(journal.backupDirectory, manifest); - if (!backup.layoutUsable) { + const backup = await this.inspectInstallation(journal.backupDirectory, manifest); + if (backup.layoutUsable) { + let displacedDirectory: string | undefined; + try { + if (fs.existsSync(this.options.installationDirectory)) { + displacedDirectory = `${this.options.installationDirectory}.recovery-${crypto.randomUUID()}`; + this.rename(this.options.installationDirectory, displacedDirectory); + } + this.rename(journal.backupDirectory, this.options.installationDirectory); + } catch (error) { + return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) }; + } + + this.cleanup(journal.stagingDirectory, true, diagnostics); + if (displacedDirectory) { + this.cleanup(displacedDirectory, true, diagnostics); + } + this.cleanup(this.journalPath(), false, diagnostics); + return { success: true }; + } + + const staging = await this.inspectInstallation(journal.stagingDirectory, manifest); + if (journal.phase !== 'staged' || !staging.strictValid || fs.existsSync(this.options.installationDirectory)) { return { success: false, error: 'recovery-restore-failed', detail: 'Kein nutzbares Tool-Backup für die Wiederherstellung vorhanden.' }; } - let displacedDirectory: string | undefined; try { - if (fs.existsSync(this.options.installationDirectory)) { - displacedDirectory = `${this.options.installationDirectory}.recovery-${crypto.randomUUID()}`; - this.rename(this.options.installationDirectory, displacedDirectory); - } - this.rename(journal.backupDirectory, this.options.installationDirectory); + this.rename(journal.stagingDirectory, this.options.installationDirectory); } catch (error) { return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) }; } - this.cleanup(journal.stagingDirectory, true, diagnostics); - if (displacedDirectory) { - this.cleanup(displacedDirectory, true, diagnostics); - } this.cleanup(this.journalPath(), false, diagnostics); return { success: true }; } - private statusFromInspection(manifest: ExternalToolManifest): ManagedToolStatus { - const inspection = this.inspectInstallation(this.options.installationDirectory, manifest); + private async statusFromInspection(manifest: ExternalToolManifest): Promise { + const inspection = await this.inspectInstallation(this.options.installationDirectory, manifest); return this.createStatus(manifest, inspection.state, inspection.strictValid, inspection.version); } - private inspectInstallation(directory: string, manifest: ExternalToolManifest): InstallationInspection { + private async inspectInstallation(directory: string, manifest: ExternalToolManifest): Promise { if (!fs.existsSync(directory)) { return { state: 'missing', version: manifest.version, layoutUsable: false, strictValid: false }; } @@ -345,7 +365,7 @@ export class ManagedToolInstaller { return { state: 'corrupt', version: manifest.version, layoutUsable: false, strictValid: false }; } - const record = this.readRecord(directory); + const record = await this.readRecord(directory); if (!record || !isRecord(record)) { return { state: 'unverified', version: manifest.version, layoutUsable: true, strictValid: false }; } @@ -357,7 +377,7 @@ export class ManagedToolInstaller { } try { - const hashesMatch = executablePaths.every(([name, executablePath]) => sha256File(executablePath).toLowerCase() === record.executableHashes[name].toLowerCase()); + const hashesMatch = (await Promise.all(executablePaths.map(async ([name, executablePath]) => (await sha256File(executablePath)).toLowerCase() === record.executableHashes[name].toLowerCase()))).every(Boolean); return hashesMatch ? { state: 'verified', version: record.version, layoutUsable: true, strictValid: true } : { state: 'corrupt', version: record.version, layoutUsable: true, strictValid: false }; @@ -374,20 +394,20 @@ export class ManagedToolInstaller { return paths as Array<[string, string]>; } - private readRecord(directory: string): unknown { + private async readRecord(directory: string): Promise { try { - return JSON.parse(fs.readFileSync(path.join(directory, RECORD_FILE_NAME), 'utf8')); + return JSON.parse(await fs.promises.readFile(path.join(directory, RECORD_FILE_NAME), 'utf8')); } catch { return null; } } - private readJournal(): unknown | typeof INVALID_JOURNAL | undefined { + private async readJournal(): Promise { try { if (!fs.existsSync(this.journalPath())) { return undefined; } - return JSON.parse(fs.readFileSync(this.journalPath(), 'utf8')); + return JSON.parse(await fs.promises.readFile(this.journalPath(), 'utf8')); } catch { return INVALID_JOURNAL; } @@ -433,12 +453,12 @@ export class ManagedToolInstaller { }; } - private failure(manifest: ExternalToolManifest, error: NonNullable, detail: string | undefined, diagnostics: string[]): ManagedToolInstallResult { + private async failure(manifest: ExternalToolManifest, error: NonNullable, detail: string | undefined, diagnostics: string[]): Promise { return { success: false, error, detail, - status: this.statusFromInspection(manifest), + status: await this.statusFromInspection(manifest), diagnostics }; } diff --git a/src/tools.ts b/src/tools.ts index 7899add..c0c565a 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -409,10 +409,10 @@ export interface ManagedToolStatuses { ffmpeg: ManagedToolStatus; } -export function getManagedToolStatuses(): ManagedToolStatuses { +export async function getManagedToolStatuses(): Promise { return { - streamlink: getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink), - ffmpeg: getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg) + streamlink: await getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink), + ffmpeg: await getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg) }; } @@ -424,17 +424,19 @@ export async function repairManagedTools(): Promise<{ success: boolean; statuses refreshBundledToolPaths(true); return { success: streamlink.success && ffmpeg.success, - statuses: getManagedToolStatuses() + statuses: await getManagedToolStatuses() }; } -export function resetManagedTools(): { success: boolean; statuses: ManagedToolStatuses } { - const streamlink = getManagedToolInstaller('streamlink').reset(APPLICATION_TOOL_MANIFEST.streamlink); - const ffmpeg = getManagedToolInstaller('ffmpeg').reset(APPLICATION_TOOL_MANIFEST.ffmpeg); +export async function resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses }> { + const [streamlink, ffmpeg] = await Promise.all([ + getManagedToolInstaller('streamlink').reset(APPLICATION_TOOL_MANIFEST.streamlink), + getManagedToolInstaller('ffmpeg').reset(APPLICATION_TOOL_MANIFEST.ffmpeg) + ]); refreshBundledToolPaths(true); return { success: streamlink.state !== 'installing' && ffmpeg.state !== 'installing', - statuses: getManagedToolStatuses() + statuses: await getManagedToolStatuses() }; } @@ -445,7 +447,7 @@ export async function ensureStreamlinkInstalled(): Promise { refreshBundledToolPaths(); const manifest = APPLICATION_TOOL_MANIFEST.streamlink; - const managedStatus = getManagedToolInstaller('streamlink').status(manifest); + const managedStatus = await getManagedToolInstaller('streamlink').status(manifest); const requiresManagedRepair = Boolean(bundledStreamlinkPath) && !managedStatus.verified; const current = getStreamlinkCommand(); const versionArgs = [...current.prefixArgs, '--version']; @@ -491,7 +493,7 @@ export async function ensureFfmpegInstalled(): Promise { refreshBundledToolPaths(); const manifest = APPLICATION_TOOL_MANIFEST.ffmpeg; - const managedStatus = getManagedToolInstaller('ffmpeg').status(manifest); + const managedStatus = await getManagedToolInstaller('ffmpeg').status(manifest); const requiresManagedRepair = Boolean(bundledFFmpegPath || bundledFFprobePath) && !managedStatus.verified; const ffmpegPath = getFFmpegPath(); const ffprobePath = getFFprobePath();