415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
|
import { createHash } from "node:crypto";
|
|
|
|
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
|
|
|
import {
|
|
metadataRowOffset,
|
|
type ManagedMetadataLayout,
|
|
} from "./ManagedMetadataLayout.js";
|
|
import { managedFailure } from "./ManagedReaderFailure.js";
|
|
|
|
/** Cursor for checked fixed-width metadata row decoding. */
|
|
export class MetadataRowCursor {
|
|
#offset: number;
|
|
|
|
constructor(
|
|
private readonly bytes: Buffer,
|
|
readonly start: number,
|
|
private readonly end: number,
|
|
private readonly scope: string,
|
|
) {
|
|
this.#offset = start;
|
|
}
|
|
|
|
get offset(): number {
|
|
return this.#offset;
|
|
}
|
|
|
|
readUInt16(): number {
|
|
this.require(2);
|
|
const value = this.bytes.readUInt16LE(this.#offset);
|
|
this.#offset += 2;
|
|
return value;
|
|
}
|
|
|
|
readUInt32(): number {
|
|
this.require(4);
|
|
const value = this.bytes.readUInt32LE(this.#offset);
|
|
this.#offset += 4;
|
|
return value;
|
|
}
|
|
|
|
readIndex(size: 2 | 4): number {
|
|
return size === 2 ? this.readUInt16() : this.readUInt32();
|
|
}
|
|
|
|
private require(length: number): void {
|
|
if (this.#offset < this.start || this.#offset > this.end - length)
|
|
throw managedFailure(
|
|
"invalid-row",
|
|
this.scope,
|
|
`${this.scope} row ended before all declared columns`,
|
|
this.#offset,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Issue code and label for the heap item a compressed length prefixes.
|
|
const PREFIXED_ITEMS = {
|
|
blob: { code: "invalid-blob", label: "Metadata blob" },
|
|
userString: { code: "invalid-string", label: "User string" },
|
|
} as const;
|
|
|
|
const compressedLength = (
|
|
bytes: Buffer,
|
|
offset: number,
|
|
scope: string,
|
|
item: keyof typeof PREFIXED_ITEMS,
|
|
): {
|
|
readonly length: number;
|
|
readonly prefix: number;
|
|
/** Whether ECMA-335 II.23.2 assigns this width to the value. */
|
|
readonly minimal: boolean;
|
|
} => {
|
|
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, minimal: true };
|
|
if ((first & 0xc0) === 0x80) {
|
|
requirePrefix(2);
|
|
const length = ((first & 0x3f) << 8) | (bytes[offset + 1] ?? 0);
|
|
return { length, prefix: 2, minimal: length >= 0x80 };
|
|
}
|
|
if ((first & 0xe0) === 0xc0) {
|
|
requirePrefix(4);
|
|
const length =
|
|
(first & 0x1f) * 0x01_00_00_00 +
|
|
(bytes[offset + 1] ?? 0) * 0x01_00_00 +
|
|
(bytes[offset + 2] ?? 0) * 0x0100 +
|
|
(bytes[offset + 3] ?? 0);
|
|
return { length, prefix: 4, minimal: length >= 0x4000 };
|
|
}
|
|
throw managedFailure(
|
|
code,
|
|
scope,
|
|
`${label} uses a reserved compressed-length prefix`,
|
|
offset,
|
|
);
|
|
};
|
|
|
|
/** Read one UTF-8 value from #Strings within its PE/CLI stream extent. */
|
|
export const readMetadataString = (
|
|
bytes: Buffer,
|
|
layout: ManagedMetadataLayout,
|
|
index: number,
|
|
heapExtent: number,
|
|
): string => {
|
|
if (index === 0) return "";
|
|
if (index < 0 || index >= layout.strings.size)
|
|
throw managedFailure(
|
|
"invalid-heap-index",
|
|
"metadata.#Strings",
|
|
"String heap index is outside #Strings",
|
|
layout.strings.offset + Math.max(index, 0),
|
|
);
|
|
const start = layout.strings.offset + index;
|
|
const cached = managedDecodeBudget(layout).cachedString(index);
|
|
if (cached !== undefined && cached.encodedBytes <= heapExtent) {
|
|
managedDecodeBudget(layout).reserveString(cached.encodedBytes, start);
|
|
return cached.value;
|
|
}
|
|
const maximum = Math.min(
|
|
layout.strings.offset + layout.strings.size,
|
|
start + heapExtent + 1,
|
|
);
|
|
let end = start;
|
|
while (end < maximum && bytes[end] !== 0) end += 1;
|
|
if (end === maximum)
|
|
throw managedFailure(
|
|
"invalid-string",
|
|
"metadata.#Strings",
|
|
"String heap item has no terminator within #Strings",
|
|
start,
|
|
);
|
|
managedDecodeBudget(layout).reserveString(end - start, start);
|
|
try {
|
|
const value = new TextDecoder("utf-8", {
|
|
fatal: true,
|
|
ignoreBOM: true,
|
|
}).decode(bytes.subarray(start, end));
|
|
managedDecodeBudget(layout).cacheString(index, value, end - start);
|
|
return value;
|
|
} catch (cause: unknown) {
|
|
void cause;
|
|
throw managedFailure(
|
|
"invalid-string",
|
|
"metadata.#Strings",
|
|
"String heap item is not valid UTF-8",
|
|
start,
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Read one #Blob item while validating its encoded extent against the heap. */
|
|
export const readMetadataBlob = (
|
|
bytes: Buffer,
|
|
layout: ManagedMetadataLayout,
|
|
index: number,
|
|
heapExtent: number,
|
|
): Buffer => {
|
|
if (index === 0) return Buffer.alloc(0);
|
|
if (index < 0 || index >= layout.blob.size)
|
|
throw managedFailure(
|
|
"invalid-heap-index",
|
|
"metadata.#Blob",
|
|
"Blob heap index is outside #Blob",
|
|
layout.blob.offset + Math.max(index, 0),
|
|
);
|
|
const start = layout.blob.offset + index;
|
|
const decoded = compressedLength(bytes, start, "metadata.#Blob", "blob");
|
|
if (decoded.length > heapExtent)
|
|
throw managedFailure(
|
|
"invalid-blob",
|
|
"metadata.#Blob",
|
|
"Blob length exceeds the admitted heap extent",
|
|
start,
|
|
);
|
|
const content = start + decoded.prefix;
|
|
if (content > layout.blob.offset + layout.blob.size - decoded.length)
|
|
throw managedFailure(
|
|
"invalid-blob",
|
|
"metadata.#Blob",
|
|
"Blob heap item leaves #Blob",
|
|
start,
|
|
);
|
|
return bytes.subarray(content, content + decoded.length);
|
|
};
|
|
|
|
/**
|
|
* Read the #US literal named by a `0x70` user-string token: a compressed byte
|
|
* length, UTF-16LE code units, then one ECMA-335 terminal flag byte. Code units
|
|
* are kept as stored, including unpaired surrogates. `parseUserStrings` owns
|
|
* which `ldstr` operands are user-string tokens.
|
|
*/
|
|
export const readMetadataUserString = (
|
|
bytes: Buffer,
|
|
layout: ManagedMetadataLayout,
|
|
token: string,
|
|
): {
|
|
readonly offset: number;
|
|
readonly value: string;
|
|
/** Conformance differences observed beside the readable literal. */
|
|
readonly issues: readonly ManagedParseIssue[];
|
|
} => {
|
|
const heap = layout.userStrings;
|
|
const scope = `metadata.#US:${token}`;
|
|
if (heap === null)
|
|
throw managedFailure(
|
|
"missing-stream",
|
|
scope,
|
|
"ldstr references #US, but the metadata has no #US stream",
|
|
);
|
|
const index = Number.parseInt(token.slice(4), 16);
|
|
if (index >= heap.size)
|
|
throw managedFailure(
|
|
"invalid-heap-index",
|
|
scope,
|
|
"ldstr operand does not name an item inside #US",
|
|
heap.offset + index,
|
|
);
|
|
const start = heap.offset + index;
|
|
const decoded = compressedLength(bytes, start, scope, "userString");
|
|
const content = start + decoded.prefix;
|
|
if (content > heap.offset + heap.size - decoded.length)
|
|
throw managedFailure(
|
|
"invalid-string",
|
|
scope,
|
|
"User string leaves #US",
|
|
start,
|
|
);
|
|
const budget = managedDecodeBudget(layout);
|
|
// A wider length prefix than the value needs still reads unambiguously, so
|
|
// it is reported like the flag rather than discarding the literal. It is
|
|
// admitted only on paths that return the literal, so a fatal item leaves
|
|
// no budget behind.
|
|
const conformance = (): ManagedParseIssue[] => {
|
|
if (decoded.minimal) return [];
|
|
budget.reserve(512, start);
|
|
return [
|
|
{
|
|
code: "invalid-string",
|
|
scope,
|
|
offset: start,
|
|
detail: `User string length ${String(decoded.length)} uses a ${String(decoded.prefix)}-byte prefix wider than ECMA-335 II.23.2 assigns; readable UTF-16 is preserved and runtime acceptance is not assessed`,
|
|
},
|
|
];
|
|
};
|
|
// ECMA-335 II.24.2.4: offset 0 is the heap's empty entry, a zero length
|
|
// with no terminal flag byte; every other item carries the flag.
|
|
if (index === 0) {
|
|
if (decoded.length === 0)
|
|
return { offset: start, value: "", issues: conformance() };
|
|
throw managedFailure(
|
|
"invalid-string",
|
|
scope,
|
|
`#US offset 0 is the reserved empty entry, but it encodes ${String(decoded.length)} byte(s)`,
|
|
start,
|
|
);
|
|
}
|
|
if (decoded.length % 2 !== 1)
|
|
throw managedFailure(
|
|
"invalid-string",
|
|
scope,
|
|
"User string length is not UTF-16 code units plus one terminal flag byte",
|
|
start,
|
|
);
|
|
const units = (decoded.length - 1) / 2;
|
|
// Admit the literal before scanning it: JSON spends at most six bytes per
|
|
// UTF-16 code unit.
|
|
budget.reserveString(units, start);
|
|
// Check the serialized flag independently of literal decoding. A difference
|
|
// from ECMA-335 is observable metadata, not proof of runtime rejection.
|
|
let expectedFlag = 0;
|
|
for (let unitIndex = 0; unitIndex < units; unitIndex++) {
|
|
const unit = bytes.readUInt16LE(content + unitIndex * 2);
|
|
if (
|
|
unit > 0xff ||
|
|
(unit >= 0x01 && unit <= 0x08) ||
|
|
(unit >= 0x0e && unit <= 0x1f) ||
|
|
unit === 0x27 ||
|
|
unit === 0x2d ||
|
|
unit === 0x7f
|
|
) {
|
|
expectedFlag = 1;
|
|
break;
|
|
}
|
|
}
|
|
const flagOffset = content + units * 2;
|
|
const actualFlag = bytes[flagOffset];
|
|
const issues = conformance();
|
|
if (actualFlag !== expectedFlag) {
|
|
budget.reserve(512, flagOffset);
|
|
issues.push({
|
|
code: "invalid-string",
|
|
scope,
|
|
offset: flagOffset,
|
|
detail: `User string terminal flag ${String(actualFlag)} differs from ECMA-335 II.24.2.4 value ${String(expectedFlag)}; readable UTF-16 is preserved and runtime acceptance is not assessed`,
|
|
});
|
|
}
|
|
return {
|
|
offset: start,
|
|
value: bytes.subarray(content, flagOffset).toString("utf16le"),
|
|
issues,
|
|
};
|
|
};
|
|
|
|
/** Read one conventional GUID value from the one-based #GUID heap. */
|
|
export const readMetadataGuid = (
|
|
bytes: Buffer,
|
|
layout: ManagedMetadataLayout,
|
|
index: number,
|
|
): string | null => {
|
|
if (index === 0) return null;
|
|
const relative = (index - 1) * 16;
|
|
if (relative < 0 || relative > layout.guid.size - 16)
|
|
throw managedFailure(
|
|
"invalid-guid",
|
|
"metadata.#GUID",
|
|
"GUID heap index is outside #GUID",
|
|
layout.guid.offset + Math.max(relative, 0),
|
|
);
|
|
const offset = layout.guid.offset + relative;
|
|
const part1 = bytes.readUInt32LE(offset).toString(16).padStart(8, "0");
|
|
const part2 = bytes
|
|
.readUInt16LE(offset + 4)
|
|
.toString(16)
|
|
.padStart(4, "0");
|
|
const part3 = bytes
|
|
.readUInt16LE(offset + 6)
|
|
.toString(16)
|
|
.padStart(4, "0");
|
|
const part4 = bytes.subarray(offset + 8, offset + 10).toString("hex");
|
|
const part5 = bytes.subarray(offset + 10, offset + 16).toString("hex");
|
|
return `${part1}-${part2}-${part3}-${part4}-${part5}`;
|
|
};
|
|
|
|
/** Format one metadata table/row pair as an exact build-local token. */
|
|
export const metadataToken = (table: number, row: number): string =>
|
|
`0x${(table * 0x01_00_00_00 + row).toString(16).padStart(8, "0")}`;
|
|
|
|
/** Hash bytes without exposing their contents. */
|
|
export const sha256Bytes = (bytes: Buffer): string =>
|
|
createHash("sha256").update(bytes).digest("hex");
|
|
|
|
/** Create a cursor bounded to one admitted metadata table row. */
|
|
export const metadataRowCursor = (
|
|
bytes: Buffer,
|
|
layout: ManagedMetadataLayout,
|
|
table: number,
|
|
row: number,
|
|
): MetadataRowCursor => {
|
|
const descriptor = layout.table(table);
|
|
const start = metadataRowOffset(layout, table, row);
|
|
if (descriptor === undefined)
|
|
throw managedFailure(
|
|
"invalid-row",
|
|
`metadata.${String(table)}`,
|
|
"Metadata table is absent",
|
|
start,
|
|
);
|
|
// Temporary cursors may revisit a row while resolving owners or references.
|
|
// Bind fixed retained bookkeeping to that row's identity, not each lookup.
|
|
managedDecodeBudget(layout).reserveRow(table, row, start);
|
|
return new MetadataRowCursor(
|
|
bytes,
|
|
start,
|
|
start + descriptor.rowSize,
|
|
`metadata.${descriptor.name}`,
|
|
);
|
|
};
|
|
|
|
/** Decode a metadata coded index without signed 32-bit coercion. */
|
|
export const metadataCodedToken = (
|
|
raw: number,
|
|
bits: number,
|
|
tables: readonly (number | undefined)[],
|
|
rowCounts: readonly number[],
|
|
): string | null => {
|
|
if (raw === 0) return null;
|
|
const tag = raw & (2 ** bits - 1);
|
|
const row = Math.floor(raw / 2 ** bits);
|
|
const table = tables[tag];
|
|
return table === undefined || row === 0 || row > (rowCounts[table] ?? 0)
|
|
? null
|
|
: metadataToken(table, row);
|
|
};
|
|
|
|
/** Explain why a non-null coded index cannot name an admitted metadata row. */
|
|
export const metadataCodedTokenInvalidReason = (
|
|
raw: number,
|
|
bits: number,
|
|
tables: readonly (number | undefined)[],
|
|
rowCounts: readonly number[],
|
|
): string | null => {
|
|
if (raw === 0) return null;
|
|
const tag = raw & (2 ** bits - 1);
|
|
const row = Math.floor(raw / 2 ** bits);
|
|
const table = tables[tag];
|
|
if (table === undefined) return `coded index tag ${tag} is not defined`;
|
|
if (row === 0) return `coded index selects row zero in table ${table}`;
|
|
const count = rowCounts[table] ?? 0;
|
|
return row > count
|
|
? `coded index selects row ${row} in table ${table}, which has ${count} rows`
|
|
: null;
|
|
};
|