diff --git a/docs/go-binaries.md b/docs/go-binaries.md
index 58b2679fd2..0a5ace4a20 100644
--- a/docs/go-binaries.md
+++ b/docs/go-binaries.md
@@ -133,9 +133,9 @@ sections retain priority, and alignment, file mapping and record validation
remain required. When ELF section names are unreadable, limitations report that
coverage gap; the fallback search policy is unchanged.
-A named ELF build-info section selects the record's discovery range. For inline
-encoding, the following version and module strings may continue past that
-section into its file-backed load mapping; their bytes are still checked
+A named ELF or Mach-O build-info section selects the record's discovery range.
+For inline encoding, the following version and module strings may continue past
+that section into its file-backed load mapping; their bytes are still checked
against the mapping and the existing aggregate string limit.
Historical moduleless Go binaries can store their empty module-string header in
@@ -163,10 +163,12 @@ and `reported_limits.maximum_input_bytes`. Select a suitable input within that
fixed allowance; changing an output-write or transport budget does not admit it.
Resource guards bound the selected file and cumulative header searches to 256 MiB
-each, aggregate embedded strings
-to 1 MiB, structural table decoding to 16 MiB and the complete normalized metadata
-report to 8 MiB. Identical search spans with the same virtual alignment are
-searched once; partially overlapping spans consume the cumulative search budget
+each, aggregate embedded string contents to 1 MiB (varint length prefixes are
+framing and do not count toward this allowance), structural table decoding to
+16 MiB and the complete normalized metadata report to 8 MiB. Inline strings must
+also fit within their actual mapped source range. Identical search spans with
+the same virtual alignment are searched once; partially overlapping spans
+consume the cumulative search budget
and fail with a CPU resource constraint when it is exhausted. Oversized results
fail without partial success. MCP also applies
its [response transport budget](mcp-contracts.md), which counts both text and
diff --git a/docs/godot-pck.md b/docs/godot-pck.md
index 659eb87b26..b81633adb2 100644
--- a/docs/godot-pck.md
+++ b/docs/godot-pck.md
@@ -34,6 +34,11 @@ The same CLI commands locate a PCK at the beginning of a file or through its
payloads or discover packs behind additional trailing signatures. The root
content SHA-256 covers the whole carrier; its content classification remains
native, while the inventory records the PCK role and exact pack bounds.
+An EOF marker is only a candidate: an invalid size or a pointed-to location
+without a `GDPC` header leaves native classification in place. Once the
+trailer points to a pack header, malformed or unsupported PCK content remains
+an error. This recognition boundary follows the Godot loader at pinned source
+revision [06d1e7a](https://github.com/godotengine/godot/blob/06d1e7a34ccde4029eed53d018cfde77dfe771d5/core/io/file_access_pack.cpp#L2099-L2146).
Ordinary native `open_binary` detection remains unchanged.
## MCP and selected facts
diff --git a/docs/native-investigation.md b/docs/native-investigation.md
index a65322a0a4..ff9a2ab4f6 100644
--- a/docs/native-investigation.md
+++ b/docs/native-investigation.md
@@ -449,6 +449,10 @@ complete observation.
failures; explicit `` represents empty data.
Each XML dictionary key must have a following value element; a final key
without one is malformed. An explicit empty string remains a value.
+ Binary UID payloads may use nonminimal byte widths. REA normalizes them only
+ in a private decoder copy; archive digests still identify the original bytes.
+ UID values above JavaScript's exact integer range return an unsupported-target
+ outcome instead of rounded reference identities.
UID 0 cannot distinguish conditional nil from ordinary nil. Limits are
64 MiB of input, 200,000 objects/references and 128 nesting levels.
XML dictionary entries decoded as `__proto__` are omitted with an explicit
diff --git a/src/application/AppBundleExecutable.test.ts b/src/application/AppBundleExecutable.test.ts
index d7ee19830e..59885158bc 100644
--- a/src/application/AppBundleExecutable.test.ts
+++ b/src/application/AppBundleExecutable.test.ts
@@ -5,8 +5,36 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
+import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
+const binaryPlistExecutableWithThreeByteUid = (): Buffer => {
+ const ascii = (value: string): Buffer => {
+ const bytes = Buffer.from(value, "ascii");
+ return bytes.length < 15
+ ? Buffer.concat([Buffer.from([0x50 | bytes.length]), bytes])
+ : Buffer.concat([Buffer.from([0x5f, 0x10, bytes.length]), bytes]);
+ };
+ const objects = [
+ Buffer.from([0xd2, 1, 3, 2, 4]),
+ ascii("CFBundleExecutable"),
+ ascii("Fixture"),
+ ascii("ignored"),
+ Buffer.from([0x82, 1, 2, 3]),
+ ];
+ const tableOffset =
+ 8 + objects.reduce((sum, object) => sum + object.length, 0);
+ const bytes = Buffer.alloc(tableOffset + objects.length + 32);
+ let cursor = 8;
+ const offsets = objects.map((object) => {
+ const offset = cursor;
+ object.copy(bytes, cursor);
+ cursor += object.length;
+ return offset;
+ });
+ return binaryPlistObjectTable(bytes, offsets, tableOffset);
+};
+
it.each([
["macOS", ["Contents"], ["Contents", "MacOS"]],
["iOS", [], []],
@@ -34,6 +62,21 @@ it.each([
);
describe("app bundle Info.plist magic", () => {
+ it("decodes an executable when an unrelated UID uses a three-byte width", async () => {
+ const root = await createTestTempDirectory("rea-app-three-byte-uid-");
+ const bundle = join(root, "Fixture.app");
+ const plistPath = join(bundle, "Contents", "Info.plist");
+ const executable = join(bundle, "Contents", "MacOS", "Fixture");
+ await mkdir(join(bundle, "Contents", "MacOS"), { recursive: true });
+ await writeFile(plistPath, binaryPlistExecutableWithThreeByteUid());
+ await writeFile(executable, "inert fixture; never executed");
+
+ expect(await resolveAppBundleExecutable(bundle)).toEqual({
+ ok: true,
+ value: { executable, infoPlist: plistPath },
+ });
+ });
+
it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-"));
try {
diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts
index ca7dfba21e..2bbbe45cfe 100644
--- a/src/application/AppBundleExecutable.ts
+++ b/src/application/AppBundleExecutable.ts
@@ -5,6 +5,7 @@ import { parseBinary } from "plist";
import { z } from "zod";
import { preflightPropertyListDecode } from "../artifacts/apple/PropertyListDecodeBudget.js";
+import { decodeBinaryPlistUids } from "../artifacts/apple/PlistScalars.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { isPathWithinRoot } from "../domain/localPath.js";
import {
@@ -335,10 +336,14 @@ const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
/** Decode a binary plist in-process, so no host plist tool is required. */
const parseBinaryPlistExecutable = (plist: Buffer): string => {
- preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
+ const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
// `plist` assigns a `__proto__` key as the prototype; copying drops it, as
// the XML path does.
- return executableName(omitPrototypeKeys(parseBinary(plist)).value);
+ return executableName(
+ omitPrototypeKeys(
+ parseBinary(decodeBinaryPlistUids(plist, plan.uidOffsetsToNormalize)),
+ ).value,
+ );
};
const isSafeExecutableName = (name: string): boolean =>
diff --git a/src/application/binaryDiagnostics/ByteEntropyService.test.ts b/src/application/binaryDiagnostics/ByteEntropyService.test.ts
index a9ef6f4922..15300d51ab 100644
--- a/src/application/binaryDiagnostics/ByteEntropyService.test.ts
+++ b/src/application/binaryDiagnostics/ByteEntropyService.test.ts
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
-import { open, stat, rename, writeFile } from "node:fs/promises";
+import { open, stat, rename, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
@@ -69,6 +69,67 @@ it("rejects missing files, non-files and windows beyond EOF, but accepts empty w
).toContainEqual(expect.stringContaining("reports a size of 0"));
});
+it.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
+ "projects real path-resolution failures as actionable input errors",
+ async () => {
+ const root = await createTestTempDirectory("rea-entropy-path-resolution-");
+ const loop = join(root, "loop");
+ await symlink("loop", loop);
+ const overlong = join(root, "x".repeat(256));
+ const cases = [
+ [loop, "ELOOP"],
+ [overlong, "ENAMETOOLONG"],
+ ] as const;
+ const service = new ByteEntropyService();
+ try {
+ for (const [path, code] of cases) {
+ let observedCode: unknown;
+ try {
+ await stat(path);
+ } catch (cause: unknown) {
+ observedCode =
+ cause instanceof Error && "code" in cause ? cause.code : undefined;
+ }
+ expect(observedCode).toBe(code);
+
+ const result = await service.inspect({ path });
+ if (result.ok) throw new Error(`Expected ${code} path failure`);
+ expect(result.error).toMatchObject({
+ _tag: "AnalysisInputError",
+ cause: { code },
+ issues: [
+ {
+ path: ["path"],
+ reason: "invalid_value",
+ },
+ ],
+ });
+ const projected = projectAnalysisError(result.error);
+ expect(projected).toMatchObject({
+ code: "invalid_request",
+ recovery: {
+ kind: "inspect_input_contract",
+ operation: "inspect_byte_entropy",
+ },
+ details: {
+ operation: "inspect_byte_entropy",
+ issues: [
+ {
+ path: ["path"],
+ reason: "invalid_value",
+ message: expect.stringContaining(code),
+ },
+ ],
+ },
+ });
+ expect(JSON.stringify(projected.details)).toContain(path);
+ }
+ } finally {
+ await service.close();
+ }
+ },
+);
+
it.each([
[{ offset: 2048 }, "offset"],
[{ offset: 2048, length: 0 }, "offset"],
diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts
index 2974b690eb..4ce07cb45c 100644
--- a/src/application/binaryDiagnostics/ByteEntropyService.ts
+++ b/src/application/binaryDiagnostics/ByteEntropyService.ts
@@ -32,6 +32,7 @@ import {
artifactFileCompletion,
assertArtifactReadActive,
artifactFileCancellationCause,
+ artifactSelectedPathErrno,
} from "../../artifacts/ArtifactFileOperation.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
@@ -149,7 +150,8 @@ export class ByteEntropyService {
cause.code,
{ cause },
);
- if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
+ const pathErrno = artifactSelectedPathErrno(cause);
+ if (pathErrno !== undefined)
return admitted
? new AnalysisArtifactChangedError(
OPERATION,
@@ -157,7 +159,18 @@ export class ByteEntropyService {
"Selected file path changed during entropy inspection.",
{ cause },
)
- : invalid("path", "Selected file could not be found.");
+ : new AnalysisInputError(OPERATION, { cause }, [
+ {
+ path: ["path"],
+ reason: "invalid_value",
+ message:
+ pathErrno === "ENOENT" || pathErrno === "ENOTDIR"
+ ? "Selected file could not be found."
+ : pathErrno === "ELOOP"
+ ? `Selected file path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${input.path}`
+ : `Selected file path exceeds the host path or component length limit (ENAMETOOLONG): ${input.path}`,
+ },
+ ]);
}
return new AnalysisOutputError(
OPERATION,
diff --git a/src/artifacts/GodotPckLocation.ts b/src/artifacts/GodotPckLocation.ts
index 783e9bee1c..cc2191b6ae 100644
--- a/src/artifacts/GodotPckLocation.ts
+++ b/src/artifacts/GodotPckLocation.ts
@@ -69,19 +69,13 @@ export const locateGodotPck = async (
if (trailer.readUInt32LE(8) !== GODOT_PCK_MAGIC) return undefined;
const end = size - 12;
const bytes = trailer.readBigUInt64LE(0);
- if (bytes < 4n || bytes > BigInt(end))
- throw new ArtifactReaderFailure(
- "format",
- "Godot PCK trailer declares an invalid pack range",
- );
+ // The terminal marker is only a candidate until its range resolves to a PCK header.
+ if (bytes < 4n || bytes > BigInt(end)) return undefined;
const start = end - Number(bytes);
if (
(await readPckBytes(handle, start, 4, end, signal)).readUInt32LE(0) !==
GODOT_PCK_MAGIC
)
- throw new ArtifactReaderFailure(
- "format",
- "Godot PCK trailer does not point at a pack header",
- );
+ return undefined;
return { start, end, embedded: true };
};
diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts
index 702ae67475..eb55c6b08f 100644
--- a/src/artifacts/ZipArtifactReader.test.ts
+++ b/src/artifacts/ZipArtifactReader.test.ts
@@ -1,4 +1,5 @@
import { writeOrderedZip } from "../../tests/fixtures/artifactEntryOrder.js";
+import { zipSymlinkMetadataFixture } from "../../tests/fixtures/zipSymlinkMetadata.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -213,6 +214,29 @@ describe("ZipArtifactReader positional reads", () => {
});
});
+it("refuses to open ZIP symlink entries even when unixMode is absent", async () => {
+ const directory = await createTestTempDirectory("rea-zip-symlink-open-");
+ const path = join(directory, "symlinks.zip");
+ await writeFile(path, await zipSymlinkMetadataFixture());
+ const reader = new ZipArtifactReader(path, "zip");
+ try {
+ const entries = [];
+ for await (const entry of reader.entries()) entries.push(entry);
+ for (const logicalPath of ["type-only-link", "permissions-link"]) {
+ const entry = entries.find((candidate) => candidate.path === logicalPath);
+ expect(entry).toMatchObject({ kind: "symlink" });
+ if (entry === undefined)
+ throw new Error(`Missing ZIP symlink ${logicalPath}`);
+ await expect(reader.open(entry)).rejects.toMatchObject({
+ reason: "format",
+ message: "ZIP entry is not a regular file",
+ });
+ }
+ } finally {
+ await reader.close();
+ }
+});
+
describe("ZipArtifactReader decompression budget", () => {
it("refuses an archive whose entries declare more than its size can justify", async () => {
const directory = await createTestTempDirectory("rea-zip-budget-bomb-");
diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts
index 61f58b9ae2..76cf292deb 100644
--- a/src/artifacts/ZipArtifactReader.ts
+++ b/src/artifacts/ZipArtifactReader.ts
@@ -256,7 +256,7 @@ export class ZipArtifactReader implements ArtifactReader {
),
);
const source = stored.source;
- if (source.directory || isSymlink(source))
+ if (source.directory || source.symlink)
return Promise.reject(
new ArtifactReaderFailure("format", "ZIP entry is not a regular file"),
);
@@ -285,7 +285,7 @@ const projectZipEntry = (
entry: Entry,
index: number,
): ArtifactEntry | undefined => {
- const kind = isSymlink(entry)
+ const kind = entry.symlink
? "symlink"
: entry.directory
? "directory"
@@ -472,8 +472,5 @@ async function* typedZipFailures(
}
}
-const isSymlink = (entry: Entry): boolean =>
- entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000;
-
const toError = (cause: unknown): Error =>
cause instanceof Error ? cause : new Error("ZIP extraction failed");
diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts
index 69fdaf3b34..4afc97a8e7 100644
--- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts
+++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts
@@ -46,6 +46,7 @@ import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
+ UnsupportedBinaryPlistUidError,
type BinaryPlistScalarDecodePlan,
} from "./PlistScalars.js";
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
@@ -132,6 +133,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
+ uidOffsetsToNormalize: [],
};
let datePrecisionOverlapCount = 0;
if (isNibArchive) {
@@ -225,6 +227,12 @@ export const analyzeInterfaceBuilderBundle = async (input: {
recordOmittedArchive(entry.path, cause.reason);
continue;
}
+ if (cause instanceof UnsupportedBinaryPlistUidError) {
+ decodeBudget.release(plistReservation);
+ omitted += 1;
+ recordOmittedArchive(entry.path, "unsupported_uid_exact_range");
+ continue;
+ }
invalid.push(
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
);
diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts
index bbfee2a1c3..ac3e28d811 100644
--- a/src/artifacts/apple/KeyedArchiveReader.ts
+++ b/src/artifacts/apple/KeyedArchiveReader.ts
@@ -18,6 +18,7 @@ import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
+ UnsupportedBinaryPlistUidError,
} from "./PlistScalars.js";
import {
omittedPrototypeKeysLimitation,
@@ -260,6 +261,13 @@ export const inspectBundleKeyedArchive = async (input: {
});
} catch (cause) {
if (cause instanceof AnalysisInputError) throw cause;
+ if (cause instanceof UnsupportedBinaryPlistUidError)
+ throw new AnalysisUnsupportedTargetError(
+ "inspect_keyed_archive",
+ join(input.bundlePath, entry.path),
+ cause.message,
+ { cause },
+ );
// An intact file of another kind is not a damaged archive.
if (cause instanceof KeyedArchiveKindError)
throw new AnalysisUnsupportedTargetError(
diff --git a/src/artifacts/apple/PlistScalars.test.ts b/src/artifacts/apple/PlistScalars.test.ts
index 75f0ee78a6..a48658e5b1 100644
--- a/src/artifacts/apple/PlistScalars.test.ts
+++ b/src/artifacts/apple/PlistScalars.test.ts
@@ -7,9 +7,13 @@ import { projectPlistValue } from "../../domain/apple/plistValue.js";
import {
binaryDateRequiresExactSeconds,
decodeBinaryPlistScalars,
+ decodeBinaryPlistUids,
scanBinaryPlistScalars,
} from "./PlistScalars.js";
-import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
+import {
+ estimateBinaryPlistExpansion,
+ PropertyListDecodeBudgetFailure,
+} from "./PropertyListDecodeBudget.js";
const dataObjectWithUnusedOverlappingInteger = (): Buffer => {
const bytes = Buffer.alloc(66);
@@ -177,6 +181,7 @@ describe("binary plist scalar bounds", () => {
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
+ uidOffsetsToNormalize: [],
});
expect(
estimateBinaryPlistExpansion(bytes, plan.estimatedBytes).estimatedBytes,
@@ -227,6 +232,78 @@ describe("binary plist scalar bounds", () => {
);
});
+const binaryPlistWithUid = (width: number, inputValue: bigint): Buffer => {
+ let value = inputValue;
+ const record = Buffer.alloc(width + 1);
+ record[0] = 0x80 | (width - 1);
+ for (let byte = width - 1; byte >= 0; byte -= 1) {
+ record[byte + 1] = Number(value & 0xffn);
+ value >>= 8n;
+ }
+ const offsetTable = 8 + record.length;
+ const bytes = Buffer.alloc(offsetTable + 1 + 32);
+ record.copy(bytes, 8);
+ return binaryPlistObjectTable(bytes, [8], offsetTable);
+};
+
+describe("binary plist UID widths", () => {
+ it.each([
+ { width: 3, value: 0xabcdefn, normalizedWidth: 4 },
+ { width: 9, value: 1n, normalizedWidth: 1 },
+ { width: 16, value: 0x1234n, normalizedWidth: 2 },
+ { width: 4, value: 0x1234n, normalizedWidth: undefined },
+ ])(
+ "preserves the full $width-byte UID value and only normalizes unsupported widths",
+ ({ width, value, normalizedWidth }) => {
+ const bytes = binaryPlistWithUid(width, value);
+ const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024);
+ expect(plan.uidOffsetsToNormalize).toEqual(
+ normalizedWidth === undefined
+ ? []
+ : [{ offset: 8, width: normalizedWidth }],
+ );
+ const compatible = decodeBinaryPlistUids(
+ bytes,
+ plan.uidOffsetsToNormalize,
+ );
+ if (normalizedWidth === undefined) expect(compatible).toBe(bytes);
+ else expect(compatible[8 + width + 1]).toBe(0x80 | (normalizedWidth - 1));
+ expect(parseBinary(compatible)).toEqual({
+ UID: Number(value),
+ });
+ },
+ );
+
+ it("accounts for appended UID records before admitting the decode copy", () => {
+ const bytes = binaryPlistWithUid(3, 0xabcdefn);
+ for (const options of [{}, { numberProjection: true }]) {
+ const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, options);
+ expect(() =>
+ estimateBinaryPlistExpansion(bytes, plan.estimatedBytes - 1, options),
+ ).toThrow(PropertyListDecodeBudgetFailure);
+ }
+ });
+
+ it("rejects a truncated UID payload and values outside exact numeric range", () => {
+ const truncated = Buffer.alloc(50);
+ truncated[8] = 0x82;
+ truncated[9] = 1;
+ binaryPlistObjectTable(truncated, [8], 10);
+ expect(() => estimateBinaryPlistExpansion(truncated, 1024)).toThrow(
+ "binary plist UID object is truncated",
+ );
+
+ const inexact = binaryPlistWithUid(8, BigInt(Number.MAX_SAFE_INTEGER) + 1n);
+ expect(() => estimateBinaryPlistExpansion(inexact, 1024)).toThrow(
+ "binary plist UID value 9007199254740992",
+ );
+ const nonzeroHighByte = binaryPlistWithUid(9, 0x1_0000_0000_0000_0000n);
+ expect(() => estimateBinaryPlistExpansion(nonzeroHighByte, 1024)).toThrow(
+ "binary plist UID value 18446744073709551616",
+ );
+ });
+});
+
describe("binary plist number rewriting", () => {
it("does not rewrite an integer marker inside another object's data", () => {
const bytes = dataObjectWithUnusedOverlappingInteger();
@@ -241,6 +318,7 @@ describe("binary plist number rewriting", () => {
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
+ uidOffsetsToNormalize: [],
}),
).toBe(bytes);
expect(bytes.subarray(13, 30)).toEqual(
diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts
index 1566608f4c..d670ca9f3e 100644
--- a/src/artifacts/apple/PlistScalars.ts
+++ b/src/artifacts/apple/PlistScalars.ts
@@ -38,8 +38,106 @@ export interface BinaryPlistScalarDecodePlan {
readonly reachableWideIntegerOffsets: readonly number[];
readonly overlappingWideIntegerOffsets: readonly number[];
readonly reachableNumericOffsets: readonly number[];
+ /** Reachable UID records whose encoded width plist@5 cannot read. */
+ readonly uidOffsetsToNormalize: readonly { offset: number; width: number }[];
}
+/** A binary-plist UID cannot be projected to plist@5's numeric UID value exactly. */
+export class UnsupportedBinaryPlistUidError extends TypeError {
+ readonly _tag = "UnsupportedBinaryPlistUidError";
+
+ constructor(
+ readonly decimalValue: string,
+ readonly sourceWidth: number,
+ ) {
+ super(
+ `binary plist UID value ${decimalValue} in ${sourceWidth}-byte encoding exceeds the exact supported range`,
+ );
+ this.name = "UnsupportedBinaryPlistUidError";
+ }
+}
+
+const readBinaryPlistUidValue = (
+ bytes: Buffer,
+ offset: number,
+ width: number,
+): bigint => {
+ let value = 0n;
+ for (let index = 0; index < width; index += 1)
+ value = (value << 8n) | BigInt(bytes[offset + 1 + index] ?? 0);
+ if (value > BigInt(Number.MAX_SAFE_INTEGER))
+ throw new UnsupportedBinaryPlistUidError(value.toString(), width);
+ return value;
+};
+
+const normalizedUidWidth = (value: bigint): number =>
+ value <= 0xffn ? 1 : value <= 0xffffn ? 2 : value <= 0xffffffffn ? 4 : 8;
+
+/** Return a supported parser width when the source UID uses another width. */
+export const binaryPlistUidNormalizationWidth = (
+ bytes: Buffer,
+ offset: number,
+): number | undefined => {
+ const marker = bytes[offset];
+ if (marker === undefined || marker >> 4 !== 8) return undefined;
+ const sourceWidth = (marker & 0x0f) + 1;
+ const value = readBinaryPlistUidValue(bytes, offset, sourceWidth);
+ if ([1, 2, 4, 8].includes(sourceWidth)) return undefined;
+ const targetWidth = normalizedUidWidth(value);
+ return targetWidth === sourceWidth ? undefined : targetWidth;
+};
+
+const binaryPlistUidRecord = (
+ bytes: Buffer,
+ offset: number,
+ targetWidth?: number,
+): Buffer => {
+ const sourceWidth = (bytes[offset]! & 0x0f) + 1;
+ let value = readBinaryPlistUidValue(bytes, offset, sourceWidth);
+ const width = targetWidth ?? normalizedUidWidth(value);
+ const record = Buffer.alloc(width + 1);
+ record[0] = 0x80 | (width - 1);
+ for (let byte = width - 1; byte >= 0; byte -= 1) {
+ record[byte + 1] = Number(value & 0xffn);
+ value >>= 8n;
+ }
+ return record;
+};
+
+/** Exact added offset-table representation size, shared with preflight. */
+export const binaryPlistCloneOffsetWidth = (
+ currentWidth: number,
+ firstCloneOffset: number,
+ cloneRecordLengths: readonly number[],
+): number | undefined => {
+ if (cloneRecordLengths.length === 0) return currentWidth;
+ let lastCloneOffset = firstCloneOffset;
+ for (let index = 0; index + 1 < cloneRecordLengths.length; index += 1)
+ lastCloneOffset += cloneRecordLengths[index] ?? 0;
+ return [currentWidth, 2, 4, 8]
+ .filter((width) => width >= currentWidth)
+ .find((width) =>
+ width === 8
+ ? lastCloneOffset <= Number.MAX_SAFE_INTEGER
+ : lastCloneOffset < 2 ** (width * 8),
+ );
+};
+
+/** Normalize unsupported binary-plist UID widths for the pinned parser. */
+export const decodeBinaryPlistUids = (
+ bytes: Buffer,
+ uidOffsets: readonly { offset: number; width: number }[],
+): Buffer => {
+ if (uidOffsets.length === 0) return bytes;
+ const replacements = new Map(
+ uidOffsets.map(({ offset, width }) => [
+ offset,
+ binaryPlistUidRecord(bytes, offset, width),
+ ]),
+ );
+ return cloneBinaryPlistObjects(bytes, replacements);
+};
+
const APPLE_EPOCH_MILLISECONDS = 978_307_200_000;
/** Whether Date's millisecond projection would lose finite, in-range source seconds. */
@@ -264,20 +362,32 @@ export const decodeBinaryPlistScalars = (
const reachableWideIntegers = metadata.wideIntegers.filter(({ offset }) =>
reachableWideIntegerOffsets.has(offset),
);
- const cloneOffsets = new Set([
- ...unrepresentableDates
- .filter(({ offset }) => overlappingDateOffsets.has(offset))
- .map(({ offset }) => offset),
- ...reachableWideIntegers
- .filter(({ offset }) => overlappingWideIntegerOffsets.has(offset))
- .map(({ offset }) => offset),
- ]);
- if (reachableWideIntegers.length === 0 && unrepresentableDates.length === 0)
+ const replacements = new Map();
+ for (const { offset, width } of scalarPlan.uidOffsetsToNormalize)
+ replacements.set(offset, binaryPlistUidRecord(bytes, offset, width));
+ for (const { offset } of unrepresentableDates)
+ if (overlappingDateOffsets.has(offset)) {
+ const replacement = Buffer.from(bytes.subarray(offset, offset + 9));
+ replacement.writeDoubleBE(Number.NaN, 1);
+ replacements.set(offset, replacement);
+ }
+ for (const { offset, value } of reachableWideIntegers)
+ if (overlappingWideIntegerOffsets.has(offset)) {
+ const replacement = Buffer.alloc(9);
+ replacement[0] = 0x23;
+ replacement.writeDoubleBE(Number(value), 1);
+ replacements.set(offset, replacement);
+ }
+ if (
+ reachableWideIntegers.length === 0 &&
+ unrepresentableDates.length === 0 &&
+ replacements.size === 0
+ )
return bytes;
const compatible =
- cloneOffsets.size === 0
+ replacements.size === 0
? Buffer.from(bytes)
- : cloneOverlappingScalars(bytes, cloneOffsets, metadata);
+ : cloneBinaryPlistObjects(bytes, replacements);
for (const { offset, value } of reachableWideIntegers) {
if (overlappingWideIntegerOffsets.has(offset)) continue;
// Convert only the decoder's input copy to a double. Exact integer metadata
@@ -294,10 +404,9 @@ export const decodeBinaryPlistScalars = (
return compatible;
};
-const cloneOverlappingScalars = (
+const cloneBinaryPlistObjects = (
bytes: Buffer,
- scalarOffsets: ReadonlySet,
- metadata: BinaryPlistScalarMetadata,
+ replacements: ReadonlyMap,
): Buffer => {
const trailerOffset = bytes.length - 32;
const offsetSize = bytes[trailerOffset + 6] ?? 0;
@@ -312,24 +421,22 @@ const cloneOverlappingScalars = (
const offsets = Array.from({ length: objectCount }, (_, index) =>
readOffset(oldTableOffset + index * offsetSize),
);
- const cloneEntries = [...scalarOffsets];
- const wideIntegersByOffset = new Map(
- metadata.wideIntegers.map((entry) => [entry.offset, entry.value]),
+ const cloneEntries = [...replacements.entries()];
+ const replacementLengths = cloneEntries.map(([, record]) => record.length);
+ const newOffsetSize = binaryPlistCloneOffsetWidth(
+ offsetSize,
+ oldTableOffset,
+ replacementLengths,
);
- const newOffsetSize = [1, 2, 4, 8].find((width) => {
- if (width < offsetSize) return false;
- const maximum =
- width === 8 ? Number.MAX_SAFE_INTEGER : 2 ** (width * 8) - 1;
- return cloneEntries.every(
- (_, index) => oldTableOffset + index * 9 <= maximum,
- );
- });
if (newOffsetSize === undefined)
throw new RangeError(
"binary plist cloned scalar offset exceeds supported width",
);
const cloneStart = oldTableOffset;
- const cloneBytes = cloneEntries.length * 9;
+ const cloneBytes = replacementLengths.reduce(
+ (sum, length) => sum + length,
+ 0,
+ );
const newTableOffset = cloneStart + cloneBytes;
const newLength =
bytes.length + cloneBytes + objectCount * (newOffsetSize - offsetSize);
@@ -340,17 +447,12 @@ const cloneOverlappingScalars = (
const compatible = Buffer.alloc(newLength);
bytes.copy(compatible, 0, 0, oldTableOffset);
const cloneOffsetBySource = new Map();
- cloneEntries.forEach((sourceOffset, index) => {
- const cloneOffset = cloneStart + index * 9;
+ let cloneCursor = cloneStart;
+ cloneEntries.forEach(([sourceOffset, replacement]) => {
+ const cloneOffset = cloneCursor;
cloneOffsetBySource.set(sourceOffset, BigInt(cloneOffset));
- const wideInteger = wideIntegersByOffset.get(sourceOffset);
- if (wideInteger !== undefined) {
- compatible[cloneOffset] = 0x23;
- compatible.writeDoubleBE(Number(wideInteger), cloneOffset + 1);
- } else {
- bytes.copy(compatible, cloneOffset, sourceOffset, sourceOffset + 9);
- compatible.writeDoubleBE(Number.NaN, cloneOffset + 1);
- }
+ replacement.copy(compatible, cloneOffset);
+ cloneCursor += replacement.length;
});
const tableStart = newTableOffset;
const tableEnd = tableStart + objectCount * newOffsetSize;
diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts
index e4b43c1b3c..30fc785a47 100644
--- a/src/artifacts/apple/PropertyListDecodeBudget.ts
+++ b/src/artifacts/apple/PropertyListDecodeBudget.ts
@@ -1,8 +1,10 @@
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
import {
+ binaryPlistCloneOffsetWidth,
binaryDateRequiresExactSeconds,
binaryPlistObjectEnd,
+ binaryPlistUidNormalizationWidth,
} from "./PlistScalars.js";
/** Preflight the representation created by the pinned plist decoder. */
@@ -42,6 +44,7 @@ export const preflightPropertyListDecode = (
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
+ uidOffsetsToNormalize: [],
};
if (expansion.estimatedBytes > maxBytes)
throw fail("expansion", `plist representation exceeds ${budget}`);
@@ -176,6 +179,8 @@ export interface PlistDecodePreflight {
readonly overlappingWideIntegerOffsets: readonly number[];
/** Reachable integer and real source records for exact number association. */
readonly reachableNumericOffsets: readonly number[];
+ /** Reachable UID records cloned to one of plist@5's supported widths. */
+ readonly uidOffsetsToNormalize: readonly { offset: number; width: number }[];
}
const PROTOTYPE_KEY = "__proto__";
@@ -209,21 +214,6 @@ const findOverlappingScalarOffsets = (
return overlappingOffsets;
};
-const findExactScalarCloneOffsetWidth = (
- currentWidth: number,
- firstCloneOffset: number,
- cloneCount: number,
-): number | undefined => {
- const lastCloneOffset = firstCloneOffset + cloneCount * 9 - 9;
- return [currentWidth, 2, 4, 8]
- .filter((width) => width >= currentWidth)
- .find((width) =>
- width === 8
- ? lastCloneOffset <= Number.MAX_SAFE_INTEGER
- : lastCloneOffset < 2 ** (width * 8),
- );
-};
-
/**
* Count every expanded binary-plist reference before calling plist.parseBinary,
* which recurses on each reference and copies shared containers. Malformed
@@ -293,6 +283,7 @@ export const estimateBinaryPlistExpansion = (
const reachableDateOffsets = new Set();
const reachableWideIntegerOffsets = new Set();
const reachableNumericOffsets = new Set();
+ const uidWidthsToNormalize = new Map();
const reachableDateOccurrences: number[] = [];
while (pending.length > 0) {
const entry = pending.pop();
@@ -339,6 +330,15 @@ export const estimateBinaryPlistExpansion = (
}
continue;
}
+ if (type === 8) {
+ const uidWidth = (marker & 0x0f) + 1;
+ if (offset + 1 + uidWidth > objectsEnd)
+ throw new TypeError("binary plist UID object is truncated");
+ const normalizedWidth = binaryPlistUidNormalizationWidth(bytes, offset);
+ if (normalizedWidth !== undefined)
+ uidWidthsToNormalize.set(offset, normalizedWidth);
+ continue;
+ }
if (type === 1 || type === 2) {
const info = marker & 0x0f;
if (type === 1 && info > 4)
@@ -435,7 +435,29 @@ export const estimateBinaryPlistExpansion = (
}
pendingObjectCount += childCount;
}
- if (!numberProjection)
+ const uidOffsetsToNormalize = [...uidWidthsToNormalize]
+ .map(([offset, width]) => ({ offset, width }))
+ .sort((left, right) => left.offset - right.offset);
+ if (!numberProjection) {
+ const cloneLengths = uidOffsetsToNormalize.map(({ width }) => width + 1);
+ if (cloneLengths.length > 0) {
+ const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0);
+ const newOffsetSize = binaryPlistCloneOffsetWidth(
+ offsetSize,
+ offsetTable,
+ cloneLengths,
+ );
+ if (newOffsetSize === undefined)
+ throw new TypeError(
+ "binary plist cloned UID offset exceeds supported width",
+ );
+ estimate += cloneBytes + objectCount * (newOffsetSize - offsetSize);
+ if (estimate > maxBytes)
+ throw fail(
+ "expansion",
+ `binary plist UID-copy expansion exceeds ${budget}`,
+ );
+ }
return {
estimatedBytes: estimate,
binaryPrototypeKeyObserved,
@@ -445,7 +467,9 @@ export const estimateBinaryPlistExpansion = (
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
+ uidOffsetsToNormalize,
};
+ }
// Distinct objects can legally overlap in binary plists. Rewriting a date or
// wide integer in place could change another reachable value, so isolate
@@ -482,12 +506,16 @@ export const estimateBinaryPlistExpansion = (
...precisionUnknownDateOffsets,
...overlappingWideIntegerOffsets,
]);
- if (cloneOffsets.size > 0) {
- const cloneBytes = cloneOffsets.size * 9;
- const newOffsetSize = findExactScalarCloneOffsetWidth(
+ const cloneLengths = [
+ ...uidOffsetsToNormalize.map(({ width }) => width + 1),
+ ...Array.from(cloneOffsets, () => 9),
+ ];
+ if (cloneLengths.length > 0) {
+ const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0);
+ const newOffsetSize = binaryPlistCloneOffsetWidth(
offsetSize,
offsetTable,
- cloneOffsets.size,
+ cloneLengths,
);
if (newOffsetSize === undefined)
throw new TypeError(
@@ -521,5 +549,6 @@ export const estimateBinaryPlistExpansion = (
reachableNumericOffsets: [...reachableNumericOffsets].sort(
(left, right) => left - right,
),
+ uidOffsetsToNormalize,
};
};
diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts
index 2d34e983ba..563dbd9a57 100644
--- a/src/go/GoBinaryImage.ts
+++ b/src/go/GoBinaryImage.ts
@@ -8,7 +8,10 @@ import {
type FileMapping,
} from "../native/metadata/BinaryContainer.js";
import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
-import { goBuildInfoSearch } from "./GoBuildInfoSearch.js";
+import {
+ goBuildInfoSearch,
+ type GoBuildInfoSearchRange,
+} from "./GoBuildInfoSearch.js";
const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex");
const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex");
@@ -49,7 +52,8 @@ export interface GoBinaryImage {
export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
const container = readBinaryContainer(bytes);
const { format, architecture, bits, byte_order } = container;
- let selected: { search: FileMapping; header: number } | null = null;
+ let selected: { search: GoBuildInfoSearchRange; header: number } | null =
+ null;
const searched = new Set();
let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES;
const sectionNamesLimitation = container.section_names_limitation;
@@ -114,16 +118,38 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
"Go build-info byte order disagrees with the image",
);
const encoding = (flags & 2) !== 0 ? "inline" : "pointer";
- const readInline = (offset: number, remaining: number) =>
- readInlineString(reader, offset, remaining);
let version: LocatedString;
let module: LocatedString;
if (encoding === "inline") {
const sourceEnd = inlineDataEnd(header, search, container);
- version = readInline(header + 32, sourceEnd - header - 32);
- const next = version.location.offset + version.location.bytes;
- module = readInline(next, sourceEnd - next);
- verifyMapping(module.location.offset + module.location.bytes - header);
+ const versionExtent = readInlineExtent(
+ reader,
+ header + 32,
+ sourceEnd - header - 32,
+ );
+ const moduleExtent = readInlineExtent(
+ reader,
+ versionExtent.next,
+ sourceEnd - versionExtent.next,
+ );
+ verifyMapping(moduleExtent.next - header);
+ assertAggregateContentLimit(versionExtent.location, moduleExtent.location);
+ version = {
+ location: versionExtent.location,
+ bytes: reader.range(
+ versionExtent.location.offset,
+ versionExtent.location.bytes,
+ "Go build-info string",
+ ),
+ };
+ module = {
+ location: moduleExtent.location,
+ bytes: reader.range(
+ moduleExtent.location.offset,
+ moduleExtent.location.bytes,
+ "Go build-info string",
+ ),
+ };
} else {
if (ptrSize !== 4 && ptrSize !== 8)
throw new BinaryFormatFailure(
@@ -145,13 +171,8 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
moduleField,
pointerBits,
);
+ assertAggregateContentLimit(version.location, module.location);
}
- if (version.location.bytes + module.location.bytes > MAX_BUILD_INFO_BYTES)
- throw new BinaryResourceFailure(
- "build-info",
- MAX_BUILD_INFO_BYTES,
- "Go build-info strings exceed the aggregate 1 MiB limit",
- );
if (version.bytes.length === 0)
throw new BinaryFormatFailure(
"malformed",
@@ -193,17 +214,30 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
};
};
-/** Inline strings follow the header in mapped Go build-info data, even when
- * the named ELF discovery section ends immediately after the header.
+/** Inline strings follow the header in its mapped Go data, even when the named
+ * ELF or Mach-O discovery section ends immediately after the header.
*/
const inlineDataEnd = (
header: number,
- search: FileMapping,
+ search: GoBuildInfoSearchRange,
container: BinaryContainer,
): number => {
- if (container.format !== "elf") return search.offset + search.size;
+ if (container.format === "pe") return search.offset + search.size;
const address = search.address + BigInt(header - search.offset);
let end = search.offset + search.size;
+ if (search.inline_mapping !== undefined) {
+ const delta = address - search.inline_mapping.address;
+ if (
+ delta >= 0n &&
+ delta < BigInt(search.inline_mapping.size) &&
+ search.inline_mapping.offset + Number(delta) === header
+ )
+ return Math.max(
+ end,
+ search.inline_mapping.offset + search.inline_mapping.size,
+ );
+ return end;
+ }
for (const { mapping } of container.load_regions ?? []) {
const delta = address - mapping.address;
if (
@@ -213,7 +247,7 @@ const inlineDataEnd = (
)
end = Math.max(end, mapping.offset + mapping.size);
}
- return Math.min(end, header + 32 + MAX_BUILD_INFO_BYTES);
+ return end;
};
const observedText = (bytes: Buffer): string | null => {
@@ -230,6 +264,18 @@ interface LocatedString {
readonly location: GoStringLocation;
}
+const assertAggregateContentLimit = (
+ version: GoStringLocation,
+ module: GoStringLocation,
+): void => {
+ if (version.bytes + module.bytes > MAX_BUILD_INFO_BYTES)
+ throw new BinaryResourceFailure(
+ "build-info",
+ MAX_BUILD_INFO_BYTES,
+ "Go build-info strings exceed the aggregate 1 MiB limit",
+ );
+};
+
const findHeader = (bytes: Buffer, search: FileMapping): number | null => {
if (search.size === 0) return null;
const end = search.offset + search.size;
@@ -253,11 +299,11 @@ const findHeader = (bytes: Buffer, search: FileMapping): number | null => {
return null;
};
-const readInlineString = (
+const readInlineExtent = (
reader: BinaryReader,
offset: number,
remaining: number,
-): LocatedString => {
+): { readonly location: GoStringLocation; readonly next: number } => {
let length = 0n;
for (let index = 0; index < 10; index++) {
if (index >= remaining)
@@ -287,8 +333,8 @@ const readInlineString = (
);
const start = offset + index + 1;
return {
- bytes: reader.range(start, size, "Go build-info string"),
location: { offset: start, bytes: size },
+ next: start + size,
};
}
}
diff --git a/src/go/GoBuildInfoSearch.ts b/src/go/GoBuildInfoSearch.ts
index 7227acb6f8..8f02bedf56 100644
--- a/src/go/GoBuildInfoSearch.ts
+++ b/src/go/GoBuildInfoSearch.ts
@@ -6,9 +6,14 @@ import {
type FileMapping,
} from "../native/metadata/BinaryContainer.js";
+export interface GoBuildInfoSearchRange extends FileMapping {
+ /** Native file mapping containing inline strings after a named Mach-O header. */
+ readonly inline_mapping?: FileMapping;
+}
+
const fallbackSearchRanges = (
container: BinaryContainer,
-): readonly FileMapping[] => {
+): readonly GoBuildInfoSearchRange[] => {
if (container.format === "elf")
return (container.load_regions ?? [])
.filter((region) => region.write && !region.execute)
@@ -46,9 +51,9 @@ const fallbackSearchRanges = (
export const goBuildInfoSearch = (
bytes: Buffer,
container: BinaryContainer,
-): readonly FileMapping[] => {
+): readonly GoBuildInfoSearchRange[] => {
const reader = new BinaryReader(bytes, container.byte_order === "little");
- const named: FileMapping[] = [];
+ const named: GoBuildInfoSearchRange[] = [];
for (const section of container.sections) {
if (section.format === "elf" && section.name === ".go.buildinfo") {
if (named.length !== 0)
@@ -113,6 +118,7 @@ export const goBuildInfoSearch = (
"Mach-O build-info section",
),
address: section.address,
+ inline_mapping: section.segment_mapping,
};
if (
mappedFileOffset(
diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts
index a0357ccba8..3c4449c1e2 100644
--- a/src/native/NativeMacOSProvider.ts
+++ b/src/native/NativeMacOSProvider.ts
@@ -58,6 +58,7 @@ import {
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
+ AnalysisUnsupportedTargetError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
@@ -137,6 +138,7 @@ import {
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js";
import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js";
+import { UnsupportedBinaryPlistUidError } from "../artifacts/apple/PlistScalars.js";
type SignatureRuntimeRootFactory = () => Promise<
Pick
@@ -1024,6 +1026,15 @@ class NativeMacOSClient implements AnalysisClient {
if (!source.ok) return source;
const parsed = parsePlistBinary(source.value);
if (!parsed.ok) {
+ if (parsed.error instanceof UnsupportedBinaryPlistUidError)
+ return err(
+ new AnalysisUnsupportedTargetError(
+ "inspect_plist",
+ fileInput.selection.path,
+ parsed.error.message,
+ { cause: parsed.error },
+ ),
+ );
return parsed.error instanceof AnalysisOutputError
? err(
unreadablePlist(
@@ -1032,7 +1043,7 @@ class NativeMacOSClient implements AnalysisClient {
{ cause: parsed.error },
),
)
- : parsed;
+ : err(parsed.error);
}
return ok(
nativePlistObservation({
diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts
index 468131ef0c..819b1315c1 100644
--- a/src/native/parsers/plist.test.ts
+++ b/src/native/parsers/plist.test.ts
@@ -244,6 +244,7 @@ it("does not treat a malformed date marker as a valid date scalar", () => {
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
+ uidOffsetsToNormalize: [],
}),
).toBe(bytes);
});
diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts
index a8d1a131bb..1e1d52b39d 100644
--- a/src/native/parsers/plist.ts
+++ b/src/native/parsers/plist.ts
@@ -32,6 +32,7 @@ import {
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
+ UnsupportedBinaryPlistUidError,
} from "../../artifacts/apple/PlistScalars.js";
import {
estimateBinaryPlistExpansion,
@@ -248,7 +249,9 @@ export const parsePlistBinary = (
bytes: Buffer,
): Result<
ParsedPlist,
- AnalysisOutputError | AnalysisResourceConstraintError
+ | AnalysisOutputError
+ | AnalysisResourceConstraintError
+ | UnsupportedBinaryPlistUidError
> => {
try {
if (!hasBinaryPlistSignature(bytes))
@@ -293,6 +296,7 @@ export const parsePlistBinary = (
],
});
} catch (cause: unknown) {
+ if (cause instanceof UnsupportedBinaryPlistUidError) return err(cause);
if (cause instanceof PropertyListDecodeBudgetFailure)
return err(
new AnalysisResourceConstraintError(
diff --git a/tests/acceptance/artifactZipSymlinkMetadata.test.ts b/tests/acceptance/artifactZipSymlinkMetadata.test.ts
new file mode 100644
index 0000000000..445023e3d9
--- /dev/null
+++ b/tests/acceptance/artifactZipSymlinkMetadata.test.ts
@@ -0,0 +1,48 @@
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { expect } from "vitest";
+
+import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
+import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
+import { parseEvidence } from "../../src/domain/evidence.js";
+import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
+import { zipSymlinkMetadataFixture } from "../fixtures/zipSymlinkMetadata.js";
+import { cliTest } from "../support/cli/cliFixture.js";
+
+cliTest(
+ "reports ZIP symlinks from type bits when Unix permission bits are absent",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-zip-symlink-metadata-");
+ const archivePath = join(root, "symlinks.zip");
+ await writeFile(archivePath, await zipSymlinkMetadataFixture());
+
+ const inspected = await cli.run({
+ arguments: ["inspect-artifact", archivePath, "--json"],
+ });
+ expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0);
+ const inspection = artifactInspectionResultSchema.parse(
+ parseEvidence(inspected.json).normalized_result,
+ );
+ const inventoryEvidence = inspection.substeps[0]?.evidence;
+ if (inventoryEvidence === undefined)
+ throw new Error("CLI inspection did not retain ZIP inventory Evidence");
+ const inventory = artifactInventoryResultSchema.parse(
+ inventoryEvidence.normalized_result,
+ );
+ const entriesByPath = new Map(
+ inventory.occurrences.map((entry) => [entry.logical_path, entry]),
+ );
+
+ expect(entriesByPath.get("target.txt")).toMatchObject({
+ entry_kind: "file",
+ });
+ for (const path of ["type-only-link", "permissions-link"])
+ expect(entriesByPath.get(path)).toMatchObject({
+ entry_kind: "symlink",
+ limitations: expect.arrayContaining([
+ "Archive symlink target was not read.",
+ ]),
+ });
+ },
+);
diff --git a/tests/acceptance/goBinaryCli.test.ts b/tests/acceptance/goBinaryCli.test.ts
index a5dfdf0bb8..d52a729242 100644
--- a/tests/acceptance/goBinaryCli.test.ts
+++ b/tests/acceptance/goBinaryCli.test.ts
@@ -12,7 +12,11 @@ import {
createGoSplitElfFixture,
createGoOverlappingElfFixture,
createGoBinaryPathFailureFixture,
+ createGoInlineAggregateFixture,
setGoPeVirtualSize,
+ setGoElfLoadFileSize,
+ setGoMachOSectionSize,
+ setGoMachOSegmentFileSize,
GO_MODULE_TEXT,
} from "../fixtures/go/image.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
@@ -21,6 +25,156 @@ import { parseEvidence } from "../../src/domain/evidence.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
+cliTest(
+ "reads Mach-O inline Go strings beyond a header-only discovery section through CLI and MCP",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-go-macho-inline-range-");
+ const { client, call } = await connectLocalToolsMcp();
+ const complete = createGoBinaryFixture({ format: "macho" });
+ setGoMachOSectionSize(complete.bytes, 32);
+ const truncated = createGoBinaryFixture({ format: "macho" });
+ setGoMachOSectionSize(truncated.bytes, 32);
+ setGoMachOSegmentFileSize(
+ truncated.bytes,
+ truncated.moduleOffset + truncated.moduleBytes.length - 1,
+ );
+
+ for (const item of [
+ { name: "header-only", bytes: complete.bytes, expected: "success" },
+ { name: "truncated", bytes: truncated.bytes, expected: "malformed" },
+ ] as const) {
+ const path = join(root, `${item.name}.macho`);
+ await writeFile(path, item.bytes);
+ const result = await runFileCli(cli, {
+ arguments: ["inspect-go-binary", path, "--json"],
+ });
+ const response = await call("inspect_go_binary", { path });
+ if (item.expected === "success") {
+ expect(result.exitCode, result.stderr).toBe(0);
+ const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
+ expect(evidence.normalized_result).toMatchObject({
+ format: "macho",
+ build_info: {
+ header_offset: complete.headerOffset,
+ go_version: "go1.26.0",
+ module: { path: "example.com/tool/cmd/tool" },
+ },
+ });
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ expect(response.structuredContent).toEqual(evidence);
+ } else {
+ const expected = {
+ code: "invalid_request",
+ details: {
+ operation: "inspect_go_binary",
+ issues: [{ path: ["path"], reason: "invalid_format" }],
+ },
+ };
+ expect(result.exitCode).toBe(1);
+ expect(result.json).toMatchObject(expected);
+ expect(parseMcpToolError(response).error).toMatchObject(expected);
+ }
+ }
+ await client.ping();
+ },
+ 60000,
+);
+
+cliTest(
+ "counts inline Go string contents separately from varint framing through CLI and MCP",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-go-inline-budget-");
+ const { client, call } = await connectLocalToolsMcp();
+ const maximum = 1024 * 1024;
+ const cases = [
+ {
+ name: "exact",
+ fixture: createGoInlineAggregateFixture(maximum),
+ outcome: "success",
+ },
+ {
+ name: "over",
+ fixture: createGoInlineAggregateFixture(maximum + 1),
+ outcome: "resource",
+ },
+ {
+ name: "truncated",
+ fixture: createGoInlineAggregateFixture(512),
+ outcome: "malformed",
+ },
+ ] as const;
+
+ for (const item of cases) {
+ if (item.name === "truncated")
+ setGoElfLoadFileSize(
+ item.fixture.bytes,
+ item.fixture.moduleOffset + item.fixture.moduleBytes.length - 1,
+ );
+ const path = join(root, `${item.name}.elf`);
+ await writeFile(path, item.fixture.bytes);
+ const result = await runFileCli(cli, {
+ arguments: ["inspect-go-binary", path, "--json"],
+ });
+ const response = await call("inspect_go_binary", { path });
+
+ if (item.outcome === "success") {
+ expect(result.exitCode, result.stderr).toBe(0);
+ const evidence = evidenceResultOf(goBinarySchema).parse(result.json);
+ const buildInfo = evidence.normalized_result.build_info;
+ expect(buildInfo).not.toBeNull();
+ expect(
+ buildInfo!.version_location.bytes + buildInfo!.module_location.bytes,
+ ).toBe(maximum);
+ expect(
+ item.fixture.moduleOffset +
+ item.fixture.moduleBytes.length -
+ item.fixture.headerOffset,
+ ).toBeGreaterThan(maximum + 32);
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ expect(response.structuredContent).toEqual(evidence);
+ } else {
+ const code =
+ item.outcome === "resource"
+ ? "resource_constraint"
+ : "invalid_request";
+ expect(result.exitCode).toBe(1);
+ expect(result.json).toMatchObject({
+ code,
+ details: {
+ operation: "inspect_go_binary",
+ ...(item.outcome === "resource"
+ ? {
+ resource: "memory",
+ reported_limits: {
+ boundary: "build-info",
+ maximum_bytes: maximum,
+ },
+ }
+ : { issues: [{ path: ["path"], reason: "invalid_format" }] }),
+ },
+ });
+ expect(parseMcpToolError(response).error).toMatchObject({
+ code,
+ details: {
+ operation: "inspect_go_binary",
+ ...(item.outcome === "resource"
+ ? {
+ resource: "memory",
+ reported_limits: {
+ boundary: "build-info",
+ maximum_bytes: maximum,
+ },
+ }
+ : { issues: [{ path: ["path"], reason: "invalid_format" }] }),
+ },
+ });
+ }
+ }
+ await client.ping();
+ },
+ 60000,
+);
+
cliTest(
"delivers a Go CLI summary with its unchanged complete source bundle",
async ({ cli }) => {
diff --git a/tests/acceptance/godotPck.test.ts b/tests/acceptance/godotPck.test.ts
index 4da902a18d..45cc36e35f 100644
--- a/tests/acceptance/godotPck.test.ts
+++ b/tests/acceptance/godotPck.test.ts
@@ -123,6 +123,72 @@ cliTest(
},
);
+cliTest(
+ "keeps native inventory when an incidental GDPC trailer does not point at a pack header",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-pck-marker-candidate-");
+ const carrier = elf(2, 1, 62);
+ const candidates = [
+ Buffer.concat([carrier, Buffer.from("12345678ABCD", "ascii")]),
+ Buffer.concat([carrier, Buffer.from("12345678GDPC", "ascii")]),
+ Buffer.concat([
+ carrier,
+ Buffer.from("notpack!", "ascii"),
+ pckU64(8),
+ Buffer.from("GDPC", "ascii"),
+ ]),
+ ];
+
+ for (const [index, bytes] of candidates.entries()) {
+ const source = join(root, `candidate-${String(index)}.x86_64`);
+ await writeFile(source, bytes);
+ const reply = await cli.run({
+ arguments: ["inspect-artifact", source, "--json"],
+ });
+ expect(reply.exitCode, reply.stdout + reply.stderr).toBe(0);
+ const graph = graphFrom(reply.json);
+ expect(graph.manifest.root_format).toBe("elf");
+ expect(
+ graph.nodes.find(
+ (row) => row.artifact_id === graph.manifest.root_artifact_id,
+ )?.format,
+ ).toBe("elf");
+ expect(graph.occurrences).toHaveLength(1);
+ }
+ },
+);
+
+cliTest(
+ "refuses an unsupported version after an embedded trailer points at a PCK header",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-pck-confirmed-malformed-");
+ const source = join(root, "unsupported.x86_64");
+ const pack = buildGodotPck(4, members);
+ pack.writeUInt32LE(1, 4);
+ await writeFile(
+ source,
+ Buffer.concat([
+ elf(2, 1, 62),
+ pack,
+ pckU64(pack.length),
+ Buffer.from("GDPC", "ascii"),
+ ]),
+ );
+
+ const reply = await cli.run({
+ arguments: ["inspect-artifact", source, "--json"],
+ });
+ expect(reply.exitCode).toBe(1);
+ expect(reply.json).toMatchObject({
+ code: "artifact_operation_failed",
+ details: {
+ reason: "unavailable",
+ detail: expect.stringContaining("Godot PCK format 1 is unsupported"),
+ },
+ });
+ },
+);
+
cliTest(
"applies strict and record-and-continue MD5 integrity to inspection and extraction",
async ({ cli }) => {
diff --git a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts
index 1f1ba969ad..8160b5b841 100644
--- a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts
+++ b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts
@@ -1,11 +1,12 @@
import { createHash } from "node:crypto";
-import { writeFile } from "node:fs/promises";
+import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
import { expect, it } from "vitest";
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
+import { keyedArchiveUidBytes } from "../../fixtures/keyedArchiveThreeByteUid.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -136,3 +137,56 @@ cliTest.for(cases)(
);
},
);
+
+cliTest.for(["three-byte-uid"] as const)(
+ "preserves three-byte UID identity through the keyed-archive CLI",
+ async (_kind, { cli }) => {
+ const root = await createTestTempDirectory("rea-keyed-three-byte-uid-");
+ const path = join(root, "archive.plist");
+ const bytes = keyedArchiveUidBytes();
+ const digest = createHash("sha256").update(bytes).digest("hex");
+ await writeFile(path, bytes);
+ const output = await cli.run({
+ arguments: ["inspect-keyed-archive", path, "--json"],
+ environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
+ });
+ expect(output.exitCode).toBe(0);
+ const result = keyedArchiveResultSchema.parse(
+ parseEvidence(output.json).normalized_result,
+ );
+ expect(result.archive_sha256).toBe(digest);
+ expect(result.roots).toEqual({ root: { UID: 66051 } });
+ expect(result.references).toContainEqual({
+ source: null,
+ path: ["root"],
+ target: 66051,
+ status: "unresolved",
+ raw: { UID: 66051 },
+ });
+ },
+);
+
+cliTest(
+ "reports an inexact binary UID as unsupported without changing the artifact",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-keyed-inexact-uid-");
+ const path = join(root, "archive.plist");
+ const bytes = keyedArchiveUidBytes(8, BigInt(Number.MAX_SAFE_INTEGER) + 1n);
+ const originalHash = createHash("sha256").update(bytes).digest("hex");
+ await writeFile(path, bytes);
+
+ const output = await cli.run({
+ arguments: ["inspect-keyed-archive", path, "--json"],
+ environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
+ });
+
+ expect(output.exitCode).toBe(1);
+ expect(output.json).toMatchObject({
+ code: "unsupported_target",
+ message: expect.stringContaining("9007199254740992"),
+ });
+ const after = await readFile(path);
+ expect(after).toEqual(bytes);
+ expect(createHash("sha256").update(after).digest("hex")).toBe(originalHash);
+ },
+);
diff --git a/tests/fixtures/go/image.ts b/tests/fixtures/go/image.ts
index fccc8b63a3..2dda4dea26 100644
--- a/tests/fixtures/go/image.ts
+++ b/tests/fixtures/go/image.ts
@@ -141,6 +141,60 @@ export const createGoBinaryFixture = (
return { bytes, headerOffset, versionOffset, moduleOffset, moduleBytes };
};
+/** Build an ELF inline record with a selected aggregate string-content size. */
+export const createGoInlineAggregateFixture = (contentBytes: number) => {
+ const fixture = createGoBinaryFixture({ moduleText: "" });
+ const version = Buffer.from("v");
+ const moduleBytes = Buffer.alloc(contentBytes - version.length, 0x78);
+ if (moduleBytes.length < 33)
+ throw new Error("Go module string must include its framing markers");
+ MODULE_START.copy(moduleBytes);
+ MODULE_END.copy(moduleBytes, moduleBytes.length - MODULE_END.length);
+ moduleBytes[moduleBytes.length - MODULE_END.length - 1] = 0x0a;
+ const versionLength = encodeVarint(version.length);
+ const moduleLength = encodeVarint(moduleBytes.length);
+ const versionOffset = fixture.headerOffset + 32 + versionLength.length;
+ const modulePrefixOffset = versionOffset + version.length;
+ const moduleOffset = modulePrefixOffset + moduleLength.length;
+ const end = moduleOffset + moduleBytes.length;
+ const bytes = Buffer.alloc(Math.ceil(Math.max(4096, end) / 16) * 16);
+ fixture.bytes.copy(bytes);
+ // Discovery names the header only; inline strings continue in PT_LOAD.
+ bytes.writeBigUInt64LE(32n, 416);
+ versionLength.copy(bytes, fixture.headerOffset + 32);
+ version.copy(bytes, versionOffset);
+ moduleLength.copy(bytes, modulePrefixOffset);
+ moduleBytes.copy(bytes, moduleOffset);
+ // ELF64 PT_LOAD p_filesz/p_memsz, matching this fixture's native identity.
+ bytes.writeBigUInt64LE(BigInt(bytes.length), 160);
+ bytes.writeBigUInt64LE(BigInt(bytes.length), 168);
+ return {
+ bytes,
+ headerOffset: fixture.headerOffset,
+ versionOffset,
+ moduleOffset,
+ moduleBytes,
+ };
+};
+
+/** Shorten the ELF file-backed load mapping while retaining the file bytes. */
+export const setGoElfLoadFileSize = (bytes: Buffer, size: number): void => {
+ bytes.writeBigUInt64LE(BigInt(size), 160);
+};
+
+/** Set the Go Mach-O discovery section while leaving its segment unchanged. */
+export const setGoMachOSectionSize = (bytes: Buffer, size: number): void => {
+ bytes.writeBigUInt64LE(BigInt(size), 144);
+};
+
+/** Shorten a Mach-O segment's file-backed range while retaining the file bytes. */
+export const setGoMachOSegmentFileSize = (
+ bytes: Buffer,
+ size: number,
+): void => {
+ bytes.writeBigUInt64LE(BigInt(size), 80);
+};
+
/** Set the first PE section's declared virtual content size in an authored fixture. */
export const setGoPeVirtualSize = (
bytes: Buffer,
diff --git a/tests/fixtures/keyedArchiveThreeByteUid.ts b/tests/fixtures/keyedArchiveThreeByteUid.ts
new file mode 100644
index 0000000000..f4e92e6c0b
--- /dev/null
+++ b/tests/fixtures/keyedArchiveThreeByteUid.ts
@@ -0,0 +1,46 @@
+const ascii = (value: string): Buffer => {
+ const bytes = Buffer.from(value, "ascii");
+ return bytes.length < 15
+ ? Buffer.concat([Buffer.from([0x50 | bytes.length]), bytes])
+ : Buffer.concat([Buffer.from([0x5f, 0x10, bytes.length]), bytes]);
+};
+
+/** NSKeyedArchiver with a caller-sized UID for `$top.root`. */
+export const keyedArchiveUidBytes = (
+ width = 3,
+ uidValue = 0x010203n,
+): Buffer => {
+ const uid = Buffer.alloc(width + 1);
+ uid[0] = 0x80 | (width - 1);
+ for (let byte = width - 1; byte >= 0; byte -= 1) {
+ uid[byte + 1] = Number(uidValue & 0xffn);
+ uidValue >>= 8n;
+ }
+ const objects = [
+ Buffer.from([0xd3, 1, 3, 4, 2, 8, 5]),
+ ascii("$archiver"),
+ ascii("NSKeyedArchiver"),
+ ascii("$objects"),
+ ascii("$top"),
+ Buffer.from([0xd1, 6, 7]),
+ ascii("root"),
+ uid,
+ Buffer.from([0xa2, 9, 10]),
+ ascii("$null"),
+ ascii("payload"),
+ ];
+ const offsets: number[] = [];
+ let position = Buffer.byteLength("bplist00", "ascii");
+ for (const object of objects) {
+ offsets.push(position);
+ position += object.length;
+ }
+ const header = Buffer.from("bplist00", "ascii");
+ const trailer = Buffer.alloc(32);
+ trailer[6] = 1;
+ trailer[7] = 1;
+ trailer.writeBigUInt64BE(BigInt(objects.length), 8);
+ trailer.writeBigUInt64BE(0n, 16);
+ trailer.writeBigUInt64BE(BigInt(position), 24);
+ return Buffer.concat([header, ...objects, Buffer.from(offsets), trailer]);
+};
diff --git a/tests/fixtures/zipSymlinkMetadata.ts b/tests/fixtures/zipSymlinkMetadata.ts
new file mode 100644
index 0000000000..b3946c0dbc
--- /dev/null
+++ b/tests/fixtures/zipSymlinkMetadata.ts
@@ -0,0 +1,17 @@
+import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+
+/** Real ZIP entries whose Unix external attributes declare symlinks. */
+export const zipSymlinkMetadataFixture = async (): Promise => {
+ const writer = new ZipWriter(new Uint8ArrayWriter(), {
+ versionMadeBy: 3 << 8,
+ });
+ await writer.add("target.txt", new TextReader("harmless target text"));
+ for (const [name, mode] of [
+ ["type-only-link", 0o120000],
+ ["permissions-link", 0o120777],
+ ] as const)
+ await writer.add(name, new TextReader("target.txt"), {
+ externalFileAttributes: (mode << 16) >>> 0,
+ });
+ return Buffer.from(await writer.close());
+};