Files
reverse-engineering-anything/src/artifacts/fit/FitStructure.ts
T
morluto cabd56ea71 refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy (#10728)
* refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy

* fix(domain): keep the leading-BOM behaviour every migrated UTF-8 decoder had

* refactor(domain): read the domain layer's strict UTF-8 through the BOM-policy owner (#10803)

Twelve domain decoders built their own fatal TextDecoder with ignoreBOM: true. They now use strictUtf8Decoder("preserve"), which keeps that behaviour: each reads bytes where a leading U+FEFF is content (binary-format names and fields, JSON and XML read byte-for-byte, recorded bodies, registry seeds whose kept bytes are re-emitted). The counted baseline drops from 74 to 62.

* refactor(artifacts): read artifact readers' strict UTF-8 through the BOM-policy owner (#10871)

Fourteen artifact reader modules (17 sites) built their own fatal TextDecoder. They now take it from strictUtf8Decoder with the policy each had: every one kept a leading BOM (ignoreBOM: true), so they use "preserve"; JavaScriptArtifactFiles keeps it for JavaScript and HTML entries and strips it otherwise, as before. The strict-utf8-decoder baseline drops those 17 sites.

* refactor(domain): drop TextDecoder imports left by the strict UTF-8 migration

* refactor(application): read MCP registration metadata through the strict UTF-8 owner

* refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner (#10903)

* refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner

* refactor(formats): drop the model readers' unused TextDecoder imports

* refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner (#10935)

* refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner

* refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner (#10936)

* refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner

* refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner (#10937)

* refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner

* refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner (#10938)

* refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner

* refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner (#10939)

* refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner

* refactor(evm): read the hex bytecode carrier's strict UTF-8 through the owner and refuse new local decoders (#10940)

* refactor(evm): read the hex bytecode carrier's strict UTF-8 through the BOM-policy owner

* chore(architecture): match local strict UTF-8 decoders in any option order
2026-11-15 19:54:39 +08:00

643 lines
22 KiB
TypeScript

import { createHash } from "node:crypto";
import { crc32 } from "node:zlib";
import {
keyedBy,
uniqueKeyed,
type Keyed,
} from "../../domain/keyedCollection.js";
import { artifactExpansionBudgetBytes } from "../../domain/artifactExpansionBudget.js";
import type {
FitImageMetadata,
FitMetadata,
} from "../../domain/artifactFormatMetadata.js";
import {
MAX_DEVICETREE_FILE_BYTES,
type Devicetree,
} from "../../domain/firmware/devicetree.js";
import {
DevicetreeLimitFailure,
parseDevicetree,
} from "../../firmware/devicetree/DevicetreeParser.js";
import { BinaryFormatFailure } from "../../native/metadata/BinaryContainer.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { strictUtf8Decoder } from "../../domain/strictUtf8.js";
// Fixed resource bounds; configurable ceilings are tracked in #6176.
const FIT_MAX_IMAGES = 1024;
const FIT_MAX_HASH_NODES_PER_IMAGE = 8;
const FIT_MAX_CONFIGURATIONS = 1024;
/** Bounds configuration references and their limitations across the FIT. */
const FIT_MAX_CONFIGURATION_REFERENCES = 16_384;
type FitNode = Devicetree["nodes"][number];
type FitProperty = FitNode["properties"][number];
export interface FitImageDeclaration {
readonly index: number;
readonly name: string;
readonly start: number;
readonly size: number;
readonly metadata: FitImageMetadata;
readonly limitations: string[];
/** Supported algorithms with a value of the right length, in node order. */
readonly hashes: { node: string; algo: string; value: Buffer }[];
}
export interface FitStructure {
readonly metadata: FitMetadata;
readonly images: FitImageDeclaration[];
readonly limitations: string[];
}
interface FitLayout {
readonly fdt: Buffer;
readonly children: ReadonlyMap<string, readonly FitNode[]>;
readonly totalsize: number;
readonly fileSize: number;
}
/** Lenient decoding records malformed descriptive facts here instead of failing. */
interface FitFacts {
readonly fdt: Buffer;
readonly limitations: string[];
}
type FitFact<Value> = (facts: FitFacts, node: FitNode, name: string) => Value;
// U-Boot's hash value lengths; crc32 is stored as 4 bytes big-endian.
const DIGEST_BYTES = new Map([
["crc32", 4],
["md5", 16],
["sha1", 20],
["sha256", 32],
["sha384", 48],
["sha512", 64],
]);
const DATA_SOURCES = ["data", "data-offset", "data-position"] as const;
const CONFIGURATION_REFERENCES = [
"kernel",
"firmware",
"fdt",
"ramdisk",
"loadables",
] as const;
const align4 = (value: number): number => Math.ceil(value / 4) * 4;
const formatFailure = (detail: string): ArtifactReaderFailure =>
new ArtifactReaderFailure("format", `FIT is malformed: ${detail}`);
const capacityFailure = (message: string): ArtifactReaderFailure =>
new ArtifactReaderFailure("limit", message, { boundary: "capacity" });
const nodeName = (node: FitNode): string =>
node.path.slice(node.path.lastIndexOf("/") + 1);
/**
* A node's one property of a name. The devicetree parser already refuses a
* repeated property name in a node as malformed, as dtc does; a repeat here
* is the same malformed structure, not a first-wins choice.
*/
const propertiesByNode = new WeakMap<FitNode, Keyed<FitProperty>>();
/** Key a node's properties once; every lookup on the node reuses the view. */
const keyedProperties = (node: FitNode): Keyed<FitProperty> => {
let keyed = propertiesByNode.get(node);
if (keyed === undefined) {
keyed = keyedBy(node.properties, (candidate) => candidate.name);
propertiesByNode.set(node, keyed);
}
return keyed;
};
const propertyOf = (node: FitNode, name: string): FitProperty | undefined => {
const property = uniqueKeyed(keyedProperties(node), name);
if (!property.ok)
throw formatFailure(
`${node.path} declares property ${name} more than once`,
);
return property.value;
};
const valueOf = (fdt: Buffer, property: FitProperty): Buffer =>
fdt.subarray(property.offset, property.offset + property.bytes);
// A property value is data: a leading U+FEFF is not a file encoding signature.
const utf8 = strictUtf8Decoder("preserve");
const nulCount = (raw: Buffer): number => {
let count = 0;
for (const byte of raw) if (byte === 0) count += 1;
return count;
};
/**
* NUL-terminated UTF-8 strings, or undefined when the value is malformed.
* More than `maxStrings` strings is malformed, counted before splitting.
*/
const nulStrings = (
fdt: Buffer,
property: FitProperty | undefined,
maxStrings = 1,
): string[] | undefined => {
if (property === undefined) return undefined;
const raw = valueOf(fdt, property);
if (raw.at(-1) !== 0 || nulCount(raw) > maxStrings) return undefined;
try {
return utf8.decode(raw.subarray(0, -1)).split("\0");
} catch {
return undefined;
}
};
/** Incremental digest of one verified FIT hash algorithm. */
export const createFitDigest = (
algo: string,
): { update(chunk: Buffer): void; digest(): Buffer } | undefined => {
if (!DIGEST_BYTES.has(algo)) return undefined;
if (algo !== "crc32") {
const hash = createHash(algo);
return {
update: (chunk) => {
hash.update(chunk);
},
digest: () => hash.digest(),
};
}
let value = 0;
return {
update: (chunk) => {
value = crc32(chunk, value);
},
digest: () => Buffer.from(value.toString(16).padStart(8, "0"), "hex"),
};
};
const fitString: FitFact<string | null> = (facts, node, name) => {
const property = propertyOf(node, name);
if (property === undefined) return null;
const strings = nulStrings(facts.fdt, property);
if (strings?.length === 1) return strings[0] ?? null;
facts.limitations.push(
`${node.path}: ${name} is not a NUL-terminated UTF-8 string`,
);
return null;
};
const fitStrings: FitFact<string[]> = (facts, node, name) => {
const property = propertyOf(node, name);
if (property === undefined) return [];
// fitConfigurations bounds every reference list before decoding.
const strings = nulStrings(
facts.fdt,
property,
FIT_MAX_CONFIGURATION_REFERENCES,
);
if (strings !== undefined) return strings;
facts.limitations.push(
`${node.path}: ${name} is not a NUL-terminated UTF-8 string list`,
);
return [];
};
const fitU32: FitFact<number | null> = (facts, node, name) => {
const property = propertyOf(node, name);
if (property === undefined) return null;
if (property.bytes === 4) return facts.fdt.readUInt32BE(property.offset);
facts.limitations.push(`${node.path}: ${name} is not a single 32-bit cell`);
return null;
};
/** Fold 32-bit big-endian cells by property length, as U-Boot does. */
const fitAddress: FitFact<string | null> = (facts, node, name) => {
const property = propertyOf(node, name);
if (property === undefined) return null;
if (property.bytes !== 4 && property.bytes !== 8) {
facts.limitations.push(
`${node.path}: ${name} is not a 1- or 2-cell address`,
);
return null;
}
let value = 0n;
for (let offset = 0; offset < property.bytes; offset += 4)
value =
(value << 32n) | BigInt(facts.fdt.readUInt32BE(property.offset + offset));
return `0x${value.toString(16)}`;
};
/** Byte-locating cells fail closed: a guessed range would misattribute bytes. */
const strictCell = (
fdt: Buffer,
node: FitNode,
property: FitProperty,
): number => {
if (property.bytes !== 4)
throw formatFailure(
`${node.path}: ${property.name} is not a single 32-bit cell`,
);
return fdt.readUInt32BE(property.offset);
};
const imageRange = (
layout: FitLayout,
node: FitNode,
): Pick<FitImageDeclaration, "start" | "size"> &
Pick<FitImageMetadata, "data_source" | "declared_offset"> => {
const declared = DATA_SOURCES.flatMap((name) => {
const property = propertyOf(node, name);
return property === undefined ? [] : [{ name, property }];
});
const [source] = declared;
if (source === undefined)
throw formatFailure(
`${node.path} has no data, data-offset or data-position`,
);
if (declared.length > 1)
throw formatFailure(`${node.path} declares more than one data source`);
if (source.name === "data")
return {
start: source.property.offset,
size: source.property.bytes,
data_source: "embedded",
declared_offset: null,
};
const declaredOffset = strictCell(layout.fdt, node, source.property);
const sizeProperty = propertyOf(node, "data-size");
if (sizeProperty === undefined)
throw formatFailure(`${node.path}: ${source.name} requires data-size`);
const size = strictCell(layout.fdt, node, sizeProperty);
// data-offset is relative to the 4-byte-aligned end of the devicetree;
// data-position is an offset from the start of the FIT (fit + position).
const start =
source.name === "data-offset"
? align4(layout.totalsize) + declaredOffset
: declaredOffset;
if (start + size > layout.fileSize)
throw formatFailure(
`${node.path}: data at [${String(start)}, ${String(start + size)}) lies outside the ${String(layout.fileSize)}-byte file`,
);
return {
start,
size,
data_source: source.name,
declared_offset: declaredOffset,
};
};
const imageHashes = (
facts: FitFacts,
image: FitNode,
children: readonly FitNode[],
) => {
const nodes = children.filter((child) => nodeName(child).startsWith("hash"));
if (nodes.length > FIT_MAX_HASH_NODES_PER_IMAGE)
throw capacityFailure(
`FIT image ${nodeName(image)} declares ${String(nodes.length)} hash nodes, above the fixed ${String(FIT_MAX_HASH_NODES_PER_IMAGE)}-node allowance`,
);
const hashes: FitImageDeclaration["hashes"] = [];
const algorithms: string[] = [];
let unverified = 0;
for (const node of nodes) {
const name = nodeName(node);
const algos = nulStrings(facts.fdt, propertyOf(node, "algo"));
const algo = algos?.length === 1 ? algos[0] : undefined;
const value = propertyOf(node, "value");
const expected = algo === undefined ? undefined : DIGEST_BYTES.get(algo);
if (algo !== undefined) algorithms.push(algo);
if (algo === undefined || expected === undefined || value === undefined) {
unverified += 1;
facts.limitations.push(
algo === undefined
? `${name} has no NUL-terminated UTF-8 algo; it is not verified`
: expected === undefined
? `${name} algorithm "${algo}" is not verified`
: `${name} has no value; it is not verified`,
);
continue;
}
if (value.bytes !== expected)
throw new ArtifactReaderFailure(
"integrity",
`FIT image ${nodeName(image)} ${name} ${algo} value is ${String(value.bytes)} bytes; ${algo} values are ${String(expected)} bytes`,
);
// Copy the value so the devicetree buffer can be released.
hashes.push({
node: name,
algo,
value: Buffer.from(valueOf(facts.fdt, value)),
});
}
return { hashes, algorithms, unverified };
};
/** Signature declarations remain visible even when their algorithm is unreadable. */
const signatureAlgorithms = (
facts: FitFacts,
children: readonly FitNode[],
): { algorithms: string[]; count: number; unreadable: number } => {
const algorithms: string[] = [];
let count = 0;
let unreadable = 0;
for (const child of children) {
if (!nodeName(child).startsWith("signature")) continue;
count += 1;
const algo = fitString(facts, child, "algo");
if (algo !== null) algorithms.push(algo);
else {
unreadable += 1;
if (propertyOf(child, "algo") === undefined)
facts.limitations.push(`${child.path}: signature algo is missing`);
}
}
return { algorithms, count, unreadable };
};
const imageDeclaration = (
layout: FitLayout,
node: FitNode,
index: number,
): FitImageDeclaration => {
const facts: FitFacts = { fdt: layout.fdt, limitations: [] };
const name = nodeName(node);
const children = layout.children.get(node.path) ?? [];
const range = imageRange(layout, node);
const imageDataProperty = propertyOf(node, "image-data");
const imageData = nulStrings(layout.fdt, imageDataProperty);
if (imageDataProperty !== undefined && imageData?.length !== 1)
throw formatFailure(`${node.path}: image-data is not one string`);
const cipher = children.find((child) => nodeName(child) === "cipher");
const unreadable = new Set<FitImageMetadata["unreadable_fields"][number]>();
const field = <Value>(
key: FitImageMetadata["unreadable_fields"][number],
reader: FitFact<Value | null>,
source = node,
property: string = key,
): Value | null => {
const value = reader(facts, source, property);
if (value === null && propertyOf(source, property) !== undefined)
unreadable.add(key);
return value;
};
const metadata = {
kind: "fit-image" as const,
index,
name,
description: field("description", fitString),
type: field("type", fitString),
arch: field("arch", fitString),
os: field("os", fitString),
compression: field("compression", fitString),
load: field("load", fitAddress),
entry: field("entry", fitAddress),
data_source: range.data_source,
declared_offset: range.declared_offset,
image_data: imageData?.[0] ?? null,
cipher_present: cipher !== undefined,
cipher_algo:
cipher === undefined
? null
: field("cipher_algo", fitString, cipher, "algo"),
};
const { hashes, algorithms, unverified } = imageHashes(facts, node, children);
const signatures = signatureAlgorithms(facts, children);
return {
index,
name,
start: range.start,
size: range.size,
limitations: facts.limitations,
hashes,
metadata: {
...metadata,
unreadable_fields: [...unreadable].sort(),
hash_algorithms: algorithms,
hashes_verified: hashes.length,
hashes_unverified: unverified,
signature_count: signatures.count,
signatures_unreadable: signatures.unreadable,
signature_algorithms: signatures.algorithms,
},
};
};
/** image-data must name an unchained image whose data properties agree. */
const assertImageDataLinks = (
images: readonly FitImageDeclaration[],
fdt: Buffer,
): void => {
const byName = new Map(images.map((image) => [image.name, image]));
for (const image of images) {
const name = image.metadata.image_data;
if (name === null) continue;
const target = byName.get(name);
const path = `/images/${image.name}`;
if (target === undefined)
throw formatFailure(`${path}: image-data names no image: ${name}`);
if (target.metadata.image_data !== null)
throw formatFailure(
`${path}: image-data target /images/${name} has its own image-data`,
);
// Embedded copies occupy distinct properties but must contain equal bytes.
if (
(target.metadata.data_source === "embedded") !==
(image.metadata.data_source === "embedded") ||
target.size !== image.size ||
(image.metadata.data_source === "embedded"
? !fdt
.subarray(image.start, image.start + image.size)
.equals(fdt.subarray(target.start, target.start + target.size))
: target.start !== image.start)
)
throw formatFailure(
`${path}: data does not match its image-data target /images/${name}`,
);
}
};
/** External ranges may not overlap the devicetree or each other, except linked shares. */
const assertDisjoint = (
images: readonly FitImageDeclaration[],
totalsize: number,
): void => {
const owner = (image: FitImageDeclaration): string =>
image.metadata.image_data ?? image.name;
const ranges = images
.filter(
(image) => image.metadata.data_source !== "embedded" && image.size > 0,
)
.sort((a, b) => a.start - b.start || a.index - b.index);
let previous: FitImageDeclaration | undefined;
let end = totalsize;
for (const image of ranges) {
if (image.start < end) {
if (
previous !== undefined &&
previous.start === image.start &&
previous.size === image.size &&
owner(previous) === owner(image)
)
continue;
throw formatFailure(
`image data overlaps: /images/${image.name} and ${previous === undefined ? "the FIT devicetree" : `/images/${previous.name}`}`,
);
}
previous = image;
end = image.start + image.size;
}
};
const fitConfiguration = (
layout: FitLayout,
facts: FitFacts,
node: FitNode,
images: ReadonlySet<string>,
): FitMetadata["configurations"][number] => {
const references = {
kernel: fitStrings(facts, node, "kernel"),
firmware: fitStrings(facts, node, "firmware"),
fdt: fitStrings(facts, node, "fdt"),
ramdisk: fitStrings(facts, node, "ramdisk"),
loadables: fitStrings(facts, node, "loadables"),
};
let unresolved = 0;
for (const values of Object.values(references))
for (const reference of values) if (!images.has(reference)) unresolved += 1;
if (unresolved > 0)
facts.limitations.push(
`${node.path}: ${String(unresolved)} image references do not name an /images node`,
);
const signatures = signatureAlgorithms(
facts,
layout.children.get(node.path) ?? [],
);
return {
name: nodeName(node),
description: fitString(facts, node, "description"),
...references,
unresolved_reference_count: unresolved,
signature_count: signatures.count,
signatures_unreadable: signatures.unreadable,
signature_algorithms: signatures.algorithms,
};
};
const fitConfigurations = (
layout: FitLayout,
facts: FitFacts,
node: FitNode | undefined,
images: ReadonlySet<string>,
): Pick<FitMetadata, "default_configuration" | "configurations"> => {
if (node === undefined)
return { default_configuration: null, configurations: [] };
const children = layout.children.get(node.path) ?? [];
if (children.length > FIT_MAX_CONFIGURATIONS)
throw capacityFailure(
`FIT declares ${String(children.length)} configurations, above the fixed ${String(FIT_MAX_CONFIGURATIONS)}-configuration allowance`,
);
let references = 0;
for (const child of children)
for (const name of CONFIGURATION_REFERENCES) {
const property = propertyOf(child, name);
if (property !== undefined)
references += nulCount(valueOf(layout.fdt, property));
}
if (references > FIT_MAX_CONFIGURATION_REFERENCES)
throw capacityFailure(
`FIT configurations declare ${String(references)} image references, above the fixed ${String(FIT_MAX_CONFIGURATION_REFERENCES)}-reference allowance`,
);
const configurations = children.map((child) =>
fitConfiguration(layout, facts, child, images),
);
const defaultConfiguration = fitString(facts, node, "default");
if (
defaultConfiguration !== null &&
!configurations.some((entry) => entry.name === defaultConfiguration)
)
facts.limitations.push(
`${node.path}: default ${defaultConfiguration} names no configuration`,
);
return { default_configuration: defaultConfiguration, configurations };
};
const parseFit = (fdt: Buffer, path: string): Devicetree => {
try {
// The artifact field is only echoed back; the reader binds the carrier hash.
return parseDevicetree(
fdt,
{ path, sha256: "", bytes: fdt.length },
{
path,
max_file_bytes: MAX_DEVICETREE_FILE_BYTES,
max_nodes: 8192,
max_properties: 32768,
max_depth: 64,
},
);
} catch (cause) {
if (cause instanceof BinaryFormatFailure)
throw formatFailure(cause.message);
if (cause instanceof DevicetreeLimitFailure)
throw new ArtifactReaderFailure(
"limit",
`FIT devicetree exceeds the fixed ${cause.limit} allowance`,
{
boundary: cause.limit === "decoded_bytes" ? "memory" : "capacity",
cause,
},
);
throw cause;
}
};
/** Decode one complete FIT devicetree whose external data lies in a fileSize-byte file. */
export const readFitStructure = (
fdt: Buffer,
path: string,
fileSize: number,
): FitStructure => {
const tree = parseFit(fdt, path);
const byPath = new Map(tree.nodes.map((node) => [node.path, node]));
const children = new Map<string, FitNode[]>();
for (const node of tree.nodes) {
if (node.parent === null) continue;
const siblings = children.get(node.parent);
if (siblings === undefined) children.set(node.parent, [node]);
else siblings.push(node);
}
const root = byPath.get("/");
if (root === undefined || !byPath.has("/images"))
throw formatFailure("/images node is missing");
const { totalsize } = tree.header;
const layout: FitLayout = { fdt, children, totalsize, fileSize };
const imageNodes = children.get("/images") ?? [];
if (imageNodes.length > FIT_MAX_IMAGES)
throw capacityFailure(
`FIT declares ${String(imageNodes.length)} images, above the fixed ${String(FIT_MAX_IMAGES)}-image allowance`,
);
const images = imageNodes.map((node, index) =>
imageDeclaration(layout, node, index),
);
assertImageDataLinks(images, fdt);
assertDisjoint(images, totalsize);
const declared = images.reduce((sum, image) => sum + image.size, 0);
const budget = artifactExpansionBudgetBytes(fileSize);
if (declared > budget)
throw new ArtifactReaderFailure(
"limit",
`FIT images declare ${String(declared)} bytes, exceeding this file's ${String(budget)}-byte expansion budget`,
{ boundary: "memory" },
);
const facts: FitFacts = { fdt, limitations: [] };
return {
images,
limitations: facts.limitations,
metadata: {
kind: "fit",
fdt_version: tree.header.version,
fdt_totalsize: totalsize,
external_data_base: align4(totalsize),
description: fitString(facts, root, "description"),
timestamp: fitU32(facts, root, "timestamp"),
address_cells: fitU32(facts, root, "#address-cells"),
...fitConfigurations(
layout,
facts,
byPath.get("/configurations"),
new Set(images.map((image) => image.name)),
),
},
};
};