diff --git a/packages/cultmesh-browser/src/content.ts b/packages/cultmesh-browser/src/content.ts new file mode 100644 index 00000000..802b4ebf --- /dev/null +++ b/packages/cultmesh-browser/src/content.ts @@ -0,0 +1,143 @@ +// Verified reads of CultMesh CDN content over HTTP(S). This module is the only place bytes are released to a caller, and it +// releases them after judging what it received: each chunk against its hash, then the whole body against the manifest's. +// The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence). +// Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here. + +import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; +import { isLoopbackEndpoint } from "cultnet-ts/authority"; + +export type CultMeshContentErrorCode = + | "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network" + | "http-status" | "length-mismatch" | "chunk-hash-mismatch" | "content-hash-mismatch"; + +const MESSAGES: Record = { + "manifest-invalid": "The CDN manifest or chunk description is invalid.", + "too-large": "The content is larger than maxBytes.", + "insecure-endpoint": "Content endpoints must be https, or http on loopback.", + "webcrypto-unavailable": "WebCrypto SHA-256 is unavailable.", + "network": "The content request failed.", + "http-status": "The content server did not answer 200.", + "length-mismatch": "The content body did not match the chunk size.", + "chunk-hash-mismatch": "A content chunk did not match its hash.", + "content-hash-mismatch": "The content did not match the manifest content hash.", +}; + +/** A refusal to release bytes. The message is fixed per code and never carries the endpoint or any body byte. */ +export class CultMeshContentError extends Error { + constructor(readonly code: CultMeshContentErrorCode, readonly chunkHash?: string, readonly status?: number, cause?: unknown) { + super(MESSAGES[code], cause === undefined ? undefined : { cause }); + this.name = "CultMeshContentError"; + } +} + +export interface CultMeshContentFetchOptions { + readonly signal?: AbortSignal; + /** Largest whole body, checked before any request. Default 256 MiB. */ + readonly maxBytes?: number; + /** Chunk requests in flight at once, an integer 1..32. Default 4. */ + readonly maxConcurrentChunkRequests?: number; + readonly fetch?: typeof globalThis.fetch; +} + +interface Resolved { readonly doFetch: typeof globalThis.fetch; readonly caller?: AbortSignal; readonly maxBytes: number; readonly concurrency: number } +interface Job { readonly url: string; readonly hash: string; readonly slot: Uint8Array } + +const fail = (code: CultMeshContentErrorCode, chunkHash?: string, status?: number, cause?: unknown) => new CultMeshContentError(code, chunkHash, status, cause); +const isByteCount = (value: number): boolean => Number.isSafeInteger(value) && value >= 0; + +// Uri.EscapeDataString: encodeURIComponent, plus the five characters it leaves bare. +const escapeData = (value: string): string => encodeURIComponent(value).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); + +/** `CultMeshHttpsContentTransportConnector.BuildChunkUri`: an existing query (even an empty one) is kept and joined with `&`. */ +function chunkUrl(endpoint: string, hash: string, recordKey: string): string { + const url = new URL(endpoint); + const kept = url.href.split("#")[0]!.includes("?") ? `${url.search.slice(1)}&` : ""; + url.search = `${kept}chunkHash=${escapeData(hash)}&recordKey=${escapeData(recordKey)}`; + return url.href; +} + +/** Options, caller abort and endpoint scheme: everything that can be refused without reading the manifest. */ +function resolve(endpoint: string, options: CultMeshContentFetchOptions): Resolved { + const { signal, maxBytes = 268435456, maxConcurrentChunkRequests: concurrency = 4, fetch } = options; + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32) throw new RangeError("maxConcurrentChunkRequests must be an integer from 1 to 32."); + if (!isByteCount(maxBytes)) throw new RangeError("maxBytes must be a non-negative safe integer."); + if (signal?.aborted) throw signal.reason; + const secure = (() => { try { const { protocol } = new URL(endpoint); return protocol === "https:" || (protocol === "http:" && isLoopbackEndpoint(endpoint)); } catch { return false; } })(); + if (!secure) throw fail("insecure-endpoint"); + return { doFetch: fetch ?? ((input, init) => globalThis.fetch(input, init)), caller: signal, maxBytes, concurrency }; +} + +const requireWebCrypto = (): void => { if (!globalThis.crypto?.subtle) throw fail("webcrypto-unavailable"); }; + +/** One chunk: GET, status 200, the body streamed into its slot and cut at the first byte past the slot, then the chunk hash. */ +async function readChunk(r: Resolved, signal: AbortSignal, { url, hash, slot }: Job): Promise { + if (slot.length === 0) { if (hash !== EMPTY_SHA256) throw fail("chunk-hash-mismatch", hash); return; } + r.caller?.throwIfAborted(); + const network = (cause: unknown) => r.caller?.aborted ? r.caller.reason : fail("network", hash, undefined, cause); + const response = await r.doFetch(url, { method: "GET", credentials: "omit", signal }).catch(cause => { throw network(cause); }); + if (response.status !== 200) { void response.body?.cancel().catch(() => {}); throw fail("http-status", hash, response.status); } + const reader = response.body?.getReader(); + let filled = 0; + try { + for (let part = await reader?.read(); part && !part.done; part = await reader!.read()) { + if (filled + part.value.length > slot.length) { void reader!.cancel().catch(() => {}); throw fail("length-mismatch", hash); } + slot.set(part.value, filled); + filled += part.value.length; + } + } catch (error) { throw error instanceof CultMeshContentError ? error : network(error); } + if (filled < slot.length) throw fail("length-mismatch", hash); + if (await sha256Hex(slot) !== hash) throw fail("chunk-hash-mismatch", hash); +} + +/** A sliding window of chunk reads. The first failure aborts every request still in flight and is the one thrown. */ +async function readChunks(r: Resolved, jobs: readonly Job[]): Promise { + const internal = new AbortController(); + const relay = () => internal.abort(r.caller!.reason); + r.caller?.addEventListener("abort", relay, { once: true }); + let failure: { error: unknown } | undefined; + let next = 0; + const worker = async () => { + for (let job = jobs[next++]; job && !failure; job = jobs[next++]) { + try { await readChunk(r, internal.signal, job); } catch (error) { failure ??= { error }; internal.abort(error); } + } + }; + try { await Promise.all(Array.from({ length: Math.min(r.concurrency, jobs.length) }, worker)); } + finally { r.caller?.removeEventListener("abort", relay); } + if (failure) throw failure.error; +} + +/** The whole body of a manifest's artifact, returned only after every chunk and the whole-body hash verified. */ +export async function fetchVerifiedContent(endpoint: string, manifest: CultMeshCdnArtifactManifest, options: CultMeshContentFetchOptions = {}): Promise { + const r = resolve(endpoint, options); + let contentHash: string; + let planned: { hash: string; url: string; offset: number; size: number }[]; + try { + validateManifest(manifest); + contentHash = parseSha256Hex(manifest.contentHash, "contentHash"); + const refs = [...manifest.chunks].sort((a, b) => a.offset - b.offset); + if (!isByteCount(manifest.sizeBytes) || !refs.every(ref => isByteCount(ref.offset) && isByteCount(ref.sizeBytes))) throw new Error("Sizes must be safe integers."); + if (refs.length > 1 && refs.some(ref => ref.sizeBytes === 0)) throw new Error("A zero-size chunk must be the only chunk."); + planned = refs.map(ref => { const hash = parseSha256Hex(ref.chunkHash, "chunkHash"); return { hash, url: chunkUrl(endpoint, hash, ref.recordKey ?? ""), offset: ref.offset, size: ref.sizeBytes }; }); + } catch (cause) { throw fail("manifest-invalid", undefined, undefined, cause); } + if (manifest.sizeBytes > r.maxBytes) throw fail("too-large"); + requireWebCrypto(); + const buffer = new Uint8Array(manifest.sizeBytes); + await readChunks(r, planned.map(({ hash, url, offset, size }) => ({ url, hash, slot: buffer.subarray(offset, offset + size) }))); + if (await sha256Hex(buffer) !== contentHash) throw fail("content-hash-mismatch"); + return buffer; +} + +/** One chunk by hash and size, over the same primitive; the record key is the one a packed manifest carries. */ +export async function fetchVerifiedChunk(endpoint: string, chunkHash: string, sizeBytes: number, options: CultMeshContentFetchOptions = {}): Promise { + const r = resolve(endpoint, options); + let hash: string; + try { + hash = parseSha256Hex(chunkHash, "chunkHash"); + if (!isByteCount(sizeBytes)) throw new Error("Size must be a safe integer."); + } catch (cause) { throw fail("manifest-invalid", undefined, undefined, cause); } + if (sizeBytes > r.maxBytes) throw fail("too-large"); + requireWebCrypto(); + const slot = new Uint8Array(sizeBytes); + await readChunks(r, [{ url: chunkUrl(endpoint, hash, chunkRecordKey(hash)), hash, slot }]); + return slot; +} diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index 5c20806c..beae9e36 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -32,6 +32,7 @@ import { type CultMeshAuthorityTrustPolicy, type CultMeshP256PublicKey, } from "cultnet-ts/authority"; +export * from "./content.js"; export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; export type CultMeshBrowserRoute = CultMeshAuthorityRouteView; diff --git a/packages/cultmesh-browser/test/content.test.ts b/packages/cultmesh-browser/test/content.test.ts new file mode 100644 index 00000000..d01089aa --- /dev/null +++ b/packages/cultmesh-browser/test/content.test.ts @@ -0,0 +1,354 @@ +// The verified content reader, rule by rule. Chunks and manifests come from cultnet-ts's own packContent, so the reader is +// judged against the shapes the provider will serve; the URL expectations are the .NET `BuildChunkUri` AbsoluteUri strings +// probed for the map (F5, F19), not a spelling of this module's own builder. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { gzipSync } from "node:zlib"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; + +import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js"; + +const ENDPOINT = "https://cdn.example/content"; +const body = (length: number): Uint8Array => Uint8Array.from({ length }, (_, index) => (index * 7 + 3) & 255); +const pack = (length: number, chunkSizeBytes: number) => packContent("art", body(length), { chunkSizeBytes, createdAtUtc: "2026-01-01T00:00:00.0000000+00:00" }); +const streamOf = (bytes: Uint8Array, onCancel?: () => void) => + new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); }, cancel: onCancel }); +const withChunk = (manifest: CultMeshCdnArtifactManifest, index: number, change: object): CultMeshCdnArtifactManifest => + ({ ...manifest, chunks: manifest.chunks.map((chunk, i) => i === index ? { ...chunk, ...change } : chunk) }); +const codeOf = (code: CultMeshContentErrorCode, extra: object = {}) => (error: unknown) => + error instanceof CultMeshContentError && error.code === code && Object.entries(extra).every(([key, value]) => (error as unknown as Record)[key] === value); + +interface Call { url: string; init: RequestInit } +type Handler = (call: Call, index: number) => Response | Promise; + +/** A fetch double: records every call, counts requests in flight (the handler's lifetime) and their peak. */ +function double(handler: Handler) { + const calls: Call[] = []; + const state = { inflight: 0, peak: 0 }; + const fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const call = { url: String(input), init: init ?? {} }; + const index = calls.push(call) - 1; + state.peak = Math.max(state.peak, ++state.inflight); + try { return await handler(call, index); } finally { state.inflight--; } + }) as typeof globalThis.fetch; + return { fetch, calls, state }; +} + +/** Serves each requested chunk by the `chunkHash` query parameter, after `delayMs`. */ +function chunkServer(chunks: { chunkHash: string; payload: Uint8Array }[], delayMs = 0) { + const byHash = new Map(chunks.map(chunk => [chunk.chunkHash, chunk.payload])); + return double(async call => { + if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs)); + const payload = byHash.get(new URL(call.url).searchParams.get("chunkHash")!); + return payload ? new Response(payload as BodyInit) : new Response(null, { status: 404 }); + }); +} + +/** A response that never settles until the request's signal aborts, as a real fetch rejects with the signal's reason. */ +const hangUntilAborted = (call: Call) => new Promise((_, reject) => { + const signal = call.init.signal!; + if (signal.aborted) reject(signal.reason); else signal.addEventListener("abort", () => reject(signal.reason), { once: true }); +}); + +test("request URLs equal the .NET BuildChunkUri AbsoluteUri for every probed endpoint shape", async () => { + const { manifest, chunks } = await pack(1, 4); + const hex = manifest.chunks[0]!.chunkHash; + const cases: [string, string, string][] = [ + ["https://ygg.example/content", "mesh:cdn:chunk:" + hex, `https://ygg.example/content?chunkHash=${hex}&recordKey=mesh%3Acdn%3Achunk%3A${hex}`], + ["https://ygg.example/content?tenant=sp", "k", `https://ygg.example/content?tenant=sp&chunkHash=${hex}&recordKey=k`], + ["https://h", "k", `https://h/?chunkHash=${hex}&recordKey=k`], + ["https://h/", "k", `https://h/?chunkHash=${hex}&recordKey=k`], + ["https://h:443/x", "k", `https://h/x?chunkHash=${hex}&recordKey=k`], + ["https://H.Example/X/", "k", `https://h.example/X/?chunkHash=${hex}&recordKey=k`], + ["https://h/x#frag", "k", `https://h/x?chunkHash=${hex}&recordKey=k#frag`], + ["https://h/x?a=%20b+c", "k", `https://h/x?a=%20b+c&chunkHash=${hex}&recordKey=k`], + ["https://h/x?", "k", `https://h/x?&chunkHash=${hex}&recordKey=k`], + ["https://[::1]:8443/c", "k", `https://[::1]:8443/c?chunkHash=${hex}&recordKey=k`], + ["https://h/x", "", `https://h/x?chunkHash=${hex}&recordKey=`], + ["https://h/x", "k with space/ü&=?#", `https://h/x?chunkHash=${hex}&recordKey=k%20with%20space%2F%C3%BC%26%3D%3F%23`], + ["https://h/x", "!'()*~-._ +é𐐀", `https://h/x?chunkHash=${hex}&recordKey=%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`], + ]; + for (const [endpoint, recordKey, expected] of cases) { + const server = chunkServer(chunks); + await fetchVerifiedContent(endpoint, withChunk(manifest, 0, { recordKey }), { fetch: server.fetch }); + assert.equal(server.calls[0]!.url, expected, endpoint); + } +}); + +test("hash spellings are normalized into the URL: a prefixed upper-case manifest hash is requested as bare lower-case hex", async () => { + const { manifest, chunks } = await pack(5, 5); + const hex = manifest.chunks[0]!.chunkHash; + const server = chunkServer(chunks); + const spelled = { ...withChunk(manifest, 0, { chunkHash: `SHA256:${hex.toUpperCase()}` }), contentHash: hex.toUpperCase() }; + assert.deepEqual(await fetchVerifiedContent(ENDPOINT, spelled, { fetch: server.fetch }), body(5)); + assert.equal(new URL(server.calls[0]!.url).searchParams.get("chunkHash"), hex); +}); + +test("a 5.5-chunk manifest returns the input bytes with one credential-free GET per chunk, whatever order the refs are listed in", async () => { + const { manifest, chunks } = await pack(55, 10); + assert.equal(manifest.chunks.length, 6); + for (const refs of [manifest.chunks, [...manifest.chunks].reverse()]) { + const server = chunkServer(chunks); + assert.deepEqual(await fetchVerifiedContent(ENDPOINT, { ...manifest, chunks: refs }, { fetch: server.fetch }), body(55)); + assert.equal(server.calls.length, 6); + assert.deepEqual(new Set(server.calls.map(call => new URL(call.url).searchParams.get("chunkHash"))), new Set(manifest.chunks.map(chunk => chunk.chunkHash))); + for (const call of server.calls) { assert.equal(call.init.method, "GET"); assert.equal(call.init.credentials, "omit"); } + } +}); + +test("with 10 chunks, maxConcurrentChunkRequests 3 peaks at exactly 3 in flight and the default at 4; bad bounds reject RangeError with no fetch", async () => { + const { manifest, chunks } = await pack(100, 10); + for (const [limit, peak] of [[3, 3], [undefined, 4], [1, 1], [32, 10]] as const) { + const server = chunkServer(chunks, 5); + assert.deepEqual(await fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, maxConcurrentChunkRequests: limit }), body(100)); + assert.equal(server.state.peak, peak, `limit ${limit}`); + } + for (const maxConcurrentChunkRequests of [0, 33, 1.5, Number.NaN]) { + const server = chunkServer(chunks); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, maxConcurrentChunkRequests }), RangeError); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash, 10, { fetch: server.fetch, maxConcurrentChunkRequests }), RangeError); + assert.equal(server.calls.length, 0); + } + for (const maxBytes of [-1, 1.5, Number.NaN]) await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { maxBytes }), RangeError); +}); + +test("an empty artifact returns an empty array with no fetch, but still answers to its content hash", async () => { + const { manifest } = await pack(0, 4); + const server = double(() => { throw new Error("no request expected"); }); + const result = await fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch }); + assert.equal(result.length, 0); + await assert.rejects(fetchVerifiedContent(ENDPOINT, { ...manifest, contentHash: "ab".repeat(32) }, { fetch: server.fetch }), codeOf("content-hash-mismatch")); + await assert.rejects(fetchVerifiedContent(ENDPOINT, withChunk(manifest, 0, { chunkHash: "ab".repeat(32) }), { fetch: server.fetch }), codeOf("chunk-hash-mismatch", { chunkHash: "ab".repeat(32) })); + assert.equal(server.calls.length, 0); +}); + +test("a zero-size chunk beside others is manifest-invalid with no fetch", async () => { + const { manifest } = await pack(8, 4); + const [first, second] = manifest.chunks as [typeof manifest.chunks[number], typeof manifest.chunks[number]]; + const flood = { ...manifest, chunks: [first, { ...first, chunkHash: EMPTY_SHA256, offset: 4, sizeBytes: 0 }, second] }; + const server = double(() => { throw new Error("no request expected"); }); + await assert.rejects(fetchVerifiedContent(ENDPOINT, flood, { fetch: server.fetch }), codeOf("manifest-invalid")); + assert.equal(server.calls.length, 0); +}); + +test("the byte cap is checked before any request: one over maxBytes and one over the 256 MiB default are too-large, the cap itself is not", async () => { + const { manifest, chunks } = await pack(10, 5); + const server = chunkServer(chunks); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, maxBytes: 9 }), codeOf("too-large")); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash, 5, { fetch: server.fetch, maxBytes: 4 }), codeOf("too-large")); + assert.equal(server.calls.length, 0); + assert.deepEqual(await fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, maxBytes: 10 }), body(10)); + const huge = (size: number) => ({ ...manifest, sizeBytes: size, chunks: [{ ...manifest.chunks[0]!, offset: 0, sizeBytes: size }] }); + const refuses = double(() => { throw new TypeError("offline"); }); + await assert.rejects(fetchVerifiedContent(ENDPOINT, huge(268435457), { fetch: refuses.fetch }), codeOf("too-large")); + assert.equal(refuses.calls.length, 0); + await assert.rejects(fetchVerifiedContent(ENDPOINT, huge(268435456), { fetch: refuses.fetch }), codeOf("network")); +}); + +test("https is required, http only on loopback; ftp and unparseable endpoints are insecure-endpoint with no fetch", async () => { + const { manifest, chunks } = await pack(5, 5); + for (const endpoint of ["http://example.com/c", "ftp://127.0.0.1/c", "not a url", "", "//cdn.example/c"]) { + const server = chunkServer(chunks); + await assert.rejects(fetchVerifiedContent(endpoint, manifest, { fetch: server.fetch }), codeOf("insecure-endpoint"), endpoint); + await assert.rejects(fetchVerifiedChunk(endpoint, manifest.chunks[0]!.chunkHash, 5, { fetch: server.fetch }), codeOf("insecure-endpoint"), endpoint); + assert.equal(server.calls.length, 0); + } + for (const endpoint of ["http://127.0.0.1:8080/c", "http://localhost/c", "http://[::1]:9/c", "https://cdn.example/c"]) { + const server = chunkServer(chunks); + assert.deepEqual(await fetchVerifiedContent(endpoint, manifest, { fetch: server.fetch }), body(5), endpoint); + } +}); + +test("non-hex hashes and a gapped manifest are manifest-invalid with no fetch, in both exports", async () => { + const { manifest, chunks } = await pack(10, 5); + const server = chunkServer(chunks); + const bad = [ + withChunk(manifest, 1, { chunkHash: "g".repeat(64) }), + { ...manifest, contentHash: "abc" }, + withChunk(manifest, 1, { offset: 6 }), + { ...manifest, sizeBytes: 11 }, + { ...manifest, sizeBytes: 10.5 }, + ]; + for (const candidate of bad) await assert.rejects(fetchVerifiedContent(ENDPOINT, candidate, { fetch: server.fetch }), codeOf("manifest-invalid")); + for (const [hash, size] of [["xyz", 5], [manifest.chunks[0]!.chunkHash, -1], [manifest.chunks[0]!.chunkHash, 1.5], [manifest.chunks[0]!.chunkHash, Number.NaN]] as const) { + await assert.rejects(fetchVerifiedChunk(ENDPOINT, hash, size, { fetch: server.fetch }), codeOf("manifest-invalid")); + } + assert.equal(server.calls.length, 0); +}); + +test("statuses 404, 500, 206 and 304 are http-status carrying the status, and the body is cancelled", async () => { + const { manifest } = await pack(5, 5); + for (const status of [404, 500, 206, 304]) { + let cancelled = false; + const server = double(() => new Response(status === 304 ? null : streamOf(body(5), () => { cancelled = true; }), { status })); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch }), codeOf("http-status", { status, chunkHash: manifest.chunks[0]!.chunkHash })); + assert.equal(cancelled, status !== 304, String(status)); + } +}); + +test("wrong bytes of the right length are chunk-hash-mismatch naming the chunk; the other in-flight requests are aborted and nothing is returned", async () => { + const { manifest, chunks } = await pack(30, 10); + const signals: AbortSignal[] = []; + const server = double((call, index) => { + signals.push(call.init.signal!); + return index === 0 ? new Response(new Uint8Array(10).fill(9)) : hangUntilAborted(call); + }); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, maxConcurrentChunkRequests: 3 }), + codeOf("chunk-hash-mismatch", { chunkHash: chunks[0]!.chunkHash })); + assert.equal(signals.length, 3); + assert.deepEqual(signals.map(signal => signal.aborted), [true, true, true]); +}); + +test("chunks that each verify under a wrong contentHash are content-hash-mismatch", async () => { + const { manifest, chunks } = await pack(20, 10); + const server = chunkServer(chunks); + await assert.rejects(fetchVerifiedContent(ENDPOINT, { ...manifest, contentHash: await sha256Hex(body(21)) }, { fetch: server.fetch }), codeOf("content-hash-mismatch")); +}); + +test("an endless body is cancelled at the first byte past the chunk, a short body is refused at its end", async () => { + const { manifest } = await pack(10, 10); + let pulled = 0; + let cancelled = false; + const endless = double(() => new Response(new ReadableStream({ + pull(controller) { pulled++; controller.enqueue(Uint8Array.of(1)); }, cancel() { cancelled = true; }, + }, { highWaterMark: 0 }))); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: endless.fetch }), codeOf("length-mismatch")); + assert.ok(pulled <= 11, `pulled ${pulled}`); + assert.ok(cancelled); + for (const length of [0, 9]) { + const short = double(() => new Response(body(10).subarray(0, length) as BodyInit)); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: short.fetch }), codeOf("length-mismatch")); + } + const bodyless = double(() => new Response(null)); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: bodyless.fetch }), codeOf("length-mismatch")); +}); + +test("a fetch TypeError and a mid-body stream error are network, with the cause kept", async () => { + const { manifest } = await pack(10, 10); + const down = new TypeError("fetch failed"); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: double(() => { throw down; }).fetch }), (error: unknown) => codeOf("network")(error) && (error as Error).cause === down); + const boom = new Error("boom"); + const broken = double(() => new Response(new ReadableStream({ start(controller) { controller.enqueue(body(4)); controller.error(boom); } }))); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: broken.fetch }), (error: unknown) => codeOf("network")(error) && (error as Error).cause === boom); +}); + +test("a pre-aborted signal rejects with its reason and no fetch; an abort mid-flight rejects with the same reason and starts no further fetch", async () => { + const { manifest } = await pack(100, 10); + const reason = new Error("caller gave up"); + const server = double(hangUntilAborted); + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, signal: AbortSignal.abort(reason) }), (error: unknown) => error === reason); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash, 10, { fetch: server.fetch, signal: AbortSignal.abort(reason) }), (error: unknown) => error === reason); + assert.equal(server.calls.length, 0); + const controller = new AbortController(); + const pending = fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, signal: controller.signal, maxConcurrentChunkRequests: 2 }); + while (server.calls.length < 2) await new Promise(resolve => setTimeout(resolve, 1)); + controller.abort(reason); + await assert.rejects(pending, (error: unknown) => error === reason); + await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(server.calls.length, 2); +}); + +test("with crypto.subtle shadowed, both exports are webcrypto-unavailable before any fetch", async () => { + const { manifest, chunks } = await pack(5, 5); + const server = chunkServer(chunks); + const original = Object.getOwnPropertyDescriptor(globalThis, "crypto")!; + Object.defineProperty(globalThis, "crypto", { value: {}, configurable: true }); + try { + await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch }), codeOf("webcrypto-unavailable")); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash, 5, { fetch: server.fetch }), codeOf("webcrypto-unavailable")); + } finally { Object.defineProperty(globalThis, "crypto", original); } + assert.equal(server.calls.length, 0); +}); + +test("fetchVerifiedChunk sends the packed record key, verifies, sends nothing for size 0, and refuses wrong bytes", async () => { + const { manifest, chunks } = await pack(10, 10); + const hex = manifest.chunks[0]!.chunkHash; + const server = chunkServer(chunks); + assert.deepEqual(await fetchVerifiedChunk(ENDPOINT, hex.toUpperCase(), 10, { fetch: server.fetch }), body(10)); + assert.equal(server.calls[0]!.url, `${ENDPOINT}?chunkHash=${hex}&recordKey=mesh%3Acdn%3Achunk%3A${hex}`); + assert.equal(server.calls[0]!.init.credentials, "omit"); + const none = double(() => { throw new Error("no request expected"); }); + assert.equal((await fetchVerifiedChunk(ENDPOINT, EMPTY_SHA256, 0, { fetch: none.fetch })).length, 0); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, hex, 0, { fetch: none.fetch }), codeOf("chunk-hash-mismatch", { chunkHash: hex })); + assert.equal(none.calls.length, 0); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, hex, 10, { fetch: double(() => new Response(new Uint8Array(10))).fetch }), codeOf("chunk-hash-mismatch", { chunkHash: hex })); +}); + +test("no error message contains the endpoint, its secret query, or a body byte", async () => { + const secret = "https://cdn.example/c?token=CANARYSECRET"; + const { manifest } = await pack(10, 10); + const canary = new TextEncoder().encode("BODYCANARY"); + const doubles: [CultMeshContentErrorCode, Handler | undefined][] = [ + ["http-status", () => new Response(canary, { status: 500 })], + ["chunk-hash-mismatch", () => new Response(canary)], + ["length-mismatch", () => new Response(new Uint8Array(40).fill(66))], + ["network", () => { throw new TypeError(`failed ${secret}`); }], + ["insecure-endpoint", undefined], + ]; + for (const [code, handler] of doubles) { + const server = double(handler ?? (() => new Response())); + const error = await fetchVerifiedContent(code === "insecure-endpoint" ? "http://example.com/c?token=CANARYSECRET" : secret, manifest, { fetch: server.fetch }).then(() => undefined, reason => reason); + assert.ok(codeOf(code)(error), code); + for (const text of [(error as Error).message, String(error)]) { assert.doesNotMatch(text, /CANARY|cdn\.example|token/); } + } +}); + +interface Stub { endpoint: string; close(): Promise } +async function stub(handler: (request: IncomingMessage, response: ServerResponse) => void): Promise { + const server = createServer(handler); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { + endpoint: `http://127.0.0.1:${(server.address() as AddressInfo).port}/content`, + close: () => new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }), + }; +} + +test("stub server through the real global fetch: gzip verifies, a short body is network, an endless body is cut, 404 is http-status", async () => { + const { manifest, chunks } = await pack(1000, 1000); + const payload = chunks[0]!.payload; + const modes = new Map void>(); + const server = await stub((request, response) => modes.get(new URL(request.url!, "http://x").searchParams.get("tenant")!)!(request, response)); + const via = (tenant: string) => fetchVerifiedContent(`${server.endpoint}?tenant=${tenant}`, manifest); + try { + modes.set("gzip", (_, response) => { + const encoded = gzipSync(payload); + response.writeHead(200, { "content-encoding": "gzip", "content-length": encoded.length, "access-control-allow-origin": "*" }); + response.end(encoded); + }); + assert.deepEqual(await via("gzip"), payload); + + modes.set("short", (_, response) => { + response.writeHead(200, { "content-length": 1000 }); + response.write(payload.subarray(0, 400)); + setTimeout(() => response.socket!.destroy(), 20); + }); + await assert.rejects(via("short"), codeOf("network")); + + let sawClose!: () => void; + const closed = new Promise(resolve => { sawClose = resolve; }); + modes.set("endless", (_, response) => { + response.writeHead(200); + const timer = setInterval(() => response.write(new Uint8Array(1000)), 1); + response.on("close", () => { clearInterval(timer); sawClose(); }); + }); + await assert.rejects(via("endless"), codeOf("length-mismatch")); + await closed; + + modes.set("missing", (_, response) => { response.writeHead(404, { "access-control-allow-origin": "*" }); response.end("nope"); }); + await assert.rejects(via("missing"), codeOf("http-status", { status: 404 })); + } finally { await server.close(); } +}); + +test("esbuild bundles dist/content.js with platform 'browser', and a node:crypto import fails the same bundle", async () => { + const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dist", "content.js"); + const options = { bundle: true, platform: "browser", format: "esm", write: false, logLevel: "silent" } as const; + const result = await build({ ...options, entryPoints: [entry] }); + assert.ok(result.outputFiles[0]!.text.includes("CultMeshContentError")); + await assert.rejects(build({ ...options, stdin: { contents: `import "node:crypto"; import ${JSON.stringify(entry)};`, resolveDir: dirname(entry) } })); +});