diff --git a/src/background/index.ts b/src/background/index.ts index f280438..0de9b4a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -294,6 +294,25 @@ async function openWelcomeIfFirstInstall( // cycles before the operator-visible error surfaces. const BUFFER_FETCH_TIMEOUT_MS = 10_000; +// ─── D-P2-01 Blob URL mint/revoke lifecycle state (P0-6 fix) ────────── +// pendingDownloadUrlResolvers maps a per-mint requestId to the resolver +// of the in-flight downloadArchive's Promise. Mirrors the pendingBuffer +// Requests pattern: the onConnect-level port message sink routes the +// DOWNLOAD_URL response by id so port replacement mid-mint does not +// drop the response. +const pendingDownloadUrlResolvers: Map void> = new Map(); +// pendingRevokes maps a chrome.downloads downloadId to the minted blob:URL +// awaiting revocation. Populated when chrome.downloads.download resolves +// with its downloadId; drained by the chrome.downloads.onChanged listener +// when the corresponding state transitions to 'complete' or 'interrupted'. +// The Map is bounded by O(saves-per-session) which is operationally <100 +// (T-02-02-02 threat-register entry); SW idle teardown clears it entirely. +const pendingRevokes: Map = new Map(); +// Outer-bound budget for the offscreen mint round-trip. The bridge is +// purely local (no network), so 5 s is generous — the inner encode + +// post round-trip is typically <100 ms for archives <10 MB. +const BLOB_URL_MINT_TIMEOUT_MS = 5_000; + // Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The // onConnect-level message sink routes BUFFER -> resolve by id, so port // replacement (videoPort changes mid-request) does NOT lose the @@ -438,6 +457,26 @@ chrome.runtime.onConnect.addListener((port) => { pending.resolve({ segments }); return; } + if (type === 'DOWNLOAD_URL') { + // D-P2-01: route the offscreen-minted blob:URL back to the + // in-flight downloadArchive Promise. Mirrors the BUFFER routing + // above — keyed by requestId so concurrent mints (theoretically + // possible across two SAVE flows) cannot cross-talk. + const requestId = (msg as { requestId?: unknown }).requestId; + if (typeof requestId !== 'string' || requestId.length === 0) { + logger.warn('DOWNLOAD_URL without a valid requestId — dropping'); + return; + } + const resolver = pendingDownloadUrlResolvers.get(requestId); + if (resolver === undefined) { + // Stale DOWNLOAD_URL (mint already timed out). Silently drop. + return; + } + pendingDownloadUrlResolvers.delete(requestId); + const url = (msg as { url?: unknown }).url; + resolver(typeof url === 'string' ? url : ''); + return; + } // Unknown traffic — drop silently (T-1-04 defense-in-depth). }); port.onDisconnect.addListener(() => { @@ -691,7 +730,24 @@ async function createArchive( return archiveBlob; } -// Скачивание архива +// Скачивание архива (D-P2-01: offscreen-minted blob: URL pipeline; P0-6 fix) +// +// Architectural triangle: SW packages zip → SW asks offscreen to mint URL +// (CREATE_DOWNLOAD_URL with base64 archive bytes) → offscreen mints via +// URL.createObjectURL (SW lacks it per DEC-006) → offscreen replies +// DOWNLOAD_URL{url} → SW calls chrome.downloads.download → onChanged +// fires 'complete'/'interrupted' → SW asks offscreen to revoke +// (REVOKE_DOWNLOAD_URL). The base64 wire-format reuses the D-12 +// precedent from src/shared/binary.ts. +// +// NO FALLBACK to the legacy data: URL pathway: real operator archives +// (5-10 MB) exceed Chrome's ~2 MB data-URL cap and would silently fail +// with a 'Network error' download (audit P0-6). The legacy encoding +// chain (blobToBase64 + chrome.downloads.download(`data:...`)) is gone. +// On any failure (mint timeout, empty url, port unavailable, non-blob: +// prefix) we throw a typed Error that routes through saveArchive's +// catch block into the RECORDING_ERROR channel — operator gets a +// visible failure, not a silently corrupted archive. async function downloadArchive(archiveBlob: Blob) { const now = new Date(); const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0]; @@ -701,20 +757,72 @@ async function downloadArchive(archiveBlob: Blob) { logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`); - // WR-08 fix: delegate to the shared `blobToBase64` helper instead of - // re-implementing the same per-byte concat + btoa inline. Keeps the - // wire-format encoding single-source-of-truth (also used by the - // offscreen↔SW port; see src/shared/binary.ts) and ensures any future - // performance work (chunked apply, etc.) propagates to both call sites. - const base64 = await blobToBase64(archiveBlob); - const url = `data:application/zip;base64,${base64}`; + if (videoPort === null) { + throw new Error('blob-url-mint-failed: offscreen port unavailable'); + } - await chrome.downloads.download({ - url: url, - filename: filename, - saveAs: false + // Encode the archive bytes for the SW→offscreen wire (D-12 base64 + // precedent — chrome.runtime.Port JSON-serializes payloads and Blobs + // arrive as empty objects without this transform). + const dataBase64 = await blobToBase64(archiveBlob); + const requestId = generateRequestId(); + const urlPromise = new Promise((resolve) => { + pendingDownloadUrlResolvers.set(requestId, resolve); }); - logger.log('Archive download started'); + try { + videoPort.postMessage({ + type: 'CREATE_DOWNLOAD_URL', + requestId, + dataBase64, + mimeType: 'application/zip', + }); + } catch (err) { + // Port disconnected synchronously between the null-check and post. + // Clean up the resolver entry so it doesn't leak; surface a typed + // error so saveArchive's catch routes it to RECORDING_ERROR. + pendingDownloadUrlResolvers.delete(requestId); + throw new Error(`blob-url-mint-failed: CREATE_DOWNLOAD_URL post threw: ${String(err)}`); + } + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('blob-url-mint-timeout')), + BLOB_URL_MINT_TIMEOUT_MS, + ); + }); + let url: string; + try { + url = await Promise.race([urlPromise, timeoutPromise]); + } catch (err) { + // Timeout fired before the offscreen responded. Drain the resolver + // map entry; the late-arriving DOWNLOAD_URL will be silently dropped + // by the onConnect sink (stale-id path). + pendingDownloadUrlResolvers.delete(requestId); + throw err; + } + if (url === '') { + throw new Error('blob-url-mint-failed: offscreen returned empty url'); + } + // T-02-02-03 mitigation (defense-in-depth): reject any URL that does + // not have the blob: scheme. The offscreen is same-extension origin + // (sender-id-checked) and the WHATWG URL spec guarantees + // URL.createObjectURL emits blob: only — this guard catches future + // regressions / hostile-peer-on-shared-port scenarios. + if (!url.startsWith('blob:')) { + throw new Error(`blob-url-mint-failed: offscreen returned non-blob: url '${url.substring(0, 40)}...'`); + } + + const downloadId = await chrome.downloads.download({ + url, + filename, + saveAs: false, + }); + if (typeof downloadId === 'number') { + // Track the (downloadId → url) pair so the chrome.downloads.onChanged + // listener (registered below) can dispatch the revoke when the + // download reaches a terminal state. + pendingRevokes.set(downloadId, url); + } + logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`); } // Сохранение архива (полный процесс) @@ -1063,6 +1171,42 @@ try { logger.warn('chrome.notifications.onClicked.addListener failed:', e); } +// chrome.downloads.onChanged: D-P2-01 (P0-6 fix) — revoke-on-terminal-state. +// Closes the URL.revokeObjectURL lifecycle by routing terminal download +// state transitions (`complete` / `interrupted`) into a REVOKE_DOWNLOAD_URL +// port message back to the offscreen document (which is the URL minting +// origin per DEC-006). On benign races (offscreenPort null at revoke time, +// e.g. SW respawn between download start and completion) the URL leaks in +// offscreen until the document is torn down — bounded per-session, +// acceptable per T-02-02-02 threat-register entry (chrome-extension:// +// scoped, never exposed to web pages). +try { + if (chrome.downloads?.onChanged?.addListener !== undefined) { + chrome.downloads.onChanged.addListener((delta) => { + if (delta.state === undefined) return; + const newState = delta.state.current; + if (newState !== 'complete' && newState !== 'interrupted') return; + const url = pendingRevokes.get(delta.id); + if (url === undefined) return; + pendingRevokes.delete(delta.id); + if (videoPort !== null) { + try { + videoPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url }); + logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`); + } catch (err) { + logger.warn('REVOKE_DOWNLOAD_URL post failed:', err); + } + } else { + logger.warn( + `videoPort null at revoke time; url ${url.substring(0, 30)}... leaks until offscreen teardown`, + ); + } + }); + } +} catch (e) { + logger.warn('chrome.downloads.onChanged.addListener failed:', e); +} + // Запуск при установке chrome.runtime.onInstalled.addListener((details) => { logger.log('Extension installed/updated:', details.reason); diff --git a/tests/background/blob-url-download.test.ts b/tests/background/blob-url-download.test.ts index 171b4ff..11a1347 100644 --- a/tests/background/blob-url-download.test.ts +++ b/tests/background/blob-url-download.test.ts @@ -362,6 +362,55 @@ async function runSaveAndCaptureDownloadArg( ); }; + // Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL + // handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as + // base64; the real offscreen mints a blob:URL via URL.createObjectURL + // (src/offscreen/recorder.ts:handleCreateDownloadUrl). In tests we + // decode the base64 to a Blob ourselves and mint the URL on the test + // side, then reply with DOWNLOAD_URL{requestId,url}. The minted URL + // round-trips through the SW's pendingDownloadUrlResolvers Map and + // becomes the arg0.url passed to chrome.downloads.download. The + // mintedUrlsHere set is exported so revoke-lifecycle tests (Test 3) + // can probe the URL string passed to URL.revokeObjectURL. + const mintedUrlsHere = new Set(); + const tryFireDownloadUrl = () => { + const mintCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL', + ); + for (const call of mintCalls) { + const mintMsg = call[0] as { + requestId?: string; + dataBase64?: string; + mimeType?: string; + }; + const requestId = mintMsg.requestId; + if (typeof requestId !== 'string' || requestId.length === 0) continue; + if (mintedUrlsHere.has(requestId)) continue; + mintedUrlsHere.add(requestId); + const dataBase64 = mintMsg.dataBase64 ?? ''; + const mimeType = mintMsg.mimeType ?? 'application/zip'; + let url = ''; + try { + // Node 24 ships URL.createObjectURL + Blob as globals. The + // resulting URL has the `blob:nodedata:` shape, which + // satisfies the `url.startsWith('blob:')` polarity guard. + const binary = atob(dataBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: mimeType }); + url = URL.createObjectURL(blob); + } catch { + url = ''; + } + port.onMessage._listeners.forEach((fn) => + fn({ type: 'DOWNLOAD_URL', requestId, url }), + ); + } + }; + const onMsg = stub.runtime.onMessage._callbacks[0]; onMsg( { type: 'SAVE_ARCHIVE' }, @@ -372,6 +421,7 @@ async function runSaveAndCaptureDownloadArg( const DRAIN_ITERATIONS = 1_500; for (let i = 0; i < DRAIN_ITERATIONS; i++) { tryFireBuffer(); + tryFireDownloadUrl(); if (stub.downloads.download.mock.calls.length > 0) break; await new Promise((resolve) => setTimeout(resolve, 5)); } @@ -509,6 +559,12 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () = ); let buffered = false; + // Plan 02-02 (D-P2-01): mirror runSaveAndCaptureDownloadArg's + // offscreen-side CREATE_DOWNLOAD_URL handler so the SW's + // downloadArchive can complete the bridge round-trip. The 6 MB + // archive's base64 payload is sizeable (~8 MB after encoding); the + // local URL.createObjectURL mint is still essentially free. + const mintedRequestIds = new Set(); const t0 = performance.now(); const DRAIN_ITERATIONS = 1_500; for (let i = 0; i < DRAIN_ITERATIONS; i++) { @@ -531,6 +587,36 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () = ); } } + const mintCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL', + ); + for (const call of mintCalls) { + const mintMsg = call[0] as { + requestId?: string; + dataBase64?: string; + mimeType?: string; + }; + const requestId = mintMsg.requestId; + if (typeof requestId !== 'string' || requestId.length === 0) continue; + if (mintedRequestIds.has(requestId)) continue; + mintedRequestIds.add(requestId); + let url = ''; + try { + const binary = atob(mintMsg.dataBase64 ?? ''); + const bytes = new Uint8Array(binary.length); + for (let k = 0; k < binary.length; k++) bytes[k] = binary.charCodeAt(k); + const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' }); + url = URL.createObjectURL(blob); + } catch { + url = ''; + } + port.onMessage._listeners.forEach((fn) => + fn({ type: 'DOWNLOAD_URL', requestId, url }), + ); + } if (stub.downloads.download.mock.calls.length > 0) break; await new Promise((resolve) => setTimeout(resolve, 5)); } @@ -580,6 +666,24 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () = const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + // Plan 02-02 (D-P2-01): the real offscreen handler in + // src/offscreen/recorder.ts calls URL.revokeObjectURL on receipt of + // REVOKE_DOWNLOAD_URL. The test stub does not load recorder.ts, so + // we route the SW-posted REVOKE_DOWNLOAD_URL through revokeSpy here + // to model the offscreen-side behaviour faithfully. + port.postMessage.mockImplementation((msg: unknown) => { + if ( + typeof msg === 'object' && + msg !== null && + (msg as { type?: unknown }).type === 'REVOKE_DOWNLOAD_URL' + ) { + const url = (msg as { url?: unknown }).url; + if (typeof url === 'string' && url.length > 0) { + URL.revokeObjectURL(url); + } + } + }); + try { const downloadArg = await runSaveAndCaptureDownloadArg(stub, port); expect( @@ -590,9 +694,10 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () = const mintedUrl = downloadArg!.url; const downloadId = (await stub.downloads.download.mock.results[0].value) as number; - // RED today: chrome.downloads.onChanged._callbacks is empty (no - // listener registered by current downloadArchive); the forEach - // is a no-op; revokeSpy stays uncalled; assertion below fails. + // GREEN under Plan 02-02: downloadArchive registers a chrome.downloads + // .onChanged listener at module init that posts REVOKE_DOWNLOAD_URL + // back to the offscreen port; our port.postMessage shim above mirrors + // that into revokeSpy. stub.downloads.onChanged._callbacks.forEach((cb) => cb({ id: downloadId, state: { current: 'complete' } }), ); diff --git a/tests/background/meta-json-urls-schema.test.ts b/tests/background/meta-json-urls-schema.test.ts index aa5a2d1..2d748f1 100644 --- a/tests/background/meta-json-urls-schema.test.ts +++ b/tests/background/meta-json-urls-schema.test.ts @@ -366,6 +366,53 @@ async function runSaveAndCaptureArchiveBlob( ); }; + // Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL + // handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as + // base64; we mint a Node-native blob: URL via URL.createObjectURL and + // reply DOWNLOAD_URL{requestId,url}. The archive bytes are captured + // here BEFORE minting so meta.json extraction works regardless of + // whether the SW ever calls chrome.downloads.download — this is the + // canonical test-side equivalent of the offscreen path described in + // src/offscreen/recorder.ts handleCreateDownloadUrl. + const mintedRequestIds = new Set(); + let capturedArchiveBytes: Uint8Array | null = null; + const tryFireDownloadUrl = () => { + const mintCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL', + ); + for (const call of mintCalls) { + const mintMsg = call[0] as { + requestId?: string; + dataBase64?: string; + mimeType?: string; + }; + const requestId = mintMsg.requestId; + if (typeof requestId !== 'string' || requestId.length === 0) continue; + if (mintedRequestIds.has(requestId)) continue; + mintedRequestIds.add(requestId); + let url = ''; + try { + const binary = atob(mintMsg.dataBase64 ?? ''); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + // Capture the archive bytes for downstream meta.json extraction. + if (capturedArchiveBytes === null) { + capturedArchiveBytes = bytes; + } + const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' }); + url = URL.createObjectURL(blob); + } catch { + url = ''; + } + port.onMessage._listeners.forEach((fn) => + fn({ type: 'DOWNLOAD_URL', requestId, url }), + ); + } + }; + const onMsg = stub.runtime.onMessage._callbacks[0]; onMsg( { type: 'SAVE_ARCHIVE' }, @@ -376,24 +423,27 @@ async function runSaveAndCaptureArchiveBlob( const DRAIN_ITERATIONS = 1_500; for (let i = 0; i < DRAIN_ITERATIONS; i++) { tryFireBuffer(); + tryFireDownloadUrl(); if (stub.downloads.download.mock.calls.length > 0) break; await new Promise((resolve) => setTimeout(resolve, 5)); } if (stub.downloads.download.mock.calls.length === 0) return undefined; const arg = stub.downloads.download.mock.calls[0][0] as { url: string }; - // Current HEAD: data:application/zip;base64,. Base64-decode. + // Plan 02-02 era: blob:URL — the archive bytes are not recoverable + // from the URL string itself. We captured the base64 payload during + // the CREATE_DOWNLOAD_URL round-trip above; decode and return. + if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) { + return new Blob([capturedArchiveBytes], { type: 'application/zip' }); + } + // Pre-Plan-02-02 path (kept for forward-compat; current HEAD on the + // post-Plan-02-02 branch never takes this branch). const DATA_PREFIX = 'data:application/zip;base64,'; if (arg.url.startsWith(DATA_PREFIX)) { const b64 = arg.url.substring(DATA_PREFIX.length); const bytes = Buffer.from(b64, 'base64'); return new Blob([bytes], { type: 'application/zip' }); } - // Plan 02-02 era: blob: URL. The blob bytes are not directly - // recoverable from the blob: URL string in Node; this RED test - // doesn't reach that branch under current HEAD. The Plan 02-03 GREEN - // implementer will likely need a different helper (e.g. spy on - // URL.createObjectURL to capture the underlying Blob reference). return undefined; } diff --git a/tests/build/strict-meta-json-validation.test.ts b/tests/build/strict-meta-json-validation.test.ts index 0ca297b..7a1565c 100644 --- a/tests/build/strict-meta-json-validation.test.ts +++ b/tests/build/strict-meta-json-validation.test.ts @@ -375,6 +375,50 @@ async function runAndParseMetaJson( ); }; + // Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL + // handler and capture the archive bytes from the port message itself + // (the SW posts the base64-encoded payload before the URL is minted). + // This is the canonical Plan-02-02-era equivalent of the prior + // data:URL base64-decode pathway — we read the same bytes from a + // different surface (port message vs. download URL). + const mintedRequestIds = new Set(); + let capturedArchiveBytes: Uint8Array | null = null; + const tryFireDownloadUrl = () => { + const mintCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL', + ); + for (const call of mintCalls) { + const mintMsg = call[0] as { + requestId?: string; + dataBase64?: string; + mimeType?: string; + }; + const requestId = mintMsg.requestId; + if (typeof requestId !== 'string' || requestId.length === 0) continue; + if (mintedRequestIds.has(requestId)) continue; + mintedRequestIds.add(requestId); + let url = ''; + try { + const binary = atob(mintMsg.dataBase64 ?? ''); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + if (capturedArchiveBytes === null) { + capturedArchiveBytes = bytes; + } + const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' }); + url = URL.createObjectURL(blob); + } catch { + url = ''; + } + port.onMessage._listeners.forEach((fn) => + fn({ type: 'DOWNLOAD_URL', requestId, url }), + ); + } + }; + const onMsg = stub.runtime.onMessage._callbacks[0]; onMsg( { type: 'SAVE_ARCHIVE' }, @@ -385,23 +429,27 @@ async function runAndParseMetaJson( const DRAIN_ITERATIONS = 1_500; for (let i = 0; i < DRAIN_ITERATIONS; i++) { tryFireBuffer(); + tryFireDownloadUrl(); if (stub.downloads.download.mock.calls.length > 0) break; await new Promise((resolve) => setTimeout(resolve, 5)); } if (stub.downloads.download.mock.calls.length === 0) return undefined; const arg = stub.downloads.download.mock.calls[0][0] as { url: string }; - const DATA_PREFIX = 'data:application/zip;base64,'; - if (!arg.url.startsWith(DATA_PREFIX)) { - // Plan 02-02 era: blob: URL. Plan 02-03 implementer will need a - // different harness (e.g. spy on URL.createObjectURL to capture - // the underlying Blob reference). This RED test runs against the - // current data: URL path. - return undefined; + // Plan 02-02 era: extract archive bytes from the captured port + // payload, not from the blob: URL string (which is opaque in Node). + let archiveBytes: Uint8Array | null = null; + if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) { + archiveBytes = capturedArchiveBytes; + } else { + const DATA_PREFIX = 'data:application/zip;base64,'; + if (arg.url.startsWith(DATA_PREFIX)) { + const b64 = arg.url.substring(DATA_PREFIX.length); + archiveBytes = Buffer.from(b64, 'base64'); + } } - const b64 = arg.url.substring(DATA_PREFIX.length); - const bytes = Buffer.from(b64, 'base64'); - const archiveBlob = new Blob([bytes], { type: 'application/zip' }); + if (archiveBytes === null) return undefined; + const archiveBlob = new Blob([archiveBytes], { type: 'application/zip' }); const zip = await JSZip.loadAsync(archiveBlob); const metaEntry = zip.file('meta.json'); if (!metaEntry) return undefined;