fix(metadata): preserve declared phandles and relocation fields

Keep malformed canonical devicetree phandles unresolved instead of falling back to legacy identities. Report the PE dynamic relocation offset and section at their exact source widths with Size-gated absence and zero preservation. Clarify classic kernel-module version coverage and byte-preserving string encoding.

Validation: 158 focused parser, service, filesystem, and finalization regressions; 14 CLI/MCP tests; build, typecheck/lint, formatting, and docs checks passed. Fixtures establish parser behavior; no external analysis engines were exercised.
This commit is contained in:
morluto
2026-10-24 23:49:03 +08:00
parent e9abf39848
commit 72e75e9a48
15 changed files with 324 additions and 29 deletions
+5 -2
View File
@@ -30,8 +30,11 @@ bootargs/stdout-path and aliases. An undecodable alias retains its name with a
null path; it is not silently treated as absent. Single-cell `interrupt-parent`, `remote-endpoint` and `msi-parent`
references resolve through phandle declarations. Missing or duplicate targets
have null target paths. A node whose `phandle` and `linux,phandle` disagree
adds a limitation, and references to either value stay unresolved. Binding-specific reference arrays and overlays are not
resolved; an empty reference list does not prove absence of hardware links.
adds a limitation, and references to either value stay unresolved. A present
`phandle` that is not one cell also adds a limitation; `linux,phandle` cannot
resolve that node when the canonical declaration is malformed. Binding-specific
reference arrays and overlays are not resolved; an empty reference list does not
prove absence of hardware links.
MCP `detail: summary` retains complete Evidence in the session. Follow its
`parent_evidence_id` with `inspect_analysis_view`:
+13 -2
View File
@@ -32,14 +32,23 @@ the signature trailer are not examined.
`.modinfo` entries stay in file order. Duplicate keys such as `alias` and
`parm` are preserved. Each entry keeps its key, value, file range, and
SHA-256. Values are the bytes after the first `=`. NUL padding between
strings is skipped.
strings is skipped. Keys and values use a byte-preserving Latin1 projection:
each source byte becomes the same-numbered Unicode code point. They are not
decoded as UTF-8. `raw_sha256` hashes the original entry bytes, including its
terminating NUL when present; `raw_hex` shows those bytes when they fit the
inline limit.
`__versions` entries are `struct modversion_info`: a 64-byte record whose CRC
is an `unsigned long` and whose name occupies the remaining bytes. ELF32
stores a 32-bit CRC and a 60-byte name. ELF64 stores a 64-bit unsigned long
and a 56-byte name. CRCs are zero-padded lowercase hex, 8 or 16 characters,
not a JavaScript number. The layout is reported from the ELF class even when
the section is absent.
the section is absent. Names use the same byte-preserving Latin1 projection
as `.modinfo` keys and values. Only this classic `__versions` layout is
decoded. `versions.status: "absent"` means no `__versions` section was found
when section names were complete; it does not establish the absence of version
metadata in `__version_ext_names` and `__version_ext_crcs`, which Linux uses
with `CONFIG_EXTENDED_MODVERSIONS` and this workflow does not decode.
`.gnu.linkonce.this_module` is reported as present, absent, or unknown.
`layout` is always `unknown` because `struct module` changes with the kernel
@@ -105,6 +114,8 @@ does not execute the module or validate an appended signature.
The record layout follows Linux v6.12's
[`modversion_info`](https://github.com/torvalds/linux/blob/v6.12/include/linux/module.h)
and [`MAX_PARAM_PREFIX_LEN`](https://github.com/torvalds/linux/blob/v6.12/include/linux/moduleparam.h).
The [Linux kbuild module versioning guide](https://docs.kernel.org/kbuild/modules.html#module-versioning)
describes the separate extended version sections.
Trailer fields and acceptance rules follow
[`module_signature`](https://github.com/torvalds/linux/blob/v6.12/include/linux/module_signature.h)
and [`mod_check_sig`](https://github.com/torvalds/linux/blob/v6.12/kernel/module_signature.c).
+5 -2
View File
@@ -28,7 +28,9 @@ directory range when the two differ. Unavailable fields are `null`; a covered ze
The report includes the security cookie, PE32 SafeSEH table/count, CFG pointers
and table/count, address-taken IAT and long-jump tables/counts, EH continuation
table/count, and XFG check pointer. CHPE and dynamic relocation pointers are raw
values; their targets and mitigation-table entries are not decoded.
values. The dynamic relocation table offset (DWORD) and section (WORD) are
reported separately when `Size` covers them, including zero values; REA does
not infer a VA from that pair or decode the target and mitigation-table entries.
Guard flags retain the raw DWORD, recognized names, unknown bits, and the CFG
entry stride. Mitigation claims derived from those flags carry
@@ -44,7 +46,8 @@ success is returned. An unmapped callback **target** retains its VA/RVA with an
unknown file offset. Load-config versions beyond the supported fields remain
undecoded.
Layout references: [Microsoft PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)
Layout references: [Microsoft PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format),
[IMAGE_LOAD_CONFIG_DIRECTORY32](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_load_config_directory32),
and [IMAGE_LOAD_CONFIG_DIRECTORY64](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_load_config_directory64).
For a real cross-compiled fixture, run `npm run verify:pe:load-config` with Zig
@@ -40,6 +40,8 @@ const legacyX86: PeLoadConfig = {
guard_long_jump_target_count: null,
dynamic_value_reloc_table: null,
chpe_metadata_pointer: null,
dynamic_value_reloc_table_offset: null,
dynamic_value_reloc_table_section: null,
guard_eh_continuation_table: null,
guard_eh_continuation_count: null,
guard_xfg_check_function_pointer: null,
+22 -2
View File
@@ -207,13 +207,22 @@ export const PE_LOAD_CONFIG_RAW_FIELDS = [
] as const;
export type PeLoadConfigRawField = (typeof PE_LOAD_CONFIG_RAW_FIELDS)[number];
/** Source values of the dynamic relocation table offset and section slots. */
export const PE_LOAD_CONFIG_NUMBER_FIELDS = [
"dynamic_value_reloc_table_offset",
"dynamic_value_reloc_table_section",
] as const;
export type PeLoadConfigNumberField =
(typeof PE_LOAD_CONFIG_NUMBER_FIELDS)[number];
export type PeLoadConfigField =
| PeLoadConfigLocatedField
| PeLoadConfigCountField
| PeLoadConfigRawField
| PeLoadConfigNumberField
| "guard_flags";
type FieldLayout = { readonly offset: number; readonly width: 4 | 8 };
type FieldLayout = { readonly offset: number; readonly width: 2 | 4 | 8 };
/**
* Offsets of IMAGE_LOAD_CONFIG_DIRECTORY32. A field is present when Size
@@ -235,6 +244,8 @@ const PE32_LOAD_CONFIG_FIELDS: Record<PeLoadConfigField, FieldLayout> = {
guard_long_jump_target_count: { offset: 116, width: 4 },
dynamic_value_reloc_table: { offset: 120, width: 4 },
chpe_metadata_pointer: { offset: 124, width: 4 },
dynamic_value_reloc_table_offset: { offset: 136, width: 4 },
dynamic_value_reloc_table_section: { offset: 140, width: 2 },
guard_eh_continuation_table: { offset: 164, width: 4 },
guard_eh_continuation_count: { offset: 168, width: 4 },
guard_xfg_check_function_pointer: { offset: 172, width: 4 },
@@ -260,6 +271,8 @@ const PE64_LOAD_CONFIG_FIELDS: Record<PeLoadConfigField, FieldLayout> = {
guard_long_jump_target_count: { offset: 184, width: 8 },
dynamic_value_reloc_table: { offset: 192, width: 8 },
chpe_metadata_pointer: { offset: 200, width: 8 },
dynamic_value_reloc_table_offset: { offset: 224, width: 4 },
dynamic_value_reloc_table_section: { offset: 228, width: 2 },
guard_eh_continuation_table: { offset: 264, width: 8 },
guard_eh_continuation_count: { offset: 272, width: 8 },
guard_xfg_check_function_pointer: { offset: 280, width: 8 },
@@ -355,6 +368,13 @@ const loadConfigSchema = z
guard_long_jump_target_count: safeCount.nullable(),
dynamic_value_reloc_table: hexVa.nullable(),
chpe_metadata_pointer: hexVa.nullable(),
dynamic_value_reloc_table_offset: uint.nullable(),
dynamic_value_reloc_table_section: z
.number()
.int()
.min(0)
.max(0xffff)
.nullable(),
guard_eh_continuation_table: locatedSchema.nullable(),
guard_eh_continuation_count: safeCount.nullable(),
guard_xfg_check_function_pointer: locatedSchema.nullable(),
@@ -368,7 +388,7 @@ export const PE_LOAD_CONFIG_LIMITATIONS = [
"Claims such as CFG instrumentation are inferred from GuardFlags bits and are not observations of instrumented instructions.",
"A missing TLS or load-config directory is observed absence of that table, not proof the image never registered callbacks or mitigations through another mechanism.",
"SafeSEH table and count are reported only for PE32. PE32+ struct slots that share those names are not a SafeSEH table.",
"CHPE metadata and dynamic value relocation pointers are raw virtual addresses. Their targets are not decoded.",
"CHPE metadata and dynamic value relocation pointers are raw virtual addresses. Dynamic relocation table offset and section are separate source values; their target is not derived or decoded.",
"Load-config fields newer than GuardXFGCheckFunctionPointer stay unread. A Size larger than the fields this version knows does not fail the read.",
] as const;
@@ -189,6 +189,61 @@ it("leaves conflicting phandle declarations unresolved", () => {
"phandle at /split conflicts with linux,phandle (0x1 vs 0x2); references to either value are unresolved.",
]);
});
it.each([
["empty", Buffer.alloc(0)],
["short", Buffer.from([0, 0])],
["multiple cells", cells(1, 2)],
])(
"does not use a legacy phandle when the canonical value is %s",
(_name, canonical) => {
const r = parse(
devicetreeFixture({
name: "",
children: [
{
name: "target",
properties: { phandle: canonical, "linux,phandle": cells(3) },
},
{ name: "consumer", properties: { "interrupt-parent": cells(3) } },
],
}),
);
expect(r.references).toMatchObject([
{ source: "/consumer", phandle: 3, target: null },
]);
expect(r.nodes[1]?.properties[0]).toMatchObject({
name: "phandle",
bytes: canonical.length,
hex: canonical.toString("hex"),
derived: { cells: null },
});
expect(r.limitations).toContain(
"phandle at /target is not a single cell; this node's reference identity remains unresolved.",
);
},
);
it("resolves a legacy phandle only when the canonical property is absent", () => {
const r = parse(
devicetreeFixture({
name: "",
children: [
{ name: "legacy", properties: { "linux,phandle": cells(3) } },
{ name: "canonical", properties: { phandle: cells(4) } },
{
name: "consumer",
properties: {
"interrupt-parent": cells(3),
"remote-endpoint": cells(4),
},
},
],
}),
);
expect(r.references.map((ref) => [ref.phandle, ref.target])).toEqual([
[3, "/legacy"],
[4, "/canonical"],
]);
});
it("rejects unterminated reservations and unknown tokens", () => {
const reservation = devicetreeFixture();
reservation.fill(1, 56, 72);
+14 -3
View File
@@ -286,10 +286,20 @@ export const parseDevicetree = (
"Phandle references cover only single-cell interrupt-parent, remote-endpoint and msi-parent; binding-specific arrays and overlays are not resolved.",
];
for (const node of nodes) {
const declared = ["phandle", "linux,phandle"].flatMap((name) => {
const handle = prop(node.path, name)?.derived.cells?.[0];
const canonical = prop(node.path, "phandle");
const legacy = prop(node.path, "linux,phandle");
const malformedCanonical =
canonical !== undefined && canonical.derived.cells === null;
const declared = [canonical, legacy].flatMap((property) => {
const handle = property?.derived.cells?.[0];
return handle === undefined ? [] : [handle];
});
if (malformedCanonical) {
admit(node.path.length * 2 + 256);
limitations.push(
`phandle at ${node.path} is not a single cell; this node's reference identity remains unresolved.`,
);
}
// Disagreeing declarations leave both values ambiguous rather than choosing one.
const conflict = declared.length === 2 && declared[0] !== declared[1];
if (conflict) {
@@ -298,12 +308,13 @@ export const parseDevicetree = (
`phandle at ${node.path} conflicts with linux,phandle (0x${declared[0]?.toString(16)} vs 0x${declared[1]?.toString(16)}); references to either value are unresolved.`,
);
}
const unambiguous = !malformedCanonical && !conflict;
for (const handle of declared)
if (handle !== 0 && handle !== 0xffffffff) {
const existing = handles.get(handle);
handles.set(
handle,
!conflict && (existing === undefined || existing === node.path)
unambiguous && (existing === undefined || existing === node.path)
? node.path
: null,
);
+2
View File
@@ -87,6 +87,8 @@ const baseLimitations = (): Limitations => [
"This workflow does not execute the module, resolve imported symbols, or validate an appended signature.",
"struct module in .gnu.linkonce.this_module is kernel-version specific and is not decoded.",
"__ksymtab entry layout is kernel-version specific and is not decoded.",
"Only classic __versions records are decoded. Their absence does not establish the absence of CONFIG_EXTENDED_MODVERSIONS metadata in __version_ext_names and __version_ext_crcs.",
"Metadata strings use a byte-preserving Latin1 projection; their original text encoding is unobserved.",
];
const headerFacts = (
+12 -1
View File
@@ -31,6 +31,8 @@ export const PE_LOAD_CONFIG_FIXTURE = {
callbackRvas: [0x2000, 0x2010, 0x9000],
chpe: 0x33333333,
dynamicReloc: 0x22222222,
dynamicRelocOffset: 0x40,
dynamicRelocSection: 1,
guardFlags: 0x500,
sehCount: 2,
ignoredSeh: 0x44444444,
@@ -275,7 +277,8 @@ const writeLoadConfig = (
if (field.offset + field.width > written) return;
const atField = configAt + field.offset;
if (field.width === 8) bytes.writeBigUInt64LE(value, atField);
else bytes.writeUInt32LE(Number(value), atField);
else if (field.width === 4) bytes.writeUInt32LE(Number(value), atField);
else bytes.writeUInt16LE(Number(value), atField);
};
const cookie =
options.securityCookie === "zero" ? 0n : vaOf(layout.cookieRva);
@@ -306,6 +309,14 @@ const writeLoadConfig = (
writeField("guard_long_jump_target_count", BigInt(layout.longjmpCount));
writeField("dynamic_value_reloc_table", BigInt(layout.dynamicReloc));
writeField("chpe_metadata_pointer", BigInt(layout.chpe));
writeField(
"dynamic_value_reloc_table_offset",
BigInt(layout.dynamicRelocOffset),
);
writeField(
"dynamic_value_reloc_table_section",
BigInt(layout.dynamicRelocSection),
);
writeField("guard_eh_continuation_table", vaOf(layout.ehTableRva));
writeField("guard_eh_continuation_count", BigInt(layout.ehCount));
writeField("guard_xfg_check_function_pointer", vaOf(layout.xfgRva));
+49 -4
View File
@@ -169,7 +169,7 @@ it("resolves callback sections in bounded time for the largest section table", a
*/
const SPEC_FIELDS: Record<
32 | 64,
readonly (readonly [PeLoadConfigField, number, 4 | 8])[]
readonly (readonly [PeLoadConfigField, number, 2 | 4 | 8])[]
> = {
32: [
["security_cookie", 60, 4],
@@ -186,6 +186,8 @@ const SPEC_FIELDS: Record<
["guard_long_jump_target_count", 116, 4],
["dynamic_value_reloc_table", 120, 4],
["chpe_metadata_pointer", 124, 4],
["dynamic_value_reloc_table_offset", 136, 4],
["dynamic_value_reloc_table_section", 140, 2],
["guard_eh_continuation_table", 164, 4],
["guard_eh_continuation_count", 168, 4],
["guard_xfg_check_function_pointer", 172, 4],
@@ -205,6 +207,8 @@ const SPEC_FIELDS: Record<
["guard_long_jump_target_count", 184, 8],
["dynamic_value_reloc_table", 192, 8],
["chpe_metadata_pointer", 200, 8],
["dynamic_value_reloc_table_offset", 224, 4],
["dynamic_value_reloc_table_section", 228, 2],
["guard_eh_continuation_table", 264, 8],
["guard_eh_continuation_count", 272, 8],
["guard_xfg_check_function_pointer", 280, 8],
@@ -215,10 +219,10 @@ const SPEC_FIELDS: Record<
const LOAD_CONFIG_AT = 0x640;
/** A distinct sentinel per field that fills its whole width. */
const sentinel = (index: number, width: 4 | 8): bigint =>
const sentinel = (index: number, width: 2 | 4 | 8): bigint =>
width === 8
? 0x0000_0102_0304_0500n + BigInt(index)
: BigInt(0x01020300 + index);
: BigInt((width === 4 ? 0x01020300 : 0x1200) + index);
/** Rewrite the struct from spec offsets alone, then declare `size`. */
const specImage = (bits: 32 | 64, size: number): Buffer => {
@@ -233,7 +237,9 @@ const specImage = (bits: 32 | 64, size: number): Buffer => {
SPEC_FIELDS[bits].forEach(([, offset, width], index) => {
const value = sentinel(index, width);
if (width === 8) bytes.writeBigUInt64LE(value, LOAD_CONFIG_AT + offset);
else bytes.writeUInt32LE(Number(value), LOAD_CONFIG_AT + offset);
else if (width === 4)
bytes.writeUInt32LE(Number(value), LOAD_CONFIG_AT + offset);
else bytes.writeUInt16LE(Number(value), LOAD_CONFIG_AT + offset);
});
return bytes;
};
@@ -245,6 +251,11 @@ const expectedField = (name: PeLoadConfigField, value: bigint): unknown => {
if (name.endsWith("_count")) return Number(value);
if (name === "dynamic_value_reloc_table" || name === "chpe_metadata_pointer")
return `0x${value.toString(16)}`;
if (
name === "dynamic_value_reloc_table_offset" ||
name === "dynamic_value_reloc_table_section"
)
return Number(value);
return expect.objectContaining({ va: `0x${value.toString(16)}`, rva: null });
};
@@ -284,3 +295,37 @@ describe.each([32, 64] as const)(
});
},
);
it.each([32, 64] as const)(
"PE%s retains dynamic relocation offset and section with a zero pointer",
async (bits) => {
const pointerOffset = bits === 32 ? 120 : 192;
const offsetOffset = bits === 32 ? 136 : 224;
const sectionOffset = bits === 32 ? 140 : 228;
for (const [offset, section] of [
[0, 0],
[0x40, 1],
[0xffffffff, 0xffff],
] as const) {
const bytes = specImage(bits, bits === 32 ? 192 : 320);
bytes.fill(
0,
LOAD_CONFIG_AT + pointerOffset,
LOAD_CONFIG_AT + pointerOffset + (bits === 32 ? 4 : 8),
);
bytes.writeUInt32LE(offset, LOAD_CONFIG_AT + offsetOffset);
bytes.writeUInt16LE(section, LOAD_CONFIG_AT + sectionOffset);
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
);
expect(peLoadConfigSchema.safeParse(report).success).toBe(true);
expect(report.load_config).toMatchObject({
dynamic_value_reloc_table: "0x0",
dynamic_value_reloc_table_offset: offset,
dynamic_value_reloc_table_section: section,
});
}
},
);
+23 -10
View File
@@ -22,6 +22,7 @@ import {
type PeLoadConfigField,
type PeLoadConfigLocated,
type PeLoadConfigLocatedField,
type PeLoadConfigNumberField,
type PeLoadConfigRawField,
type PeLoadConfigTls,
} from "../../domain/native/peLoadConfig.js";
@@ -419,7 +420,7 @@ const readLoadConfig = ({
const readField = (name: PeLoadConfigField): bigint | null => {
if (!loadConfigFieldCovered(bits, size, name)) return null;
const field = loadConfigFieldLayout(bits, name);
return readPointerWidth(
return readUnsignedField(
bytes,
directory.file_offset + field.offset,
field.width,
@@ -432,7 +433,9 @@ const readLoadConfig = ({
const value = readField(name);
return value === null ? null : locate(value);
};
const count = (name: PeLoadConfigCountField): number | null => {
const number = (
name: PeLoadConfigCountField | PeLoadConfigNumberField,
): number | null => {
const value = readField(name);
if (value === null) return null;
if (value > BigInt(Number.MAX_SAFE_INTEGER))
@@ -454,13 +457,13 @@ const readLoadConfig = ({
structure_location: { offset: directory.file_offset, bytes: size },
security_cookie: located("security_cookie"),
se_handler_table: located("se_handler_table"),
se_handler_count: count("se_handler_count"),
se_handler_count: number("se_handler_count"),
guard_cf_check_function_pointer: located("guard_cf_check_function_pointer"),
guard_cf_dispatch_function_pointer: located(
"guard_cf_dispatch_function_pointer",
),
guard_cf_function_table: located("guard_cf_function_table"),
guard_cf_function_count: count("guard_cf_function_count"),
guard_cf_function_count: number("guard_cf_function_count"),
guard_flags:
decoded === null
? null
@@ -473,15 +476,21 @@ const readLoadConfig = ({
guard_address_taken_iat_entry_table: located(
"guard_address_taken_iat_entry_table",
),
guard_address_taken_iat_entry_count: count(
guard_address_taken_iat_entry_count: number(
"guard_address_taken_iat_entry_count",
),
guard_long_jump_target_table: located("guard_long_jump_target_table"),
guard_long_jump_target_count: count("guard_long_jump_target_count"),
guard_long_jump_target_count: number("guard_long_jump_target_count"),
dynamic_value_reloc_table: raw("dynamic_value_reloc_table"),
chpe_metadata_pointer: raw("chpe_metadata_pointer"),
dynamic_value_reloc_table_offset: number(
"dynamic_value_reloc_table_offset",
),
dynamic_value_reloc_table_section: number(
"dynamic_value_reloc_table_section",
),
guard_eh_continuation_table: located("guard_eh_continuation_table"),
guard_eh_continuation_count: count("guard_eh_continuation_count"),
guard_eh_continuation_count: number("guard_eh_continuation_count"),
guard_xfg_check_function_pointer: located(
"guard_xfg_check_function_pointer",
),
@@ -489,10 +498,14 @@ const readLoadConfig = ({
};
};
const readPointerWidth = (
const readUnsignedField = (
bytes: Buffer,
offset: number,
width: 4 | 8,
width: 2 | 4 | 8,
readPointer: (offset: number) => bigint,
): bigint =>
width === 8 ? readPointer(offset) : BigInt(bytes.readUInt32LE(offset));
width === 8
? readPointer(offset)
: BigInt(
width === 4 ? bytes.readUInt32LE(offset) : bytes.readUInt16LE(offset),
);
+36 -1
View File
@@ -3,7 +3,10 @@ import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { devicetreeFixture } from "../../src/firmware/devicetree/Devicetree.fixture.js";
import {
cells,
devicetreeFixture,
} from "../../src/firmware/devicetree/Devicetree.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -37,6 +40,38 @@ cliTest("inspects a relative DTB and preserves the source", async ({ cli }) => {
});
expect(await readFile(path)).toEqual(bytes);
});
cliTest(
"does not resolve through a legacy phandle when the canonical value is malformed",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-dtb-cli-phandle-");
const path = join(root, "board.dtb");
await writeFile(
path,
devicetreeFixture({
name: "",
children: [
{
name: "target",
properties: { phandle: Buffer.alloc(0), "linux,phandle": cells(3) },
},
{ name: "consumer", properties: { "interrupt-parent": cells(3) } },
],
}),
);
const result = await cli.run({
arguments: ["inspect-devicetree", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const report = toolContract("inspect_devicetree").outputSchema.parse(
result.json,
).normalized_result;
if (!("references" in report)) throw new Error("Expected complete report");
expect(report.references).toMatchObject([{ phandle: 3, target: null }]);
expect(report.limitations).toContain(
"phandle at /target is not a single cell; this node's reference identity remains unresolved.",
);
},
);
cliTest(
"retains complete DTB Evidence when delivering a summary",
async ({ cli }) => {
+10 -1
View File
@@ -2,7 +2,10 @@ import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { peLoadConfigImage } from "../../src/native/pe/PeLoadConfig.fixture.js";
import {
PE_LOAD_CONFIG_FIXTURE,
peLoadConfigImage,
} from "../../src/native/pe/PeLoadConfig.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -20,6 +23,12 @@ for (const bits of [32, 64] as const) {
);
expect(evidence.normalized_result.bits).toBe(bits);
expect(evidence.normalized_result.tls?.callbacks).toHaveLength(3);
expect(evidence.normalized_result.load_config).toMatchObject({
dynamic_value_reloc_table_offset:
PE_LOAD_CONFIG_FIXTURE.dynamicRelocOffset,
dynamic_value_reloc_table_section:
PE_LOAD_CONFIG_FIXTURE.dynamicRelocSection,
});
expect(evidence.subject?.local_path).toBe(join(root, "app.exe"));
});
}
+66
View File
@@ -16,6 +16,7 @@ import { onTestFinished } from "vitest";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
cells,
devicetreeFixture,
dtString,
} from "../../../src/firmware/devicetree/Devicetree.fixture.js";
@@ -172,6 +173,71 @@ mcpTest(
},
60_000,
);
mcpTest(
"keeps a malformed canonical phandle unresolved in retained reference pages",
async ({ mcp }) => {
const session = createTestBinarySession(() => {
throw new Error("DTB inspection must not start an engine");
});
const client = await mcp.connect(createDtbServer(session));
try {
const root = await createTestTempDirectory("rea-dtb-mcp-phandle-");
const path = join(root, "board.dtb");
await writeFile(
path,
devicetreeFixture({
name: "",
children: [
{
name: "target",
properties: { phandle: cells(1, 2), "linux,phandle": cells(3) },
},
{ name: "consumer", properties: { "interrupt-parent": cells(3) } },
],
}),
);
const response = await client.callTool({
name: "inspect_devicetree",
arguments: { path, detail: "summary" },
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const result = toolContract("inspect_devicetree").outputSchema.parse(
response.structuredContent,
).normalized_result;
if (!("parent_evidence_id" in result))
throw new Error("Expected summary view");
expect(result.summary.unresolved_reference_count).toBe(1);
await unlink(path);
const page = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: result.parent_evidence_id,
},
view: { kind: "devicetree", collection: "references" },
},
});
expect(page.isError, JSON.stringify(page)).not.toBe(true);
expect(page.structuredContent).toMatchObject({
normalized_result: {
devicetree: { unresolved_reference_count: 1 },
items: [
{
source: "/consumer",
property: "interrupt-parent",
phandle: 3,
target: null,
},
],
},
});
} finally {
await session.close();
}
},
60_000,
);
mcpTest("rejects relative paths and unsupported versions", async ({ mcp }) => {
const session = createTestBinarySession(() => {
throw new Error("No engine");
+10 -1
View File
@@ -3,7 +3,10 @@ import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createServer } from "../../../src/server/createServer.js";
import { peLoadConfigImage } from "../../../src/native/pe/PeLoadConfig.fixture.js";
import {
PE_LOAD_CONFIG_FIXTURE,
peLoadConfigImage,
} from "../../../src/native/pe/PeLoadConfig.fixture.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
@@ -48,6 +51,12 @@ mcpTest(
expect(
evidence.normalized_result.load_config?.guard_flags?.flags,
).toContain("IMAGE_GUARD_CF_INSTRUMENTED");
expect(evidence.normalized_result.load_config).toMatchObject({
dynamic_value_reloc_table_offset:
PE_LOAD_CONFIG_FIXTURE.dynamicRelocOffset,
dynamic_value_reloc_table_section:
PE_LOAD_CONFIG_FIXTURE.dynamicRelocSection,
});
expect(await readFile(path)).toEqual(bytes);
const bad = await client.callTool(
{