fix(apple): preserve post-table plist objects (#7820)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 04:59:14 +08:00
1 parent bd687bbc40
commit 675c73fb4d
4 files changed
+95 -20

No files matched your search

+11 -2
View File
@@ -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),
+8 -11
View File
@@ -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<number, bigint>();
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;
};
@@ -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",
@@ -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);
}
},
);