fix(security): harden privileged renderer boundaries
Reject renderer-owned queue internals before persistence and bind privileged handlers to trusted renderer events. Extend cutter session capabilities without weakening owner, purpose, path identity, or expiry checks. Migrate release harness contracts to opaque capabilities and add an invisible Node gate.
This commit is contained in:
+54
-42
@@ -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<Config>, 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<QueueItem, 'id' | 'status' | 'progress'>) => {
|
||||
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<QueueItem, 'id' | 'status' | 'prog
|
||||
return downloadQueue;
|
||||
}
|
||||
|
||||
const queueItem: QueueItem = {
|
||||
...item,
|
||||
id: generateQueueItemId(),
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
};
|
||||
downloadQueue.push(queueItem);
|
||||
downloadQueue.push(item);
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('remove-from-queue', async (_, id: string) => {
|
||||
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<FileCapabilityPurpose, Set<string>>();
|
||||
|
||||
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<string, ActiveClipDownloadTracking>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, (event: { trusted: boolean }) => 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function createTrustedIpcHandler<TEvent, TArgs extends unknown[], TResult, TDenied>(
|
||||
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<TEvent, TArgs extends unknown[], TResult, TDenied>(
|
||||
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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CustomClip, QueueItem } from '../../types';
|
||||
|
||||
export type RendererQueueInput = Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str'> & { 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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] : []),
|
||||
];
|
||||
}
|
||||
+1
-1
@@ -112,7 +112,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
addToQueue: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>) => 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),
|
||||
|
||||
Vendored
+1
-1
@@ -374,7 +374,7 @@ interface ApiBridge {
|
||||
getUserId(username: string): Promise<string | null>;
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
getQueue(): Promise<QueueItem[]>;
|
||||
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
||||
addToQueue(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueItem[]>;
|
||||
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
|
||||
Reference in New Issue
Block a user