fix(parsers): respect container facts and real decoding boundaries
Use zip.js's parsed symlink fact for inventory and open refusal. Treat an unmatched Godot trailer as an unconfirmed candidate while preserving failures for confirmed malformed packs. Report entropy path-resolution failures at the selected-input boundary. Separate Go header discovery from inline string extents in ELF and Mach-O, and apply the aggregate content allowance after validating real source ranges. Normalize binary plist UID widths through the existing scalar-copy owner, budget exact clone growth, preserve source identity, and refuse inexact UIDs. Validation: 389 distinct tests passed, one Linux /proc test skipped on Darwin; build, typecheck/lint, docs, single-thread formatting, and discovery (2142 files) passed. CLI/MCP Go fixtures and public ZIP/Godot/keyed-archive workflows ran. Real producer prevalence and external provider execution were not established. Repeated ordinary keyed-archive CLI inventory facts and estimated reply tokens were equal before/after. Warm median elapsed was 0.5166s / 0.5177s; no speed claim. Fixes #6751 Fixes #6763 Fixes #6754 Fixes #6767 Fixes #6762 Fixes #6779
This commit is contained in:
1 parent
bd4f5e9c19
commit
6ba6aea5eb
27 files changed
+991
-111
No files matched your search
+9
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -449,6 +449,10 @@ complete observation.
|
||||
failures; explicit `<data></data>` 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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-");
|
||||
|
||||
@@ -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<T>(
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -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"}`,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<number, Buffer>();
|
||||
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<number>,
|
||||
metadata: BinaryPlistScalarMetadata,
|
||||
replacements: ReadonlyMap<number, Buffer>,
|
||||
): 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<number, bigint>();
|
||||
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;
|
||||
|
||||
@@ -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<number>();
|
||||
const reachableWideIntegerOffsets = new Set<number>();
|
||||
const reachableNumericOffsets = new Set<number>();
|
||||
const uidWidthsToNormalize = new Map<number, number>();
|
||||
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,
|
||||
};
|
||||
};
|
||||
+68
-22
@@ -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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PrivateRuntimeRoot, "path" | "close">
|
||||
@@ -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({
|
||||
|
||||
@@ -244,6 +244,7 @@ it("does not treat a malformed date marker as a valid date scalar", () => {
|
||||
reachableWideIntegerOffsets: [],
|
||||
overlappingWideIntegerOffsets: [],
|
||||
reachableNumericOffsets: [],
|
||||
uidOffsetsToNormalize: [],
|
||||
}),
|
||||
).toBe(bytes);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
Vendored
+54
@@ -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,
|
||||
|
||||
+46
@@ -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]);
|
||||
};
|
||||
Vendored
+17
@@ -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<Buffer> => {
|
||||
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());
|
||||
};
|
||||
Reference in new issue
Block a user