From 5998ee95a0dbceb9aba413d0b1028301a35b77bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 18:26:26 +0200 Subject: [PATCH] cultmesh-browser: reader refuses redirects and URL-divergent endpoints, surfaces failure at once - redirect: "error" on every request, so no byte comes from a place the endpoint gate did not see - endpoints on which .NET UriBuilder and WHATWG URL differ are refused (insecure-endpoint) instead of translated - the first failure or caller abort rejects without waiting for a fetch that ignores its signal - a caller abort landing after the last byte rejects instead of returning bytes - an unallocatable size is too-large, not a raw RangeError - the reader's own whole-number size rule is deleted; validateManifest owns it Co-Authored-By: Claude Opus 5.5 --- packages/cultmesh-browser/src/content.ts | 40 ++++++++++++++++-------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/cultmesh-browser/src/content.ts b/packages/cultmesh-browser/src/content.ts index 18b5e659..bc452265 100644 --- a/packages/cultmesh-browser/src/content.ts +++ b/packages/cultmesh-browser/src/content.ts @@ -13,7 +13,7 @@ export type CultMeshContentErrorCode = 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.", + "insecure-endpoint": "Content endpoints must be plain 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.", @@ -48,6 +48,14 @@ const isByteCount = (value: number): boolean => Number.isSafeInteger(value) && v // 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()}`); +// An endpoint the request URL of which is the same string in .NET `UriBuilder` and WHATWG `URL`. Both are free to rewrite what +// they dislike (`'` in a query, `{}|^\` and non-ASCII anywhere, a percent sign that is not an escape, an escaped unreserved +// character, an IDN host), and they rewrite it differently, which would split CDN cache entries between runtimes; so those +// spellings are refused instead of translated. +const PLAIN_ENDPOINT = /^(?:[A-Za-z0-9\-._~:/?#@!$&*()+,;=[\]]|%[0-9A-Fa-f]{2})*$/; +const ESCAPED_UNRESERVED = /%(?:2[de]|3\d|4[1-9a-f]|5[0-9af]|6[1-9a-f]|7[0-9ae])/i; +const isPlainEndpoint = (endpoint: string): boolean => PLAIN_ENDPOINT.test(endpoint) && !ESCAPED_UNRESERVED.test(endpoint); + /** `CultMeshHttpsContentTransportConnector.BuildChunkUri`: an existing query (even an empty one, leading `?`s trimmed) is kept and joined with `&`. */ function chunkUrl(endpoint: string, hash: string, recordKey: string): string { const url = new URL(endpoint); @@ -62,19 +70,24 @@ function resolve(endpoint: string, options: CultMeshContentFetchOptions): Resolv 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; } })(); + const secure = (() => { try { const { protocol } = new URL(endpoint); return isPlainEndpoint(endpoint) && (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 }; } +/** The one private buffer. A size the runtime cannot allocate is an artifact too large for this reader, not a bad option. */ +function allocate(sizeBytes: number): Uint8Array { + try { return new Uint8Array(sizeBytes); } catch (cause) { throw fail("too-large", undefined, undefined, cause); } +} + 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. */ +/** One chunk: GET (a redirect is a failure: the endpoint gate cannot see where it would lead), 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); }); + const response = await r.doFetch(url, { method: "GET", credentials: "omit", redirect: "error", 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; @@ -89,21 +102,21 @@ async function readChunk(r: Resolved, signal: AbortSignal, { url, hash, slot }: 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. */ +/** A sliding window of chunk reads. The first failure aborts every request still in flight and is thrown at once. */ 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); } + for (let job = jobs[next++]; job && !internal.signal.aborted; job = jobs[next++]) { + try { await readChunk(r, internal.signal, job); } catch (error) { internal.abort(error); } } }; - try { await Promise.all(Array.from({ length: Math.min(r.concurrency, jobs.length) }, worker)); } + // The rejection waits for no worker: a fetch that ignores its signal must not hold a failure back. + const failed = new Promise((_, reject) => internal.signal.addEventListener("abort", () => reject(internal.signal.reason), { once: true })); + try { await Promise.race([Promise.all(Array.from({ length: Math.min(r.concurrency, jobs.length) }, worker)), failed]); } 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. */ @@ -115,15 +128,15 @@ export async function fetchVerifiedContent(endpoint: string, manifest: CultMeshC validateManifest(manifest); contentHash = parseSha256Hex(manifest.contentHash, "contentHash"); const refs = manifest.chunks; - if (!isByteCount(manifest.sizeBytes) || !refs.every(ref => 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); + const buffer = allocate(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"); + r.caller?.throwIfAborted(); return buffer; } @@ -137,7 +150,8 @@ export async function fetchVerifiedChunk(endpoint: string, chunkHash: string, si } catch (cause) { throw fail("manifest-invalid", undefined, undefined, cause); } if (sizeBytes > r.maxBytes) throw fail("too-large"); requireWebCrypto(); - const slot = new Uint8Array(sizeBytes); + const slot = allocate(sizeBytes); await readChunks(r, [{ url: chunkUrl(endpoint, hash, chunkRecordKey(hash)), hash, slot }]); + r.caller?.throwIfAborted(); return slot; }