cultmesh-browser content: trim every leading '?' like TrimStart, drop the unneeded sort, test the survivors
BuildChunkUri trims all leading '?' of an existing query; the reader trimmed one. Chunk placement is by offset, so sorting the refs decided nothing and is gone; the size guard checks sizes, which fix the offsets. New behavioural tests for the mutation survivors: absent recordKey, fractional/infinite sizes, the fetchVerifiedChunk byte-cap boundary, undefined crypto, error messages, abort-listener removal. Builds and passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
55530a941f
commit
f7579e2999
2 changed files with 46 additions and 7 deletions
|
|
@ -48,10 +48,10 @@ 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()}`);
|
||||
|
||||
/** `CultMeshHttpsContentTransportConnector.BuildChunkUri`: an existing query (even an empty one) is kept and joined with `&`. */
|
||||
/** `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);
|
||||
const kept = url.href.split("#")[0]!.includes("?") ? `${url.search.slice(1)}&` : "";
|
||||
const kept = url.href.split("#")[0]!.includes("?") ? `${url.search.replace(/^\?+/, "")}&` : "";
|
||||
url.search = `${kept}chunkHash=${escapeData(hash)}&recordKey=${escapeData(recordKey)}`;
|
||||
return url.href;
|
||||
}
|
||||
|
|
@ -114,8 +114,8 @@ export async function fetchVerifiedContent(endpoint: string, manifest: CultMeshC
|
|||
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.");
|
||||
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); }
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ test("request URLs equal the .NET BuildChunkUri AbsoluteUri for every probed end
|
|||
["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://h/x??a=1", "k", `https://h/x?a=1&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`],
|
||||
|
|
@ -79,6 +80,9 @@ test("request URLs equal the .NET BuildChunkUri AbsoluteUri for every probed end
|
|||
await fetchVerifiedContent(endpoint, withChunk(manifest, 0, { recordKey }), { fetch: server.fetch });
|
||||
assert.equal(server.calls[0]!.url, expected, endpoint);
|
||||
}
|
||||
const absent = chunkServer(chunks);
|
||||
await fetchVerifiedContent(ENDPOINT, withChunk(manifest, 0, { recordKey: undefined }), { fetch: absent.fetch });
|
||||
assert.equal(absent.calls[0]!.url, `${ENDPOINT}?chunkHash=${hex}&recordKey=`);
|
||||
});
|
||||
|
||||
test("hash spellings are normalized into the URL: a prefixed upper-case manifest hash is requested as bare lower-case hex", async () => {
|
||||
|
|
@ -116,6 +120,8 @@ test("with 10 chunks, maxConcurrentChunkRequests 3 peaks at exactly 3 in flight
|
|||
assert.equal(server.calls.length, 0);
|
||||
}
|
||||
for (const maxBytes of [-1, 1.5, Number.NaN]) await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { maxBytes }), RangeError);
|
||||
await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { maxConcurrentChunkRequests: 0 }), /maxConcurrentChunkRequests/);
|
||||
await assert.rejects(fetchVerifiedContent(ENDPOINT, manifest, { maxBytes: -1 }), /maxBytes/);
|
||||
});
|
||||
|
||||
test("an empty artifact returns an empty array with no fetch, but still answers to its content hash", async () => {
|
||||
|
|
@ -144,6 +150,7 @@ test("the byte cap is checked before any request: one over maxBytes and one over
|
|||
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));
|
||||
assert.deepEqual(await fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash, 5, { fetch: server.fetch, maxBytes: 5 }), body(5));
|
||||
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"));
|
||||
|
|
@ -174,6 +181,9 @@ test("non-hex hashes and a gapped manifest are manifest-invalid with no fetch, i
|
|||
withChunk(manifest, 1, { offset: 6 }),
|
||||
{ ...manifest, sizeBytes: 11 },
|
||||
{ ...manifest, sizeBytes: 10.5 },
|
||||
// fractional chunk sizes that still tile the manifest, and an infinite one: contiguous, so only a size check refuses them
|
||||
{ ...manifest, chunks: [{ ...manifest.chunks[0]!, sizeBytes: 4 }, { ...manifest.chunks[1]!, offset: 4, sizeBytes: 1.5 }, { ...manifest.chunks[1]!, offset: 5.5, sizeBytes: 4.5 }] },
|
||||
{ ...manifest, sizeBytes: Infinity, chunks: [{ ...manifest.chunks[0]!, sizeBytes: Infinity }] },
|
||||
];
|
||||
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) {
|
||||
|
|
@ -258,10 +268,12 @@ test("with crypto.subtle shadowed, both exports are webcrypto-unavailable before
|
|||
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"));
|
||||
for (const value of [{}, undefined]) {
|
||||
Object.defineProperty(globalThis, "crypto", { value, configurable: true });
|
||||
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);
|
||||
});
|
||||
|
|
@ -299,6 +311,33 @@ test("no error message contains the endpoint, its secret query, or a body byte",
|
|||
}
|
||||
});
|
||||
|
||||
test("every error code has its own fixed message, and a cause only when one was given", () => {
|
||||
const codes: CultMeshContentErrorCode[] = ["manifest-invalid", "too-large", "insecure-endpoint", "webcrypto-unavailable", "network", "http-status", "length-mismatch", "chunk-hash-mismatch", "content-hash-mismatch"];
|
||||
const messages = codes.map(code => new CultMeshContentError(code).message);
|
||||
assert.equal(new Set(messages).size, codes.length);
|
||||
assert.ok(messages.every(message => message.length > 0 && !/https?:/.test(message)));
|
||||
assert.equal("cause" in new CultMeshContentError("network"), false);
|
||||
assert.equal(new CultMeshContentError("network", undefined, undefined, 0).cause, 0);
|
||||
});
|
||||
|
||||
test("the caller's abort listener is added once and removed again, after success and after failure", async () => {
|
||||
const { manifest, chunks } = await pack(10, 5);
|
||||
for (const handler of [undefined, () => new Response(null, { status: 500 })]) {
|
||||
const controller = new AbortController();
|
||||
const added: unknown[] = [];
|
||||
const removed: unknown[] = [];
|
||||
const add = controller.signal.addEventListener.bind(controller.signal);
|
||||
const remove = controller.signal.removeEventListener.bind(controller.signal);
|
||||
controller.signal.addEventListener = ((type: string, listener: EventListener, options?: unknown) => { added.push([type, listener]); add(type, listener, options as AddEventListenerOptions); }) as typeof add;
|
||||
controller.signal.removeEventListener = ((type: string, listener: EventListener) => { removed.push([type, listener]); remove(type, listener); }) as typeof remove;
|
||||
const server = handler ? double(handler) : chunkServer(chunks);
|
||||
await fetchVerifiedContent(ENDPOINT, manifest, { fetch: server.fetch, signal: controller.signal }).catch(() => undefined);
|
||||
assert.equal(added.length, 1);
|
||||
assert.deepEqual(removed, added);
|
||||
assert.equal((added[0] as [string])[0], "abort");
|
||||
}
|
||||
});
|
||||
|
||||
interface Stub { endpoint: string; close(): Promise<void> }
|
||||
async function stub(handler: (request: IncomingMessage, response: ServerResponse) => void): Promise<Stub> {
|
||||
const server = createServer(handler);
|
||||
|
|
|
|||
Loading…
Reference in a new issue