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:
1 parent
f801b2fbfd
commit
f5410bdc4c
4 files changed
+232
-2
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
Reference in new issue
Block a user