diff --git a/apps/browser-demos/test/closed-lazy-asset-sources-browser.spec.ts b/apps/browser-demos/test/closed-lazy-asset-sources-browser.spec.ts new file mode 100644 index 0000000000..33cde5b0a7 --- /dev/null +++ b/apps/browser-demos/test/closed-lazy-asset-sources-browser.spec.ts @@ -0,0 +1,265 @@ +import { expect, test } from "@playwright/test"; +import { createHash } from "node:crypto"; +import { createServer, type ServerResponse } from "node:http"; +import type { AddressInfo, Socket } from "node:net"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const modulePath = fileURLToPath( + new URL("../../../host/src/vfs/closed-lazy-assets.ts", import.meta.url), +); + +test("Chromium verifies and closes native lazy-asset transports", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the transport contract targets Chromium"); + expect(baseURL).toBeTruthy(); + + const viteModuleUrl = new URL(`/@fs/${modulePath}`, baseURL!).href; + const viteModuleResponse = await fetch(viteModuleUrl); + const viteModuleSource = await viteModuleResponse.text(); + expect( + viteModuleResponse.ok, + `${viteModuleResponse.status} ${viteModuleResponse.url}: ` + + viteModuleSource.slice(0, 500), + ).toBe(true); + + const decodedPayload = Buffer.from("lazy Homebrew bottle bytes\n".repeat(512)); + const encodedPayload = gzipSync(decodedPayload); + const state = { + cookieProbe: "", + gzipCookie: "", + redirectTargetHits: 0, + overflowClosed: false, + overflowFinished: false, + slowClosed: false, + slowFinished: false, + streamErrorClosed: false, + }; + const sockets = new Set(); + const streamingResponses = new Set(); + const trackStreamingResponse = ( + response: ServerResponse, + kind: "overflow" | "slow" | "stream-error", + ): void => { + streamingResponses.add(response); + response.once("close", () => { + streamingResponses.delete(response); + if (kind === "overflow") state.overflowClosed = true; + if (kind === "slow") state.slowClosed = true; + if (kind === "stream-error") state.streamErrorClosed = true; + }); + response.once("finish", () => { + if (kind === "overflow") state.overflowFinished = true; + if (kind === "slow") state.slowFinished = true; + }); + }; + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + switch (url.pathname) { + case "/": + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "set-cookie": "closed-source-session=present; Path=/; SameSite=Lax", + }); + response.end("closed lazy source transport"); + return; + case "/cookie-probe": + state.cookieProbe = request.headers.cookie ?? ""; + response.writeHead(200, { "content-type": "text/plain" }); + response.end("cookie observed"); + return; + case "/closed-lazy-assets.ts": + response.writeHead(200, { + "content-type": "application/javascript; charset=utf-8", + }); + response.end(viteModuleSource); + return; + case "/gzip": + state.gzipCookie = request.headers.cookie ?? ""; + response.writeHead(200, { + "content-encoding": "gzip", + "content-length": String(encodedPayload.byteLength), + "content-type": "application/octet-stream", + }); + response.end(encodedPayload); + return; + case "/redirect": + response.writeHead(302, { location: "/redirect-target" }); + response.end(); + return; + case "/redirect-target": + state.redirectTargetHits += 1; + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.end(Buffer.from([1])); + return; + case "/overflow": + trackStreamingResponse(response, "overflow"); + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.flushHeaders(); + response.write(Buffer.from([1, 2, 3, 4])); + return; + case "/slow": + trackStreamingResponse(response, "slow"); + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.flushHeaders(); + response.write(Buffer.from([9])); + return; + case "/stream-error": + trackStreamingResponse(response, "stream-error"); + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.flushHeaders(); + response.write(Buffer.from([7])); + setTimeout(() => response.socket?.destroy(), 10); + return; + default: + response.writeHead(404); + response.end(); + } + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + try { + const { port } = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${port}`; + await page.goto(origin, { waitUntil: "domcontentloaded" }); + expect(await page.evaluate(async () => (await fetch("/cookie-probe")).text())) + .toBe("cookie observed"); + expect(state.cookieProbe).toContain("closed-source-session=present"); + + // Execute Vite's transformation of the real host source from the same + // origin as the native transport endpoints. This keeps root-relative + // source URLs meaningful without weakening the browser's CORS policy. + const moduleUrl = `${origin}/closed-lazy-assets.ts`; + const sha256 = createHash("sha256").update(decodedPayload).digest("hex"); + const result = await page.evaluate(async ({ moduleUrl, sha256, size }) => { + const { loadClosedLazyAssetSources } = await import( + /* @vite-ignore */ moduleUrl + ); + const binding = (sourceUrl: string, index: number, expectedSize = 3) => ({ + url: `https://example.test/releases/v1/asset-${index}.bin`, + sourceUrl, + sha256: "0".repeat(64), + size: expectedSize, + }); + const rejection = async (promise: Promise) => { + try { + await promise; + return { rejected: false, name: "", message: "" }; + } catch (error) { + return { + rejected: true, + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + }; + } + }; + + const loaded = await loadClosedLazyAssetSources([{ + url: "https://example.test/releases/v1/gzip.bin", + sourceUrl: "/gzip?credential-check=private", + sha256, + size, + }]); + const redirect = await rejection(loadClosedLazyAssetSources([ + binding("/redirect", 1, 1), + ])); + const overflow = await rejection(loadClosedLazyAssetSources([ + binding("/overflow", 2), + ])); + const streamError = await rejection(loadClosedLazyAssetSources([ + binding("/stream-error", 3), + ])); + + const controller = new AbortController(); + const abortReason = new Error("browser caller stopped lazy loading"); + let abortTimer: ReturnType | undefined; + const slowPromise = loadClosedLazyAssetSources([ + binding("/slow", 4), + ], { + signal: controller.signal, + fetchImpl: async (input: string | URL, init?: RequestInit) => { + const response = await fetch(input, init); + abortTimer = setTimeout(() => controller.abort(abortReason), 10); + return response; + }, + }); + let slowSameReason = false; + const slow = await slowPromise.then( + () => ({ rejected: false, name: "", message: "" }), + (error: unknown) => { + slowSameReason = error === abortReason; + return { + rejected: true, + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + }; + }, + ); + if (abortTimer !== undefined) clearTimeout(abortTimer); + + return { + capabilities: { + cryptoDigest: typeof crypto.subtle.digest, + readableStream: typeof ReadableStream, + secureContext: isSecureContext, + }, + gzipBytes: Array.from(loaded[0]!.bytes), + redirect, + overflow, + streamError, + slow, + slowSameReason, + }; + }, { + moduleUrl, + sha256, + size: decodedPayload.byteLength, + }); + + expect(result.capabilities).toEqual({ + cryptoDigest: "function", + readableStream: "function", + secureContext: true, + }); + expect(Buffer.from(result.gzipBytes)).toEqual(decodedPayload); + expect(encodedPayload.byteLength).not.toBe(decodedPayload.byteLength); + expect(state.gzipCookie).toBe(""); + expect(result.redirect.rejected).toBe(true); + expect(state.redirectTargetHits).toBe(0); + expect(result.overflow).toMatchObject({ + rejected: true, + message: expect.stringContaining("exceeds 3 bytes"), + }); + expect(result.streamError.rejected).toBe(true); + expect(result.slow).toMatchObject({ + rejected: true, + message: "browser caller stopped lazy loading", + }); + expect(result.slowSameReason).toBe(true); + await expect.poll(() => state.overflowClosed).toBe(true); + await expect.poll(() => state.slowClosed).toBe(true); + await expect.poll(() => state.streamErrorClosed).toBe(true); + expect(state.overflowFinished).toBe(false); + expect(state.slowFinished).toBe(false); + } finally { + for (const response of streamingResponses) response.destroy(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/host/src/vfs/closed-lazy-assets.ts b/host/src/vfs/closed-lazy-assets.ts index f9f9a1482c..febcefd463 100644 --- a/host/src/vfs/closed-lazy-assets.ts +++ b/host/src/vfs/closed-lazy-assets.ts @@ -11,10 +11,162 @@ export interface ClosedLazyAsset { bytes: Uint8Array; } +/** + * One acceptance-only transport source whose bytes are bound to the canonical + * HTTPS URL stored in a deferred VFS tree only after verification. `sourceUrl` + * may be a canonical root-relative URL or a canonical absolute HTTP(S) URL. + * Fetches omit credentials and reject redirects; the exact size and SHA-256 + * declared here remain the authority. + */ +export interface ClosedLazyAssetSource { + url: string; + sourceUrl: string; + sha256: string; + size: number; +} + +type FetchLike = (input: string | URL, init?: RequestInit) => Promise; + const SHA256_RE = /^[0-9a-f]{64}$/; export const MAX_CLOSED_LAZY_ASSETS = 128; export const MAX_CLOSED_LAZY_ASSET_BYTES = 512 * 1024 * 1024; +/** + * Fetch and verify acceptance-only sources before giving them canonical lazy + * transport identities. This never treats the source URL as VFS authority: + * only the separately declared HTTPS URL, digest, and size survive. A caller + * abort or first source failure stops new work and closes every active body + * before the loader rejects with that exact first reason. + */ +export async function loadClosedLazyAssetSources( + sources: readonly ClosedLazyAssetSource[], + options: { + fetchImpl?: FetchLike; + maxConcurrency?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const validated = validateClosedLazyAssetSources(sources); + const fetchImpl = options.fetchImpl ?? fetch; + const maxConcurrency = options.maxConcurrency ?? 4; + if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 16) { + throw new Error( + "closed lazy asset source concurrency must be an integer from 1 to 16", + ); + } + + const controller = new AbortController(); + let firstFailure: { reason: unknown } | undefined; + const fail = (reason: unknown): void => { + if (firstFailure !== undefined) return; + firstFailure = { reason }; + controller.abort(reason); + }; + + const callerSignal = options.signal; + const onCallerAbort = (): void => fail(callerSignal!.reason); + let callerListenerAdded = false; + if (callerSignal?.aborted) { + fail(callerSignal.reason); + } else if (callerSignal !== undefined) { + callerSignal.addEventListener("abort", onCallerAbort, { once: true }); + callerListenerAdded = true; + } + + const output = new Array(validated.length); + let next = 0; + const loadOne = async ( + source: ClosedLazyAssetSource, + ): Promise => { + const diagnosticUrl = redactSourceUrl(source.sourceUrl); + try { + throwIfAborted(controller.signal); + const response = await fetchImpl(source.sourceUrl, { + cache: "no-store", + credentials: "omit", + redirect: "error", + signal: controller.signal, + }); + if (controller.signal.aborted) { + await cancelResponseBody(response, controller.signal.reason); + throw controller.signal.reason; + } + if (response.redirected) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} followed a redirect`, + ); + fail(error); + await cancelResponseBody(response, error); + throw error; + } + if (!response.ok) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} returned HTTP ${response.status}`, + ); + fail(error); + await cancelResponseBody(response, error); + throw error; + } + const bytes = await readExactResponseBytes( + response, + source.size, + diagnosticUrl, + controller.signal, + fail, + ); + throwIfAborted(controller.signal); + const actualSha256 = hex( + new Uint8Array( + await crypto.subtle.digest("SHA-256", bytes.buffer), + ), + ); + throwIfAborted(controller.signal); + if (actualSha256 !== source.sha256) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} changed SHA-256`, + ); + fail(error); + throw error; + } + return { + url: source.url, + sha256: source.sha256, + size: source.size, + bytes, + }; + } catch (reason) { + fail(reason); + throw reason; + } + }; + + try { + const workers = Array.from( + { length: Math.min(maxConcurrency, validated.length) }, + async () => { + while (firstFailure === undefined) { + const index = next; + next += 1; + if (index >= validated.length) return; + try { + output[index] = await loadOne(validated[index]!); + } catch (reason) { + fail(reason); + return; + } + } + }, + ); + await Promise.all(workers); + if (firstFailure !== undefined) throw firstFailure.reason; + return output; + } finally { + if (callerListenerAdded) { + callerSignal!.removeEventListener("abort", onCallerAbort); + } + } +} + /** Validate and snapshot a bounded, canonical HTTPS URL-to-byte binding. */ export function snapshotClosedLazyAssets( assets: readonly ClosedLazyAsset[], @@ -36,32 +188,25 @@ function validateClosedLazyAssets( } const seen = new Set(); let totalBytes = 0; - return assets.map((asset, index) => { + const validated = new Array(assets.length); + for (let index = 0; index < assets.length; index += 1) { + if (!Object.hasOwn(assets, index)) { + throw new Error(`closed lazy asset ${index} is missing`); + } + const asset = assets[index]; if (typeof asset !== "object" || asset === null) { throw new Error(`closed lazy asset ${index} is not an object`); } const { url, sha256, size, bytes } = asset; if ( - typeof url !== "string" || !SHA256_RE.test(sha256) || + typeof url !== "string" || typeof sha256 !== "string" || + !SHA256_RE.test(sha256) || !Number.isSafeInteger(size) || size <= 0 || !(bytes instanceof Uint8Array) ) { throw new Error(`closed lazy asset ${index} has invalid fields`); } - let parsed: URL; - try { - parsed = new URL(url); - } catch (error) { - throw new Error(`closed lazy asset ${index} URL is invalid`, { cause: error }); - } - if ( - parsed.protocol !== "https:" || parsed.username !== "" || - parsed.password !== "" || parsed.hash !== "" || parsed.href !== url - ) { - throw new Error( - `closed lazy asset ${index} must use one canonical credential-free HTTPS URL`, - ); - } + validateCanonicalClosedUrl(url, `closed lazy asset ${index}`); if (seen.has(url)) { throw new Error(`closed lazy assets duplicate URL ${url}`); } @@ -86,8 +231,15 @@ function validateClosedLazyAssets( `closed lazy asset ${index} ownership requires one whole ordinary ArrayBuffer`, ); } - return { url, sha256, size, bytes: copy ? copyBytes(bytes) : bytes }; - }); + validated[index] = { url, sha256, size, bytes }; + } + if (!copy) return validated; + return validated.map(({ url, sha256, size, bytes }) => ({ + url, + sha256, + size, + bytes: copyBytes(bytes), + })); } /** @@ -144,6 +296,195 @@ function hex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } +function validateClosedLazyAssetSources( + sources: readonly ClosedLazyAssetSource[], +): ClosedLazyAssetSource[] { + if (!Array.isArray(sources) || sources.length === 0) { + throw new Error("closed lazy asset sources must contain at least one binding"); + } + if (sources.length > MAX_CLOSED_LAZY_ASSETS) { + throw new Error( + `closed lazy asset sources exceed ${MAX_CLOSED_LAZY_ASSETS} bindings`, + ); + } + const seen = new Set(); + let totalBytes = 0; + const validated = new Array(sources.length); + for (let index = 0; index < sources.length; index += 1) { + if (!Object.hasOwn(sources, index)) { + throw new Error(`closed lazy asset source ${index} is missing`); + } + const source = sources[index]; + if (typeof source !== "object" || source === null) { + throw new Error(`closed lazy asset source ${index} is not an object`); + } + const { url, sourceUrl, sha256, size } = source; + if ( + typeof url !== "string" || typeof sourceUrl !== "string" || + sourceUrl.length === 0 || typeof sha256 !== "string" || + !SHA256_RE.test(sha256) || + !Number.isSafeInteger(size) || size <= 0 + ) { + throw new Error(`closed lazy asset source ${index} has invalid fields`); + } + validateCanonicalClosedUrl(url, `closed lazy asset source ${index}`); + validateClosedSourceUrl(sourceUrl, index); + if (seen.has(url)) { + throw new Error(`closed lazy asset sources duplicate URL ${url}`); + } + totalBytes += size; + if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_CLOSED_LAZY_ASSET_BYTES) { + throw new Error( + `closed lazy asset sources exceed ${MAX_CLOSED_LAZY_ASSET_BYTES} bytes`, + ); + } + seen.add(url); + validated[index] = { url, sourceUrl, sha256, size }; + } + return validated; +} + +function validateCanonicalClosedUrl(url: string, label: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch (error) { + throw new Error(`${label} URL is invalid`, { cause: error }); + } + if ( + parsed.protocol !== "https:" || parsed.username !== "" || + parsed.password !== "" || parsed.hash !== "" || url.includes("#") || + parsed.href !== url + ) { + throw new Error( + `${label} must use one canonical credential-free HTTPS URL`, + ); + } +} + +function validateClosedSourceUrl(sourceUrl: string, index: number): void { + const validationOrigin = "https://closed-source.invalid"; + let parsed: URL; + try { + parsed = new URL(sourceUrl, `${validationOrigin}/`); + } catch (error) { + throw new Error(`closed lazy asset source ${index} fetch URL is invalid`, { + cause: error, + }); + } + const relative = sourceUrl.startsWith("/"); + const serializedRelative = parsed.href.slice(validationOrigin.length); + if ( + (parsed.protocol !== "https:" && parsed.protocol !== "http:") || + parsed.username !== "" || parsed.password !== "" || parsed.hash !== "" || + sourceUrl.includes("#") || + (relative + ? parsed.origin !== validationOrigin || serializedRelative !== sourceUrl + : parsed.href !== sourceUrl) + ) { + throw new Error( + `closed lazy asset source ${index} fetch URL must be canonical HTTP(S)`, + ); + } +} + +async function readExactResponseBytes( + response: Response, + expectedBytes: number, + diagnosticUrl: string, + signal: AbortSignal, + fail: (reason: unknown) => void, +): Promise> { + if (response.body === null) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} has no response body`, + ); + fail(error); + throw error; + } + const output = new Uint8Array(expectedBytes); + const reader = response.body.getReader(); + let cancelPromise: Promise | undefined; + const cancel = (reason: unknown): Promise => { + if (cancelPromise !== undefined) return cancelPromise; + try { + cancelPromise = reader.cancel(reason).then( + () => {}, + () => {}, + ); + } catch { + cancelPromise = Promise.resolve(); + } + return cancelPromise; + }; + const onAbort = (): void => { + void cancel(signal.reason); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + let offset = 0; + try { + throwIfAborted(signal); + while (true) { + const { done, value } = await reader.read(); + throwIfAborted(signal); + if (done) break; + if (value.byteLength > expectedBytes - offset) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} exceeds ${expectedBytes} bytes`, + ); + fail(error); + await cancel(error); + throw error; + } + output.set(value, offset); + offset += value.byteLength; + } + if (offset !== expectedBytes) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} has ${offset} bytes, ` + + `expected ${expectedBytes}`, + ); + fail(error); + throw error; + } + return output; + } catch (reason) { + fail(reason); + throw reason; + } finally { + signal.removeEventListener("abort", onAbort); + if (signal.aborted) await cancel(signal.reason); + if (cancelPromise !== undefined) await cancelPromise; + reader.releaseLock(); + } +} + +async function cancelResponseBody( + response: Response, + reason: unknown, +): Promise { + if (response.body === null) return; + try { + await response.body.cancel(reason); + } catch { + // Cleanup failures must not replace the original transport failure. + } +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason; +} + +function redactSourceUrl(sourceUrl: string): string { + const queryIndex = sourceUrl.indexOf("?"); + if (queryIndex === -1) return sourceUrl; + return `${sourceUrl.slice(0, queryIndex)}?`; +} + function copyBytes(bytes: Uint8Array): Uint8Array { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index 10a59359b2..270098ffdf 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -10,11 +10,15 @@ export { export type { VfsDeferredTreeUsage } from "./deferred-tree-limits"; export { createClosedLazyAssetFetcher, + loadClosedLazyAssetSources, MAX_CLOSED_LAZY_ASSETS, MAX_CLOSED_LAZY_ASSET_BYTES, snapshotClosedLazyAssets, } from "./closed-lazy-assets"; -export type { ClosedLazyAsset } from "./closed-lazy-assets"; +export type { + ClosedLazyAsset, + ClosedLazyAssetSource, +} from "./closed-lazy-assets"; export type { LazyDownloadEvent, LazyDownloadKind, diff --git a/host/test/closed-lazy-assets.test.ts b/host/test/closed-lazy-assets.test.ts index ec2d3f8d6e..7f6e9b2fc3 100644 --- a/host/test/closed-lazy-assets.test.ts +++ b/host/test/closed-lazy-assets.test.ts @@ -1,9 +1,13 @@ import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createClosedLazyAssetFetcher, + loadClosedLazyAssetSources, + MAX_CLOSED_LAZY_ASSET_BYTES, + MAX_CLOSED_LAZY_ASSETS, snapshotClosedLazyAssets, type ClosedLazyAsset, + type ClosedLazyAssetSource, } from "../src/vfs/closed-lazy-assets"; const URL_A = "https://github.com/example/project/releases/download/v1/a.tar.gz"; @@ -21,7 +25,534 @@ function asset( }; } +function sourceBinding( + url = URL_B, + sourceUrl = "/assets/package-tree.zip", + bytes = new Uint8Array([4, 5, 6]), +): ClosedLazyAssetSource { + return { + url, + sourceUrl, + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.byteLength, + }; +} + describe("closed lazy assets", () => { + it("loads a verified transport source under its canonical deferred-tree URL", async () => { + const source = new Uint8Array([4, 5, 6, 7]); + const fetchImpl = vi.fn(async () => new Response(source, { + headers: { "content-length": String(source.byteLength) }, + })); + const loaded = await loadClosedLazyAssetSources([ + sourceBinding(URL_B, "/assets/package-tree.zip", source), + ], { fetchImpl }); + + expect(fetchImpl).toHaveBeenCalledWith("/assets/package-tree.zip", { + cache: "no-store", + credentials: "omit", + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(loaded).toEqual([asset(URL_B, source)]); + + const fetcher = createClosedLazyAssetFetcher([ + asset(URL_A, new Uint8Array([1, 2, 3])), + ...loaded, + ]); + expect(new Uint8Array(await (await fetcher(URL_B)).arrayBuffer())).toEqual(source); + await expect(fetcher("https://example.test/unbound.zip")).rejects.toThrow( + "do not bind URL", + ); + }); + + it("rejects missing, truncated, oversized, and changed transport sources", async () => { + const source = new Uint8Array([4, 5, 6]); + const identity = sourceBinding(URL_B, "/assets/package-tree.zip", source); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(null, { status: 404 }), + })).rejects.toThrow("returned HTTP 404"); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(source.slice(0, 2)), + })).rejects.toThrow("has 2 bytes, expected 3"); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(new Uint8Array([4, 5, 6, 7])), + })).rejects.toThrow("exceeds 3 bytes"); + await expect(loadClosedLazyAssetSources([{ + ...identity, + sha256: "0".repeat(64), + }], { + fetchImpl: async () => new Response(source), + })).rejects.toThrow("changed SHA-256"); + }); + + it("trusts the decoded stream length instead of transport Content-Length", async () => { + const identity = sourceBinding(); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => + new Response(new Uint8Array([4, 5, 6]), { + headers: { "content-length": "03" }, + }), + })).resolves.toEqual([asset(URL_B, new Uint8Array([4, 5, 6]))]); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => + new Response(new Uint8Array([4, 5, 6]), { + headers: { + "content-encoding": "gzip", + "content-length": "1", + }, + }), + })).resolves.toEqual([asset(URL_B, new Uint8Array([4, 5, 6]))]); + }); + + it("rejects a successful response without a body", async () => { + const identity = sourceBinding(); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(null, { status: 200 }), + })).rejects.toThrow("has no response body"); + }); + + it("validates transport-source identities before fetching", async () => { + const source = new Uint8Array([4, 5, 6]); + const identity = sourceBinding(URL_B, "/assets/package-tree.zip", source); + const fetchImpl = vi.fn(async () => new Response(source)); + await expect(loadClosedLazyAssetSources([ + identity, + { ...identity, sourceUrl: "/assets/duplicate.zip" }, + ], { fetchImpl })).rejects.toThrow("duplicate URL"); + await expect(loadClosedLazyAssetSources([{ + ...identity, + sourceUrl: "data:text/plain,not-http", + }], { fetchImpl })).rejects.toThrow("must be canonical HTTP(S)"); + await expect(loadClosedLazyAssetSources([{ + ...identity, + url: "http://example.test/not-https", + }], { fetchImpl })).rejects.toThrow("canonical credential-free HTTPS"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([ + "/assets/package-tree.zip?", + "/assets/package-tree.zip?channel=closed", + "http://assets.example.test/package-tree.zip", + "http://assets.example.test/package-tree.zip?", + "https://assets.example.test/package-tree.zip", + "https://assets.example.test/package-tree.zip?channel=closed", + ])("accepts canonical transport source URL %s", async (sourceUrl) => { + const source = new Uint8Array([4, 5, 6]); + const fetchImpl = vi.fn(async () => new Response(source)); + await expect(loadClosedLazyAssetSources([ + sourceBinding(URL_B, sourceUrl, source), + ], { fetchImpl })).resolves.toEqual([asset(URL_B, source)]); + expect(fetchImpl).toHaveBeenCalledWith(sourceUrl, { + cache: "no-store", + credentials: "omit", + redirect: "error", + signal: expect.any(AbortSignal), + }); + }); + + it.each([ + ["credentials", "https://user:secret@assets.example.test/package.zip"], + ["fragment", "https://assets.example.test/package.zip#fragment"], + ["empty fragment", "https://assets.example.test/package.zip#"], + ["relative empty fragment", "/assets/package.zip#"], + ["uppercase host", "https://ASSETS.example.test/package.zip"], + ["dot-segment normalization", "https://assets.example.test/a/../package.zip"], + ["relative dot-segment normalization", "/assets/a/../package.zip"], + ["non-root-relative path", "assets/package.zip"], + ["network-path reference", "//assets.example.test/package.zip"], + ])("rejects a transport source URL with %s", async (_name, sourceUrl) => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + await expect(loadClosedLazyAssetSources([ + sourceBinding(URL_B, sourceUrl), + ], { fetchImpl })).rejects.toThrow("must be canonical HTTP(S)"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([0, 17, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects invalid maxConcurrency %s before fetching", + async (maxConcurrency) => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + await expect(loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + maxConcurrency, + })).rejects.toThrow("concurrency must be an integer from 1 to 16"); + expect(fetchImpl).not.toHaveBeenCalled(); + }, + ); + + it("rejects empty, sparse, and coercible source manifests before fetching", async () => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + await expect(loadClosedLazyAssetSources([], { fetchImpl })).rejects.toThrow( + "at least one binding", + ); + + const sparse = new Array(2); + sparse[0] = sourceBinding(); + await expect(loadClosedLazyAssetSources(sparse, { fetchImpl })).rejects.toThrow( + "source 1 is missing", + ); + + const coercibleSha = { + toString: () => sourceBinding().sha256, + } as unknown as string; + await expect(loadClosedLazyAssetSources([{ + ...sourceBinding(), + sha256: coercibleSha, + }], { fetchImpl })).rejects.toThrow("invalid fields"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("snapshots every source field before starting transport I/O", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const original = sourceBinding(URL_B, "/assets/original.zip", bytes); + const mutable = { ...original }; + const manifest = [mutable]; + let resolveFetch!: (response: Response) => void; + const fetchImpl = vi.fn(() => new Promise((resolve) => { + resolveFetch = resolve; + })); + + const loading = loadClosedLazyAssetSources(manifest, { fetchImpl }); + mutable.url = URL_A; + mutable.sourceUrl = "/assets/mutated.zip"; + mutable.sha256 = "0".repeat(64); + mutable.size = 1; + manifest.push(sourceBinding()); + resolveFetch(new Response(bytes)); + + await expect(loading).resolves.toEqual([asset(original.url, bytes)]); + expect(fetchImpl.mock.calls[0]![0]).toBe(original.sourceUrl); + }); + + it("hashes the owned response buffer without an aggregate-sized copy", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const digest = crypto.subtle.digest.bind(crypto.subtle); + const digestInputs: BufferSource[] = []; + const digestSpy = vi.spyOn(crypto.subtle, "digest").mockImplementation( + async (algorithm, input) => { + digestInputs.push(input); + return digest(algorithm, input); + }, + ); + try { + const loaded = await loadClosedLazyAssetSources([ + sourceBinding(URL_B, "/assets/package.zip", bytes), + ], { + fetchImpl: async () => new Response(bytes), + }); + expect(digestInputs).toHaveLength(1); + expect(digestInputs[0]).toBe(loaded[0]!.bytes.buffer); + } finally { + digestSpy.mockRestore(); + } + }); + + it("redacts source queries and cancels an unused HTTP-error body", async () => { + const cancellationError = new Error("secondary cancellation failure"); + let cancellationReason: unknown; + const response = new Response(new ReadableStream({ + cancel(reason) { + cancellationReason = reason; + return Promise.reject(cancellationError); + }, + }), { status: 503 }); + const loading = loadClosedLazyAssetSources([ + sourceBinding(URL_B, "/assets/package.zip?token=private-value"), + ], { fetchImpl: async () => response }); + + const failure = await loading.then( + () => undefined, + (reason: unknown) => reason, + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain( + "/assets/package.zip? returned HTTP 503", + ); + expect((failure as Error).message).not.toContain("private-value"); + expect(cancellationReason).toBe(failure); + }); + + it("rejects an injected redirected response and cancels its body", async () => { + let cancellationReason: unknown; + const response = new Response(new ReadableStream({ + cancel(reason) { + cancellationReason = reason; + }, + })); + Object.defineProperty(response, "redirected", { value: true }); + const failure = await loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl: async () => response, + }).then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("followed a redirect"); + expect(cancellationReason).toBe(failure); + }); + + it("preserves a pre-aborted caller reason without starting I/O", async () => { + const controller = new AbortController(); + const reason = new Error("caller stopped before loading"); + controller.abort(reason); + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + + await expect(loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + signal: controller.signal, + })).rejects.toBe(reason); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("relays a caller abort to an active fetch and removes its listener", async () => { + const controller = new AbortController(); + const addListener = vi.spyOn(controller.signal, "addEventListener"); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + let internalSignal!: AbortSignal; + let started!: () => void; + const didStart = new Promise((resolve) => { + started = resolve; + }); + const fetchImpl = vi.fn((_input: string | URL, init?: RequestInit) => { + internalSignal = init!.signal as AbortSignal; + started(); + return new Promise((_resolve, reject) => { + internalSignal.addEventListener( + "abort", + () => reject(internalSignal.reason), + { once: true }, + ); + }); + }); + const loading = loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + signal: controller.signal, + }); + await didStart; + const reason = new Error("caller stopped active loading"); + controller.abort(reason); + + await expect(loading).rejects.toBe(reason); + expect(internalSignal).not.toBe(controller.signal); + expect(internalSignal.aborted).toBe(true); + expect(internalSignal.reason).toBe(reason); + const listener = addListener.mock.calls[0]![1]; + expect(removeListener).toHaveBeenCalledWith("abort", listener); + }); + + it("removes the caller abort listener after success and transport failure", async () => { + const scenarios = [ + async () => new Response(new Uint8Array([4, 5, 6])), + async () => { + throw new Error("transport failed"); + }, + ]; + for (const fetchImpl of scenarios) { + const controller = new AbortController(); + const addListener = vi.spyOn(controller.signal, "addEventListener"); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + await loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + signal: controller.signal, + }).catch(() => undefined); + + const listener = addListener.mock.calls[0]![1]; + expect(removeListener).toHaveBeenCalledWith("abort", listener); + } + }); + + it("keeps the first worker failure, stops dequeuing, and waits for peer cleanup", async () => { + const inputs = [0, 1, 2].map((index) => { + const bytes = new Uint8Array([10 + index]); + return sourceBinding( + `https://example.test/releases/${index}.zip`, + `/assets/${index}.zip`, + bytes, + ); + }); + const firstFailure = new Error("first source failed"); + let peerCancelReason: unknown; + let releasePeerCancel!: () => void; + const peerCancelGate = new Promise((resolve) => { + releasePeerCancel = resolve; + }); + const peerResponse = new Response(new ReadableStream({ + cancel(reason) { + peerCancelReason = reason; + return peerCancelGate; + }, + })); + const started: string[] = []; + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + started.push(url); + if (url === inputs[0]!.sourceUrl) throw firstFailure; + if (url === inputs[1]!.sourceUrl) return peerResponse; + return new Response(new Uint8Array([12])); + }); + + const loading = loadClosedLazyAssetSources(inputs, { + fetchImpl, + maxConcurrency: 2, + }); + let settled = false; + void loading.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(peerCancelReason).toBe(firstFailure)); + expect(started).toEqual([inputs[0]!.sourceUrl, inputs[1]!.sourceUrl]); + expect(settled).toBe(false); + releasePeerCancel(); + + await expect(loading).rejects.toBe(firstFailure); + expect(started).not.toContain(inputs[2]!.sourceUrl); + }); + + it("returns the exact overflow error only after stream cancellation finishes", async () => { + let cancellationReason: unknown; + let releaseCancellation!: () => void; + const cancellationGate = new Promise((resolve) => { + releaseCancellation = resolve; + }); + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([4, 5, 6, 7])); + }, + cancel(reason) { + cancellationReason = reason; + return cancellationGate; + }, + })); + const loading = loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl: async () => response, + }); + let settled = false; + void loading.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(cancellationReason).toBeInstanceOf(Error)); + expect((cancellationReason as Error).message).toContain("exceeds 3 bytes"); + expect(settled).toBe(false); + releaseCancellation(); + + await expect(loading).rejects.toBe(cancellationReason); + }); + + it("preserves an exact stream read failure", async () => { + const streamFailure = new Error("transport stream failed"); + const response = new Response(new ReadableStream({ + start(controller) { + controller.error(streamFailure); + }, + })); + + await expect(loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl: async () => response, + })).rejects.toBe(streamFailure); + }); + + it("limits concurrent fetches while preserving source order", async () => { + const inputs = [0, 1, 2].map((index) => { + const bytes = new Uint8Array([10 + index]); + return { + bytes, + binding: sourceBinding( + `https://example.test/releases/${index}.zip`, + `https://assets.example.test/releases/${index}.zip`, + bytes, + ), + }; + }); + const started: string[] = []; + const pending = new Map void>(); + let active = 0; + let peakActive = 0; + const fetchImpl = vi.fn((input: string | URL) => { + const url = String(input); + started.push(url); + active += 1; + peakActive = Math.max(peakActive, active); + return new Promise((resolve) => { + pending.set(url, (response) => { + active -= 1; + resolve(response); + }); + }); + }); + + const loading = loadClosedLazyAssetSources( + inputs.map(({ binding }) => binding), + { fetchImpl, maxConcurrency: 2 }, + ); + expect(started).toEqual([ + inputs[0]!.binding.sourceUrl, + inputs[1]!.binding.sourceUrl, + ]); + + pending.get(inputs[1]!.binding.sourceUrl)!(new Response(inputs[1]!.bytes)); + await vi.waitFor(() => { + expect(started).toEqual([ + inputs[0]!.binding.sourceUrl, + inputs[1]!.binding.sourceUrl, + inputs[2]!.binding.sourceUrl, + ]); + }); + pending.get(inputs[2]!.binding.sourceUrl)!(new Response(inputs[2]!.bytes)); + pending.get(inputs[0]!.binding.sourceUrl)!(new Response(inputs[0]!.bytes)); + + const loaded = await loading; + expect(peakActive).toBe(2); + expect(loaded.map(({ url }) => url)).toEqual( + inputs.map(({ binding }) => binding.url), + ); + expect(loaded.map(({ bytes }) => Array.from(bytes))).toEqual([[10], [11], [12]]); + }); + + it("bounds transport source count and declared total bytes before fetching", async () => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]))); + const tooMany = Array.from( + { length: MAX_CLOSED_LAZY_ASSETS + 1 }, + (_, index) => ({ + ...sourceBinding( + `https://example.test/releases/${index}.zip`, + `/assets/${index}.zip`, + new Uint8Array([index & 0xff]), + ), + }), + ); + await expect(loadClosedLazyAssetSources(tooMany, { fetchImpl })).rejects.toThrow( + `exceed ${MAX_CLOSED_LAZY_ASSETS} bindings`, + ); + + const oversized = [ + { + ...sourceBinding(URL_A, "/assets/a.zip", new Uint8Array([1])), + size: MAX_CLOSED_LAZY_ASSET_BYTES, + }, + { + ...sourceBinding(URL_B, "/assets/b.zip", new Uint8Array([2])), + size: 1, + }, + ]; + await expect(loadClosedLazyAssetSources(oversized, { fetchImpl })).rejects.toThrow( + `exceed ${MAX_CLOSED_LAZY_ASSET_BYTES} bytes`, + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("serves exact snapshotted bytes and content length", async () => { const source = new Uint8Array([1, 2, 3]); const fetcher = createClosedLazyAssetFetcher([asset(URL_A, source)]); @@ -69,6 +600,11 @@ describe("closed lazy assets", () => { [asset("https://example.test/a#fragment")], "canonical credential-free HTTPS", ], + [ + "empty-fragment URL", + [asset("https://example.test/a#")], + "canonical credential-free HTTPS", + ], [ "noncanonical URL", [asset("https://EXAMPLE.test/a")], @@ -88,6 +624,20 @@ describe("closed lazy assets", () => { expect(() => snapshotClosedLazyAssets(assets)).toThrow(message); }); + it("rejects sparse assets and coercible digests before copying bytes", () => { + const sparse = new Array(2); + sparse[0] = asset(); + expect(() => snapshotClosedLazyAssets(sparse)).toThrow("asset 1 is missing"); + + const coercibleSha = { + toString: () => asset().sha256, + } as unknown as string; + expect(() => snapshotClosedLazyAssets([{ + ...asset(), + sha256: coercibleSha, + }])).toThrow("invalid fields"); + }); + it("returns defensive byte copies", () => { const source = new Uint8Array([4, 5, 6]); const snapshot = snapshotClosedLazyAssets([asset(URL_A, source)]);