* feat(managed): decode ldstr user strings in member inspection
inspect_managed_members reported every ldstr operand as a bare 0x70 token,
so an analyst could see that a method loads a literal but not which one.
Answering "where is this path or key used" required a second tool.
Read the optional #US stream and add a top-level user_strings inventory:
each distinct token loaded by an inspected method body, once, in token
order, with its #US file offset and decoded UTF-16 value. Instruction
anchors and normalized CIL digests keep their build-local tokens. A missing
#US stream or malformed item is reported against its token as partial
coverage. Evidence recorded before this field reads user_strings as null.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): collect user strings only from ldstr
A malformed ldtoken can carry a 0x70 token, which the operand decoder
classifies as a string operand. Only ldstr loads a literal, so collect
user strings by opcode.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): admit user-string tokens and failures before collecting them
Charge each distinct ldstr token to the decode budget before adding it to
the collection set, and admit each #US failure before retaining it. A
missing #US stream is now one stream-level issue rather than one per
token.
Document why the #US terminal flag is not validated: Mono and Roslyn
assemblies disagree with ECMA-335 II.24.2.4 and the runtime ignores it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(managed): describe missing #US reporting and the unvalidated flag byte
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(managed): cover #US items that run past the stream end
An odd length whose payload crosses the end of #US, and a two-byte
length prefix that starts on the last #US byte, are both rejected as
"User string leaves #US" beside a readable literal. The test fails if
that bounds check is removed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): report reserved #US length prefixes as string failures
The compressed-length reader shared by #Blob and #US hard-coded
invalid-blob and "Metadata blob" for a reserved prefix, so a malformed
user string was published as a blob failure. The caller now names the
item it reads: #Blob keeps its code and message, and #US reports
invalid-string.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): append member inspection issues without argument spreading
inspectManagedMembersBytes gathered every producer's issues with
issues.push(...list), passing one argument per issue. A large malformed
input, such as hundreds of thousands of distinct bad ldstr operands or
MemberRef rows, overflows the stack with an untyped RangeError instead of
returning partial coverage. All six sites now use
appendManagedParseIssues, which appends one issue at a time.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* 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 <[email protected]>
* fix(managed): retain user-string terminal flag conformance issues
* test(managed): write ECMA-335 #US terminal flags and cover flag issues
d5b7b0704 reports a #US terminal flag that differs from ECMA-335
II.24.2.4. The fixture heap always wrote 0, so its non-Latin-1 literals
now read as nonconforming. The fixture writes the spec flag, and a new
test flips one flag each way ("ok" set to 1, "it's" set to 0) and checks
that both literals remain beside one issue each.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): classify ldstr operands before collapsing a missing #US
With no #US stream, every collected ldstr operand was counted as a #US
reference and reported only as missing-stream, while the same non-0x70
operand got invalid-heap-index when the stream existed. parseUserStrings
now owns which operands are user-string tokens: any other operand is
invalid-heap-index either way, and only 0x70 tokens count toward the
missing-stream issue. The heap reader drops its duplicate kind check.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): state user-string limits in member comparisons
compare_managed_members ignored user_strings, so a method whose ldstr
literal changed at the same token, or legacy Evidence that never examined
literals, could read as unchanged with no caveat. The comparison now adds
an unknown limitation when either side predates user strings and a
not-compared limitation when literals were observed. ExportedType and
user-string inventories share one uncomparedInventory rule.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(managed): advertise ldstr literals in member inspection help
The MCP tool description, the type_names input, and the CLI command and
--type help now say that inspect_managed_members returns the #US string
literals that ldstr loads, and that a type selection limits them to the
literals the selected types' method bodies load.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): read the empty #US entry at offset 0
ECMA-335 II.24.2.4 makes the first #US entry the empty blob: a zero
length with no terminal flag byte. ldstr 0x70000000 loads it, but the
odd-length check rejected it as invalid-string, dropping the literal and
making coverage partial. Offset 0 with length 0 now reads as "".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): reserve #US offset 0 and admit literals before scanning
Offset 0 is the reserved empty #US entry, so a nonempty item there is now
invalid-string instead of a published literal. The literal's projection
is reserved before the terminal-flag scan, so an inadmissible item fails
at the budget without reading every code unit. The scan's loop index no
longer shadows the token's heap index.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): report #US length prefixes wider than ECMA-335 assigns
compressedLength accepted an overlong encoding, such as 0x80 0x05 for a
5-byte item, without comment. It now reports whether the width is the
one ECMA-335 II.23.2 assigns, and the #US reader reports a non-minimal
prefix as an invalid-string conformance issue beside the readable
literal, like the terminal flag. readMetadataUserString returns every
issue it observes. #Blob behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): check the #US length width before the empty entry
The offset-0 empty entry returned "" before the length-width check, so an
overlong empty entry such as 0x80 0x00 read as complete. The width issue
is now collected first and returned with the empty literal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): admit #US width issues only for returned literals
The overlong-prefix issue was reserved before the fatal shape checks, so
an item that then failed, such as 0x80 0x00 away from offset 0, leaked
512 bytes of budget per token. The width issue is now built and admitted
only on paths that return the literal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): keep #US issues out of member metadata status
User-string issues, including terminal-flag and length-prefix
conformance differences, marked metadata.status partial, so
compare_managed_members turned one-sided methods and fields into unknown
even though every member row was examined. #US issues now make coverage
partial while metadata.status describes the member inventory.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): give user strings their own coverage
#US issues entered the member coverage, so every consumer that reads it
as member coverage (application graph member facts, comparison status)
reported examined members as partial over a literal conformance
difference. user_strings is now { state, literals, issues }: #US issues
live there and never change the member coverage or metadata.status.
parseUserStrings returns that inventory directly.
compare_managed_members inspects with user strings skipped, since it
never reads literals, so a large literal cannot exhaust the decode budget
of a comparison. Its limitations state once that literals are not
compared, and the ExportedType limitations return to their main form.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): mark user strings partial when a body is not fully decoded
With user strings owning their coverage, an inspected method body that
was partial, malformed, or truncated no longer reached their state, so a
literal after the decode stopped was missing from a "complete" inventory.
parseUserStrings now marks the inventory partial unless every inspected
body was fully decoded, keeping the literals it did observe.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): mark user strings partial when a method row is omitted
A MethodDef row that failed before its body was read was recorded as an
issue and omitted, so its ldstr instructions were never scanned, yet the
user-string inventory could still read as complete. parseUserStrings now
takes the parseMethods result and stays partial whenever a method row
failed, as well as when an inspected body was not fully decoded.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): count only method rows that may hide a selected body
Any parseMethods issue marked user strings partial, including an
unreadable name on a method outside the type selection, so a fully
scanned selection read as incomplete. parseMethods now reads each row's
owner first and reports unscannedRows: rows that produced no method while
their owner is selected or unknown. parseUserStrings uses that count.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): treat unreadably named types as possibly selected
Under a type_names selection, a type whose name could not be read may be
a requested type, but its methods were skipped without counting toward
unscannedRows, so an unexamined body left user strings complete. A
method whose owner's name is unreadable now counts as possibly selected.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): judge user-string scans by CIL decoding, not body status
body.status is malformed for an invalid exception section even when
every instruction decoded, so user strings read as partial although every
ldstr was examined. The body reader now reports whether its CIL was fully
scanned, independent of exception-region validity, and parseMethods folds
unscanned bodies and unproduced rows into one unscannedMethods count that
parseUserStrings uses instead of reinterpreting body.status.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): stop deduplicating user-string issues
The inventory passed its issues through uniqueManagedParseIssues, which
serializes every issue into an unbudgeted map: about 42 MB of heap and
113 ms for 125,000 issues on Node 24. The issues are unique by
construction (one failure or per-offset conformance issues per distinct
token, one missing-stream issue), so the inventory now returns them, and
its literals, without copies.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(managed): preserve selected string coverage unknowns
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: morluto <[email protected]>
465 lines
13 KiB
TypeScript
465 lines
13 KiB
TypeScript
import {
|
|
attachManagedDecodeBudget,
|
|
ManagedDecodeBudget,
|
|
} from "./ManagedDecodeBudget.js";
|
|
import { managedFailure } from "./ManagedReaderFailure.js";
|
|
import {
|
|
computeRowSize,
|
|
type RowSizeContext,
|
|
} from "./ManagedMetadataRowSizes.js";
|
|
|
|
export const METADATA_TABLE_NAMES = [
|
|
"Module",
|
|
"TypeRef",
|
|
"TypeDef",
|
|
"FieldPtr",
|
|
"Field",
|
|
"MethodPtr",
|
|
"MethodDef",
|
|
"ParamPtr",
|
|
"Param",
|
|
"InterfaceImpl",
|
|
"MemberRef",
|
|
"Constant",
|
|
"CustomAttribute",
|
|
"FieldMarshal",
|
|
"DeclSecurity",
|
|
"ClassLayout",
|
|
"FieldLayout",
|
|
"StandAloneSig",
|
|
"EventMap",
|
|
"EventPtr",
|
|
"Event",
|
|
"PropertyMap",
|
|
"PropertyPtr",
|
|
"Property",
|
|
"MethodSemantics",
|
|
"MethodImpl",
|
|
"ModuleRef",
|
|
"TypeSpec",
|
|
"ImplMap",
|
|
"FieldRVA",
|
|
"ENCLog",
|
|
"ENCMap",
|
|
"Assembly",
|
|
"AssemblyProcessor",
|
|
"AssemblyOS",
|
|
"AssemblyRef",
|
|
"AssemblyRefProcessor",
|
|
"AssemblyRefOS",
|
|
"File",
|
|
"ExportedType",
|
|
"ManifestResource",
|
|
"NestedClass",
|
|
"GenericParam",
|
|
"MethodSpec",
|
|
"GenericParamConstraint",
|
|
] as const;
|
|
|
|
interface MetadataStream {
|
|
readonly name: string;
|
|
readonly offset: number;
|
|
readonly size: number;
|
|
}
|
|
|
|
export interface MetadataTableLayout {
|
|
readonly index: number;
|
|
readonly name: string;
|
|
readonly rowCount: number;
|
|
readonly rowSize: number;
|
|
readonly offset: number;
|
|
}
|
|
|
|
export interface ManagedMetadataLayout {
|
|
readonly rootOffset: number;
|
|
readonly size: number;
|
|
readonly version: string;
|
|
readonly streamNames: readonly string[];
|
|
readonly strings: MetadataStream;
|
|
readonly guid: MetadataStream;
|
|
readonly blob: MetadataStream;
|
|
/** Optional: assemblies without `ldstr` literals may omit #US. */
|
|
readonly userStrings: MetadataStream | null;
|
|
readonly tables: ReadonlyMap<number, MetadataTableLayout>;
|
|
readonly rowCounts: readonly number[];
|
|
readonly stringIndexSize: 2 | 4;
|
|
readonly guidIndexSize: 2 | 4;
|
|
readonly blobIndexSize: 2 | 4;
|
|
table(index: number): MetadataTableLayout | undefined;
|
|
tableIndexSize(index: number): 2 | 4;
|
|
codedIndexSize(name: CodedIndexName): 2 | 4;
|
|
}
|
|
|
|
export type CodedIndexName = keyof typeof CODED_INDEXES;
|
|
|
|
const CODED_INDEXES = {
|
|
TypeDefOrRef: { bits: 2, tables: [2, 1, 27] },
|
|
HasConstant: { bits: 2, tables: [4, 8, 23] },
|
|
HasCustomAttribute: {
|
|
bits: 5,
|
|
tables: [
|
|
6, 4, 1, 2, 8, 9, 10, 0, 14, 23, 20, 17, 26, 27, 32, 35, 38, 39, 40, 42,
|
|
44, 43,
|
|
],
|
|
},
|
|
HasFieldMarshal: { bits: 1, tables: [4, 8] },
|
|
HasDeclSecurity: { bits: 2, tables: [2, 6, 32] },
|
|
MemberRefParent: { bits: 3, tables: [2, 1, 26, 6, 27] },
|
|
HasSemantics: { bits: 1, tables: [20, 23] },
|
|
MethodDefOrRef: { bits: 1, tables: [6, 10] },
|
|
MemberForwarded: { bits: 1, tables: [4, 6] },
|
|
Implementation: { bits: 2, tables: [38, 35, 39] },
|
|
CustomAttributeType: { bits: 3, tables: [6, 10] },
|
|
ResolutionScope: { bits: 2, tables: [0, 26, 35, 1] },
|
|
TypeOrMethodDef: { bits: 1, tables: [2, 6] },
|
|
} as const;
|
|
|
|
const SUPPORTED_TABLE_MASK = (1n << BigInt(METADATA_TABLE_NAMES.length)) - 1n;
|
|
|
|
const requireRange = (
|
|
bytes: Buffer,
|
|
offset: number,
|
|
length: number,
|
|
scope: string,
|
|
): void => {
|
|
if (
|
|
!Number.isSafeInteger(offset) ||
|
|
!Number.isSafeInteger(length) ||
|
|
offset < 0 ||
|
|
length < 0 ||
|
|
offset > bytes.length - length
|
|
)
|
|
throw managedFailure(
|
|
"invalid-metadata-root",
|
|
scope,
|
|
`${scope} leaves the metadata byte range`,
|
|
Number.isSafeInteger(offset) && offset >= 0 ? offset : null,
|
|
);
|
|
};
|
|
|
|
const align4 = (value: number): number => Math.ceil(value / 4) * 4;
|
|
|
|
const readStreamName = (
|
|
bytes: Buffer,
|
|
offset: number,
|
|
): { readonly name: string; readonly next: number } => {
|
|
const endLimit = Math.min(bytes.length, offset + 32);
|
|
let end = offset;
|
|
while (end < endLimit && bytes[end] !== 0) end += 1;
|
|
if (end === endLimit)
|
|
throw managedFailure(
|
|
"invalid-stream",
|
|
"metadata.stream-name",
|
|
"Metadata stream name is missing a bounded terminator",
|
|
offset,
|
|
);
|
|
const nameBytes = bytes.subarray(offset, end);
|
|
if (nameBytes.some((byte) => byte < 0x20 || byte > 0x7e))
|
|
throw managedFailure(
|
|
"invalid-stream",
|
|
"metadata.stream-name",
|
|
"Metadata stream name is not printable ASCII",
|
|
offset,
|
|
);
|
|
return {
|
|
name: nameBytes.toString("ascii"),
|
|
next: align4(end + 1),
|
|
};
|
|
};
|
|
|
|
const streamByName = (
|
|
streams: ReadonlyMap<string, MetadataStream>,
|
|
names: readonly string[],
|
|
): MetadataStream | undefined => {
|
|
for (const name of names) {
|
|
const stream = streams.get(name);
|
|
if (stream !== undefined) return stream;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const requireStream = (
|
|
streams: ReadonlyMap<string, MetadataStream>,
|
|
name: string,
|
|
): MetadataStream => {
|
|
const stream = streams.get(name);
|
|
if (stream === undefined)
|
|
throw managedFailure(
|
|
"missing-stream",
|
|
`metadata.${name}`,
|
|
`Required metadata stream ${name} is absent`,
|
|
);
|
|
return stream;
|
|
};
|
|
|
|
const codedSize = (
|
|
name: CodedIndexName,
|
|
rowCounts: readonly number[],
|
|
): 2 | 4 => {
|
|
const descriptor = CODED_INDEXES[name];
|
|
let maximum = 0;
|
|
for (const table of descriptor.tables)
|
|
maximum = Math.max(maximum, rowCounts[table] ?? 0);
|
|
return maximum < 2 ** (16 - descriptor.bits) ? 2 : 4;
|
|
};
|
|
|
|
const tableSize = (index: number, rows: readonly number[]): 2 | 4 =>
|
|
(rows[index] ?? 0) < 0x1_0000 ? 2 : 4;
|
|
|
|
interface ParsedStreams {
|
|
readonly streams: ReadonlyMap<string, MetadataStream>;
|
|
readonly version: string;
|
|
}
|
|
|
|
const readMetadataStreams = (
|
|
bytes: Buffer,
|
|
rootOffset: number,
|
|
budget: ManagedDecodeBudget,
|
|
): ParsedStreams => {
|
|
if (bytes.readUInt32LE(0) !== 0x424a_5342)
|
|
throw managedFailure(
|
|
"invalid-metadata-root",
|
|
"metadata.signature",
|
|
"CLI metadata signature is not BSJB",
|
|
rootOffset,
|
|
);
|
|
const versionLength = bytes.readUInt32LE(12);
|
|
requireRange(bytes, 16, versionLength, "metadata.version");
|
|
const versionBytes = bytes.subarray(16, 16 + versionLength);
|
|
const zero = versionBytes.indexOf(0);
|
|
const decodedVersionLength = zero < 0 ? versionBytes.length : zero;
|
|
budget.reserveString(decodedVersionLength, rootOffset + 16);
|
|
const version = versionBytes
|
|
.subarray(0, decodedVersionLength)
|
|
.toString("utf8");
|
|
let cursor = align4(16 + versionLength);
|
|
requireRange(bytes, cursor, 4, "metadata.stream-count");
|
|
const streamCount = bytes.readUInt16LE(cursor + 2);
|
|
if (streamCount === 0 || streamCount > 64)
|
|
throw managedFailure(
|
|
"invalid-stream",
|
|
"metadata.stream-count",
|
|
"Metadata stream count is outside the admitted range",
|
|
rootOffset + cursor + 2,
|
|
);
|
|
cursor += 4;
|
|
const streams = new Map<string, MetadataStream>();
|
|
for (let index = 0; index < streamCount; index += 1) {
|
|
requireRange(bytes, cursor, 8, "metadata.stream-header");
|
|
const relativeOffset = bytes.readUInt32LE(cursor);
|
|
const streamSize = bytes.readUInt32LE(cursor + 4);
|
|
const named = readStreamName(bytes, cursor + 8);
|
|
if (streams.has(named.name))
|
|
throw managedFailure(
|
|
"invalid-stream",
|
|
"metadata.stream-header",
|
|
`Duplicate metadata stream ${named.name}`,
|
|
rootOffset + cursor,
|
|
);
|
|
requireRange(bytes, relativeOffset, streamSize, `metadata.${named.name}`);
|
|
streams.set(named.name, {
|
|
name: named.name,
|
|
offset: rootOffset + relativeOffset,
|
|
size: streamSize,
|
|
});
|
|
cursor = named.next;
|
|
}
|
|
return { streams, version };
|
|
};
|
|
|
|
interface HeapSizes {
|
|
readonly string: 2 | 4;
|
|
readonly guid: 2 | 4;
|
|
readonly blob: 2 | 4;
|
|
}
|
|
|
|
interface ParsedTablesHeader {
|
|
readonly tableBytes: Buffer;
|
|
readonly tablesOffset: number;
|
|
readonly heaps: HeapSizes;
|
|
readonly valid: bigint;
|
|
}
|
|
|
|
const readTablesHeader = (
|
|
bytes: Buffer,
|
|
streams: ReadonlyMap<string, MetadataStream>,
|
|
): ParsedTablesHeader => {
|
|
const tablesStream = streamByName(streams, ["#~", "#-"]);
|
|
if (tablesStream === undefined)
|
|
throw managedFailure(
|
|
"missing-stream",
|
|
"metadata.tables",
|
|
"Required metadata table stream #~ or #- is absent",
|
|
);
|
|
const tableBytes = bytes.subarray(
|
|
tablesStream.offset,
|
|
tablesStream.offset + tablesStream.size,
|
|
);
|
|
requireRange(tableBytes, 0, 24, "metadata.tables-header");
|
|
const heapSizes = tableBytes[6] ?? 0;
|
|
const heaps = {
|
|
string: (heapSizes & 0x01) === 0 ? (2 as const) : (4 as const),
|
|
guid: (heapSizes & 0x02) === 0 ? (2 as const) : (4 as const),
|
|
blob: (heapSizes & 0x04) === 0 ? (2 as const) : (4 as const),
|
|
};
|
|
const valid = tableBytes.readBigUInt64LE(8);
|
|
if ((valid & ~SUPPORTED_TABLE_MASK) !== 0n)
|
|
throw managedFailure(
|
|
"invalid-tables",
|
|
"metadata.valid-mask",
|
|
"Metadata valid mask names unsupported tables outside the admitted ECMA-335 range",
|
|
tablesStream.offset + 8,
|
|
);
|
|
return { tableBytes, tablesOffset: tablesStream.offset, heaps, valid };
|
|
};
|
|
|
|
const readRowCounts = (
|
|
tableBytes: Buffer,
|
|
valid: bigint,
|
|
): readonly number[] => {
|
|
const rowCounts = Array<number>(METADATA_TABLE_NAMES.length).fill(0);
|
|
let tableCursor = 24;
|
|
for (let index = 0; index < METADATA_TABLE_NAMES.length; index += 1) {
|
|
if ((valid & (1n << BigInt(index))) === 0n) continue;
|
|
requireRange(tableBytes, tableCursor, 4, "metadata.table-row-count");
|
|
const count = tableBytes.readUInt32LE(tableCursor);
|
|
rowCounts[index] = count;
|
|
tableCursor += 4;
|
|
}
|
|
return rowCounts;
|
|
};
|
|
|
|
const buildRowSizeContext = (
|
|
rowCounts: readonly number[],
|
|
heaps: HeapSizes,
|
|
): RowSizeContext => ({
|
|
tableSize: (index) => tableSize(index, rowCounts),
|
|
codedSize: (name) => codedSize(name, rowCounts),
|
|
stringSize: heaps.string,
|
|
guidSize: heaps.guid,
|
|
blobSize: heaps.blob,
|
|
});
|
|
|
|
const buildMetadataTables = ({
|
|
tableBytes,
|
|
rowCounts,
|
|
heaps,
|
|
valid,
|
|
tablesOffset,
|
|
}: {
|
|
readonly tableBytes: Buffer;
|
|
readonly rowCounts: readonly number[];
|
|
readonly heaps: HeapSizes;
|
|
readonly valid: bigint;
|
|
readonly tablesOffset: number;
|
|
}): ReadonlyMap<number, MetadataTableLayout> => {
|
|
const tables = new Map<number, MetadataTableLayout>();
|
|
const context = buildRowSizeContext(rowCounts, heaps);
|
|
let tableCursor = 24;
|
|
for (let index = 0; index < METADATA_TABLE_NAMES.length; index += 1) {
|
|
if ((valid & (1n << BigInt(index))) === 0n) continue;
|
|
tableCursor += 4;
|
|
}
|
|
// CLR readers honor an optional extra-data word after the row counts.
|
|
// Its value is opaque; it is not a length or part of the first table row.
|
|
if (((tableBytes[6] ?? 0) & 0x40) !== 0) {
|
|
if (tableCursor > tableBytes.length - 4)
|
|
throw managedFailure(
|
|
"invalid-metadata-root",
|
|
"metadata.tables-extra-data",
|
|
"Metadata tables extra-data word is truncated",
|
|
tablesOffset + tableCursor,
|
|
);
|
|
tableCursor += 4;
|
|
}
|
|
for (let index = 0; index < METADATA_TABLE_NAMES.length; index += 1) {
|
|
const count = rowCounts[index] ?? 0;
|
|
if ((valid & (1n << BigInt(index))) === 0n) continue;
|
|
const sizeOfRow = computeRowSize(index, context);
|
|
const byteLength = count * sizeOfRow;
|
|
if (!Number.isSafeInteger(byteLength))
|
|
throw managedFailure(
|
|
"invalid-tables",
|
|
"metadata.tables",
|
|
"Metadata table byte size overflowed",
|
|
tablesOffset + tableCursor,
|
|
);
|
|
requireRange(tableBytes, tableCursor, byteLength, "metadata.table-data");
|
|
tables.set(index, {
|
|
index,
|
|
name: METADATA_TABLE_NAMES[index] ?? `Table${String(index)}`,
|
|
rowCount: count,
|
|
rowSize: sizeOfRow,
|
|
offset: tablesOffset + tableCursor,
|
|
});
|
|
tableCursor += byteLength;
|
|
}
|
|
return tables;
|
|
};
|
|
|
|
/** Parse ECMA-335 stream and table layout with checked byte extents. */
|
|
export const readManagedMetadataLayout = (
|
|
artifact: Buffer,
|
|
rootOffset: number,
|
|
size: number,
|
|
): ManagedMetadataLayout => {
|
|
if (size < 20 || rootOffset < 0 || rootOffset > artifact.length - size)
|
|
throw managedFailure(
|
|
"invalid-metadata-root",
|
|
"metadata.root",
|
|
"CLI metadata directory leaves the artifact byte range",
|
|
rootOffset,
|
|
);
|
|
const bytes = artifact.subarray(rootOffset, rootOffset + size);
|
|
const budget = new ManagedDecodeBudget();
|
|
const { streams, version } = readMetadataStreams(bytes, rootOffset, budget);
|
|
const { tableBytes, tablesOffset, heaps, valid } = readTablesHeader(
|
|
artifact,
|
|
streams,
|
|
);
|
|
const rowCounts = readRowCounts(tableBytes, valid);
|
|
const tables = buildMetadataTables({
|
|
tableBytes,
|
|
rowCounts,
|
|
heaps,
|
|
valid,
|
|
tablesOffset,
|
|
});
|
|
const layout: ManagedMetadataLayout = {
|
|
rootOffset,
|
|
size,
|
|
version,
|
|
streamNames: [...streams.keys()].sort(),
|
|
strings: requireStream(streams, "#Strings"),
|
|
guid: requireStream(streams, "#GUID"),
|
|
blob: requireStream(streams, "#Blob"),
|
|
userStrings: streams.get("#US") ?? null,
|
|
tables,
|
|
rowCounts,
|
|
stringIndexSize: heaps.string,
|
|
guidIndexSize: heaps.guid,
|
|
blobIndexSize: heaps.blob,
|
|
table: (index) => tables.get(index),
|
|
tableIndexSize: (index) => tableSize(index, rowCounts),
|
|
codedIndexSize: (name) => codedSize(name, rowCounts),
|
|
};
|
|
attachManagedDecodeBudget(layout, budget);
|
|
return layout;
|
|
};
|
|
|
|
/** Resolve a one-based metadata row to its exact file offset. */
|
|
export const metadataRowOffset = (
|
|
layout: ManagedMetadataLayout,
|
|
table: number,
|
|
row: number,
|
|
): number => {
|
|
const descriptor = layout.table(table);
|
|
if (descriptor === undefined || row < 1 || row > descriptor.rowCount)
|
|
throw managedFailure(
|
|
"invalid-row",
|
|
`metadata.${METADATA_TABLE_NAMES[table] ?? String(table)}`,
|
|
`Metadata row ${String(row)} is outside its table`,
|
|
);
|
|
return descriptor.offset + (row - 1) * descriptor.rowSize;
|
|
};
|