From 1c20e2f511d1c4dbbcd4a3774eda50a4d7d85a9b Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 13 Oct 2026 07:21:56 +0000 Subject: [PATCH] fix(managed): classify truncated #US and #Blob length prefixes by item A two- or four-byte compressed-length prefix cut off by the end of the artifact went through the generic range check and was reported as invalid-row. The prefix reader now uses the item's own code for both reserved and truncated prefixes: invalid-string for #US, invalid-blob for #Blob. The unused generic range helper is removed. Co-Authored-By: Claude Opus 5.5 --- src/dotnet/ManagedMetadataHeaps.test.ts | 27 ++++++++++++++++++- src/dotnet/ManagedMetadataHeaps.ts | 35 +++++++++++-------------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/dotnet/ManagedMetadataHeaps.test.ts b/src/dotnet/ManagedMetadataHeaps.test.ts index f72f915e74..ebc2a0d491 100644 --- a/src/dotnet/ManagedMetadataHeaps.test.ts +++ b/src/dotnet/ManagedMetadataHeaps.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { readMetadataBlob } from "./ManagedMetadataHeaps.js"; +import { + readMetadataBlob, + readMetadataUserString, +} from "./ManagedMetadataHeaps.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; describe("managed metadata heaps", () => { @@ -31,4 +34,26 @@ describe("managed metadata heaps", () => { itemLength, ); }); + + it.each([[[0x80]], [[0xc0, 0x00, 0x00]]])( + "reports a #US length prefix cut off by the end of the artifact as a string failure", + (prefix) => { + // #US ends at the end of the artifact, inside a multi-byte length prefix. + const bytes = Buffer.from([0x00, ...prefix]); + const layout = { + userStrings: { name: "#US", offset: 0, size: bytes.length }, + } as unknown as ManagedMetadataLayout; + + expect(() => + readMetadataUserString(bytes, layout, "0x70000001"), + ).toThrowError( + expect.objectContaining({ + issue: expect.objectContaining({ + code: "invalid-string", + scope: "metadata.#US:0x70000001", + }), + }), + ); + }, + ); }); diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index ff91a10ecd..8fca5b860a 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -8,21 +8,6 @@ import { } from "./ManagedMetadataLayout.js"; import { managedFailure } from "./ManagedReaderFailure.js"; -const requireRange = ( - bytes: Buffer, - offset: number, - length: number, - scope: string, -): void => { - if (offset < 0 || length < 0 || offset > bytes.length - length) - throw managedFailure( - "invalid-row", - scope, - `${scope} leaves the artifact byte range`, - offset >= 0 ? offset : null, - ); -}; - /** Cursor for checked fixed-width metadata row decoding. */ export class MetadataRowCursor { #offset: number; @@ -81,18 +66,28 @@ const compressedLength = ( scope: string, item: keyof typeof PREFIXED_ITEMS, ): { readonly length: number; readonly prefix: number } => { - requireRange(bytes, offset, 1, scope); + const { code, label } = PREFIXED_ITEMS[item]; + const requirePrefix = (size: number): void => { + if (offset < 0 || offset > bytes.length - size) + throw managedFailure( + code, + scope, + `${label} length prefix leaves the artifact byte range`, + offset >= 0 ? offset : null, + ); + }; + requirePrefix(1); const first = bytes[offset] ?? 0; if ((first & 0x80) === 0) return { length: first, prefix: 1 }; if ((first & 0xc0) === 0x80) { - requireRange(bytes, offset, 2, scope); + requirePrefix(2); return { length: ((first & 0x3f) << 8) | (bytes[offset + 1] ?? 0), prefix: 2, }; } if ((first & 0xe0) === 0xc0) { - requireRange(bytes, offset, 4, scope); + requirePrefix(4); return { length: (first & 0x1f) * 0x01_00_00_00 + @@ -103,9 +98,9 @@ const compressedLength = ( }; } throw managedFailure( - PREFIXED_ITEMS[item].code, + code, scope, - `${PREFIXED_ITEMS[item].label} uses a reserved compressed-length prefix`, + `${label} uses a reserved compressed-length prefix`, offset, ); };