cultnet-ts: validateManifest refuses sizes the reference's types cannot hold

Manifest total and chunk sizes must be whole numbers below 2^53, and a chunk size must fit an int (2147483647), for hand-built values as well as decoded ones. Messages for the whole-number rule now name the bound. No C#-written vector changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 20:48:06 +02:00
commit 1082b55f51
2 changed files with 28 additions and 8 deletions

View file

@ -97,18 +97,23 @@ export async function artifactRecordKey(artifactId: string | null, version: stri
// ---- validation ------------------------------------------------------------------------------------------------------
const INT32_MAX = 2 ** 31 - 1;
/**
* `ValidateManifestShape`, in its order and with its messages, then the metadata keys a dictionary could not hold twice.
* C# sizes are `long`, so a fraction, NaN or Infinity cannot exist on the wire; a hand-built manifest is refused here, once.
* Sizes are the reference's types: the manifest total and every offset a `long` (held here below 2^53, the largest integer a
* number keeps exact) and a chunk size an `int`. A fraction, NaN, Infinity or a size past those bounds cannot exist on the wire
* and is refused here, once, whether it was decoded or hand-built.
*/
export function validateManifest(manifest: CultMeshCdnArtifactManifest): void {
if (manifest.sizeBytes < 0) throw new Error("CDN artifact manifest has a negative size.");
if (!Number.isInteger(manifest.sizeBytes)) throw new Error("CDN artifact manifest size is not a whole number.");
if (!Number.isSafeInteger(manifest.sizeBytes)) throw new Error("CDN artifact manifest size is not a whole number below 2^53.");
let expectedOffset = 0;
for (const reference of [...manifest.chunks].sort((a, b) => a.offset - b.offset)) {
if (reference.offset !== expectedOffset) throw new Error("CDN artifact chunks are not contiguous.");
if (reference.sizeBytes < 0) throw new Error("CDN artifact chunk has a negative size.");
if (!Number.isInteger(reference.sizeBytes)) throw new Error("CDN artifact chunk size is not a whole number.");
if (!Number.isSafeInteger(reference.sizeBytes)) throw new Error("CDN artifact chunk size is not a whole number below 2^53.");
if (reference.sizeBytes > INT32_MAX) throw new Error("CDN artifact chunk size is larger than 2147483647.");
expectedOffset += reference.sizeBytes;
}
if (expectedOffset !== manifest.sizeBytes) throw new Error("CDN artifact chunk sizes do not sum to the manifest size.");
@ -286,7 +291,7 @@ export async function packContent(
): Promise<{ manifest: CultMeshCdnArtifactManifest; chunks: CultMeshCdnPackedChunk[] }> {
if (isNullOrWhiteSpaceCSharp(artifactId)) throw new Error("Artifact id must be non-empty. (Parameter 'artifactId')");
const size = options.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES;
if (!Number.isInteger(size) || size < 1 || size > 2 ** 31 - 1) throw new Error("ChunkSizeBytes must be between 1 and 2147483647.");
if (!Number.isInteger(size) || size < 1 || size > INT32_MAX) throw new Error("ChunkSizeBytes must be between 1 and 2147483647.");
const offsets = bytes.length === 0 ? [0] : Array.from({ length: Math.ceil(bytes.length / size) }, (_, index) => index * size);
const chunks = await Promise.all(offsets.map(async offset => {
const payload = bytes.slice(offset, offset + size);

View file

@ -231,12 +231,12 @@ test("validateManifest refuses negative size, a gap, an overlap and a size-sum m
validateManifest(manifestOf({ sizeBytes: 0, chunks: [] }));
});
test("validateManifest refuses a size that is not a whole number: a fraction, NaN or Infinity, in the manifest or in a chunk", () => {
test("validateManifest refuses a size that is not a whole number below 2^53 and a chunk size an int cannot hold, whether decoded or hand-built", () => {
const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k" });
const refuses = (over: Partial<CultMeshCdnArtifactManifest>, message: string) =>
assert.throws(() => validateManifest(manifestOf(over)), new Error(message));
const manifestMessage = "CDN artifact manifest size is not a whole number.";
const chunkMessage = "CDN artifact chunk size is not a whole number.";
const manifestMessage = "CDN artifact manifest size is not a whole number below 2^53.";
const chunkMessage = "CDN artifact chunk size is not a whole number below 2^53.";
for (const size of [1.5, Number.NaN, Number.POSITIVE_INFINITY]) refuses({ sizeBytes: size, chunks: [] }, manifestMessage);
// fractional chunk sizes that still tile the manifest are contiguous, so only the whole-number rule refuses them
refuses({ sizeBytes: 6, chunks: [chunk(0, 4), chunk(4, 1.5), chunk(5.5, 0.5)] }, chunkMessage);
@ -245,7 +245,22 @@ test("validateManifest refuses a size that is not a whole number: a fraction, Na
refuses({ sizeBytes: Number.POSITIVE_INFINITY, chunks: [chunk(0, Number.POSITIVE_INFINITY)] }, manifestMessage);
refuses({ sizeBytes: 4, chunks: [chunk(0, 2), chunk(2, Number.POSITIVE_INFINITY)] }, chunkMessage);
refuses({ sizeBytes: 0, chunks: [chunk(0, -1)] }, "CDN artifact chunk has a negative size.");
validateManifest(manifestOf({ sizeBytes: 2 ** 53 - 1, chunks: [chunk(0, 2 ** 53 - 1)] }));
// C# holds the manifest total and offsets in a long, a chunk size in an int; a number holds integers exactly only below 2^53
const int32Max = 2 ** 31 - 1;
const tooBig = "CDN artifact chunk size is larger than 2147483647.";
refuses({ sizeBytes: 2 ** 31, chunks: [chunk(0, 2 ** 31)] }, tooBig);
refuses({ sizeBytes: 2 ** 31 + 5, chunks: [chunk(0, 5), chunk(5, 2 ** 31)] }, tooBig);
refuses({ sizeBytes: 2 ** 53 - 1, chunks: [chunk(0, 2 ** 53 - 1)] }, tooBig);
for (const size of [2 ** 53, 2 ** 63, 2 ** 64]) {
refuses({ sizeBytes: size, chunks: [] }, manifestMessage);
refuses({ sizeBytes: size, chunks: [chunk(0, size)] }, manifestMessage);
refuses({ sizeBytes: 4, chunks: [chunk(0, size)] }, chunkMessage);
}
// the sum can only pass 2^53 by exceeding the manifest total, which is below it; an offset past 2^53 matches no sum
refuses({ sizeBytes: 2 ** 53, chunks: [chunk(0, int32Max), chunk(int32Max, 2 ** 53 - int32Max)] }, manifestMessage);
refuses({ sizeBytes: 1, chunks: [chunk(2 ** 53, 1)] }, "CDN artifact chunks are not contiguous.");
validateManifest(manifestOf({ sizeBytes: 2 ** 31 + 2, chunks: [chunk(0, 1), chunk(1, int32Max), chunk(2 ** 31, 2)] }));
validateManifest(manifestOf({ sizeBytes: int32Max, chunks: [chunk(0, int32Max)] }));
});
test("normalizeHash: 'SHA256:ABC ' -> 'abc', ' sha256:abc' -> 'sha256:abc', blank throws", () => {