diff --git a/src/background/index.ts b/src/background/index.ts index 8ebd119..2f5cb92 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -153,21 +153,43 @@ async function getVideoBufferFromOffscreen(): Promise { // RESEARCH.md Pattern 3. const wireSegments = (msg as { segments?: TransferredVideoSegment[] }).segments ?? []; - const segments: VideoSegment[] = []; - for (const wire of wireSegments) { + // WR-07 fix: filter empty wire segments BEFORE base64 decode. + // An empty wire.data would decode to a zero-byte Blob; the + // SW-side mergeVideoSegments would then concat it into the + // output WebM, producing a stray empty EBML segment that + // breaks Chrome playback. We split into two passes (filter → + // decode → filter-non-empty) so the iteration semantics stay + // declarative (no early-return in the loop body). + const nonEmptyWires = wireSegments.filter((wire) => { + const isEmpty = !wire.data || wire.data.length === 0; + if (isEmpty) { + logger.warn( + 'Skipping empty wire segment (zero-length base64)', + 'timestamp:', wire.timestamp, + ); + } + return !isEmpty; + }); + const segments: VideoSegment[] = nonEmptyWires.flatMap((wire) => { try { - segments.push({ - data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK), - timestamp: wire.timestamp, - }); + const blob = base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK); + if (blob.size === 0) { + logger.warn( + 'Skipping segment that decoded to zero bytes', + 'timestamp:', wire.timestamp, + ); + return []; + } + return [{ data: blob, timestamp: wire.timestamp }]; } catch (err) { logger.warn( 'base64ToBlob failed; skipping segment', 'timestamp:', wire.timestamp, 'error:', err, ); + return []; } - } + }); resolve({ segments }); } }; diff --git a/src/shared/binary.ts b/src/shared/binary.ts index 18bb4c0..ec8fdee 100644 --- a/src/shared/binary.ts +++ b/src/shared/binary.ts @@ -65,6 +65,17 @@ export async function blobToBase64(blob: Blob): Promise { * @returns A Blob whose bytes match the original encoded blob exactly. */ export function base64ToBlob(b64: string, mimeType: string): Blob { + // WR-07 fix: defensive early-return for empty input. `atob('')` returns + // an empty string and the resulting Blob has size 0 — currently no + // caller filters zero-size segments, which would corrupt the + // concatenated WebM with a stray empty EBML segment. Return an + // explicit empty Blob here so the SW-side filter on + // `segment.data.size > 0` (see src/background/index.ts + // getVideoBufferFromOffscreen) can pre-filter cleanly without an + // extra atob round-trip. + if (b64.length === 0) { + return new Blob([], { type: mimeType }); + } const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { diff --git a/tests/offscreen/port-serialization.test.ts b/tests/offscreen/port-serialization.test.ts index 8c5bc66..9cb70ca 100644 --- a/tests/offscreen/port-serialization.test.ts +++ b/tests/offscreen/port-serialization.test.ts @@ -193,3 +193,44 @@ describe('port serialization (GREEN — pins the eventual fix contract)', () => expect(merged.size).not.toBe(75); // the bug is gone in the fixed form. }); }); + +describe('binary.ts adversarial input (WR-07 + sweep target #6)', () => { + // These tests exercise the PRODUCTION helpers in src/shared/binary.ts, + // not local re-implementations. They pin the WR-07 + sweep-#6 + // defensive contracts: + // 1. Empty-string input → zero-byte Blob (early-return shortcut, + // no atob('') round-trip). + // 2. Invalid base64 input → throws DOMException-like InvalidCharacterError, + // caller is expected to wrap in try/catch. + // + // The empty-segment filter in SW's getVideoBufferFromOffscreen relies + // on (1) so the multi-EBML-header concat output never includes a stray + // zero-byte segment. The error-classification step relies on (2) being + // a regular throw so the per-segment try/catch can drop a single bad + // entry without aborting the whole transfer. + + it('base64ToBlob("") returns a zero-byte Blob with the requested MIME', async () => { + const { base64ToBlob } = await import('../../src/shared/binary'); + const blob = base64ToBlob('', 'video/webm'); + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBe(0); + expect(blob.type).toBe('video/webm'); + }); + + it('blobToBase64(emptyBlob) round-trips through base64ToBlob to zero bytes', async () => { + const { blobToBase64, base64ToBlob } = await import('../../src/shared/binary'); + const original = new Blob([], { type: 'video/webm' }); + const b64 = await blobToBase64(original); + expect(b64).toBe(''); + const restored = base64ToBlob(b64, 'video/webm'); + expect(restored.size).toBe(0); + }); + + it('base64ToBlob throws on invalid base64 alphabet (caller wraps with try/catch)', async () => { + const { base64ToBlob } = await import('../../src/shared/binary'); + // Characters outside the base64 alphabet — atob throws InvalidCharacterError + // synchronously. The SW caller (getVideoBufferFromOffscreen) has a + // try/catch that drops a single bad segment without aborting the batch. + expect(() => base64ToBlob('!!!not-base64!!!', 'video/webm')).toThrow(); + }); +});