Files
reverse-engineering-anything/src/artifacts/WxapkgStructures.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

216 lines
7.0 KiB
TypeScript

import type { FileHandle } from "node:fs/promises";
import {
ArtifactReaderFailure,
artifactFixedLimitFailure,
} from "./ArtifactReader.js";
import { readArtifactFileBytes } from "./ArtifactFileRange.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { setImmediate } from "node:timers/promises";
import {
hasWxapkgEncryptedMagic,
readWxapkgHeaderFacts,
resolveWxapkgIndexLayouts,
selectWxapkgIndexLayout,
WXAPKG_MIN_ROW_BYTES,
type WxapkgIndexLayout,
type WxapkgIndexLayoutFailure,
} from "../domain/wxapkgFormat.js";
import { strictUtf8Decoder } from "../domain/strictUtf8.js";
/** The member table is a flat name/offset/size row list. */
const MAX_TABLE_BYTES = 16 * 1024 * 1024;
/** Encoded retained directory representation; paths are charged separately. */
const DIRECTORY_BUDGET_BYTES = 64 * 1024 * 1024;
/** Member names are relative package paths; larger claims are malformed. */
const MAX_MEMBER_NAME_BYTES = 64 * 1024;
/** Detecting prefix covers the fixed header, table head and encrypted magic. */
const HEADER_PREFIX_BYTES = 64;
const UTF8 = strictUtf8Decoder("preserve");
export const wxapkgFormatFailure = (detail: string): ArtifactReaderFailure =>
new ArtifactReaderFailure("format", `wxapkg is malformed: ${detail}`);
/** Reserve buffers, JS rows and escaped name projections before allocating. */
class WxapkgDirectoryBudget {
#remaining = DIRECTORY_BUDGET_BYTES;
reserve(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw artifactFixedLimitFailure(
"wxapkg directory representation",
DIRECTORY_BUDGET_BYTES,
"bytes",
);
this.#remaining -= bytes;
}
}
export interface WxapkgHeader {
readonly info: number;
readonly indexLength: number;
readonly dataLength: number;
readonly fileCount: number;
readonly layout:
| { readonly status: "encrypted" }
| {
readonly status: "available";
readonly indexLayouts: readonly WxapkgIndexLayout[];
};
}
export interface WxapkgMember {
readonly offset: number;
readonly size: number;
/** Package-relative path; declared root prefixes are translated here. */
readonly path: string;
/** Declared member name as stored in the table. */
readonly declaredPath: string;
}
export interface WxapkgFileTable {
readonly members: readonly WxapkgMember[];
}
/** Decode header fields once; encrypted variants and absent signatures are distinct. */
export const readWxapkgHeader = async (
handle: FileHandle,
size: number,
signal?: AbortSignal,
): Promise<WxapkgHeader | undefined> => {
const prefix = await readArtifactFileBytes(
handle,
0,
Math.min(size, HEADER_PREFIX_BYTES),
size,
signal,
);
if (hasWxapkgEncryptedMagic(prefix))
return {
info: 0,
indexLength: 0,
dataLength: 0,
fileCount: 0,
layout: { status: "encrypted" },
};
const facts = readWxapkgHeaderFacts(prefix);
if (facts === undefined) return undefined;
const indexes = resolveWxapkgIndexLayouts(facts, size);
if (!indexes.ok) throw wxapkgIndexLayoutFailure(indexes.error);
return {
...facts,
layout: { status: "available", indexLayouts: indexes.value },
};
};
const wxapkgIndexLayoutFailure = (
failure: WxapkgIndexLayoutFailure,
): ArtifactReaderFailure =>
wxapkgFormatFailure(
failure._tag === "DataRegionExceedsFile"
? "declared data region exceeds the file"
: "declared member table exceeds the file",
);
export const readWxapkgFileTable = async (
handle: FileHandle,
header: WxapkgHeader,
size: number,
signal?: AbortSignal,
): Promise<WxapkgFileTable> => {
const layout = header.layout;
if (layout.status !== "available")
throw new ArtifactReaderFailure(
"unavailable",
"wxapkg directory is encrypted",
);
const indexLayouts = layout.indexLayouts;
let readBytes = 0;
let tableStart: number | undefined;
for (const indexLayout of indexLayouts) {
if (tableStart === undefined) tableStart = indexLayout.tableStart;
if (indexLayout.tableBytes > readBytes) readBytes = indexLayout.tableBytes;
}
if (tableStart === undefined)
throw wxapkgFormatFailure("declared member table exceeds the file");
if (readBytes > MAX_TABLE_BYTES)
throw artifactFixedLimitFailure(
"wxapkg member table",
MAX_TABLE_BYTES,
"bytes",
);
const budget = new WxapkgDirectoryBudget();
budget.reserve(readBytes);
const table = await readArtifactFileBytes(
handle,
tableStart,
readBytes,
size,
signal,
);
// Loose lower bound already checked in facts; precise rows are checked here.
if (table.length < header.fileCount * WXAPKG_MIN_ROW_BYTES)
throw wxapkgFormatFailure("member table is truncated");
const members: WxapkgMember[] = [];
let cursor = 0;
for (let index = 0; index < header.fileCount; index++) {
if (index % 256 === 0) await setImmediate();
assertArtifactReadActive(signal);
if (cursor + 4 > table.length)
throw wxapkgFormatFailure("member row is truncated");
const nameLength = table.readUInt32BE(cursor);
cursor += 4;
if (
nameLength === 0 ||
nameLength > MAX_MEMBER_NAME_BYTES ||
cursor + nameLength + 8 > table.length
)
throw wxapkgFormatFailure("member row exceeds the table");
// Row storage plus the reader's owned entries/map; charge before decode.
budget.reserve(512 + nameLength * 24);
let declaredPath: string;
try {
declaredPath = UTF8.decode(table.subarray(cursor, cursor + nameLength));
} catch (cause: unknown) {
throw new ArtifactReaderFailure(
"format",
"wxapkg member path is not valid UTF-8",
{
cause,
},
);
}
cursor += nameLength;
const offset = table.readUInt32BE(cursor);
const sizeMember = table.readUInt32BE(cursor + 4);
cursor += 8;
// Producer names are root-prefixed; translate to package-relative once.
const path = declaredPath.startsWith("/")
? declaredPath.slice(1)
: declaredPath;
if (path.length === 0)
throw wxapkgFormatFailure("member name has no package-relative path");
members.push({ offset, size: sizeMember, path, declaredPath });
}
const candidates = indexLayouts.filter((index) => index.tableBytes >= cursor);
if (candidates.length === 0)
throw wxapkgFormatFailure("member table is truncated");
// Rows and member ranges choose the convention. EOF is not a signal: four
// trailing bytes can make the other convention end on the carrier.
const index = selectWxapkgIndexLayout(candidates, cursor, members);
if (index === undefined) {
const outside = members.find((member) => {
const extentEnd = member.offset + member.size;
return candidates.every(
(candidate) =>
member.offset < candidate.dataStart ||
extentEnd > candidate.dataEnd ||
extentEnd < member.offset,
);
});
throw wxapkgFormatFailure(
`member exceeds the data region: ${outside?.path ?? ""}`,
);
}
return { members };
};