mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import type { FileHandle } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { setImmediate } from "node:timers/promises";
|
|
import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js";
|
|
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
|
import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js";
|
|
import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js";
|
|
import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js";
|
|
import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js";
|
|
import { ManagedReaderFailure } from "../dotnet/ManagedReaderFailure.js";
|
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
|
import { abortIfNeeded } from "./ArtifactHash.js";
|
|
import { hasMachoMagic } from "./apple/MachoLoadCommandReader.js";
|
|
|
|
export interface DesktopReadBudget {
|
|
bytes: number;
|
|
readonly maximum: number;
|
|
retainedBytes: number;
|
|
readonly maximumRetainedBytes: number;
|
|
}
|
|
|
|
/** A resource refusal is separate from malformed format declarations. */
|
|
class DesktopProbeLimit extends ArtifactReaderFailure {
|
|
constructor() {
|
|
super("limit", "Directory probe byte budget exhausted", {
|
|
boundary: "capacity",
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Inspect selected ranges on an already admitted descriptor; never hash payloads. */
|
|
export const inspectDesktopFile = async (
|
|
file: FileHandle,
|
|
row: DesktopFile,
|
|
budget: DesktopReadBudget,
|
|
signal?: AbortSignal,
|
|
unityManagedDirectory: string | null = null,
|
|
): Promise<void> => {
|
|
const size = row.bytes;
|
|
if (size === null)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"File extent exceeds exact host offsets",
|
|
{ boundary: "file-size" },
|
|
);
|
|
const read = async (offset: number, bytes: number): Promise<Buffer> => {
|
|
abortIfNeeded(signal);
|
|
if (
|
|
!Number.isSafeInteger(offset) ||
|
|
offset < 0 ||
|
|
!Number.isSafeInteger(bytes) ||
|
|
bytes < 0 ||
|
|
offset > size ||
|
|
bytes > size - offset
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"Declared byte range lies outside the file",
|
|
);
|
|
if (bytes > budget.maximum - budget.bytes) throw new DesktopProbeLimit();
|
|
const result = Buffer.alloc(bytes);
|
|
let consumed = 0;
|
|
while (consumed < bytes) {
|
|
abortIfNeeded(signal);
|
|
const part = await file.read(
|
|
result,
|
|
consumed,
|
|
bytes - consumed,
|
|
offset + consumed,
|
|
);
|
|
budget.bytes += part.bytesRead;
|
|
if (part.bytesRead === 0)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"File ended before its observed extent",
|
|
);
|
|
consumed += part.bytesRead;
|
|
}
|
|
abortIfNeeded(signal);
|
|
row.ranges.push({
|
|
offset,
|
|
bytes,
|
|
sha256: createHash("sha256").update(result).digest("hex"),
|
|
});
|
|
return result;
|
|
};
|
|
const fact = (observation: DesktopFact) => {
|
|
const remaining = budget.maximumRetainedBytes - budget.retainedBytes;
|
|
const reservation =
|
|
512 + jsonValueByteLength(observation.value, (remaining - 512) / 12) * 12;
|
|
if (reservation > budget.maximumRetainedBytes - budget.retainedBytes)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"Directory facts reached the retained metadata budget",
|
|
{ boundary: "memory" },
|
|
);
|
|
budget.retainedBytes += reservation;
|
|
row.facts.push(observation);
|
|
};
|
|
const prefix = await read(0, Math.min(size, 64));
|
|
const metadataPrefix = observeIl2cppMetadataHeaderPrefix(prefix);
|
|
if (metadataPrefix.kind === "il2cpp")
|
|
fact({
|
|
kind: "il2cpp-metadata-header",
|
|
offset: 0,
|
|
value: {
|
|
magic: "0xfab11baf",
|
|
raw_version: metadataPrefix.rawVersion,
|
|
},
|
|
});
|
|
if (prefix.length >= 20 && prefix.toString("latin1", 0, 4) === "GDPC")
|
|
fact({
|
|
kind: "godot-pck-header",
|
|
offset: 0,
|
|
value: {
|
|
pack_version: prefix.readUInt32LE(4),
|
|
engine_version: [
|
|
prefix.readUInt32LE(8),
|
|
prefix.readUInt32LE(12),
|
|
prefix.readUInt32LE(16),
|
|
],
|
|
placement: "standalone",
|
|
},
|
|
});
|
|
if (
|
|
prefix.length >= 16 &&
|
|
prefix.toString("latin1", 0, 4) === "FORM" &&
|
|
prefix.toString("latin1", 8, 12) === "GEN8"
|
|
) {
|
|
const containerBytes = prefix.readUInt32LE(4);
|
|
const chunkBytes = prefix.readUInt32LE(12);
|
|
if (
|
|
containerBytes >= 8 &&
|
|
containerBytes <= size - 8 &&
|
|
chunkBytes <= containerBytes - 8
|
|
)
|
|
fact({
|
|
kind: "gamemaker-form-header",
|
|
offset: 0,
|
|
value: {
|
|
container: "FORM",
|
|
first_chunk: "GEN8",
|
|
},
|
|
});
|
|
}
|
|
if (
|
|
prefix.length >= 16 &&
|
|
prefix.toString("latin1", 0, 16) === "-==--==--==--==-"
|
|
)
|
|
fact({
|
|
kind: "unreal-container-convention",
|
|
offset: 0,
|
|
value: { format: "IoStore TOC" },
|
|
});
|
|
const native =
|
|
prefix.length >= 4 &&
|
|
(prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
|
|
hasMachoMagic(prefix));
|
|
if (native)
|
|
fact({
|
|
kind: "native-image-header",
|
|
offset: 0,
|
|
value: {
|
|
format: prefix[0] === 0x7f ? "elf" : "mach-o",
|
|
signature: prefix.subarray(0, 4).toString("hex"),
|
|
},
|
|
});
|
|
const observe = async (operation: () => Promise<void>) => {
|
|
try {
|
|
await operation();
|
|
} catch (cause: unknown) {
|
|
if (
|
|
!(cause instanceof ArtifactReaderFailure) ||
|
|
(cause.reason !== "format" && cause.reason !== "limit")
|
|
)
|
|
throw cause;
|
|
if (!row.issues.includes(cause.message)) row.issues.push(cause.message);
|
|
}
|
|
};
|
|
if (prefix.length === 64 && prefix.readUInt16LE(0) === 0x5a4d)
|
|
await observe(() =>
|
|
inspectPeFile(prefix, row, read, fact, unityManagedDirectory, signal),
|
|
);
|
|
if (
|
|
(native || (prefix.length >= 2 && prefix.readUInt16LE(0) === 0x5a4d)) &&
|
|
size >= 12
|
|
)
|
|
await observe(() => inspectPckTrailer(size, read, fact));
|
|
};
|
|
|
|
type DesktopByteRead = (offset: number, bytes: number) => Promise<Buffer>;
|
|
type RecordDesktopFact = (observation: DesktopFact) => void;
|
|
|
|
const inspectPeFile = async (
|
|
prefix: Buffer,
|
|
row: DesktopFile,
|
|
read: DesktopByteRead,
|
|
fact: RecordDesktopFact,
|
|
unityManagedDirectory: string | null,
|
|
signal?: AbortSignal,
|
|
): Promise<void> => {
|
|
const peOffset = prefix.readUInt32LE(0x3c);
|
|
const coff = await read(peOffset, 24);
|
|
if (coff.toString("latin1", 0, 4) !== "PE\0\0") return;
|
|
const optionalSize = coff.readUInt16LE(20);
|
|
if (optionalSize > 4096)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"PE optional header exceeds the supported format bound",
|
|
);
|
|
const optional = await read(peOffset + 24, optionalSize);
|
|
const parsed = parsePeRecord(Buffer.concat([coff, optional]), undefined);
|
|
if (!parsed.ok) {
|
|
row.issues.push(parsed.error);
|
|
return;
|
|
}
|
|
if (parsed.value.format !== "pe") return;
|
|
fact({
|
|
kind: "native-image-header",
|
|
offset: peOffset,
|
|
value: { format: "pe", machine: coff.readUInt16LE(4) },
|
|
});
|
|
if (!parsed.value.managed) return;
|
|
const directories = optional.readUInt16LE(0) === 0x20b ? 112 : 96;
|
|
const cliRva = optional.readUInt32LE(directories + 14 * 8);
|
|
const cliSize = optional.readUInt32LE(directories + 14 * 8 + 4);
|
|
fact({
|
|
kind: "pe-cli-directory",
|
|
offset: peOffset + 24 + directories + 14 * 8,
|
|
value: { rva: cliRva, bytes: cliSize },
|
|
});
|
|
if (unityManagedDirectory === null) return;
|
|
const sectionCount = coff.readUInt16LE(6);
|
|
const sections = await read(peOffset + 24 + optionalSize, sectionCount * 40);
|
|
const mapped = (rva: number, bytes: number) =>
|
|
peRvaToFileOffset(
|
|
rva,
|
|
sections,
|
|
sectionCount,
|
|
optional.readUInt32LE(60),
|
|
bytes,
|
|
);
|
|
const cliOffset = mapped(cliRva, 16);
|
|
if (cliOffset === undefined || cliSize < 72)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"CLI metadata directory cannot be mapped",
|
|
);
|
|
const cli = await read(cliOffset, 16);
|
|
if (cli.readUInt32LE(0) < 72)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"CLI header declares a truncated runtime header",
|
|
);
|
|
const metadataBytes = cli.readUInt32LE(12);
|
|
const metadataOffset = mapped(cli.readUInt32LE(8), metadataBytes);
|
|
if (metadataOffset === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"CLI metadata range cannot be mapped",
|
|
);
|
|
const bytes = await read(metadataOffset, metadataBytes);
|
|
try {
|
|
const layout = readManagedMetadataLayout(bytes, 0, bytes.length);
|
|
const refs = layout.table(35);
|
|
for (let index = 1; index <= (refs?.rowCount ?? 0); index++) {
|
|
// Yield between bounded batches so metadata names cannot monopolize MCP.
|
|
if ((index - 1) % 256 === 0) {
|
|
await setImmediate();
|
|
abortIfNeeded(signal);
|
|
}
|
|
const reference = readAssemblyReferenceName(
|
|
bytes,
|
|
layout,
|
|
index,
|
|
layout.strings.size,
|
|
);
|
|
if (
|
|
reference.name !== "UnityEngine" &&
|
|
!reference.name.startsWith("UnityEngine.")
|
|
)
|
|
continue;
|
|
fact({
|
|
kind: "unity-assembly-reference",
|
|
offset: metadataOffset + reference.row_offset,
|
|
value: {
|
|
name: reference.name,
|
|
token: reference.token,
|
|
managed_directory: unityManagedDirectory,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof ManagedReaderFailure)) throw cause;
|
|
throw new ArtifactReaderFailure(
|
|
cause.issue.code === "resource-limit" ? "limit" : "format",
|
|
cause.message,
|
|
{
|
|
cause,
|
|
...(cause.issue.code === "resource-limit"
|
|
? { boundary: "memory" as const }
|
|
: {}),
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
const inspectPckTrailer = async (
|
|
size: number,
|
|
read: DesktopByteRead,
|
|
fact: RecordDesktopFact,
|
|
): Promise<void> => {
|
|
const trailer = await read(size - 12, 12);
|
|
if (trailer.toString("latin1", 8, 12) === "GDPC") {
|
|
const packSize = trailer.readBigUInt64LE(0);
|
|
if (packSize < 20n || packSize > BigInt(size - 12))
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"Embedded PCK trailer declares an invalid pack extent",
|
|
);
|
|
const offset = size - 12 - Number(packSize);
|
|
const header = await read(offset, 20);
|
|
if (header.toString("latin1", 0, 4) !== "GDPC")
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"Embedded PCK trailer does not locate a pack header",
|
|
);
|
|
fact({
|
|
kind: "godot-pck-header",
|
|
offset,
|
|
value: {
|
|
pack_version: header.readUInt32LE(4),
|
|
engine_version: [
|
|
header.readUInt32LE(8),
|
|
header.readUInt32LE(12),
|
|
header.readUInt32LE(16),
|
|
],
|
|
placement: "embedded-trailer",
|
|
},
|
|
});
|
|
}
|
|
};
|