Skip to content

Commit acf518c

Browse files
committed
fix: verify preserved recording packets with one complete decode
1 parent c6c9fe8 commit acf518c

5 files changed

Lines changed: 928 additions & 100 deletions

File tree

apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ describe("media routes real-world integration tests", () => {
550550
recordingVerification,
551551
"inspectRecordingSources",
552552
);
553-
const localDecode = spyOn(recordingVerification, "verifyRecording");
553+
const localDecode = spyOn(recordingVerification, "verifyRemuxedRecording");
554554
const remoteDecode = spyOn(recordingVerification, "verifyRemoteRecording");
555555
const bytesOnly = spyOn(
556556
recordingVerification,
@@ -574,7 +574,7 @@ describe("media routes real-world integration tests", () => {
574574
expect(job.attemptId).toBe(body.attemptId);
575575
expect(job.inventorySha256).toBe(body.inventorySha256);
576576
expect(job.metadata?.duration).toBeCloseTo(1, 3);
577-
expect(sourceDecode).toHaveBeenCalledTimes(1);
577+
expect(sourceDecode).not.toHaveBeenCalled();
578578
expect(localDecode).toHaveBeenCalledTimes(1);
579579
expect(remoteDecode).not.toHaveBeenCalled();
580580
expect(bytesOnly).toHaveBeenCalledTimes(1);

apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { join } from "node:path";
1414
import { Readable } from "node:stream";
1515
import { EncodedPacketSink, FilePathSource, Input, MP4 } from "mediabunny";
1616
import { muxMediaTracksToMp4 } from "../../lib/media-video";
17+
import { proveRecordingPackets } from "../../lib/recording-packet-proof";
1718
import {
1819
RecordingTimingError,
1920
readRecordingVideoTiming,
@@ -24,6 +25,7 @@ import {
2425
verifyRecording,
2526
verifyRemoteRecording,
2627
verifyRemoteRecordingBytes,
28+
verifyRemuxedRecording,
2729
} from "../../lib/recording-verification";
2830

2931
const FIXTURES = join(import.meta.dir, "..", "fixtures");
@@ -486,6 +488,158 @@ afterAll(async () => {
486488
if (directory) await rm(directory, { recursive: true, force: true });
487489
});
488490

491+
describe("encoded recording preservation", () => {
492+
test("verifies looped AAC through decoded evidence when edit-list rounding changes the tail", async () => {
493+
const input = join(directory, "looped-audio.mp4");
494+
const output = join(directory, "looped-audio-remux.mp4");
495+
await run([
496+
"ffmpeg",
497+
"-v",
498+
"error",
499+
"-stream_loop",
500+
"2",
501+
"-i",
502+
silent,
503+
"-c",
504+
"copy",
505+
input,
506+
]);
507+
await muxMediaTracksToMp4(input, input, output);
508+
const verified = await verifyRemuxedRecording(input, input, output, {
509+
requireAudio: true,
510+
});
511+
expect(verified.fullDecode).toBe(true);
512+
expect(verified.sourcePreserved).toBe(true);
513+
const source = await inspectRecordingSources(input, input);
514+
expect(verified.audio).toEqual(source.audio);
515+
expect(verified.video).toEqual(source.video);
516+
});
517+
test("refuses identical corrupt packets rather than treating preservation as decodability", async () => {
518+
await expect(
519+
verifyRemuxedRecording(corruptTail, corruptTail, corruptTail, {
520+
requireAudio: true,
521+
}),
522+
).rejects.toThrow();
523+
});
524+
test("binds backward presentation timestamps without changing the recording", async () => {
525+
const samples = bFrameSamples.map((sample) => ({ ...sample }));
526+
samples[13].pts -= 6000;
527+
const input = join(directory, "packet-backward-pts.mp4");
528+
await writeFile(
529+
input,
530+
Buffer.concat([
531+
bFrameInit,
532+
...samples.map((sample, index) => sampleFragment([sample], index + 1)),
533+
]),
534+
);
535+
const output = join(directory, "packet-backward-output.mp4");
536+
await muxMediaTracksToMp4(input, null, output);
537+
const result = await verifyRemuxedRecording(input, null, output, {
538+
requireAudio: false,
539+
});
540+
expect(result.sourcePreserved).toBe(true);
541+
expect(result.video.frameCount).toBe(samples.length);
542+
expect(result.integrity).toBeUndefined();
543+
});
544+
test("applies one deadline to packet inspection and decode", async () => {
545+
await expect(
546+
verifyRemuxedRecording(silent, silent, silent, {
547+
requireAudio: true,
548+
timeoutMs: 1,
549+
}),
550+
).rejects.toThrow();
551+
expect(await decoderPids(silent)).toEqual([]);
552+
});
553+
554+
test.each([true, false])(
555+
"decodes preserved packets once with audio=%s",
556+
async (audio) => {
557+
const input = join(
558+
FIXTURES,
559+
audio ? "test-with-audio.mp4" : "test-no-audio.mp4",
560+
);
561+
const output = join(directory, `packet-proof-${audio}.mp4`);
562+
await muxMediaTracksToMp4(input, audio ? input : null, output);
563+
await proveRecordingPackets(
564+
input,
565+
audio ? input : null,
566+
output,
567+
AbortSignal.timeout(5000),
568+
);
569+
const verified = await verifyRemuxedRecording(
570+
input,
571+
audio ? input : null,
572+
output,
573+
{ requireAudio: audio },
574+
);
575+
expect(verified.fullDecode).toBe(true);
576+
expect(verified.sourcePreserved).toBe(true);
577+
expect(Boolean(verified.audio)).toBe(audio);
578+
expect(verified.integrity).toBeUndefined();
579+
},
580+
);
581+
test("rejects changed source bytes", async () => {
582+
await expect(
583+
proveRecordingPackets(
584+
silent,
585+
silent,
586+
corruptTail,
587+
AbortSignal.timeout(5000),
588+
),
589+
).rejects.toThrow();
590+
await expect(
591+
verifyRemuxedRecording(silent, silent, corruptTail, {
592+
requireAudio: true,
593+
}),
594+
).rejects.toThrow();
595+
});
596+
test("retains the strict fallback for packet transformations", async () => {
597+
const output = join(directory, "proof-short-audio.mp4");
598+
await muxMediaTracksToMp4(silent, shortAudio, output);
599+
const verified = await verifyRemuxedRecording(silent, shortAudio, output, {
600+
requireAudio: true,
601+
});
602+
expect(verified.sourcePreserved).toBe(true);
603+
});
604+
test("rejects shifted audio despite identical encoded content", async () => {
605+
const output = join(directory, "proof-shifted-audio.mp4");
606+
await run([
607+
"ffmpeg",
608+
"-v",
609+
"error",
610+
"-i",
611+
silent,
612+
"-itsoffset",
613+
"0.5",
614+
"-i",
615+
silent,
616+
"-map",
617+
"0:v:0",
618+
"-map",
619+
"1:a:0",
620+
"-c",
621+
"copy",
622+
output,
623+
]);
624+
await expect(
625+
proveRecordingPackets(silent, silent, output, AbortSignal.timeout(5000)),
626+
).rejects.toThrow();
627+
await expect(
628+
verifyRemuxedRecording(silent, silent, output, { requireAudio: true }),
629+
).rejects.toThrow();
630+
});
631+
test("does not start cancelled packet verification", async () => {
632+
const controller = new AbortController();
633+
controller.abort();
634+
await expect(
635+
verifyRemuxedRecording(silent, silent, silent, {
636+
requireAudio: true,
637+
abortSignal: controller.signal,
638+
}),
639+
).rejects.toThrow();
640+
});
641+
});
642+
489643
describe("complete recording decode", () => {
490644
test.each([
491645
{ audio: true, hasAudio: true },
@@ -640,6 +794,7 @@ describe("complete recording decode", () => {
640794
requireAudio: false,
641795
timeoutMs: 110_000,
642796
});
797+
const stockElapsed = performance.now() - stockStarted;
643798
expect(stock.integrity?.video.contentSha256).toBe(
644799
source.integrity.video.contentSha256,
645800
);
@@ -648,8 +803,23 @@ describe("complete recording decode", () => {
648803
);
649804
expect(source.video).toEqual(stock.video);
650805
expect(source.audio).toEqual(stock.audio);
806+
const efficientStarted = performance.now();
807+
const efficient = await verifyRemuxedRecording(input, input, input, {
808+
requireAudio: true,
809+
timeoutMs: 30_000,
810+
});
811+
const efficientElapsed = performance.now() - efficientStarted;
812+
expect(efficient.sourcePreserved).toBe(true);
813+
expect(efficient.integrity).toBeUndefined();
814+
expect(efficient.video).toEqual(source.video);
815+
expect(efficient.audio).toEqual(source.audio);
816+
expect(efficientElapsed).toBeLessThan(30_000);
817+
console.info(
818+
`Packet-bound full verification: ${efficientElapsed.toFixed(0)} ms`,
819+
);
820+
651821
console.info(
652-
`Recording decode: ${elapsed.toFixed(0)} ms, stock ${(performance.now() - stockStarted).toFixed(0)} ms, ${((sourcePeak - baseline) / 1_024 / 1_024).toFixed(1)} MiB peak RSS increase`,
822+
`Recording decode: ${elapsed.toFixed(0)} ms, stock ${stockElapsed.toFixed(0)} ms, ${((sourcePeak - baseline) / 1_024 / 1_024).toFixed(1)} MiB peak RSS increase`,
653823
);
654824
} finally {
655825
clearInterval(memory);
@@ -2153,6 +2323,68 @@ function objectResponse(
21532323
}
21542324

21552325
describe("remote recording object identity", () => {
2326+
test("resumes interrupted byte verification without rereading the verified prefix", async () => {
2327+
const identity = '"resume-object"';
2328+
let reads = 0;
2329+
let sent = 0;
2330+
const server = Bun.serve({
2331+
hostname: "127.0.0.1",
2332+
port: 0,
2333+
fetch(request) {
2334+
if (request.method === "HEAD")
2335+
return new Response(null, {
2336+
headers: {
2337+
ETag: identity,
2338+
"Content-Length": String(silentBytes.length),
2339+
},
2340+
});
2341+
const range = request.headers.get("range");
2342+
if (range === "bytes=0-0")
2343+
return new Response(silentBytes.slice(0, 1), {
2344+
status: 206,
2345+
headers: {
2346+
ETag: identity,
2347+
"Content-Range": `bytes 0-0/${silentBytes.length}`,
2348+
},
2349+
});
2350+
expect(request.headers.get("if-match")).toBe(identity);
2351+
reads++;
2352+
if (reads === 1) {
2353+
sent += 128;
2354+
return new Response(silentBytes.slice(0, 128), {
2355+
headers: { ETag: identity },
2356+
});
2357+
}
2358+
expect(range).toBe(`bytes=128-${silentBytes.length - 1}`);
2359+
sent += silentBytes.length - 128;
2360+
return new Response(silentBytes.slice(128), {
2361+
status: 206,
2362+
headers: {
2363+
ETag: identity,
2364+
"Content-Range": `bytes 128-${silentBytes.length - 1}/${silentBytes.length}`,
2365+
},
2366+
});
2367+
},
2368+
});
2369+
try {
2370+
const result = await verifyRemoteRecordingBytes(
2371+
`http://127.0.0.1:${server.port}/recording.mp4`,
2372+
{
2373+
expectedObjectIdentity: identity,
2374+
expectedSha256: createHash("sha256")
2375+
.update(silentBytes)
2376+
.digest("hex"),
2377+
expectedFileSize: silentBytes.length,
2378+
},
2379+
);
2380+
expect(result.fileSize).toBe(silentBytes.length);
2381+
expect(reads).toBe(2);
2382+
expect(sent).toBe(silentBytes.length);
2383+
} finally {
2384+
await server.stop(true);
2385+
}
2386+
});
2387+
21562388
test("binds remote bytes without manufacturing decoded evidence", async () => {
21572389
const identity = '"byte-bound-output"';
21582390
const sha256 = createHash("sha256").update(silentBytes).digest("hex");

0 commit comments

Comments
 (0)