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 <noreply@anthropic.com>
This commit is contained in:
parent
c2bb187843
commit
5998ee95a0
1 changed files with 27 additions and 13 deletions
|
|
@ -13,7 +13,7 @@ export type CultMeshContentErrorCode =
|
|||
const MESSAGES: Record<CultMeshContentErrorCode, string> = {
|
||||
"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<void> {
|
||||
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<void> {
|
||||
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<never>((_, 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue