refactor(managed): share compressed integer format decoding
This commit is contained in:
1 parent
87a5be3000
commit
6d4cc6e6a4
7 files changed
+272
-112
No files matched your search
@@ -318,6 +318,95 @@ describe("managed artifact inventory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("managed attribute SerString encoding", () => {
|
||||
it.each([
|
||||
[0, [0x00]],
|
||||
[0x7f, [0x7f]],
|
||||
[0x80, [0x80, 0x80]],
|
||||
[0x3fff, [0xbf, 0xff]],
|
||||
[0x4000, [0xc0, 0x00, 0x40, 0x00]],
|
||||
] as const)(
|
||||
"reads a target-framework SerString of %i UTF-8 bytes",
|
||||
(length, prefix) => {
|
||||
const targetFramework =
|
||||
"é".repeat(Math.floor(length / 2)) + (length % 2 === 0 ? "" : "A");
|
||||
const value = Buffer.concat([
|
||||
Buffer.from([1, 0, ...prefix]),
|
||||
Buffer.from(targetFramework),
|
||||
Buffer.from([0, 0]),
|
||||
]);
|
||||
const bytes = buildManagedPeFixture({ targetFramework });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.target_frameworks).toEqual([targetFramework]);
|
||||
expect(result.attributes).toMatchObject([
|
||||
{
|
||||
decoded_fixed_string: targetFramework,
|
||||
value_length: value.length,
|
||||
value_sha256: createHash("sha256").update(value).digest("hex"),
|
||||
},
|
||||
]);
|
||||
expect(result.coverage).toEqual({ state: "complete", issues: [] });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "missing length", value: [1, 0], decoded: null },
|
||||
{ name: "null sentinel", value: [1, 0, 0xff], decoded: null },
|
||||
{ name: "truncated two-byte prefix", value: [1, 0, 0x80], decoded: null },
|
||||
{
|
||||
name: "truncated four-byte prefix",
|
||||
value: [1, 0, 0xc0, 0, 0],
|
||||
decoded: null,
|
||||
},
|
||||
{ name: "reserved prefix", value: [1, 0, 0xe0], decoded: null },
|
||||
{ name: "content outside value", value: [1, 0, 5, 0x41], decoded: null },
|
||||
{
|
||||
name: "maximum length without content",
|
||||
value: [1, 0, 0xdf, 0xff, 0xff, 0xff],
|
||||
decoded: null,
|
||||
},
|
||||
{ name: "invalid UTF-8", value: [1, 0, 1, 0xff, 0, 0], decoded: null },
|
||||
{ name: "invalid prolog", value: [0, 0, 1, 0x41, 0, 0], decoded: null },
|
||||
{
|
||||
name: "overlong two-byte length",
|
||||
value: [1, 0, 0x80, 1, 0x41, 0, 0],
|
||||
decoded: "A",
|
||||
},
|
||||
{
|
||||
name: "overlong four-byte length",
|
||||
value: [1, 0, 0xc0, 0, 0, 1, 0x41, 0, 0],
|
||||
decoded: "A",
|
||||
},
|
||||
])("preserves attribute evidence for $name", ({ value, decoded }) => {
|
||||
const customAttributeValue = Buffer.from(value);
|
||||
const bytes = buildManagedPeFixture({ customAttributeValue });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.attributes).toMatchObject([
|
||||
{
|
||||
parent_token: "0x20000001",
|
||||
constructor_token: "0x0a000001",
|
||||
type_name: "System.Runtime.Versioning.TargetFrameworkAttribute",
|
||||
decoded_fixed_string: decoded,
|
||||
value_length: customAttributeValue.length,
|
||||
value_sha256: createHash("sha256")
|
||||
.update(customAttributeValue)
|
||||
.digest("hex"),
|
||||
},
|
||||
]);
|
||||
expect(result.target_frameworks).toEqual(decoded === null ? [] : [decoded]);
|
||||
expect(result.metadata.status).toBe("complete");
|
||||
expect(result.coverage).toEqual({ state: "complete", issues: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed artifact coded indexes", () => {
|
||||
it("reports an invalid ManifestResource implementation while retaining the resource", () => {
|
||||
const bytes = buildManagedPeFixture({ resourceImplementationRaw: 9 });
|
||||
|
||||
@@ -1993,6 +1993,72 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[[], "truncated compressed integer"],
|
||||
[[0x80], "reserved compressed integer"],
|
||||
[[0xc0, 0, 0], "reserved compressed integer"],
|
||||
[[0xe0], "reserved compressed integer"],
|
||||
] as const)(
|
||||
"retains TypeSpec prefix failure %j at its owning row",
|
||||
(prefix, issue) => {
|
||||
const bytes = buildManagedPeFixture({
|
||||
typeSpecSignatures: [Buffer.from([0x12, ...prefix])],
|
||||
memberRefParentRaw: (1 << 3) | 4,
|
||||
});
|
||||
const pe = readManagedPeLayout(bytes);
|
||||
if (pe.cli === null) throw new Error("fixture has no CLI header");
|
||||
const { rva, size } = pe.cli.metadata;
|
||||
const layout = readManagedMetadataLayout(
|
||||
bytes,
|
||||
pe.rvaToOffset(rva, size, "metadata"),
|
||||
size,
|
||||
);
|
||||
const result = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.member_refs[0]?.parent_name).toBeNull();
|
||||
expect(result.coverage.issues).toContainEqual({
|
||||
code: "invalid-blob",
|
||||
scope: "metadata.TypeSpec:0x1b000001",
|
||||
offset: metadataRowOffset(layout, 27, 1),
|
||||
detail: issue,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[[0x01], -64],
|
||||
[[0x80, 0x01], -8192],
|
||||
[[0xc0, 0x00, 0x00, 0x01], -268435456],
|
||||
] as const)(
|
||||
"preserves compressed signed width for array bound %j",
|
||||
(bound, lower) => {
|
||||
const bytes = buildManagedPeFixture({
|
||||
fieldSignature: Buffer.from([
|
||||
0x06,
|
||||
0x14,
|
||||
0x08,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
...bound,
|
||||
]),
|
||||
});
|
||||
const result = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.fields[0]?.signature).toMatchObject({
|
||||
parse_status: "decoded",
|
||||
field_type: `i4[${lower}...]`,
|
||||
});
|
||||
expect(result.coverage).toEqual({ state: "complete", issues: [] });
|
||||
},
|
||||
);
|
||||
|
||||
it("decodes generic instantiation member signatures as complete coverage", () => {
|
||||
// System.Runtime.Caching 0x06000001: instance void (Dictionary<string, object>)
|
||||
// shape, with TypeRef/TypeSpec coded tokens kept as encoded.
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from "./ManagedMetadataLayout.js";
|
||||
import {
|
||||
metadataRowCursor,
|
||||
readManagedCompressedUnsigned,
|
||||
decodeMetadataCodedIndex,
|
||||
metadataCodedIndexCoordinate,
|
||||
type MetadataCodedIndexCoordinate,
|
||||
@@ -285,38 +286,17 @@ export const readCompressed = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
): { readonly value: number; readonly next: number } => {
|
||||
const first = bytes[offset];
|
||||
if (first === undefined)
|
||||
const decoded = readManagedCompressedUnsigned(bytes, offset);
|
||||
if (!decoded.ok)
|
||||
throw managedFailure(
|
||||
"invalid-blob",
|
||||
"signature",
|
||||
"truncated compressed integer",
|
||||
decoded.error.kind === "truncated" && decoded.error.width === 1
|
||||
? "truncated compressed integer"
|
||||
: "reserved compressed integer",
|
||||
offset,
|
||||
);
|
||||
if ((first & 0x80) === 0) return { value: first, next: offset + 1 };
|
||||
const second = bytes[offset + 1];
|
||||
if ((first & 0xc0) === 0x80 && second !== undefined)
|
||||
return { value: ((first & 0x3f) << 8) | second, next: offset + 2 };
|
||||
const b1 = bytes[offset + 1];
|
||||
const b2 = bytes[offset + 2];
|
||||
const b3 = bytes[offset + 3];
|
||||
if (
|
||||
(first & 0xe0) === 0xc0 &&
|
||||
b1 !== undefined &&
|
||||
b2 !== undefined &&
|
||||
b3 !== undefined
|
||||
)
|
||||
return {
|
||||
value:
|
||||
(first & 0x1f) * 0x01_00_00_00 + b1 * 0x01_00_00 + b2 * 0x0100 + b3,
|
||||
next: offset + 4,
|
||||
};
|
||||
throw managedFailure(
|
||||
"invalid-blob",
|
||||
"signature",
|
||||
"reserved compressed integer",
|
||||
offset,
|
||||
);
|
||||
return { value: decoded.value.value, next: offset + decoded.value.width };
|
||||
};
|
||||
|
||||
const ELEMENT_TYPES = new Map<number, string>([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||
|
||||
@@ -55,6 +56,39 @@ export class MetadataRowCursor {
|
||||
}
|
||||
}
|
||||
|
||||
interface ManagedCompressedUnsigned {
|
||||
readonly value: number;
|
||||
readonly width: 1 | 2 | 4;
|
||||
readonly minimal: boolean;
|
||||
}
|
||||
|
||||
type ManagedCompressedUnsignedFailure =
|
||||
| { readonly kind: "truncated"; readonly width: 1 | 2 | 4 }
|
||||
| { readonly kind: "reserved" };
|
||||
|
||||
/** ECMA-335 unsigned encoding facts, without heap or signature admission policy. */
|
||||
export const readManagedCompressedUnsigned = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
): Result<ManagedCompressedUnsigned, ManagedCompressedUnsignedFailure> => {
|
||||
const first = bytes[offset];
|
||||
if (first === undefined) return err({ kind: "truncated", width: 1 });
|
||||
if ((first & 0x80) === 0)
|
||||
return ok({ value: first, width: 1, minimal: true });
|
||||
const width =
|
||||
(first & 0xc0) === 0x80 ? 2 : (first & 0xe0) === 0xc0 ? 4 : null;
|
||||
if (width === null) return err({ kind: "reserved" });
|
||||
if (offset > bytes.length - width) return err({ kind: "truncated", width });
|
||||
const value =
|
||||
width === 2
|
||||
? ((first & 0x3f) << 8) | (bytes[offset + 1] ?? 0)
|
||||
: (first & 0x1f) * 0x01_00_00_00 +
|
||||
(bytes[offset + 1] ?? 0) * 0x01_00_00 +
|
||||
(bytes[offset + 2] ?? 0) * 0x0100 +
|
||||
(bytes[offset + 3] ?? 0);
|
||||
return ok({ value, width, minimal: value >= (width === 2 ? 0x80 : 0x4000) });
|
||||
};
|
||||
|
||||
// Issue code and label for the heap item a compressed length prefixes.
|
||||
const PREFIXED_ITEMS = {
|
||||
blob: { code: "invalid-blob", label: "Metadata blob" },
|
||||
@@ -73,38 +107,21 @@ const compressedLength = (
|
||||
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,
|
||||
);
|
||||
const decoded = readManagedCompressedUnsigned(bytes, offset);
|
||||
if (!decoded.ok)
|
||||
throw managedFailure(
|
||||
code,
|
||||
scope,
|
||||
decoded.error.kind === "truncated"
|
||||
? `${label} length prefix leaves the artifact byte range`
|
||||
: `${label} uses a reserved compressed-length prefix`,
|
||||
offset >= 0 ? offset : null,
|
||||
);
|
||||
return {
|
||||
length: decoded.value.value,
|
||||
prefix: decoded.value.width,
|
||||
minimal: decoded.value.minimal,
|
||||
};
|
||||
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. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./ManagedMetadataLayout.js";
|
||||
import {
|
||||
metadataRowCursor,
|
||||
readManagedCompressedUnsigned,
|
||||
decodeMetadataCodedIndex,
|
||||
type ManagedCodedIndex,
|
||||
metadataToken,
|
||||
@@ -432,52 +433,24 @@ const attributeTypeName = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const decodeCompressedLength = (
|
||||
blob: Buffer,
|
||||
offset: number,
|
||||
): { readonly length: number; readonly prefix: number } | undefined => {
|
||||
const first = blob[offset];
|
||||
if (first === undefined) return undefined;
|
||||
if ((first & 0x80) === 0) return { length: first, prefix: 1 };
|
||||
if ((first & 0xc0) === 0x80 && blob[offset + 1] !== undefined)
|
||||
return {
|
||||
length: ((first & 0x3f) << 8) | (blob[offset + 1] ?? 0),
|
||||
prefix: 2,
|
||||
};
|
||||
if (
|
||||
(first & 0xe0) === 0xc0 &&
|
||||
blob[offset + 1] !== undefined &&
|
||||
blob[offset + 2] !== undefined &&
|
||||
blob[offset + 3] !== undefined
|
||||
)
|
||||
return {
|
||||
length:
|
||||
(first & 0x1f) * 0x01_00_00_00 +
|
||||
(blob[offset + 1] ?? 0) * 0x01_00_00 +
|
||||
(blob[offset + 2] ?? 0) * 0x0100 +
|
||||
(blob[offset + 3] ?? 0),
|
||||
prefix: 4,
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const decodeFixedString = (
|
||||
blob: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
): string | null => {
|
||||
if (blob.length < 3 || blob.readUInt16LE(0) !== 1 || blob[2] === 0xff)
|
||||
return null;
|
||||
const length = decodeCompressedLength(blob, 2);
|
||||
if (length === undefined) return null;
|
||||
const start = 2 + length.prefix;
|
||||
if (start > blob.length - length.length) return null;
|
||||
const decoded = readManagedCompressedUnsigned(blob, 2);
|
||||
if (!decoded.ok) return null;
|
||||
const { value: length, width } = decoded.value;
|
||||
const start = 2 + width;
|
||||
if (start > blob.length - length) return null;
|
||||
managedDecodeBudget(layout).reserveString(
|
||||
length.length,
|
||||
length,
|
||||
layout.blob?.offset ?? layout.rootOffset,
|
||||
);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
blob.subarray(start, start + length.length),
|
||||
blob.subarray(start, start + length),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// Invalid UTF-8 blobs decode as null.
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ManagedPeFixtureOptions {
|
||||
readonly cliFlags?: number;
|
||||
readonly corruptMetadataSignature?: boolean;
|
||||
readonly customAttributeTypeRaw?: number;
|
||||
readonly customAttributeValue?: Buffer;
|
||||
readonly extendsRaw?: number;
|
||||
readonly exportedTypes?: readonly {
|
||||
readonly namespace: string;
|
||||
@@ -100,6 +101,18 @@ class StringHeap {
|
||||
}
|
||||
}
|
||||
|
||||
const compressedUnsigned = (value: number): Buffer =>
|
||||
value <= 0x7f
|
||||
? Buffer.from([value])
|
||||
: value <= 0x3fff
|
||||
? Buffer.from([0x80 | (value >> 8), value & 0xff])
|
||||
: Buffer.from([
|
||||
0xc0 | (value >> 24),
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
value & 0xff,
|
||||
]);
|
||||
|
||||
class BlobHeap {
|
||||
readonly #chunks: Buffer[] = [Buffer.from([0])];
|
||||
#size = 1;
|
||||
@@ -111,17 +124,7 @@ class BlobHeap {
|
||||
add(value: Buffer): number {
|
||||
if (value.length === 0) return 0;
|
||||
const index = this.#size;
|
||||
const prefix =
|
||||
value.length <= 0x7f
|
||||
? Buffer.from([value.length])
|
||||
: value.length <= 0x3fff
|
||||
? Buffer.from([0x80 | (value.length >> 8), value.length & 0xff])
|
||||
: Buffer.from([
|
||||
0xc0 | (value.length >> 24),
|
||||
(value.length >> 16) & 0xff,
|
||||
(value.length >> 8) & 0xff,
|
||||
value.length & 0xff,
|
||||
]);
|
||||
const prefix = compressedUnsigned(value.length);
|
||||
this.#chunks.push(prefix, value);
|
||||
this.#size += value.length + prefix.length;
|
||||
return index;
|
||||
@@ -428,9 +431,12 @@ const exportedTypeRow = (
|
||||
|
||||
const fixedStringAttributeBlob = (value: string): Buffer => {
|
||||
const bytes = Buffer.from(textEncoder.encode(value));
|
||||
if (bytes.length > 0x7f)
|
||||
throw new RangeError("Managed PE fixture target framework is too long");
|
||||
return Buffer.concat([Buffer.from([1, 0, bytes.length]), bytes, u16(0)]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([1, 0]),
|
||||
compressedUnsigned(bytes.length),
|
||||
bytes,
|
||||
u16(0),
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1061,11 +1067,12 @@ const buildManagedFixtureMetadata = (
|
||||
);
|
||||
const referenceBlobs = referenceBlobIndexes(blobs, options);
|
||||
const attributeBlob = blobs.add(
|
||||
fixedStringAttributeBlob(
|
||||
options.targetFramework === null
|
||||
? ".NETCoreApp,Version=v8.0"
|
||||
: (options.targetFramework ?? ".NETCoreApp,Version=v8.0"),
|
||||
),
|
||||
options.customAttributeValue ??
|
||||
fixedStringAttributeBlob(
|
||||
options.targetFramework === null
|
||||
? ".NETCoreApp,Version=v8.0"
|
||||
: (options.targetFramework ?? ".NETCoreApp,Version=v8.0"),
|
||||
),
|
||||
);
|
||||
const stringSize = indexSize(strings.size);
|
||||
const blobSize = indexSize(blobs.size);
|
||||
|
||||
@@ -526,3 +526,31 @@ cliTest(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"managed CLI retains target frameworks with multi-byte SerString lengths",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-managed-serstring-");
|
||||
for (const length of [0x80, 0x4000]) {
|
||||
const targetFramework = "A".repeat(length);
|
||||
const path = join(root, `framework-${length}.dll`);
|
||||
await writeFile(path, buildManagedPeFixture({ targetFramework }));
|
||||
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-managed-artifact", path, "--json"],
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
operation: "inspect_managed_artifact",
|
||||
normalized_result: {
|
||||
target_frameworks: [targetFramework],
|
||||
attributes: [
|
||||
expect.objectContaining({ decoded_fixed_string: targetFramework }),
|
||||
],
|
||||
coverage: { state: "complete", issues: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user