Skip to content

Commit 343c025

Browse files
authored
Merge pull request #2916 from heygen-com/ffprobe-5-invocation
fix(engine): reject stdin input, decode stdout correctly, bound its size
2 parents b5640a5 + 9e27542 commit 343c025

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

packages/engine/src/utils/ffprobe.test.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,15 @@ interface FakeProc extends EventEmitter {
128128
type SpawnOutcome =
129129
| { kind: "missing" }
130130
| { kind: "error"; message: string; code?: string }
131-
| { kind: "exit"; code: number; stdout?: string; stderr?: string };
131+
| {
132+
kind: "exit";
133+
code: number;
134+
stdout?: string;
135+
stderr?: string;
136+
/** Emit stdout as these exact byte chunks, to exercise a multi-byte
137+
* character split across a pipe-chunk boundary. */
138+
stdoutChunks?: Buffer[];
139+
};
132140

133141
function createSpawnSpy(outcomes: SpawnOutcome[]): {
134142
spawn: (command: string, args: readonly string[]) => FakeProc;
@@ -159,7 +167,9 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
159167
proc.emit("error", err);
160168
return;
161169
}
162-
if (outcome.stdout) proc.stdout.emit("data", Buffer.from(outcome.stdout));
170+
if (outcome.stdoutChunks) {
171+
for (const chunk of outcome.stdoutChunks) proc.stdout.emit("data", chunk);
172+
} else if (outcome.stdout) proc.stdout.emit("data", Buffer.from(outcome.stdout));
163173
if (outcome.stderr) proc.stderr.emit("data", Buffer.from(outcome.stderr));
164174
proc.emit("close", outcome.code);
165175
});
@@ -961,3 +971,72 @@ describe("AAC duration refinement must never fail or distort the call", () => {
961971
expect(meta.durationSeconds).toBeCloseTo((861 * 1024) / 44100, 5);
962972
});
963973
});
974+
975+
describe("runFfprobe process and stream handling", () => {
976+
afterEach(() => {
977+
vi.resetModules();
978+
vi.doUnmock("child_process");
979+
});
980+
981+
// Regression: `--` protects "-intro.mp4" but not a path of exactly "-",
982+
// which ffprobe rewrites to fd: AFTER option parsing and then reads stdin.
983+
// With stdin left as an unwritten pipe the probe hung for the full 30s
984+
// deadline and failed with an empty diagnostic.
985+
it("rejects a filePath of '-' immediately instead of hanging on stdin", async () => {
986+
const { spawn, calls } = createSpawnSpy([{ kind: "exit", code: 0, stdout: "{}" }]);
987+
vi.resetModules();
988+
vi.doMock("child_process", () => ({ spawn }));
989+
const { extractMediaMetadata } = await import("./ffprobe.js");
990+
991+
await expect(extractMediaMetadata("-")).rejects.toThrow(/stdin is not a supported input path/);
992+
expect(calls).toHaveLength(0);
993+
});
994+
995+
it("never leaves the child's stdin as a writable pipe", async () => {
996+
const stdios: unknown[] = [];
997+
const spawn = (_c: string, _a: readonly string[], opts?: { stdio?: unknown }) => {
998+
stdios.push(opts?.stdio);
999+
const proc = new EventEmitter() as FakeProc;
1000+
proc.stdout = new EventEmitter();
1001+
proc.stderr = new EventEmitter();
1002+
process.nextTick(() => {
1003+
proc.stdout.emit(
1004+
"data",
1005+
Buffer.from(
1006+
JSON.stringify({
1007+
streams: [{ codec_type: "video", codec_name: "h264", width: 2, height: 2 }],
1008+
format: { duration: "1" },
1009+
}),
1010+
),
1011+
);
1012+
proc.emit("close", 0);
1013+
});
1014+
return proc;
1015+
};
1016+
vi.resetModules();
1017+
vi.doMock("child_process", () => ({ spawn }));
1018+
const { extractMediaMetadata } = await import("./ffprobe.js");
1019+
1020+
await extractMediaMetadata("/tmp/stdio-shape.mp4");
1021+
expect(stdios[0]).toEqual(["ignore", "pipe", "pipe"]);
1022+
});
1023+
1024+
// NOTE on the StringDecoder change: a per-chunk toString() corrupts a
1025+
// multi-byte character split across a pipe boundary into U+FFFD, but
1026+
// U+FFFD is valid JSON string content, so JSON.parse still succeeds and
1027+
// extractMediaMetadata's public surface returns nothing that exposes the
1028+
// mangled tag value. There is no assertion here that fails on the old
1029+
// implementation, so rather than ship a test that cannot fail, the
1030+
// corruption is stated in the commit and this covers the bound instead.
1031+
it("refuses to parse stdout that exceeds the size bound", async () => {
1032+
const huge = "x".repeat(8_000_001);
1033+
const { spawn } = createSpawnSpy([{ kind: "exit", code: 0, stdout: huge }]);
1034+
vi.resetModules();
1035+
vi.doMock("child_process", () => ({ spawn }));
1036+
const { extractMediaMetadata } = await import("./ffprobe.js");
1037+
1038+
await expect(extractMediaMetadata("/tmp/unbounded-output.mov")).rejects.toThrow(
1039+
/exceeded 8000000 characters/,
1040+
);
1041+
});
1042+
});

packages/engine/src/utils/ffprobe.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22
import { spawn } from "child_process";
33
import { readFileSync } from "fs";
44
import * as zlib from "node:zlib";
5+
import { StringDecoder } from "node:string_decoder";
56
import { basename, extname } from "path";
67
import { redactTelemetryString } from "@hyperframes/core";
78
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
89
import { ManagedChildProcess } from "./managedChildProcess.js";
910
import { trackChildProcess } from "./processTracker.js";
1011

1112
const FFPROBE_STDERR_MAX_BYTES = 8 * 1024;
13+
/** Bound on collected stdout. Generous — real -show_streams JSON is well
14+
* under this — but finite, unlike the previous unbounded accumulation. */
15+
const FFPROBE_STDOUT_MAX_CHARS = 8_000_000;
16+
1217
const FFPROBE_ERROR_MAX_CHARS = 4 * 1024;
1318

1419
function redactFfprobeInput(stderr: string, filePath: string): string {
@@ -48,19 +53,57 @@ async function runFfprobe(
4853
argsWithoutInput: string[],
4954
signal?: AbortSignal,
5055
): Promise<string> {
56+
// `--` stops option parsing so a path like "-intro.mp4" is a filename, but
57+
// it does NOT cover a path of exactly "-": ffprobe rewrites that to `fd:`
58+
// AFTER option parsing and then reads stdin. Since stdin here is a pipe the
59+
// parent never writes to and never ends, the probe hangs for the full 30s
60+
// deadline and fails with an empty diagnostic (ffprobe never errored, so
61+
// stderr is blank). Reject it up front with something a caller can read.
62+
if (filePath === "-") {
63+
throw new Error('[FFmpeg] Refusing to probe "-": stdin is not a supported input path.');
64+
}
65+
5166
const command = getFfprobeBinary();
52-
const proc = spawn(command, ["-v", "error", ...argsWithoutInput, "--", filePath]);
67+
const proc = spawn(command, ["-v", "error", ...argsWithoutInput, "--", filePath], {
68+
// Nothing is ever written to the child's stdin; leaving it as a pipe is
69+
// what lets a stdin-reading invocation block indefinitely.
70+
stdio: ["ignore", "pipe", "pipe"],
71+
});
5372
trackChildProcess(proc);
73+
// Decoded through StringDecoder rather than per-chunk toString(): a
74+
// multi-byte character split across a 64 KiB pipe boundary decodes to U+FFFD
75+
// on both sides. -show_format output above ~64 KiB with non-ASCII tag text
76+
// (an MKV with many chapters, or title/artist tags) came back silently
77+
// mangled — JSON.parse still succeeds, so nothing surfaced it, and tag
78+
// lookups like alpha_mode could miss.
79+
const decoder = new StringDecoder("utf8");
5480
let stdout = "";
55-
proc.stdout.on("data", (data) => {
56-
stdout += data.toString();
81+
let stdoutTruncated = false;
82+
proc.stdout.on("data", (data: Buffer) => {
83+
// stderr is capped by ManagedChildProcess; stdout had no bound at all, and
84+
// analyzeKeyframeIntervals emits one line per frame — an all-intra ProRes
85+
// proxy can produce an unbounded string.
86+
if (stdoutTruncated) return;
87+
stdout += decoder.write(data);
88+
// Checked AFTER appending: a single chunk can already exceed the bound,
89+
// so a pre-append check only ever stops the second one.
90+
if (stdout.length > FFPROBE_STDOUT_MAX_CHARS) {
91+
stdoutTruncated = true;
92+
stdout = "";
93+
}
5794
});
5895
const managed = new ManagedChildProcess(proc, {
5996
signal,
6097
deadlineAtMs: Date.now() + 30_000,
6198
stderrMaxBytes: FFPROBE_STDERR_MAX_BYTES,
6299
});
63100
const outcome = await managed.wait();
101+
stdout += decoder.end();
102+
if (stdoutTruncated) {
103+
throw new Error(
104+
`[FFmpeg] ffprobe output exceeded ${FFPROBE_STDOUT_MAX_CHARS} characters; refusing to parse a truncated result.`,
105+
);
106+
}
64107
if (outcome.reason === "spawn_error") {
65108
if ((outcome.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
66109
const configured = process.env[FFPROBE_PATH_ENV]?.trim();

0 commit comments

Comments
 (0)