import { MediaCommandBuilder, type Demuxer } from "./command"; import { spawnSandbox, type SandboxChild, type SandboxOptions } from "./runtime"; export interface ProbeResult { format: { format_name?: string; duration?: string; size?: string }; streams: Array<{ index: number; codec_type: "video" | "audio" | "subtitle" | "data" | "attachment"; codec_name?: string; width?: number; height?: number; sample_rate?: string; channels?: number }>; } const fail = (): never => { throw new Error("invalid probe output"); }; function record(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) fail(); return value as Record; } function keys(value: Record, allowed: string[]): void { if (Object.keys(value).some(key => !allowed.includes(key))) fail(); } export function validateProbe(value: unknown): ProbeResult { const root = record(value); keys(root, ["format", "streams"]); const format = record(root.format); keys(format, ["format_name", "duration", "size"]); for (const [k, v] of Object.entries(format)) { if (typeof v !== "string" || v.length > 128 || !(k === "format_name" ? /^[a-z0-9_,]+$/ : /^\d+(?:\.\d+)?$/).test(v)) fail(); if (k !== "format_name" && !Number.isFinite(Number(v))) fail(); } if (!Array.isArray(root.streams) || root.streams.length > 64) fail(); const indices = new Set(); for (const raw of root.streams as unknown[]) { const stream = record(raw); keys(stream, ["index", "codec_type", "codec_name", "width", "height", "sample_rate", "channels"]); if (!Number.isSafeInteger(stream.index) || Number(stream.index) < 0 || indices.has(Number(stream.index))) fail(); indices.add(Number(stream.index)); if (!["video", "audio", "subtitle", "data", "attachment"].includes(String(stream.codec_type))) fail(); for (const k of ["width", "height", "channels"]) { if (stream[k] !== undefined && (!Number.isSafeInteger(stream[k]) || Number(stream[k]) <= 0 || Number(stream[k]) > (k === "channels" ? 64 : 16384))) fail(); } if (stream.codec_name !== undefined && (typeof stream.codec_name !== "string" || !/^[a-z0-9_]{1,128}$/.test(stream.codec_name))) fail(); if (stream.sample_rate !== undefined && (typeof stream.sample_rate !== "string" || !/^\d{1,7}$/.test(stream.sample_rate) || Number(stream.sample_rate) > 768000)) fail(); } return value as ProbeResult; } /** Exported collector allows testing hostile/oversized output using a real child. */ export async function collectProbe(job: SandboxChild, maxBytes = 256 * 1024, onLog: (line: string) => void = () => undefined): Promise { if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1024 * 1024) { job.kill(); throw new Error("invalid probe output cap"); } let size = 0; let oversized = false; const chunks: Buffer[] = []; job.child.stdout?.on("data", (chunk: Buffer) => { size += chunk.length; if (size > maxBytes) { oversized = true; job.kill(); return; } chunks.push(chunk); }); job.child.stderr?.on("data", () => onLog("media probe stderr suppressed")); let spawnFailed = false; job.child.once("error", () => { spawnFailed = true; }); const result = await job.exited; if (oversized) throw new Error("probe output limit exceeded"); if (spawnFailed || result.code !== 0) throw new Error("media probe failed"); let parsed: unknown; try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return fail(); } return validateProbe(parsed); } export function probe(assetId: string, format: Demuxer, options: SandboxOptions, onLog?: (line: string) => void): Promise { if (!options.asset || options.asset.id !== assetId) throw new Error("probe asset mapping required"); return collectProbe(spawnSandbox(new MediaCommandBuilder().probe(assetId, format), options, { stdio: ["ignore", "pipe", "pipe"] }), undefined, onLog); }