From d5468e40c36fcc154b2ea3d9b53ee9822c17e08b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 16:23:56 +0200 Subject: [PATCH] Content acceptance: truncation, header families, integer widths; cut redundant null checks Stryker found 30 survivors in the reader. Those that were real gaps get C#-judged vectors (early ends, a fixint or fixmap where an array or map header must be, every integer family at a value needing its width and sign, zero-filled fixext, extras at the header-family edges) and a test that a refusal names its true cause (negative size, nil chunk, reserved code, early end). The null checks ahead of isNullOrWhiteSpaceCSharp were redundant with it and are gone. Vectors regenerate in the next commit. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-ts/src/content.ts | 10 ++--- packages/cultnet-ts/test/content.test.ts | 17 ++++++++ .../CultMeshContentVectorTests.cs | 40 ++++++++++++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/cultnet-ts/src/content.ts b/packages/cultnet-ts/src/content.ts index c5174b5e..ee501551 100644 --- a/packages/cultnet-ts/src/content.ts +++ b/packages/cultnet-ts/src/content.ts @@ -62,8 +62,8 @@ const lowerInvariant = (text: string): string => /** `CultMeshCdn.NormalizeHash`: a case-insensitive `sha256:` prefix is stripped before the trim, and nothing checks for hex. */ export function normalizeHash(hash: string | null | undefined, parameterName = "hash"): string { - if (hash === null || hash === undefined || isNullOrWhiteSpaceCSharp(hash)) throw new Error(`Hash must be non-empty. (Parameter '${parameterName}')`); - return lowerInvariant(trimCSharp(hash.slice(0, 7).toLowerCase() === "sha256:" ? hash.slice(7) : hash)); + if (isNullOrWhiteSpaceCSharp(hash)) throw new Error(`Hash must be non-empty. (Parameter '${parameterName}')`); + return lowerInvariant(trimCSharp(hash!.slice(0, 7).toLowerCase() === "sha256:" ? hash!.slice(7) : hash!)); } /** The hash a file name or a fetch may rest on: `normalizeHash`, then exactly 64 lowercase hexadecimal digits. */ @@ -89,9 +89,9 @@ async function stableToken(value: string): Promise { } export async function artifactRecordKey(artifactId: string | null, version: string | null, contentHash: string | null): Promise { - if (artifactId === null || isNullOrWhiteSpaceCSharp(artifactId)) throw new Error("Value must be non-empty. (Parameter 'artifactId')"); - const versioned = version === null || isNullOrWhiteSpaceCSharp(version) ? "unversioned" : await stableToken(version); - return `mesh:cdn:artifact:${await stableToken(artifactId)}:${versioned}:${normalizeHash(contentHash, "contentHash")}`; + if (isNullOrWhiteSpaceCSharp(artifactId)) throw new Error("Value must be non-empty. (Parameter 'artifactId')"); + const versioned = isNullOrWhiteSpaceCSharp(version) ? "unversioned" : await stableToken(version!); + return `mesh:cdn:artifact:${await stableToken(artifactId!)}:${versioned}:${normalizeHash(contentHash, "contentHash")}`; } // ---- validation ------------------------------------------------------------------------------------------------------ diff --git a/packages/cultnet-ts/test/content.test.ts b/packages/cultnet-ts/test/content.test.ts index af18b16d..490eec9d 100644 --- a/packages/cultnet-ts/test/content.test.ts +++ b/packages/cultnet-ts/test/content.test.ts @@ -372,3 +372,20 @@ test("nil in a reference slot reads as null and writes back as nil; a missing tr const short = decodeManifest(encode(["id"])); assert.deepEqual(short, { artifactId: "id", kind: "asset", version: "", contentHash: "", sizeBytes: 0, mimeType: "application/octet-stream", createdAtUtc: "", chunks: [], tags: [], metadata: [] }); }); + +// Refusals that are the same refusal in the reference but would read differently if the wrong rule fired: the message +// names the true cause (the sign, the nil chunk, the reserved code, the early end), never a neighbouring field. +test("a refusal names its true cause", () => { + const refuses = (bytes: Uint8Array, message: RegExp) => assert.throws(() => decodeManifest(bytes), message); + refuses(wire({ 4: -1, 7: [] }), /negative size/); + refuses(wire({ 4: -32, 7: [] }), /negative size/); + refuses(wire({ 4: -(2n ** 53n - 1n), 7: [] }), /negative size/); + refuses(wire({ 4: -(2n ** 53n), 7: [] }), /field 'sizeBytes' has the wrong type/); + refuses(wire({ 4: 0, 7: [[HASH, 0, -32, "k"]] }), /chunk has a negative size/); + refuses(wire({ 7: null }), /field 'chunks' has the wrong type/); + refuses(wire({ 7: [null] }), /field 'chunks' has the wrong type/); + refuses(Uint8Array.of(0xc0), /field 'manifest' has the wrong type/); + refuses(wire().slice(0, 8), /ends before its last field/); + refuses(Uint8Array.of(0x91, 0xa3, 0x41), /ends before its last field/); + refuses(Uint8Array.from([0x9b, ...wire().slice(1), 0xc1]), /field 'manifest' has the wrong type/); +}); diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs index b54406b2..0797ca07 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -276,6 +276,8 @@ public sealed class CultMeshContentVectorTests ("fixstr", Str("ab")), ("f32", F32(1)), ("f64", F64(1)), ("u8", Raw(0xcc, 1)), ("u16", Be(0xcd, 1, 2)), ("u32", Be(0xce, 1, 4)), ("u64", Be(0xcf, 1, 8)), ("i8", Raw(0xd0, 1)), ("i16", Be(0xd1, 1, 2)), ("i32", Be(0xd2, 1, 4)), ("i64", Be(0xd3, 1, 8)), ("true", Raw(0xc3)), ("false", Raw(0xc2)), ("nil", Nil), ("negfixint", Raw(0xff)), ("reserved", Raw(0xc1)), + ("fixint_max", Raw(0x7f)), ("negfixint_min", Raw(0xe0)), ("fixstr_31", Cat(Raw(0xbf), new byte[31])), + ("fixmap_15", Cat(Raw(0x8f), Cat(Enumerable.Range(0, 15).Select(_ => Raw(1, 2)).ToArray()))), ("fixarray_15", Cat(Raw(0x9f), new byte[15])), ("nested", Arr(MapOf(Str("a"), Arr(Raw(1), Raw(2))), Arr())) }; foreach (var (name, value) in extras) @@ -333,7 +335,9 @@ public sealed class CultMeshContentVectorTests ("i8", Raw(0xd0, 0)), ("i16", Be(0xd1, 0, 2)), ("i32", Be(0xd2, 0, 4)), ("i64", Be(0xd3, 0, 8)), ("f32", F32(0)), ("f32_negzero", F32(int.MinValue)), ("f64", F64(0)), ("f64_negzero", F64(long.MinValue)), ("f64_one", F64(0x3FF0000000000000)), - ("true", Raw(0xc3)), ("false", Raw(0xc2)), ("str", Str("0")), ("bin", Raw(0xc4, 1, 0)), ("array", Arr()), ("ext", Raw(0xd4, 1, 0)) + ("true", Raw(0xc3)), ("false", Raw(0xc2)), ("str", Str("0")), ("bin", Raw(0xc4, 1, 0)), ("array", Arr()), ("ext", Raw(0xd4, 1, 0)), + // Zero-filled so that a reader which took them for an int32 would read 0. + ("fixext4", Cat(Raw(0xd6), new byte[5])), ("fixext8", Cat(Raw(0xd7), new byte[9])), ("fixext16", Cat(Raw(0xd8), new byte[17])) }) { AddFields($"size_{name}", (4, zero)); @@ -376,6 +380,40 @@ public sealed class CultMeshContentVectorTests AddFields("id_blank", (0, Str(" "))); AddFields("hash_blank", (3, Str(" "))); AddFields("hash_prefix_upper_long_s", (3, Str("ſha256:abc"))); + // Shapes that end early, and header families a lenient reader would take for another. + var whole = Doc(fields); + Add("truncated_string_body", whole[..^1]); + Add("truncated_str8_length", Raw(0x91, 0xd9)); + Add("truncated_u16", Cat(Raw(0x95), Str("a"), Str("k"), Str("v"), Str(EmptyHash), Raw(0xcd, 0))); + Add("truncated_u64", Cat(Raw(0x95), Str("a"), Str("k"), Str("v"), Str(EmptyHash), Raw(0xcf, 0, 0, 0))); + Add("truncated_array16", Raw(0xdc, 0)); + Add("extra_11_truncated_string", Doc(fields.Append(Raw(0xa5, 0x41)))); + Add("extra_11_truncated_str8_length", Doc(fields.Append(Raw(0xd9)))); + Add("array_15", Doc(fields.Concat(Enumerable.Repeat(Nil, 5)))); + AddFields("metadata_15_entries", (9, Cat(Raw(0x8f), Cat(Enumerable.Range(0, 15).Select(i => Cat(Str($"k{i}"), Str("v"))).ToArray())))); + Add("tags_fixint_then_trailing", Cat(Raw(0x99), Cat(fields[..8]), Raw(1), Str("a"))); + Add("tags_fixmap", Cat(Raw(0x99), Cat(fields[..8]), MapOf(Str("a"), Str("b")))); + Add("metadata_fixint_then_trailing", Cat(Raw(0x9a), Cat(fields[..9]), Raw(1), Str("a"), Str("b"))); + Add("metadata_array_then_trailing", Cat(Raw(0x9a), Cat(fields[..9]), Arr(Str("a"), Str("b")), Str("c"), Str("d"))); + + // Every integer family at a value that needs its full width and its own signedness: as the sizes, then as an offset. + foreach (var (name, value, n) in new (string, byte[], long)[] + { + ("u8_200", Raw(0xcc, 200), 200), ("u16_258", Be(0xcd, 258, 2), 258), ("u16_40000", Be(0xcd, 40000, 2), 40000), + ("u32_258", Be(0xce, 258, 4), 258), ("u64_258", Be(0xcf, 258, 8), 258), ("i8_100", Raw(0xd0, 100), 100), + ("i16_258", Be(0xd1, 258, 2), 258), ("i32_258", Be(0xd2, 258, 4), 258), ("i64_258", Be(0xd3, 258, 8), 258), + ("fixint_127", Raw(0x7f), 127) + }) + { + AddFields($"width_{name}", (4, value), (7, Arr(Chunk(Raw(0), value)))); + AddFields($"width_{name}_offset", (4, Be(0xcd, n + 1, 2)), (7, Arr(Chunk(Raw(0), Be(0xcd, n, 2)), Chunk(value, Raw(1))))); + } + AddFields("width_u32_over_int32", (4, Be(0xce, 3_000_000_000, 4)), + (7, Arr(Chunk(Raw(0), Be(0xce, int.MaxValue, 4)), Chunk(Be(0xce, int.MaxValue, 4), Be(0xce, 3_000_000_000 - int.MaxValue, 4))))); + AddFields("width_negative_fixint", (4, Raw(0xff))); + AddFields("width_negative_fixint_min", (4, Raw(0xe0))); + AddFields("width_negative_i64_safe", (4, Be(0xd3, -((1L << 53) - 1), 8))); + AddFields("width_negative_i64_unsafe", (4, Be(0xd3, -(1L << 53), 8))); return cases; }