diff --git a/package.json b/package.json index 5d9dbcd..6360767 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,9 @@ "test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js", "test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js", "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", + "test:capability-contract": "node scripts/smoke-test-file-capability-contract.js", "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", - "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", + "test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", diff --git a/scripts/file-capability-contract.js b/scripts/file-capability-contract.js new file mode 100644 index 0000000..60ff538 --- /dev/null +++ b/scripts/file-capability-contract.js @@ -0,0 +1,25 @@ +function fileName(value) { + return String(value).split(/[/\\]/).pop() || ''; +} + +function requireFileCapability(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('File capability reference is required'); + if (typeof value.token !== 'string' || value.token.length < 32) throw new Error('Opaque file capability token is required'); + if (typeof value.name !== 'string' || !value.name || fileName(value.name) !== value.name) throw new Error('Safe file display name is required'); + return { token: value.token, name: value.name }; +} + +function createCutterExportRequest(capability, outputFile, state) { + const file = requireFileCapability(capability); + const outputName = fileName(outputFile); + if (!outputName.toLowerCase().endsWith('.mp4')) throw new Error('MP4 output name is required'); + return { + inputCapability: file.token, + outputName, + trimStart: state.trimStart, + trimEnd: state.trimEnd, + cuts: state.cuts, + }; +} + +module.exports = { requireFileCapability, createCutterExportRequest }; diff --git a/scripts/smoke-test-cutter.js b/scripts/smoke-test-cutter.js index 46136fc..737de71 100644 --- a/scripts/smoke-test-cutter.js +++ b/scripts/smoke-test-cutter.js @@ -4,6 +4,7 @@ const os = require('os'); const path = require('path'); const { pathToFileURL } = require('url'); const { spawnSync } = require('child_process'); +const { requireFileCapability } = require('./file-capability-contract'); const { createE2eEnvironment, getElectronLaunchOptions, @@ -48,6 +49,32 @@ function getProcessTreeMemoryMb(rootPid) { return Number.isFinite(value) ? value : 0; } +async function createCutterCapability(win, filePath) { + const inputId = `cutter-capability-${Date.now()}-${Math.random().toString(36).slice(2)}`; + await win.evaluate((id) => { + const input = document.createElement('input'); + input.type = 'file'; + input.id = id; + document.body.appendChild(input); + }, inputId); + await win.locator(`#${inputId}`).setInputFiles(filePath); + const capability = await win.evaluate(async (id) => { + const input = document.getElementById(id); + const file = input instanceof HTMLInputElement ? input.files?.[0] : null; + const capability = file ? await window.api.selectDroppedVideo(file) : null; + input?.remove(); + if (!capability || typeof capability.token !== 'string' || !capability.token) throw new Error('Cutter capability was not issued'); + return capability; + }, inputId); + return requireFileCapability(capability); +} + +async function loadCutterCapability(win, filePath) { + const capability = await createCutterCapability(win, filePath); + await win.evaluate((selection) => window.loadCutterFromPath(selection), capability); + return capability; +} + function createTestVideo(environment) { const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4'); runBinary(resolveBinary(environment, 'ffmpeg'), [ @@ -173,6 +200,11 @@ async function run() { await win.setViewportSize({ width: 1440, height: 900 }); await win.emulateMedia({ reducedMotion: 'reduce' }); await win.evaluate(() => window.showTab('cutter')); + const additionalContainerCapabilities = await Promise.all(Object.entries(additionalContainerFiles).map(async ([extension, filePath]) => ({ + extension, + capability: await createCutterCapability(win, filePath), + sourceUrl: pathToFileURL(filePath).href + }))); const additionalContainerSupport = await win.evaluate(async (entries) => { const probe = (sourceUrl) => new Promise((resolve) => { const video = document.createElement('video'); @@ -197,7 +229,7 @@ async function run() { const results = []; for (const entry of entries) { const nativePlayable = await probe(entry.sourceUrl); - const media = await window.api.prepareVideoEditorMedia(entry.filePath); + const media = await window.api.prepareVideoEditorMedia(entry.capability.token); results.push({ extension: entry.extension, nativePlayable, @@ -206,7 +238,7 @@ async function run() { }); } return results; - }, Object.entries(additionalContainerFiles).map(([extension, filePath]) => ({ extension, filePath, sourceUrl: pathToFileURL(filePath).href }))); + }, additionalContainerCapabilities); check(additionalContainerSupport.every((entry) => entry.prepared && entry.editorPlayable), `Additional video containers are not usable in the editor: ${JSON.stringify(additionalContainerSupport)}`); const cutterArtifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'cutter'); fs.mkdirSync(cutterArtifactDir, { recursive: true }); @@ -289,7 +321,8 @@ async function run() { return; } const firstAssetsStartedAt = Date.now(); - const initialOriginalDevicePixelRatio = await win.evaluate((filePath) => { + const inputCapability = await createCutterCapability(win, inputFile); + const initialOriginalDevicePixelRatio = await win.evaluate((fileCapability) => { const originalDevicePixelRatio = window.devicePixelRatio; Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }); const workspace = document.getElementById('cutterWorkspace'); @@ -347,9 +380,9 @@ async function run() { if (window.__cutterAssetAudit.waveformLoads.at(-1)?.signature === signature) return; window.__cutterAssetAudit.waveformLoads.push({ signature, width: image.naturalWidth, height: image.naturalHeight }); }); - window.__cutterLoadPromise = window.loadCutterFromPath(filePath); + window.__cutterLoadPromise = window.loadCutterFromPath(fileCapability); return originalDevicePixelRatio; - }, inputFile); + }, inputCapability); await win.waitForFunction(() => document.getElementById('cutterWorkspace').classList.contains('shown'), null, { timeout: 15000 }); await win.evaluate(() => window.__cutterLoadPromise); await win.waitForFunction(() => { @@ -640,10 +673,11 @@ async function run() { && cutterInfoAlignment.valueLineHeights.every(Number.isFinite), `Cutter information labels and values are not aligned to common rows: ${JSON.stringify(cutterInfoAlignment)}` ); + const replacementCapability = await createCutterCapability(win, scrubStressInputFile); await app.evaluate(({ ipcMain }, nextFile) => { ipcMain.removeHandler('select-video-file'); ipcMain.handle('select-video-file', () => nextFile); - }, scrubStressInputFile); + }, replacementCapability); await win.evaluate(() => { cutterEditorState.trimStart = 1; renderCutterEditor(); @@ -718,7 +752,7 @@ async function run() { check(replacementPlaybackState.paused && !replacementPlaybackState.playingClass && replacementPlaybackState.playIconVisible && !replacementPlaybackState.pauseIconVisible, `Replacing a playing video leaves stale playback UI: ${JSON.stringify(replacementPlaybackState)}`); } const scrubFirstAssetsStartedAt = Date.now(); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), scrubStressInputFile); + await loadCutterCapability(win, scrubStressInputFile); await win.waitForFunction(() => { const video = document.getElementById('cutterVideo'); return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.duration >= 8 && video.duration <= 10; @@ -945,7 +979,7 @@ async function run() { `Trim handles are not as immediate and frame-synchronized as the playhead: ${JSON.stringify(trimScrubProbe)}` ); const mediumLoadStarted = Date.now(); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), mediumInputFile); + await loadCutterCapability(win, mediumInputFile); await win.waitForFunction(() => { const video = document.getElementById('cutterVideo'); const waveform = document.getElementById('cutterWaveform'); @@ -1010,7 +1044,7 @@ async function run() { ); const memoryBeforeStressMb = getProcessTreeMemoryMb(app.process().pid); const longLoadStarted = Date.now(); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), longInputFile); + const longCapability = await loadCutterCapability(win, longInputFile); await win.waitForFunction(() => { const video = document.getElementById('cutterVideo'); return video.readyState >= HTMLMediaElement.HAVE_METADATA && Math.abs(video.duration - 1800) < 1; @@ -1020,9 +1054,10 @@ async function run() { await win.evaluate(() => window.showTab('vods')); await win.waitForTimeout(80); await win.evaluate(() => window.showTab('cutter')); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), unsupportedInputFile); + const unsupportedCapability = { token: 'forged-unsupported-capability', name: path.basename(unsupportedInputFile) }; + await win.evaluate((capability) => window.loadCutterFromPath(capability), unsupportedCapability); await win.waitForFunction(() => !document.getElementById('cutterWorkspace').classList.contains('loading')); - const longPreservedAfterAssetInterruptions = await win.evaluate((expectedFile) => cutterFile === expectedFile, longInputFile); + const longPreservedAfterAssetInterruptions = await win.evaluate((expectedToken) => cutterFile?.token === expectedToken, longCapability.token); check(longPreservedAfterAssetInterruptions, 'Long editor was replaced after an interrupted asset load and unsupported file selection'); await win.waitForFunction(() => Number(document.getElementById('cutterThumbnailStrip').dataset.thumbnailCount || document.querySelectorAll('#cutterThumbnailStrip img').length) >= 30, null, { timeout: 90000 }); const longAssetsReadyMs = Date.now() - longLoadStarted; @@ -1082,28 +1117,30 @@ async function run() { longScrubPresentation.frames >= 12 && longScrubPresentation.maximumGap !== null && longScrubPresentation.maximumGap <= 0.75, `Long-video scrubbing skips too much visible media: ${JSON.stringify(longScrubPresentation)}` ); + const rapidSwitchCapabilities = await Promise.all([inputFile, longInputFile, silentInputFile, longInputFile, inputFile, silentInputFile, longInputFile, silentInputFile].map((filePath) => createCutterCapability(win, filePath))); const rapidSwitch = await win.evaluate(async (files) => { - await Promise.allSettled(files.map((filePath) => window.loadCutterFromPath(filePath))); - return cutterFile; - }, [inputFile, longInputFile, silentInputFile, longInputFile, inputFile, silentInputFile, longInputFile, silentInputFile]); - await win.waitForFunction((expectedFile) => cutterFile === expectedFile && document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, silentInputFile, { timeout: 15000 }); - check(rapidSwitch === silentInputFile, `Rapid file switching committed stale media: ${rapidSwitch}`); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), inputFile); + await Promise.allSettled(files.map((file) => window.loadCutterFromPath(file))); + return cutterFile?.token || null; + }, rapidSwitchCapabilities); + const expectedRapidSwitchToken = rapidSwitchCapabilities.at(-1).token; + await win.waitForFunction((expectedToken) => cutterFile?.token === expectedToken && document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, expectedRapidSwitchToken, { timeout: 15000 }); + check(rapidSwitch === expectedRapidSwitchToken, `Rapid file switching committed stale media: ${rapidSwitch}`); + await win.evaluate((capability) => window.loadCutterFromPath(capability), inputCapability); await win.waitForFunction(() => document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA && Number(document.getElementById('cutterThumbnailStrip').dataset.thumbnailCount || document.querySelectorAll('#cutterThumbnailStrip img').length) >= 30, null, { timeout: 90000 }); const memoryAfterStressMb = getProcessTreeMemoryMb(app.process().pid); const stressMemoryDeltaMb = memoryBeforeStressMb && memoryAfterStressMb ? memoryAfterStressMb - memoryBeforeStressMb : 0; check(stressMemoryDeltaMb < 350, `Rapid media switching retained too much process memory: ${stressMemoryDeltaMb.toFixed(1)} MB`); - const probedMedia = await win.evaluate((filePath) => window.api.getVideoInfo(filePath), inputFile); + const probedMedia = await win.evaluate((capability) => window.api.getVideoInfo(capability.token), inputCapability); check(probedMedia?.videoCodec === 'h264' && probedMedia?.audioCodec === 'aac' && probedMedia?.previewCompatible && !probedMedia?.variableFrameRate, `Media capability probe is inconsistent: ${JSON.stringify(probedMedia)}`); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), unsupportedInputFile); + await win.evaluate((capability) => window.loadCutterFromPath(capability), unsupportedCapability); await win.waitForFunction(() => !document.getElementById('cutterWorkspace').classList.contains('loading')); const preservedAfterUnsupported = await win.evaluate((expectedFile) => ({ - filePreserved: cutterFile === expectedFile, + filePreserved: cutterFile?.token === expectedFile, statePreserved: Boolean(cutterEditorState) && cutterEditorState.duration === 10, playerPreserved: document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, waveformPreserved: !document.getElementById('cutterWaveform').hidden && document.getElementById('cutterWaveform').naturalWidth === 32000, exportEnabled: !document.getElementById('btnCut').disabled - }), inputFile); + }), inputCapability.token); check(Object.values(preservedAfterUnsupported).every(Boolean), `Unsupported replacement corrupted the loaded editor: ${JSON.stringify(preservedAfterUnsupported)}`); await win.locator('#timelineContainer').scrollIntoViewIfNeeded(); const timeline = await win.locator('#timeline').boundingBox(); @@ -1815,28 +1852,28 @@ async function run() { trimEnd: cutterEditorState.trimEnd, cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })) })); - const sourceProtection = await win.evaluate(({ inputFile, editorState }) => window.api.exportVideoEdit({ - inputFile, - outputFile: inputFile.toUpperCase(), + const sourceProtection = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, trimStart: editorState.trimStart, trimEnd: editorState.trimEnd, cuts: editorState.cuts - }), { inputFile, editorState }); + }), { outputName: path.basename(inputFile).toUpperCase(), editorState }); check(!sourceProtection.success && fs.existsSync(inputFile) && fs.statSync(inputFile).size > 256, `Source overwrite protection failed: ${JSON.stringify(sourceProtection)}`); const invalidOutputFile = path.join(environment.mediaDir, 'Invalid request.mp4'); - const invalidRequest = await win.evaluate(({ inputFile, outputFile, editorState }) => window.api.exportVideoEdit({ - inputFile, - outputFile, + const invalidRequest = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, trimStart: Number.NaN, trimEnd: editorState.trimEnd, cuts: editorState.cuts - }), { inputFile, outputFile: invalidOutputFile, editorState }); + }), { outputName: path.basename(invalidOutputFile), editorState }); check(!invalidRequest.success && !fs.existsSync(invalidOutputFile), `Invalid numeric request was accepted: ${JSON.stringify(invalidRequest)}`); const cancelledOutputFile = path.join(environment.mediaDir, 'Cancelled export.mp4'); - const cancelledExport = await win.evaluate(async ({ inputFile, outputFile, editorState }) => { + const cancelledExport = await win.evaluate(async ({ outputName, editorState }) => { const exportPromise = window.api.exportVideoEdit({ - inputFile, - outputFile, + inputCapability: cutterFile.token, + outputName, trimStart: editorState.trimStart, trimEnd: editorState.trimEnd, cuts: editorState.cuts @@ -1844,16 +1881,16 @@ async function run() { await new Promise((resolve) => setTimeout(resolve, 120)); const cancelAccepted = await window.api.cancelVideoEdit(); return { cancelAccepted, result: await exportPromise }; - }, { inputFile, outputFile: cancelledOutputFile, editorState }); + }, { outputName: path.basename(cancelledOutputFile), editorState }); check(cancelledExport.cancelAccepted && !cancelledExport.result.success && cancelledExport.result.cancelled === true && !fs.existsSync(cancelledOutputFile), `Export cancellation did not return a clean cancelled result or published a file: ${JSON.stringify(cancelledExport)}`); fs.writeFileSync(outputFile, 'previous-output', 'utf8'); - const exportResult = await win.evaluate(({ inputFile, outputFile, editorState }) => window.api.exportVideoEdit({ - inputFile, - outputFile, + const exportResult = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, trimStart: editorState.trimStart, trimEnd: editorState.trimEnd, cuts: editorState.cuts - }), { inputFile, outputFile, editorState }); + }), { outputName: path.basename(outputFile), editorState }); check(exportResult.success && fs.existsSync(outputFile), `Export failed: ${JSON.stringify(exportResult)}`); if (fs.existsSync(outputFile)) { const probe = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', outputFile])); @@ -1869,24 +1906,24 @@ async function run() { start: 1.2 + index * 0.2, end: 1.28 + index * 0.2 })); - const manyCutsExport = await win.evaluate(({ inputFile, outputFile, cuts }) => window.api.exportVideoEdit({ - inputFile, - outputFile, + const manyCutsExport = await win.evaluate(({ outputName, cuts }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, trimStart: 1, trimEnd: 9.5, cuts - }), { inputFile, outputFile: manyCutsOutputFile, cuts: manyCuts }); + }), { outputName: path.basename(manyCutsOutputFile), cuts: manyCuts }); check(manyCutsExport.success && fs.existsSync(manyCutsOutputFile) && fs.statSync(manyCutsOutputFile).size > 256, `Many-cut export failed: ${JSON.stringify(manyCutsExport)}`); const sourceStatBeforeMutation = fs.statSync(inputFile); fs.utimesSync(inputFile, sourceStatBeforeMutation.atime, new Date(sourceStatBeforeMutation.mtimeMs + 5000)); const changedSourceOutputFile = path.join(environment.mediaDir, 'Changed source rejected.mp4'); - const changedSourceExport = await win.evaluate(({ inputFile, outputFile }) => window.api.exportVideoEdit({ - inputFile, - outputFile, + const changedSourceExport = await win.evaluate(({ outputName }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, trimStart: 0, trimEnd: 10, cuts: [] - }), { inputFile, outputFile: changedSourceOutputFile }); + }), { outputName: path.basename(changedSourceOutputFile) }); check(!changedSourceExport.success && !fs.existsSync(changedSourceOutputFile), `Changed source identity was accepted: ${JSON.stringify(changedSourceExport)}`); await win.evaluate(() => { window.updateCutterZoom(1); @@ -1894,7 +1931,7 @@ async function run() { }); await win.waitForTimeout(250); await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true }); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), silentInputFile); + await loadCutterCapability(win, silentInputFile); await win.waitForFunction(() => { const video = document.getElementById('cutterVideo'); return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth === 360; @@ -1907,13 +1944,13 @@ async function run() { emptyVisible: !document.getElementById('cutterAudioEmpty').hidden })); check(silentState.waveformHidden && silentState.emptyVisible, `Silent video does not show the no-audio state: ${JSON.stringify(silentState)}`); - const silentExportResult = await win.evaluate(({ inputFile, outputFile, state }) => window.api.exportVideoEdit({ - inputFile, - outputFile, + const silentExportResult = await win.evaluate(({ outputName, state }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, trimStart: state.trimStart, trimEnd: state.trimEnd, cuts: state.cuts - }), { inputFile: silentInputFile, outputFile: silentOutputFile, state: silentState }); + }), { outputName: path.basename(silentOutputFile), state: silentState }); check(silentExportResult.success && fs.existsSync(silentOutputFile), `Silent export failed: ${JSON.stringify(silentExportResult)}`); if (fs.existsSync(silentOutputFile)) { const silentProbe = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', silentOutputFile])); @@ -1925,11 +1962,11 @@ async function run() { check(runtimeIssues.length === 0, runtimeIssues.join('\n')); const cutterTempDirectoriesBeforeShutdown = new Set(fs.readdirSync(os.tmpdir()).filter((name) => /^tvm-editor-(?:media|waveform|preview)-/.test(name))); const shutdownOutputFile = path.join(environment.mediaDir, 'Shutdown export.mp4'); - await win.evaluate((filePath) => window.loadCutterFromPath(filePath), longInputFile); + await loadCutterCapability(win, longInputFile); await win.waitForFunction(() => document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, null, { timeout: 15000 }); - await win.evaluate(({ inputFile, outputFile }) => { - void window.api.exportVideoEdit({ inputFile, outputFile, trimStart: 0, trimEnd: 1800, cuts: [] }); - }, { inputFile: longInputFile, outputFile: shutdownOutputFile }); + await win.evaluate(({ outputName }) => { + void window.api.exportVideoEdit({ inputCapability: cutterFile.token, outputName, trimStart: 0, trimEnd: 1800, cuts: [] }); + }, { outputName: path.basename(shutdownOutputFile) }); await win.waitForTimeout(150); const appClosed = app.waitForEvent('close', { timeout: 15000 }); try { await app.evaluate(({ app: electronApp }) => electronApp.quit()); } catch { } diff --git a/scripts/smoke-test-file-capability-contract.js b/scripts/smoke-test-file-capability-contract.js new file mode 100644 index 0000000..a3f747d --- /dev/null +++ b/scripts/smoke-test-file-capability-contract.js @@ -0,0 +1,22 @@ +const assert = require('assert'); +const { createCutterExportRequest, requireFileCapability } = require('./file-capability-contract'); + +assert.throws(() => requireFileCapability('C:\\forged\\video.mp4')); +assert.throws(() => requireFileCapability({ token: 'short', name: 'video.mp4' })); +assert.throws(() => requireFileCapability({ token: 'x'.repeat(43), name: 'C:\\forged\\video.mp4' })); + +const capability = { token: 'x'.repeat(43), name: 'video.mp4', displayPath: 'C:\\private\\video.mp4' }; +assert.deepStrictEqual(requireFileCapability(capability), { token: 'x'.repeat(43), name: 'video.mp4' }); + +const request = createCutterExportRequest(capability, 'C:\\exports\\result.mp4', { trimStart: 1, trimEnd: 5, cuts: [] }); +assert.deepStrictEqual(request, { + inputCapability: 'x'.repeat(43), + outputName: 'result.mp4', + trimStart: 1, + trimEnd: 5, + cuts: [], +}); +assert.strictEqual('inputFile' in request, false); +assert.strictEqual('outputFile' in request, false); + +console.log('File capability contract tests passed.'); diff --git a/scripts/smoke-test-full.js b/scripts/smoke-test-full.js index d00afd1..bcea234 100644 --- a/scripts/smoke-test-full.js +++ b/scripts/smoke-test-full.js @@ -86,6 +86,14 @@ async function run() { const isolation = await verifyE2eIsolation(app, win, environment); const fixtures = await installOfflineFixtures(app); const issues = []; + const mergedOutputFile = path.join(environment.mediaDir, 'merged_full.mp4'); + await app.evaluate(({ dialog }, files) => { + dialog.showOpenDialog = async (_window, options) => ({ + canceled: false, + filePaths: options?.properties?.includes('multiSelections') ? [files.mediaA, files.mediaB] : [files.mediaA] + }); + dialog.showSaveDialog = async () => ({ canceled: false, filePath: files.mergedOutputFile }); + }, { mediaA, mediaB, mergedOutputFile }); win.on('pageerror', (err) => { issues.push(`pageerror: ${String(err)}`); @@ -206,7 +214,7 @@ async function run() { assert(deState.deActive, 'German language button did not activate'); assert(enState.enActive, 'English language button did not activate'); - await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir }); + await window.api.saveConfig({ client_id: '', client_secret: '' }); window.showTab('vods'); await window.selectStreamer('fixture_streamer'); @@ -310,16 +318,27 @@ async function run() { await clearQueue(); - const info = await window.api.getVideoInfo(mediaA); - const frame = await window.api.extractFrame(mediaA, 1); - const cut = await window.api.cutVideo(mediaA, 0.5, 1.7); - const merge = await window.api.mergeVideos([mediaA, mediaB], `${tmpDir.replace(/\\/g, '/')}/merged_full.mp4`); + const cutterInput = await window.api.selectVideoFile(); + const mergeInputs = await window.api.selectMultipleVideos(); + const mergeOutput = await window.api.saveVideoDialog('merged_full.mp4'); + const capabilityContract = Boolean( + cutterInput?.token + && mergeInputs?.length === 2 + && mergeInputs.every((file) => typeof file.token === 'string' && file.token) + && mergeOutput?.token + ); + const info = capabilityContract ? await window.api.getVideoInfo(cutterInput.token) : null; + const frame = capabilityContract ? await window.api.extractFrame(cutterInput.token, 1) : null; + const cut = capabilityContract ? await window.api.cutVideo(cutterInput.token, 0.5, 1.7) : { success: false }; + const merge = capabilityContract ? await window.api.mergeVideos(mergeInputs.map((file) => file.token), mergeOutput.token) : { success: false }; checks.media = { + capabilityContract, infoOk: !!info && info.duration > 0, frameOk: typeof frame === 'string' && frame.length > 100, cutOk: cut.success, mergeOk: merge.success }; + assert(checks.media.capabilityContract, 'File dialogs did not issue capability references'); assert(checks.media.infoOk, 'getVideoInfo failed for test media'); assert(checks.media.frameOk, 'extractFrame failed for test media'); assert(checks.media.cutOk, 'cutVideo failed for test media'); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index 777b6ce..a95bca7 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -176,19 +176,19 @@ async function run() { const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4'); fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8'); - await app.evaluate(({ ipcMain }) => { - globalThis.__workspaceCutterDropPaths = { media: '' }; + await app.evaluate(({ ipcMain }, expectedPath) => { + globalThis.__workspaceCutterDropPaths = { mediaCapability: '', expectedPath }; ipcMain.removeHandler('prepare-video-editor-media'); - ipcMain.handle('prepare-video-editor-media', (_, filePath) => { - globalThis.__workspaceCutterDropPaths.media = filePath; + ipcMain.handle('prepare-video-editor-media', (_, capability) => { + globalThis.__workspaceCutterDropPaths.mediaCapability = capability; return { - sourceUrl: encodeURI(`file:///${filePath.replace(/\\/g, '/')}`), + sourceUrl: encodeURI(`file:///${expectedPath.replace(/\\/g, '/')}`), info: { duration: 120, width: 1920, height: 1080, fps: 60, hasAudio: false }, thumbnails: ['data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='], waveform: null }; }); - }); + }, cutterDropFixturePath); await win.evaluate(() => { window.showTab('cutter'); const input = document.createElement('input'); @@ -200,7 +200,7 @@ async function run() { const cutterFileObject = await win.evaluate(() => { const input = document.getElementById('workspaceCutterDropInput'); const file = input instanceof HTMLInputElement ? input.files?.[0] : undefined; - if (!file) return { name: '', legacyPathType: 'missing', apiType: typeof window.api.getPathForFile }; + if (!file) return { name: '', legacyPathType: 'missing', apiType: typeof window.api.selectDroppedVideo }; const transfer = new DataTransfer(); transfer.items.add(file); document.getElementById('cutterTab')?.dispatchEvent(new DragEvent('drop', { @@ -211,7 +211,7 @@ async function run() { return { name: file.name, legacyPathType: typeof file.path, - apiType: typeof window.api.getPathForFile + apiType: typeof window.api.selectDroppedVideo }; }); await win.waitForTimeout(250); @@ -229,8 +229,9 @@ async function run() { ui: cutterDropUi }; check(cutterFileObject.legacyPathType === 'undefined', `Electron File.path is unexpectedly ${cutterFileObject.legacyPathType}`); - check(cutterDropUi.filePath === cutterDropFixturePath, `Cutter drop resolved "${cutterDropUi.filePath}" instead of the Electron file path`); - check(cutterDropPaths.media === cutterDropFixturePath, `Cutter drop sent "${cutterDropPaths.media}" to media preparation instead of the Electron file path`); + check(cutterFileObject.apiType === 'function', `Cutter capability API is ${cutterFileObject.apiType}`); + check(cutterDropUi.filePath === path.basename(cutterDropFixturePath), `Cutter drop displayed "${cutterDropUi.filePath}" instead of the safe file name`); + check(typeof cutterDropPaths.mediaCapability === 'string' && cutterDropPaths.mediaCapability.length >= 32 && cutterDropPaths.mediaCapability !== cutterDropFixturePath, `Cutter drop did not send an opaque capability to media preparation: ${JSON.stringify(cutterDropPaths)}`); check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls'); const queueEmptyActions = await win.evaluate(() => ({ diff --git a/src/main.ts b/src/main.ts index f6286cd..c13e740 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,12 +41,15 @@ import { getWindowsAppIdentity } from './main/domain/app-identity'; import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor'; import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export'; import { + CUTTER_SESSION_CAPABILITY_TTL_MS, FileCapabilityStore, isTrustedFileIpcSender, publishCapabilityOutput, type FileCapabilityPurpose, type FileCapabilityReference, } from './main/domain/file-capability'; +import { registerTrustedIpcHandler } from './main/domain/privileged-ipc'; +import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input'; import { setDebugLogFn, initToolDirs, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, @@ -7222,12 +7225,14 @@ ipcMain.handle('get-automation-status', () => ({ } })); -ipcMain.handle('trigger-auto-record-scan', async () => { +ipcMain.handle('trigger-auto-record-scan', async (event) => { + if (!isTrustedRendererEvent(event)) return { triggered: 0 }; const triggered = await runAutoRecordPoll(); return { triggered }; }); -ipcMain.handle('trigger-auto-vod-scan', async () => { +ipcMain.handle('trigger-auto-vod-scan', async (event) => { + if (!isTrustedRendererEvent(event)) return { queuedCount: 0 }; const queuedCount = await runAutoVodPoll(); return { queuedCount }; }); @@ -7317,7 +7322,8 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability return config; }); -ipcMain.handle('login', async () => { +ipcMain.handle('login', async (event) => { + if (!isTrustedRendererEvent(event)) return false; return await twitchLogin(); }); @@ -7335,7 +7341,8 @@ ipcMain.handle('get-queue', (event) => { return downloadQueue; }); -ipcMain.handle('start-live-recording', async (_, streamerName: string) => { +ipcMain.handle('start-live-recording', async (event, streamerName: string) => { + if (!isTrustedRendererEvent(event)) return { success: false, error: 'Access denied' }; if (typeof streamerName !== 'string' || !streamerName) { return { success: false, error: 'Invalid streamer name' }; } @@ -7379,7 +7386,9 @@ ipcMain.handle('start-live-recording', async (_, streamerName: string) => { return { success: true, streamer: login, title: liveInfo.title || login }; }); -ipcMain.handle('add-to-queue', (_, item: Omit) => { +registerTrustedIpcHandler(ipcMain, 'add-to-queue', isTrustedRendererEvent, () => downloadQueue, (_, input: unknown) => { + const item = createRendererQueueItem(input, generateQueueItemId()); + if (!item) return downloadQueue; if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { runtimeMetrics.duplicateSkips += 1; mainWindow?.webContents.send('queue-duplicate-skipped', { @@ -7395,19 +7404,14 @@ ipcMain.handle('add-to-queue', (_, item: Omit { +registerTrustedIpcHandler(ipcMain, 'remove-from-queue', isTrustedRendererEvent, () => Promise.resolve(downloadQueue), async (_, id: string) => { + if (typeof id !== 'string' || !id) return downloadQueue; const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id) || queueProcessRegistry.activeItemIds().includes(id); if (wasActiveItem) { @@ -7423,14 +7427,8 @@ ipcMain.handle('remove-from-queue', async (_, id: string) => { // Clean up merge-group temp files (must run for any merge group, not just active) const removedItem = downloadQueue.find(item => item.id === id); - if (removedItem?.mergeGroup) { - const mg = removedItem.mergeGroup; - for (const key of Object.keys(mg.downloadedFiles)) { - try { if (fs.existsSync(mg.downloadedFiles[Number(key)])) fs.unlinkSync(mg.downloadedFiles[Number(key)]); } catch { } - } - if (mg.mergedFile) { - try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { } - } + for (const cleanupPath of getMergeGroupCleanupPaths(removedItem)) { + try { if (fs.existsSync(cleanupPath)) fs.unlinkSync(cleanupPath); } catch { } } downloadQueue = downloadQueue.filter(item => item.id !== id); @@ -7439,14 +7437,16 @@ ipcMain.handle('remove-from-queue', async (_, id: string) => { return downloadQueue; }); -ipcMain.handle('clear-completed', () => { +ipcMain.handle('clear-completed', (event) => { + if (!isTrustedRendererEvent(event)) return downloadQueue; downloadQueue = downloadQueue.filter(item => item.status !== 'completed'); saveQueue(downloadQueue); emitQueueUpdated(); return downloadQueue; }); -ipcMain.handle('reorder-queue', (_, orderIds: string[]) => { +ipcMain.handle('reorder-queue', (event, orderIds: string[]) => { + if (!isTrustedRendererEvent(event) || !Array.isArray(orderIds)) return downloadQueue; const order = new Map(orderIds.map((id, idx) => [id, idx])); const withOrder = [...downloadQueue].sort((a, b) => { const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER; @@ -7460,7 +7460,8 @@ ipcMain.handle('reorder-queue', (_, orderIds: string[]) => { return downloadQueue; }); -ipcMain.handle('retry-failed-downloads', async () => { +ipcMain.handle('retry-failed-downloads', async (event) => { + if (!isTrustedRendererEvent(event)) return downloadQueue; const failedIds = downloadQueue.filter((item) => item.status === 'error').map((item) => item.id); await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id))); for (const id of failedIds) queueProcessRegistry.resetItem(id); @@ -7485,7 +7486,8 @@ ipcMain.handle('retry-failed-downloads', async () => { return downloadQueue; }); -ipcMain.handle('retry-queue-item', async (_, id: string) => { +ipcMain.handle('retry-queue-item', async (event, id: string) => { + if (!isTrustedRendererEvent(event)) return downloadQueue; if (typeof id !== 'string' || !id) return downloadQueue; const idx = downloadQueue.findIndex((it) => it.id === id); if (idx < 0) return downloadQueue; @@ -7513,7 +7515,8 @@ ipcMain.handle('retry-queue-item', async (_, id: string) => { return downloadQueue; }); -ipcMain.handle('create-merge-group', (_, itemIds: string[]) => { +ipcMain.handle('create-merge-group', (event, itemIds: string[]) => { + if (!isTrustedRendererEvent(event) || !Array.isArray(itemIds)) return downloadQueue; const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id)); if (selectedItems.length < 2) { @@ -7590,7 +7593,8 @@ ipcMain.handle('create-merge-group', (_, itemIds: string[]) => { return downloadQueue; }); -ipcMain.handle('start-download', async () => { +ipcMain.handle('start-download', async (event) => { + if (!isTrustedRendererEvent(event)) return false; if (isDownloading && queuePaused) { queuePaused = false; for (const item of downloadQueue) { @@ -7620,7 +7624,8 @@ ipcMain.handle('start-download', async () => { return true; }); -ipcMain.handle('pause-download', async () => { +ipcMain.handle('pause-download', async (event) => { + if (!isTrustedRendererEvent(event)) return false; if (!isDownloading || queuePaused) return false; queuePaused = true; @@ -7639,7 +7644,8 @@ ipcMain.handle('pause-download', async () => { return true; }); -ipcMain.handle('cancel-download', async () => { +ipcMain.handle('cancel-download', async (event) => { + if (!isTrustedRendererEvent(event)) return false; isDownloading = false; queuePaused = false; const activeItemIds = queueProcessRegistry.activeItemIds(); @@ -7652,8 +7658,8 @@ const fileCapabilities = new FileCapabilityStore(); const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi']; const knownRendererPaths = new Map>(); -function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = []): FileCapabilityReference { - return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions }); +function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = [], ttlMs?: number): FileCapabilityReference { + return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions, ttlMs }); } function resolveFileCapability(event: IpcMainInvokeEvent, token: string, purpose: FileCapabilityPurpose, consume = false, protectedPaths: string[] = []): string | null { @@ -7715,14 +7721,14 @@ ipcMain.handle('select-video-file', async (event) => { ] }); return result.filePaths[0] - ? issueFileCapability(event, 'cutter-input', result.filePaths[0], 'input-file', VIDEO_FILE_EXTENSIONS) + ? issueFileCapability(event, 'cutter-input', result.filePaths[0], 'input-file', VIDEO_FILE_EXTENSIONS, CUTTER_SESSION_CAPABILITY_TTL_MS) : null; }); ipcMain.handle('grant-dropped-video', (event, filePath: string): FileCapabilityReference | null => { if (!isTrustedRendererEvent(event)) return null; try { - return issueFileCapability(event, 'cutter-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS); + return issueFileCapability(event, 'cutter-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS, CUTTER_SESSION_CAPABILITY_TTL_MS); } catch { return null; } @@ -7780,7 +7786,8 @@ ipcMain.handle('show-in-folder', (event, capability: string): boolean => { ipcMain.handle('get-version', () => APP_VERSION); -ipcMain.handle('check-update', async () => { +ipcMain.handle('check-update', async (event) => { + if (!isTrustedRendererEvent(event)) return { error: true }; try { setupAutoUpdater(); const result = await requestUpdateCheck('manual', true); @@ -7797,7 +7804,8 @@ ipcMain.handle('check-update', async () => { } }); -ipcMain.handle('download-update', async () => { +ipcMain.handle('download-update', async (event) => { + if (!isTrustedRendererEvent(event)) return { error: true }; try { setupAutoUpdater(); const result = await requestUpdateDownload('manual'); @@ -7814,11 +7822,13 @@ ipcMain.handle('download-update', async () => { } }); -ipcMain.handle('install-update', () => { +ipcMain.handle('install-update', (event) => { + if (!isTrustedRendererEvent(event)) return; autoUpdater.quitAndInstall(true, true); }); -ipcMain.handle('open-external', async (_, url: string) => { +ipcMain.handle('open-external', async (event, url: string) => { + if (!isTrustedRendererEvent(event)) return; // Only allow https / http URLs — never let the renderer push a // file://, javascript:, or shell:-style URL through to the OS // shell.openExternal handler. The renderer is contextIsolated + @@ -7845,7 +7855,7 @@ interface ActiveClipDownloadTracking { } const activeClipProcesses = new Map(); -ipcMain.handle('download-clip', async (_, clipUrl: string) => { +registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () => Promise.resolve({ success: false, error: 'File access denied' }), async (_, clipUrl: string) => { let clipId = ''; const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/); const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/); @@ -7952,11 +7962,11 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => { }); }); -ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => { +registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () => Promise.resolve(null), async (_, autoFix: boolean = false) => { return await runPreflight(autoFix); }); -ipcMain.handle('get-debug-log', async (_, lines: number = 200) => { +registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => { // Cap so a misbehaving renderer (or future feature) cannot ask the // main process to slice millions of lines from a multi-MB log. const safeLines = Number.isFinite(lines) ? Math.max(1, Math.min(5000, Math.floor(lines))) : 200; @@ -8127,7 +8137,8 @@ ipcMain.handle('export-runtime-metrics', async (event) => { } }); -ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => { +ipcMain.handle('mark-vod-downloaded', (event, vodId: string, mark: boolean): { success: boolean } => { + if (!isTrustedRendererEvent(event)) return { success: false }; if (typeof vodId !== 'string' || !vodId) return { success: false }; if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; const has = config.downloaded_vod_ids.includes(vodId); @@ -8143,7 +8154,8 @@ ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { succe return { success: true }; }); -ipcMain.handle('reset-downloaded-vod-ids', () => { +ipcMain.handle('reset-downloaded-vod-ids', (event) => { + if (!isTrustedRendererEvent(event)) return { success: false, removedCount: 0 }; const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0; config.downloaded_vod_ids = []; saveConfig(config); diff --git a/src/main/domain/file-capability.test.ts b/src/main/domain/file-capability.test.ts index 5a49c12..b55e7c4 100644 --- a/src/main/domain/file-capability.test.ts +++ b/src/main/domain/file-capability.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { + CUTTER_SESSION_CAPABILITY_TTL_MS, FileCapabilityStore, isTrustedFileIpcSender, publishCapabilityOutput, @@ -50,6 +51,31 @@ describe('file capability boundary', () => { expect(() => store.resolve(expired.token, 7, 'chat-input')).toThrow('Expired file capability'); }); + it('keeps a purpose-bound cutter session usable after fifteen minutes without weakening owner or purpose checks', () => { + const fixture = createFixture(); + let now = 10_000; + const store = new FileCapabilityStore({ now: () => now, defaultTtlMs: 500 }); + const cutter = store.issue({ + ownerId: 7, + purpose: 'cutter-input', + path: fixture.video, + kind: 'input-file', + extensions: ['mp4'], + ttlMs: CUTTER_SESSION_CAPABILITY_TTL_MS, + }); + + now += 16 * 60 * 1000; + const assetInput = store.resolve(cutter.token, 7, 'cutter-input'); + const exportInput = store.resolve(cutter.token, 7, 'cutter-input'); + expect(assetInput).toBe(realpathSync.native(fixture.video)); + expect(exportInput).toBe(realpathSync.native(fixture.video)); + expect(() => store.resolve(cutter.token, 8, 'cutter-input')).toThrow('Invalid file capability owner'); + expect(() => store.resolve(cutter.token, 7, 'merge-input')).toThrow('Invalid file capability purpose'); + + now = 10_000 + 8 * 60 * 60 * 1000; + expect(() => store.resolve(cutter.token, 7, 'cutter-input')).toThrow('Expired file capability'); + }); + it('binds canonical input and output paths to the allowed extension and semantics', () => { const fixture = createFixture(); const store = new FileCapabilityStore(); diff --git a/src/main/domain/file-capability.ts b/src/main/domain/file-capability.ts index a49aff4..2713d08 100644 --- a/src/main/domain/file-capability.ts +++ b/src/main/domain/file-capability.ts @@ -17,6 +17,8 @@ export type FileCapabilityPurpose = export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory'; +export const CUTTER_SESSION_CAPABILITY_TTL_MS = 8 * 60 * 60 * 1000; + export interface FileCapabilityReference { token: string; name: string; diff --git a/src/main/domain/privileged-ipc.test.ts b/src/main/domain/privileged-ipc.test.ts new file mode 100644 index 0000000..0f78168 --- /dev/null +++ b/src/main/domain/privileged-ipc.test.ts @@ -0,0 +1,40 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { registerTrustedIpcHandler } from './privileged-ipc'; + +describe('privileged IPC behavior', () => { + const directories: string[] = []; + + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); + }); + + it.each(['add-to-queue', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])( + 'registers %s so an untrusted renderer event cannot execute it', + async (channel) => { + const directory = mkdtempSync(join(tmpdir(), 'tvm-privileged-ipc-')); + directories.push(directory); + const marker = join(directory, `${channel}.txt`); + const handlers = new Map unknown>(); + registerTrustedIpcHandler( + { handle: (registeredChannel, handler) => handlers.set(registeredChannel, handler) }, + channel, + (event: { trusted: boolean }) => event.trusted, + () => ({ denied: true }), + async () => { + writeFileSync(marker, channel); + return { denied: false }; + }, + ); + const handler = handlers.get(channel); + + expect(handler).toBeTypeOf('function'); + expect(await handler?.({ trusted: false })).toEqual({ denied: true }); + expect(() => readFileSync(marker, 'utf8')).toThrow(); + expect(await handler?.({ trusted: true })).toEqual({ denied: false }); + expect(readFileSync(marker, 'utf8')).toBe(channel); + }, + ); +}); diff --git a/src/main/domain/privileged-ipc.ts b/src/main/domain/privileged-ipc.ts new file mode 100644 index 0000000..29deb79 --- /dev/null +++ b/src/main/domain/privileged-ipc.ts @@ -0,0 +1,17 @@ +export function createTrustedIpcHandler( + isTrusted: (event: TEvent) => boolean, + deniedResult: () => TDenied, + handler: (event: TEvent, ...args: TArgs) => TResult, +): (event: TEvent, ...args: TArgs) => TResult | TDenied { + return (event, ...args) => isTrusted(event) ? handler(event, ...args) : deniedResult(); +} + +export function registerTrustedIpcHandler( + registrar: { handle(channel: string, handler: (event: TEvent, ...args: TArgs) => TResult | TDenied): void }, + channel: string, + isTrusted: (event: TEvent) => boolean, + deniedResult: () => TDenied, + handler: (event: TEvent, ...args: TArgs) => TResult, +): void { + registrar.handle(channel, createTrustedIpcHandler(isTrusted, deniedResult, handler)); +} diff --git a/src/main/domain/renderer-queue-input.test.ts b/src/main/domain/renderer-queue-input.test.ts new file mode 100644 index 0000000..32e2269 --- /dev/null +++ b/src/main/domain/renderer-queue-input.test.ts @@ -0,0 +1,90 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createRendererQueueItem, getMergeGroupCleanupPaths, normalizeRendererQueueInput } from './renderer-queue-input'; + +describe('renderer queue input', () => { + it('keeps only validated renderer-owned queue fields', () => { + const directory = mkdtempSync(join(tmpdir(), 'tvm-renderer-queue-')); + const victim = join(directory, 'important.txt'); + writeFileSync(victim, 'keep'); + const normalized = normalizeRendererQueueInput({ + id: 'forged-id', + status: 'completed', + progress: 100, + url: 'https://www.twitch.tv/videos/123456789', + title: 'Fixture title', + date: '2026-08-11T20:00:00.000Z', + streamer: 'fixture_streamer', + duration_str: '1h2m3s', + outputFiles: ['C:\\Users\\victim\\important.txt'], + mergeGroup: { + items: [], + mergePhase: 'done', + currentItemIndex: 0, + downloadedFiles: { 0: 'C:\\Users\\victim\\important.txt' }, + mergedFile: 'C:\\Users\\victim\\another-important.txt', + }, + isLive: true, + recordingHealth: 'ok', + last_error: 'forged', + }); + + expect(normalized).toEqual({ + url: 'https://www.twitch.tv/videos/123456789', + title: 'Fixture title', + date: '2026-08-11T20:00:00.000Z', + streamer: 'fixture_streamer', + duration_str: '1h2m3s', + }); + expect(JSON.stringify(normalized)).not.toContain('important.txt'); + const queueItem = createRendererQueueItem({ + ...normalized, + mergeGroup: { downloadedFiles: { 0: victim }, mergedFile: victim }, + }, 'main-owned-id'); + for (const cleanupPath of getMergeGroupCleanupPaths(queueItem ?? undefined)) rmSync(cleanupPath, { force: true }); + expect(existsSync(victim)).toBe(true); + rmSync(directory, { recursive: true, force: true }); + }); + + it('normalizes a valid custom clip without accepting extra fields', () => { + const normalized = normalizeRendererQueueInput({ + url: 'https://www.twitch.tv/videos/123456789', + title: 'Fixture title', + date: '2026-08-11T20:00:00.000Z', + streamer: 'fixture_streamer', + duration_str: '1h2m3s', + customClip: { + startSec: 12.5, + durationSec: 30, + startPart: 1, + filenameFormat: 'template', + filenameTemplate: '{date}_{title}', + mergedFile: 'C:\\forged.mp4', + }, + }); + + expect(normalized?.customClip).toEqual({ + startSec: 12.5, + durationSec: 30, + startPart: 1, + filenameFormat: 'template', + filenameTemplate: '{date}_{title}', + }); + }); + + it('rejects malformed queue requests before they can enter persistent state', () => { + expect(normalizeRendererQueueInput(null)).toBeNull(); + expect(normalizeRendererQueueInput({ url: 'file:///C:/victim.txt', title: 'x', date: 'x', streamer: 'x', duration_str: '1s' })).toBeNull(); + expect(normalizeRendererQueueInput({ url: 'https://www.twitch.tv/videos/1', title: '', date: 'x', streamer: 'x', duration_str: '1s' })).toBeNull(); + expect(normalizeRendererQueueInput({ + url: 'https://www.twitch.tv/videos/1', + title: 'x', + date: 'x', + streamer: 'x', + duration_str: '1s', + customClip: { startSec: -1, durationSec: 10, startPart: 1, filenameFormat: 'simple' }, + })).toBeNull(); + }); +}); diff --git a/src/main/domain/renderer-queue-input.ts b/src/main/domain/renderer-queue-input.ts new file mode 100644 index 0000000..797d201 --- /dev/null +++ b/src/main/domain/renderer-queue-input.ts @@ -0,0 +1,67 @@ +import type { CustomClip, QueueItem } from '../../types'; + +export type RendererQueueInput = Pick & { customClip?: CustomClip }; + +function normalizedString(value: unknown, maximumLength: number): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized && normalized.length <= maximumLength ? normalized : null; +} + +function normalizeCustomClip(value: unknown): CustomClip | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record; + if (!Number.isFinite(raw.startSec) || Number(raw.startSec) < 0) return null; + if (!Number.isFinite(raw.durationSec) || Number(raw.durationSec) <= 0 || Number(raw.durationSec) > 24 * 60 * 60) return null; + if (!Number.isInteger(raw.startPart) || Number(raw.startPart) < 1 || Number(raw.startPart) > 100000) return null; + if (!['simple', 'timestamp', 'template', 'parts'].includes(String(raw.filenameFormat))) return null; + const filenameFormat = raw.filenameFormat as CustomClip['filenameFormat']; + const filenameTemplate = raw.filenameTemplate === undefined + ? undefined + : normalizedString(raw.filenameTemplate, 500); + if (raw.filenameTemplate !== undefined && !filenameTemplate) return null; + if (filenameFormat === 'template' && !filenameTemplate) return null; + return { + startSec: Number(raw.startSec), + durationSec: Number(raw.durationSec), + startPart: Number(raw.startPart), + filenameFormat, + ...(filenameTemplate ? { filenameTemplate } : {}), + }; +} + +export function normalizeRendererQueueInput(value: unknown): RendererQueueInput | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record; + const url = normalizedString(raw.url, 2048); + const title = normalizedString(raw.title, 500); + const date = normalizedString(raw.date, 100); + const streamer = normalizedString(raw.streamer, 25); + const duration = normalizedString(raw.duration_str, 64); + if (!url || !/^https:\/\/(?:www\.)?twitch\.tv\/videos\/\d+(?:[/?#].*)?$/i.test(url)) return null; + if (!title || !date || !streamer || !/^[a-z0-9_]+$/i.test(streamer) || !duration) return null; + const customClip = raw.customClip === undefined ? undefined : normalizeCustomClip(raw.customClip); + if (raw.customClip !== undefined && !customClip) return null; + return { + url, + title, + date, + streamer: streamer.toLowerCase(), + duration_str: duration, + ...(customClip ? { customClip } : {}), + }; +} + +export function createRendererQueueItem(value: unknown, id: string): QueueItem | null { + const input = normalizeRendererQueueInput(value); + if (!input || !id) return null; + return { ...input, id, status: 'pending', progress: 0 }; +} + +export function getMergeGroupCleanupPaths(item: QueueItem | undefined): string[] { + if (!item?.mergeGroup) return []; + return [ + ...Object.values(item.mergeGroup.downloadedFiles), + ...(item.mergeGroup.mergedFile ? [item.mergeGroup.mergedFile] : []), + ]; +} diff --git a/src/preload.ts b/src/preload.ts index 6c3168c..7b92639 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -112,7 +112,7 @@ contextBridge.exposeInMainWorld('api', { // Queue getQueue: () => ipcRenderer.invoke('get-queue'), - addToQueue: (item: Omit) => ipcRenderer.invoke('add-to-queue', item), + addToQueue: (item: Pick) => ipcRenderer.invoke('add-to-queue', item), startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 71b7635..0086009 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -374,7 +374,7 @@ interface ApiBridge { getUserId(username: string): Promise; getVODs(userId: string, forceRefresh?: boolean): Promise; getQueue(): Promise; - addToQueue(item: Omit): Promise; + addToQueue(item: Pick): Promise; startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>; removeFromQueue(id: string): Promise; reorderQueue(orderIds: string[]): Promise;