diff --git a/src/artifacts/apple/PlistScalars.test.ts b/src/artifacts/apple/PlistScalars.test.ts index bf280afec0..75f0ee78a6 100644 --- a/src/artifacts/apple/PlistScalars.test.ts +++ b/src/artifacts/apple/PlistScalars.test.ts @@ -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(); diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts index ae25eb3342..1566608f4c 100644 --- a/src/artifacts/apple/PlistScalars.ts +++ b/src/artifacts/apple/PlistScalars.ts @@ -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; diff --git a/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts b/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts index 54f54a4cf6..9531fb743f 100644 --- a/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts +++ b/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts @@ -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) => { diff --git a/tests/fixtures/keyedArchiveWideIntegers.ts b/tests/fixtures/keyedArchiveWideIntegers.ts index 4bf4362488..91e708f475 100644 --- a/tests/fixtures/keyedArchiveWideIntegers.ts +++ b/tests/fixtures/keyedArchiveWideIntegers.ts @@ -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 = [