fix(apple): keep scalar facts when object-table entries share a record (#5806)

Binary plist object tables may list one object offset more than once;
plistlib reads each entry independently. The scalar scanner marked any
repeated offset as incomplete metadata, which made the number projection
drop every exact decimal in the archive. A keyed archive whose entries
10 and 11 share an Int64 9007199254740993 reported both as observed
9007199254740992 and rounded an unrelated 2^60+3 sibling, with only the
generic incomplete-metadata caveat.

A repeated valid offset carries the same source facts as its first
visit, so observe it once and continue. Offsets that fail to parse still
mark metadata incomplete. Shared 16-byte integers and dates are rewritten
once in the decoder copy, which every entry then reads; the overlap plan
already treats one shared offset as one span. Keyed archives and
Interface Builder decoding share this scanner.
This commit is contained in:
Shreyash Patange authored and GitHub committed 2026-10-21 17:51:24 +08:00
1 parent f801b2fbfd
commit f5410bdc4c
4 files changed
+232 -2

No files matched your search

+121
View File
@@ -290,6 +290,127 @@ describe("binary plist number rewriting", () => {
});
});
/**
* Three-element array whose last two entries share one scalar record, beside
* an unrelated Int64 sibling. Objects: array, shared scalar, duplicate entry,
* sibling.
*/
const sharedScalarEntries = (scalar: Buffer): Buffer => {
const sibling = Buffer.alloc(9);
sibling[0] = 0x13;
sibling.writeBigInt64BE(1152921504606846979n, 1);
const scalarOffset = 12;
const siblingOffset = scalarOffset + scalar.length;
const tableOffset = siblingOffset + sibling.length;
const bytes = Buffer.alloc(tableOffset + 4 + 32);
bytes.set([0xa3, 1, 2, 3], 8);
scalar.copy(bytes, scalarOffset);
sibling.copy(bytes, siblingOffset);
return binaryPlistObjectTable(
bytes,
[8, scalarOffset, scalarOffset, siblingOffset],
tableOffset,
);
};
const int64 = (value: bigint): Buffer => {
const bytes = Buffer.alloc(9);
bytes[0] = 0x13;
bytes.writeBigInt64BE(value, 1);
return bytes;
};
describe("binary plist shared object-table entries", () => {
const plan = (bytes: Buffer) =>
estimateBinaryPlistExpansion(bytes, 1024 * 1024, {
numberProjection: true,
});
it("keeps every exact integer when two entries share one Int64 record", () => {
const bytes = sharedScalarEntries(int64(9007199254740993n));
const original = Buffer.from(bytes);
const scalarPlan = plan(bytes);
const metadata = scanBinaryPlistScalars(bytes, scalarPlan);
expect(metadata.literals.incomplete).toBe(false);
// The shared record is one source literal, not two.
expect(metadata.literals.unsafeIntegerLiterals).toBe(2);
const decoded = decodeBinaryPlistScalars(bytes, metadata, scalarPlan);
const parsed = projectPlistValue(parseBinary(decoded));
const numbers = createPlistNumberProjection(metadata.literals);
expect(numbers.project(parsed.value)).toEqual([
{ $plist_type: "integer", decimal: "9007199254740993" },
{ $plist_type: "integer", decimal: "9007199254740993" },
{ $plist_type: "integer", decimal: "1152921504606846979" },
]);
expect(numbers.limitations().join("\n")).not.toContain(
"could not be completely observed",
);
expect(bytes).toEqual(original);
});
it("rewrites a shared 16-byte integer once for every entry", () => {
const wide = Buffer.alloc(17);
wide[0] = 0x14;
wide.writeBigUInt64BE(1n, 1);
wide.writeBigUInt64BE(2n, 9);
const bytes = sharedScalarEntries(wide);
const original = Buffer.from(bytes);
const scalarPlan = plan(bytes);
const metadata = scanBinaryPlistScalars(bytes, scalarPlan);
expect(metadata.literals.incomplete).toBe(false);
expect(metadata.wideIntegers).toEqual([
{ offset: 12, value: (1n << 64n) | 2n },
]);
const decoded = decodeBinaryPlistScalars(bytes, metadata, scalarPlan);
const parsed = projectPlistValue(parseBinary(decoded));
expect(
createPlistNumberProjection(metadata.literals).project(parsed.value),
).toEqual([
{ $plist_type: "integer", decimal: "18446744073709551618" },
{ $plist_type: "integer", decimal: "18446744073709551618" },
{ $plist_type: "integer", decimal: "1152921504606846979" },
]);
expect(bytes).toEqual(original);
});
it("records a shared date once and keeps its sibling exact", () => {
const date = Buffer.alloc(9);
date[0] = 0x33;
date.writeDoubleBE(0.1005, 1);
const bytes = sharedScalarEntries(date);
const scalarPlan = plan(bytes);
const metadata = scanBinaryPlistScalars(bytes, scalarPlan);
expect(metadata.literals.incomplete).toBe(false);
expect(metadata.dates).toEqual([
{ offset: 12, payloadOffset: 13, secondsSince2001: 0.1005 },
]);
const decoded = decodeBinaryPlistScalars(bytes, metadata, scalarPlan);
const [first, second, sibling] = parseBinary(decoded) as [
Date,
Date,
number,
];
expect(Number.isNaN(first.getTime())).toBe(true);
expect(Number.isNaN(second.getTime())).toBe(true);
expect(
createPlistNumberProjection(metadata.literals).project(sibling),
).toEqual({ $plist_type: "integer", decimal: "1152921504606846979" });
});
it("still reports metadata incomplete for an out-of-range entry", () => {
const bytes = sharedScalarEntries(int64(9007199254740993n));
expect(scanBinaryPlistScalars(bytes).literals.incomplete).toBe(false);
// Repoint the duplicate into the header, before the object region.
const table = Number(bytes.readBigUInt64BE(bytes.length - 8));
bytes[table + 2] = 7;
expect(bytes.subarray(0, 8).toString("ascii")).toBe("bplist00");
expect(scanBinaryPlistScalars(bytes).literals.incomplete).toBe(true);
});
});
describe("binary plist numeric association", () => {
it("keeps an overlapping int64 exact without rewriting either object", () => {
const bytes = integerPayloadContainingReal();
+4 -1
View File
@@ -191,10 +191,13 @@ export const scanBinaryPlistScalars = (
});
}
}
if (!validOffsets.has(offset) || visitedOffsets.has(offset)) {
if (!validOffsets.has(offset)) {
incomplete = true;
continue;
}
// Several table entries may share one record. Its source facts are
// identical for every entry, so observe them once.
if (visitedOffsets.has(offset)) continue;
visitedOffsets.add(offset);
const marker = bytes[offset] ?? 0;
const type = marker >> 4;
@@ -1,8 +1,13 @@
import { readFile } from "node:fs/promises";
import { expect, it } from "vitest";
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { keyedArchiveWideIntegerFixture } from "../../fixtures/keyedArchiveWideIntegers.js";
import {
keyedArchiveSharedEntryFixture,
keyedArchiveWideIntegerFixture,
} from "../../fixtures/keyedArchiveWideIntegers.js";
import {
archiveNumberMetadataCases,
expectKeyedArchiveNumberMetadata,
@@ -75,6 +80,55 @@ cliTest(
},
);
const sharedEntryObjects = [
expect.objectContaining({
id: 1,
status: "observed",
value: { $plist_type: "integer", decimal: "9007199254740993" },
}),
expect.objectContaining({
id: 2,
status: "observed",
value: { $plist_type: "integer", decimal: "9007199254740993" },
}),
expect.objectContaining({
id: 3,
status: "observed",
value: { $plist_type: "integer", decimal: "1152921504606846979" },
}),
];
it("keeps exact integers when two object-table entries share one record", async () => {
const fixture = await keyedArchiveSharedEntryFixture();
const result = await inspectBundleKeyedArchive({
bundlePath: fixture.root,
targetSha256: fixture.digest,
parameters: { path: "archive.plist" },
});
expect(result.archive_sha256).toBe(fixture.digest);
expect(result.objects.slice(1)).toEqual(sharedEntryObjects);
expect(result.limitations).not.toContainEqual(
expect.stringMatching(/metadata could not be completely observed/u),
);
expect(await readFile(fixture.path)).toEqual(fixture.bytes);
});
cliTest(
"compiled CLI keeps exact integers when object-table entries share one record",
async ({ cli }) => {
const fixture = await keyedArchiveSharedEntryFixture();
const output = await cli.run({
arguments: ["inspect-keyed-archive", fixture.path, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(0);
const result = parseEvidence(output.json).normalized_result as {
objects: unknown[];
};
expect(result.objects.slice(1)).toEqual(sharedEntryObjects);
},
);
it.each(archiveNumberMetadataCases)(
"filesystem preserves numeric meaning for $name",
async (item) => {
+52
View File
@@ -27,6 +27,58 @@ const integer64 = (value: bigint): Buffer => {
return bytes;
};
/**
* Minimal valid NSKeyedArchiver whose object-table entries 10 and 11 both
* point at one Int64 record, beside an unrelated Int64 sibling (entry 12).
* The shared record is encoded once; only its offset is listed twice.
*/
const sharedEntryArchiveBytes = (): Buffer => {
const objects = [
Buffer.from([0xd3, 1, 3, 4, 2, 8, 5]), // root dict: three key/value pairs
ascii("$archiver"),
ascii("NSKeyedArchiver"),
ascii("$objects"),
ascii("$top"),
Buffer.from([0xd1, 6, 7]), // $top.root -> UID 1
ascii("root"),
Buffer.from([0x80, 1]), // UID 1
Buffer.from([0xa4, 9, 10, 11, 12]), // $objects refs
ascii("$null"),
integer64(9_007_199_254_740_993n),
integer64(1_152_921_504_606_846_979n),
];
const header = Buffer.from("bplist00", "ascii");
const offsets: number[] = [];
let position = header.length;
for (const object of objects) {
offsets.push(position);
position += object.length;
}
// Entries 10 and 11 share the first Int64 record; entry 12 is the sibling.
const [shared, sibling] = [offsets[10] ?? 0, offsets[11] ?? 0];
offsets.splice(11, 1, shared, sibling);
const trailer = Buffer.alloc(32);
trailer[6] = 1;
trailer[7] = 1;
trailer.writeBigUInt64BE(BigInt(offsets.length), 8);
trailer.writeBigUInt64BE(0n, 16);
trailer.writeBigUInt64BE(BigInt(position), 24);
return Buffer.concat([header, ...objects, Buffer.from(offsets), trailer]);
};
export const keyedArchiveSharedEntryFixture = async () => {
const root = await createTestTempDirectory("rea-keyed-shared-entry-");
const path = join(root, "archive.plist");
const bytes = sharedEntryArchiveBytes();
await writeFile(path, bytes);
return {
root,
path,
bytes,
digest: createHash("sha256").update(bytes).digest("hex"),
};
};
/** Minimal valid NSKeyedArchiver with a 16-byte integer and an Int64 control. */
const archiveBytes = (): Buffer => {
const objects = [