diff --git a/src/artifacts/apple/PlistScalars.test.ts b/src/artifacts/apple/PlistScalars.test.ts index 4a12750862..a3d0d97fb6 100644 --- a/src/artifacts/apple/PlistScalars.test.ts +++ b/src/artifacts/apple/PlistScalars.test.ts @@ -4,6 +4,7 @@ import { parseBinary } from "plist"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { readBinaryPlistLayout } from "./BinaryPlistLayout.js"; import { binaryDateRequiresExactSeconds, decodeBinaryPlistScalars, @@ -267,7 +268,12 @@ describe("binary plist UID widths", () => { plan, ); if (normalizedWidth === undefined) expect(compatible).toBe(bytes); - else expect(compatible[8 + width + 1]).toBe(0x80 | (normalizedWidth - 1)); + else { + const layout = readBinaryPlistLayout(compatible); + if (!layout.ok) throw new Error("Invalid compatible plist layout"); + const [offset] = layout.value.objectOffsets(); + expect(compatible[Number(offset)]).toBe(0x80 | (normalizedWidth - 1)); + } expect(parseBinary(compatible)).toEqual({ UID: Number(value), }); @@ -354,7 +360,10 @@ describe("binary plist number rewriting", () => { ).toThrow(/budget/u); const decoded = decodeBinaryPlistScalars(bytes, metadata, plan); expect(decoded).not.toBe(bytes); - expect(decoded.readDoubleBE(29)).toBeNaN(); + const layout = readBinaryPlistLayout(decoded); + if (!layout.ok) throw new Error("Invalid compatible plist layout"); + const offsets = [...layout.value.objectOffsets()]; + expect(decoded.readDoubleBE(Number(offsets[2]) + 1)).toBeNaN(); const parsed = projectPlistValue(parseBinary(decoded)); expect( createPlistNumberProjection(metadata.literals).project(parsed.value), diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts index 0ca721dae4..6fd530886f 100644 --- a/src/artifacts/apple/PlistScalars.ts +++ b/src/artifacts/apple/PlistScalars.ts @@ -146,7 +146,7 @@ const binaryPlistContainerRecord = ( return record; }; -/** Exact added offset-table representation size, shared with preflight. */ +/** Select a width that can address the appended clone records and table. */ export const binaryPlistCloneOffsetWidth = ( currentWidth: number, firstCloneOffset: number, @@ -461,31 +461,31 @@ const cloneBinaryPlistObjects = ( const trailerOffset = sourceLayout.trailerOffset; const offsetSize = sourceLayout.offsetWidth; const objectCount = Number(sourceLayout.objectCount); - const oldTableOffset = Number(sourceLayout.offsetTable); const offsets = [...sourceLayout.objectOffsets()]; const cloneEntries = [...replacements.entries()]; const replacementLengths = cloneEntries.map(([, record]) => record.length); const newOffsetSize = normalizedOffsetSize ?? - binaryPlistCloneOffsetWidth(offsetSize, oldTableOffset, replacementLengths); + binaryPlistCloneOffsetWidth(offsetSize, trailerOffset, replacementLengths); if (newOffsetSize === undefined) throw new RangeError( "binary plist cloned scalar offset exceeds supported width", ); - const cloneStart = oldTableOffset; + // Preserve every source offset, including objects after the old table. The + // decoder copy keeps the old table as inert bytes and appends a new one. + const cloneStart = trailerOffset; const cloneBytes = replacementLengths.reduce( (sum, length) => sum + length, 0, ); const newTableOffset = cloneStart + cloneBytes; - const newLength = - bytes.length + cloneBytes + objectCount * (newOffsetSize - offsetSize); + const newLength = bytes.length + cloneBytes + objectCount * newOffsetSize; if (!Number.isSafeInteger(newLength)) throw new RangeError( "binary plist cloned scalar representation is too large", ); const compatible = Buffer.alloc(newLength); - bytes.copy(compatible, 0, 0, oldTableOffset); + bytes.copy(compatible, 0, 0, trailerOffset); const cloneOffsetBySource = new Map(); let cloneCursor = cloneStart; cloneEntries.forEach(([sourceOffset, replacement]) => { @@ -496,8 +496,6 @@ const cloneBinaryPlistObjects = ( }); const tableStart = newTableOffset; const tableEnd = tableStart + objectCount * newOffsetSize; - const oldTableEnd = oldTableOffset + objectCount * offsetSize; - const padding = bytes.subarray(oldTableEnd, trailerOffset); offsets.forEach((sourceOffset, index) => { const sourceNumber = sourceOffset <= BigInt(Number.MAX_SAFE_INTEGER) @@ -519,7 +517,6 @@ const cloneBinaryPlistObjects = ( if (normalizedReferenceSize !== undefined) trailer[7] = normalizedReferenceSize; trailer.writeBigUInt64BE(BigInt(newTableOffset), 24); - padding.copy(compatible, tableEnd); - trailer.copy(compatible, tableEnd + padding.length); + trailer.copy(compatible, tableEnd); return compatible; }; diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts index 5c426a5b9c..1f499510ac 100644 --- a/src/artifacts/apple/PropertyListDecodeBudget.ts +++ b/src/artifacts/apple/PropertyListDecodeBudget.ts @@ -561,9 +561,11 @@ export const estimateBinaryPlistExpansion = ( }, ); const normalizeOffsetWidth = (cloneLengths: readonly number[]) => { + if (cloneLengths.length === 0 && [1, 2, 4, 8].includes(offsetSize)) + return offsetSize; const width = binaryPlistCloneOffsetWidth( offsetSize, - offsetTable, + trailer, cloneLengths, ); if (width === undefined) @@ -576,10 +578,13 @@ export const estimateBinaryPlistExpansion = ( ...containerCloneLengths, ]; const normalizedOffsetSize = normalizeOffsetWidth(cloneLengths); - if (cloneLengths.length > 0 || normalizedOffsetSize !== offsetSize) { + if ( + cloneLengths.length > 0 || + normalizedOffsetSize !== offsetSize || + normalizedReferenceSize !== referenceSize + ) { const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0); - estimate += - 2 * (cloneBytes + objectCount * (normalizedOffsetSize - offsetSize)); + estimate += 2 * (cloneBytes + objectCount * normalizedOffsetSize); if (estimate > maxBytes) throw fail( "expansion", @@ -648,10 +653,13 @@ export const estimateBinaryPlistExpansion = ( ...Array.from(cloneOffsets, () => 9), ]; const normalizedOffsetSize = normalizeOffsetWidth(cloneLengths); - if (cloneLengths.length > 0 || normalizedOffsetSize !== offsetSize) { + if ( + cloneLengths.length > 0 || + normalizedOffsetSize !== offsetSize || + normalizedReferenceSize !== referenceSize + ) { const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0); - estimate += - 2 * (cloneBytes + objectCount * (normalizedOffsetSize - offsetSize)); + estimate += 2 * (cloneBytes + objectCount * normalizedOffsetSize); if (estimate > maxBytes) throw fail( "expansion", diff --git a/tests/acceptance/applications/keyedArchivePostTable.test.ts b/tests/acceptance/applications/keyedArchivePostTable.test.ts new file mode 100644 index 0000000000..371f084dcb --- /dev/null +++ b/tests/acceptance/applications/keyedArchivePostTable.test.ts @@ -0,0 +1,61 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +// Inert NSKeyedArchiver fixture: the final string follows the offset table. +const source = Buffer.from( + "YnBsaXN0MDBVJG51bGzUAwUGBwQICQtZJGFyY2hpdmVyXxAPTlNLZXllZEFyY2hpdmVyWCRvYmplY3RzVCR0b3BYJHZlcnNpb26iAAHRCgxUcm9vdBIAAYaggAEAAAgAAIMAAA4AABcAACEAADMAADwAAEEAAEoAAE0AAFAAAFUAAFpVaGVsbG8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAQAAAAAAAAANAAAAAAAAAAIAAAAAAAAAXA==", + "base64", +); + +const fixture = (width: number, postTable: boolean): Buffer => { + const oldTable = 92; + const count = 13; + const oldEnd = oldTable + count * 3; + const trailer = source.length - 32; + const content = source.subarray(oldEnd, trailer); + const table = postTable ? oldTable : oldTable + content.length; + const result = Buffer.alloc(trailer - count * 3 + count * width + 32); + source.copy(result, 0, 0, oldTable); + content.copy(result, postTable ? table + count * width : oldTable); + for (let i = 0; i < count; i += 1) { + const value = source.readUIntBE(oldTable + i * 3, 3); + const relocated = + value === oldEnd ? (postTable ? table + count * width : oldTable) : value; + result.writeUIntBE(relocated, table + i * width, width); + } + const newTrailer = result.length - 32; + source.copy(result, newTrailer, trailer); + result[newTrailer + 6] = width; + result.writeBigUInt64BE(BigInt(table), newTrailer + 24); + return result; +}; + +cliTest( + "preserves post-table keyed archive strings through offset normalization", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-post-table-"); + for (const [width, postTable] of [ + [1, true], + [3, true], + [4, true], + [3, false], + ] as const) { + const path = join(root, `${width}-${String(postTable)}.bplist`); + const original = fixture(width, postTable); + await writeFile(path, original); + const result = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--format", "json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.normalized_result.objects[1]).toMatchObject({ + status: "observed", + value: "hello", + }); + expect((await readFile(path)).equals(original)).toBe(true); + } + }, +);