fix(artifacts): preserve keyed archive integer precision
Integrate the numeric precision correction from #1135 with complete supplemental metadata handling. Keep decoded numbers with an explicit limitation for mixed XML content or unclassified integer text, rather than assigning another literal's exact decimal. Project nonnumeric UID and CF$UID dictionary values recursively while preserving numeric references, graph identity and original byte digests. Add filesystem, built CLI and MCP regressions for the review findings, adjacent XML representations and numeric reference controls. Retain the original native Foundation qualification fixtures and verifier. Verification: 84 focused tests; npm run check; npm run docs:check; npm run verify:test-discovery; npm run verify:keyed-archive (real Foundation, CLI and stdio MCP). No real Hopper or Ghidra verification was needed or run. Co-authored-by: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Signed-off-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
1d7115b7b4
commit
4034883e98
|
|
@ -383,3 +383,25 @@ A keyed-archive hierarchy UID without an object-table entry marks the hierarchy
|
|||
as partial while preserving decoded objects and known links. UID 0 remains
|
||||
archived nil and does not count as a missing reference. Archive and evidence
|
||||
digests identify the original serialized bytes.
|
||||
|
||||
## Keyed archive integer precision
|
||||
|
||||
The archive reader observes XML number element types and binary number markers.
|
||||
Integers beyond the exact range of a JSON number are reported with the original
|
||||
decimal text as `{ "$plist_type": "integer", "decimal": "<exact digits>" }` when
|
||||
that source value can be associated unambiguously. Integral real values remain
|
||||
numbers. Colliding integer/real values, unclassified numbers, and malformed UID
|
||||
markers remain decoded with an explicit precision limitation. The limitation
|
||||
also counts observed unsafe integer literals, including observations outside the
|
||||
selected object page. Incomplete supplemental metadata produces an explicit
|
||||
precision note without rejecting an archive accepted by the byte decoder.
|
||||
Mixed XML number content (comments, CDATA, processing instructions or child
|
||||
elements) and integer text the supplemental reader cannot classify disable exact
|
||||
association and retain decoded numbers with that precision note. Nonnumeric
|
||||
`UID` and `CF$UID` dictionary values remain ordinary data and are projected
|
||||
recursively; numeric reference markers retain their original representation.
|
||||
|
||||
Value projection preserves serialized node kinds, IDs, references and pagination.
|
||||
Exact and ambiguous value counts describe emitted root/object value instances;
|
||||
a root and an object may contain the same value. Archive digests identify the
|
||||
original bytes.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -151,6 +151,26 @@ try {
|
|||
[{ source: null, path: ["UID"], target: 1, status: "resolved" }],
|
||||
);
|
||||
}
|
||||
const numericTests = spawnSync(
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"test:focused",
|
||||
"--",
|
||||
"tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts",
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
stdio: "inherit",
|
||||
timeout: 240_000,
|
||||
},
|
||||
);
|
||||
if (numericTests.error) throw numericTests.error;
|
||||
assert.equal(
|
||||
numericTests.status,
|
||||
0,
|
||||
"Native Foundation integer cases failed",
|
||||
);
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { z } from "zod";
|
|||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { projectPlistValue } from "../../domain/apple/plistValue.js";
|
||||
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
|
||||
import {
|
||||
binaryPlistNumberLiterals,
|
||||
xmlPlistNumberLiterals,
|
||||
} from "./PlistNumberLiterals.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
|
|
@ -32,15 +37,36 @@ export const decodeKeyedArchiveBytes = (
|
|||
"Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder",
|
||||
);
|
||||
const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00";
|
||||
const xmlText = binary ? undefined : decodeXmlPlistText(bytes);
|
||||
const parsed = binary
|
||||
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
|
||||
: parseXmlPropertyList(decodeXmlPlistText(bytes));
|
||||
: parseXmlPropertyList(xmlText ?? "");
|
||||
const graph = projectKeyedArchive(normalizePlist(parsed.value), selection);
|
||||
const numbers = createPlistNumberProjection(
|
||||
binary
|
||||
? binaryPlistNumberLiterals(bytes)
|
||||
: xmlPlistNumberLiterals(xmlText ?? ""),
|
||||
);
|
||||
// Classify the serialized graph before projecting values to typed decimals.
|
||||
// Numeric IDs, node kinds, references and pagination retain their original meaning.
|
||||
const roots = Object.fromEntries(
|
||||
Object.entries(graph.roots).map(([name, value]) => [
|
||||
name,
|
||||
numbers.project(value),
|
||||
]),
|
||||
);
|
||||
const objects = graph.objects.map((object) => ({
|
||||
...object,
|
||||
value: numbers.project(object.value),
|
||||
}));
|
||||
return {
|
||||
archive_format: binary ? ("binary-plist" as const) : ("xml-plist" as const),
|
||||
...graph,
|
||||
roots,
|
||||
objects,
|
||||
limitations: [
|
||||
...graph.limitations,
|
||||
...numbers.limitations(),
|
||||
...(parsed.omittedPrototypeKeys === 0
|
||||
? []
|
||||
: [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
|
||||
import { decodeXmlText } from "../../domain/propertyListKeys.js";
|
||||
|
||||
const collector = () => {
|
||||
const integers = new Map<number, Set<string>>();
|
||||
const reals = new Set<number>();
|
||||
let unsafeIntegerLiterals = 0;
|
||||
let incompleteMetadata = false;
|
||||
return {
|
||||
integer: (literal: string) => {
|
||||
if (!/^[+-]?\d+$/u.test(literal)) {
|
||||
incompleteMetadata = true;
|
||||
return;
|
||||
}
|
||||
const exact = BigInt(literal);
|
||||
if (
|
||||
exact >= BigInt(Number.MIN_SAFE_INTEGER) &&
|
||||
exact <= BigInt(Number.MAX_SAFE_INTEGER)
|
||||
)
|
||||
return;
|
||||
unsafeIntegerLiterals += 1;
|
||||
const rounded = Number(exact);
|
||||
integers.set(
|
||||
rounded,
|
||||
(integers.get(rounded) ?? new Set()).add(exact.toString()),
|
||||
);
|
||||
},
|
||||
real: (value: number) => {
|
||||
reals.add(value);
|
||||
},
|
||||
markIncomplete: () => {
|
||||
incompleteMetadata = true;
|
||||
},
|
||||
result: (incomplete = false): PlistNumberLiterals => ({
|
||||
incomplete: incomplete || incompleteMetadata,
|
||||
integers,
|
||||
reals,
|
||||
unsafeIntegerLiterals,
|
||||
}),
|
||||
};
|
||||
};
|
||||
// Consume quoted tag attributes and opaque comment/CDATA tokens whole.
|
||||
const XML_TOKEN =
|
||||
/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<(?:[^>"']|"[^"]*"|'[^']*')*>/gu;
|
||||
const OPAQUE_ELEMENTS = new Set(["string", "key", "data", "date"]);
|
||||
|
||||
/** Observe plist number element types without interpreting strings or class code. */
|
||||
export const xmlPlistNumberLiterals = (xml: string): PlistNumberLiterals => {
|
||||
const output = collector();
|
||||
const elements: string[] = [];
|
||||
let number:
|
||||
| {
|
||||
kind: "integer" | "real";
|
||||
text: string;
|
||||
depth: number;
|
||||
plainText: boolean;
|
||||
}
|
||||
| undefined;
|
||||
let cursor = 0;
|
||||
for (const match of xml.matchAll(XML_TOKEN)) {
|
||||
const index = match.index;
|
||||
if (number !== undefined) number.text += xml.slice(cursor, index);
|
||||
const token = match[0];
|
||||
cursor = index + token.length;
|
||||
if (token.startsWith("<!--") || token.startsWith("<![CDATA[")) {
|
||||
if (number !== undefined) {
|
||||
number.plainText = false;
|
||||
output.markIncomplete();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const tag = /^<\s*(\/?)\s*([\w:-]+)/u.exec(token);
|
||||
if (tag === null) {
|
||||
if (number !== undefined) {
|
||||
number.plainText = false;
|
||||
output.markIncomplete();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const closing = tag[1] === "/";
|
||||
const name = tag[2];
|
||||
if (name === undefined) continue;
|
||||
if (closing) {
|
||||
if (
|
||||
number !== undefined &&
|
||||
number.kind === name &&
|
||||
number.depth === elements.length
|
||||
) {
|
||||
if (number.plainText) {
|
||||
const text = decodeXmlText(number.text).trim();
|
||||
if (number.kind === "integer") output.integer(text);
|
||||
else output.real(Number.parseFloat(text));
|
||||
}
|
||||
number = undefined;
|
||||
}
|
||||
elements.pop();
|
||||
continue;
|
||||
}
|
||||
// The pinned decoder reads only the first child for integers and only text
|
||||
// nodes for reals. Mixed content cannot establish an exact association.
|
||||
if (number !== undefined) {
|
||||
number.plainText = false;
|
||||
output.markIncomplete();
|
||||
}
|
||||
if (/\/\s*>$/u.test(token)) continue;
|
||||
if (
|
||||
number === undefined &&
|
||||
(name === "integer" || name === "real") &&
|
||||
!elements.some((parent) => OPAQUE_ELEMENTS.has(parent))
|
||||
)
|
||||
number = {
|
||||
kind: name,
|
||||
text: "",
|
||||
depth: elements.length + 1,
|
||||
plainText: true,
|
||||
};
|
||||
elements.push(name);
|
||||
}
|
||||
return output.result();
|
||||
};
|
||||
|
||||
/** Observe bounded binary number markers without changing primary decode acceptance. */
|
||||
export const binaryPlistNumberLiterals = (
|
||||
bytes: Buffer,
|
||||
): PlistNumberLiterals => {
|
||||
const output = collector();
|
||||
if (
|
||||
bytes.length < 40 ||
|
||||
bytes.subarray(0, 8).toString("ascii") !== "bplist00"
|
||||
)
|
||||
return output.result(true);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const trailer = bytes.length - 32;
|
||||
const offsetWidth = bytes[trailer + 6] ?? 0;
|
||||
if (![1, 2, 4, 8].includes(offsetWidth)) return output.result(true);
|
||||
const countRaw = view.getBigUint64(trailer + 8);
|
||||
const tableRaw = view.getBigUint64(trailer + 24);
|
||||
const safeMaximum = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
if (countRaw === 0n || countRaw > safeMaximum || tableRaw > safeMaximum)
|
||||
return output.result(true);
|
||||
if (tableRaw < 8n || tableRaw >= BigInt(trailer)) return output.result(true);
|
||||
const offsetTable = Number(tableRaw);
|
||||
const capacity = Math.floor((trailer - offsetTable) / offsetWidth);
|
||||
if (countRaw > BigInt(capacity)) return output.result(true);
|
||||
const count = Number(countRaw);
|
||||
const unsigned = (offset: number): number | undefined => {
|
||||
if (offset < offsetTable || offset + offsetWidth > trailer)
|
||||
return undefined;
|
||||
if (offsetWidth === 1) return view.getUint8(offset);
|
||||
if (offsetWidth === 2) return view.getUint16(offset);
|
||||
if (offsetWidth === 4) return view.getUint32(offset);
|
||||
const value = view.getBigUint64(offset);
|
||||
return value > safeMaximum ? undefined : Number(value);
|
||||
};
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = unsigned(offsetTable + index * offsetWidth);
|
||||
if (offset === undefined || offset < 8 || offset >= offsetTable)
|
||||
return output.result(true);
|
||||
const marker = bytes[offset] ?? 0;
|
||||
const type = marker >> 4;
|
||||
if (type !== 1 && type !== 2) continue;
|
||||
const width = 2 ** (marker & 0xf);
|
||||
if (offset + 1 + width > offsetTable) return output.result(true);
|
||||
if (type === 1 && width === 8)
|
||||
output.integer(view.getBigInt64(offset + 1).toString());
|
||||
else if (type === 1 && width > 4) return output.result(true);
|
||||
else if (type === 2 && width === 4)
|
||||
output.real(view.getFloat32(offset + 1));
|
||||
else if (type === 2 && width === 8)
|
||||
output.real(view.getFloat64(offset + 1));
|
||||
else if (type === 2) return output.result(true);
|
||||
}
|
||||
return output.result();
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { JsonValue } from "../jsonValue.js";
|
||||
|
||||
/** Source element types needed to distinguish large integers from integral reals. */
|
||||
export interface PlistNumberLiterals {
|
||||
readonly integers: ReadonlyMap<number, ReadonlySet<string>>;
|
||||
readonly reals: ReadonlySet<number>;
|
||||
/** Integer literals in the original serialized plist, not decoded graph slots. */
|
||||
readonly unsafeIntegerLiterals: number;
|
||||
/** Supplemental byte metadata was not completely observable. */
|
||||
readonly incomplete: boolean;
|
||||
}
|
||||
|
||||
/** Preserve unambiguous exact integers; disclose rounded or unclassified numbers. */
|
||||
export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
|
||||
let exactIntegerCount = 0;
|
||||
let ambiguousNumberCount = 0;
|
||||
const mapNumber = (item: number): JsonValue => {
|
||||
if (Number.isSafeInteger(item) || !Number.isInteger(item)) return item;
|
||||
if (literals.incomplete) {
|
||||
ambiguousNumberCount += 1;
|
||||
return item;
|
||||
}
|
||||
const decimals = literals.integers.get(item);
|
||||
if (decimals === undefined) {
|
||||
if (!literals.reals.has(item)) ambiguousNumberCount += 1;
|
||||
return item;
|
||||
}
|
||||
const [decimal] = decimals;
|
||||
if (
|
||||
decimals.size !== 1 ||
|
||||
decimal === undefined ||
|
||||
literals.reals.has(item)
|
||||
) {
|
||||
ambiguousNumberCount += 1;
|
||||
return item;
|
||||
}
|
||||
exactIntegerCount += 1;
|
||||
return { $plist_type: "integer", decimal };
|
||||
};
|
||||
const map = (item: JsonValue): JsonValue => {
|
||||
if (typeof item === "number") return mapNumber(item);
|
||||
if (Array.isArray(item)) return item.map(map);
|
||||
if (item !== null && typeof item === "object") {
|
||||
const keys = Object.keys(item);
|
||||
const marker = item[keys[0] ?? ""];
|
||||
const reference =
|
||||
keys.length === 1 &&
|
||||
(keys[0] === "UID" || keys[0] === "CF$UID") &&
|
||||
typeof marker === "number";
|
||||
if (reference) {
|
||||
// Keep malformed numeric reference markers recognizable by the graph reader.
|
||||
if (
|
||||
typeof marker === "number" &&
|
||||
!Number.isSafeInteger(marker) &&
|
||||
(literals.integers.has(marker) || !literals.reals.has(marker))
|
||||
)
|
||||
ambiguousNumberCount += 1;
|
||||
return item;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(item).map(([key, child]) => [key, map(child)]),
|
||||
);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
return {
|
||||
project: map,
|
||||
limitations: () => [
|
||||
...(literals.incomplete
|
||||
? [
|
||||
"Original archive number metadata could not be completely observed; decoded numbers may lose integer precision and exact integer/real association is unavailable.",
|
||||
]
|
||||
: []),
|
||||
...(literals.unsafeIntegerLiterals === 0
|
||||
? []
|
||||
: [
|
||||
`${String(literals.unsafeIntegerLiterals)} observed integer literal(s) in the original archive exceed the exact range of a JSON number. Decoded numbers may lose integer precision unless represented as { "$plist_type": "integer", "decimal": "<exact digits>" }; the original archive bytes retain those literals.`,
|
||||
]),
|
||||
...(exactIntegerCount === 0
|
||||
? []
|
||||
: [
|
||||
`${String(exactIntegerCount)} unambiguous integer value(s) are reported with exact decimal text as { "$plist_type": "integer", "decimal": "<exact digits>" }.`,
|
||||
]),
|
||||
...(ambiguousNumberCount === 0
|
||||
? []
|
||||
: [
|
||||
`${String(ambiguousNumberCount)} number(s) beyond the exact integer range remain as decoded because their source integer/real type or reference marker cannot be associated unambiguously; integer precision loss remains possible.`,
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
@ -61,7 +61,7 @@ const XML_ENTITIES: Readonly<Record<string, string>> = {
|
|||
* Unicode stays as written: comments and CDATA may hold such text literally,
|
||||
* and the XML decoder rejects it anywhere else.
|
||||
*/
|
||||
const decodeXmlText = (text: string): string =>
|
||||
export const decodeXmlText = (text: string): string =>
|
||||
text.replace(
|
||||
/&(?:#x([\da-f]+)|#(\d+)|([a-z]+));/giu,
|
||||
(entity, hex?: string, decimal?: string, name?: string) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { build, buildBinary, parseBinary } from "plist";
|
||||
import { expect, it } from "vitest";
|
||||
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const scenarios = [
|
||||
"integer-collision",
|
||||
"integer-real-collision",
|
||||
"opaque-xml",
|
||||
"supplemental-binary-table",
|
||||
] as const;
|
||||
const fixture = async (scenario: (typeof scenarios)[number]) => {
|
||||
const root = await createTestTempDirectory("rea-integer-ambiguity-");
|
||||
const fields =
|
||||
scenario === "integer-collision"
|
||||
? { first: "__FIRST__", second: "__SECOND__" }
|
||||
: scenario === "integer-real-collision"
|
||||
? { first: "__FIRST__", real: "__REAL__" }
|
||||
: {
|
||||
first: "__FIRST__",
|
||||
real: "__REAL__",
|
||||
text: "<integer>9007199254740993</integer>",
|
||||
};
|
||||
const source = {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: ["$null", fields],
|
||||
$top: { root: { CF$UID: 1 } },
|
||||
};
|
||||
let bytes: Buffer;
|
||||
if (scenario === "supplemental-binary-table") {
|
||||
bytes = Buffer.from(
|
||||
buildBinary({
|
||||
...source,
|
||||
$objects: ["$null", { real: 9007199254740992 }],
|
||||
}),
|
||||
);
|
||||
const trailer = bytes.length - 32;
|
||||
// Add one unreachable offset entry backed by the first trailer byte. The
|
||||
// locked primary decoder accepts it; supplemental pre-trailer capacity does not.
|
||||
bytes.writeBigUInt64BE(
|
||||
bytes.readBigUInt64BE(trailer + 8) + 1n,
|
||||
trailer + 8,
|
||||
);
|
||||
const original = parseBinary(bytes);
|
||||
expect(original).toMatchObject({
|
||||
$objects: ["$null", { real: 9007199254740992 }],
|
||||
});
|
||||
} else {
|
||||
let xml = build(source)
|
||||
.replace(
|
||||
"<string>__FIRST__</string>",
|
||||
`<integer>${scenario === "integer-collision" ? "9007199254740992" : "9007199254740993"}</integer>`,
|
||||
)
|
||||
.replace(
|
||||
"<string>__SECOND__</string>",
|
||||
"<integer>9007199254740993</integer>",
|
||||
)
|
||||
.replace(
|
||||
"<string>__REAL__</string>",
|
||||
`<real>${scenario === "opaque-xml" ? "9007199254740996" : "9007199254740992"}</real>`,
|
||||
);
|
||||
if (scenario === "opaque-xml")
|
||||
xml = xml.replace(
|
||||
"</plist>",
|
||||
"<!-- <integer>9007199254740993</integer><real>9007199254740992</real> --></plist>",
|
||||
);
|
||||
bytes = Buffer.from(xml);
|
||||
}
|
||||
const path = join(root, "archive.plist");
|
||||
await writeFile(path, bytes);
|
||||
return {
|
||||
root,
|
||||
path,
|
||||
digest: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
};
|
||||
const verify = (
|
||||
result: ReturnType<typeof keyedArchiveResultSchema.parse>,
|
||||
scenario: (typeof scenarios)[number],
|
||||
digest: string,
|
||||
) => {
|
||||
expect(result.archive_sha256).toBe(digest);
|
||||
expect(result.objects).toHaveLength(2);
|
||||
expect(result.references).toContainEqual(
|
||||
expect.objectContaining({ source: null, target: 1, status: "resolved" }),
|
||||
);
|
||||
const value = result.objects.find(({ id }) => id === 1)?.value;
|
||||
expect(value).toBeDefined();
|
||||
expect(value).not.toBeNull();
|
||||
expect(Array.isArray(value)).toBe(false);
|
||||
expect(typeof value).toBe("object");
|
||||
const fields = value as Record<string, unknown>;
|
||||
const precisionNote = result.limitations.some(
|
||||
(note) =>
|
||||
/integer|number/iu.test(note) &&
|
||||
/precision|exact range|rounded/iu.test(note),
|
||||
);
|
||||
if (scenario === "integer-collision") {
|
||||
// Neither colliding decoded slot may be assigned one arbitrary exact literal.
|
||||
expect(fields.first).toBe(9007199254740992);
|
||||
expect(fields.second).toBe(9007199254740992);
|
||||
expect(precisionNote).toBe(true);
|
||||
} else if (scenario === "integer-real-collision") {
|
||||
expect(fields.first).toBe(9007199254740992);
|
||||
expect(fields.real).toBe(9007199254740992);
|
||||
expect(precisionNote).toBe(true);
|
||||
} else if (scenario === "opaque-xml") {
|
||||
expect(fields.text).toBe("<integer>9007199254740993</integer>");
|
||||
expect(fields.real).toBe(9007199254740996);
|
||||
expect(fields.first).toBeDefined();
|
||||
const exact =
|
||||
typeof fields.first === "object" &&
|
||||
fields.first !== null &&
|
||||
JSON.stringify(fields.first) ===
|
||||
JSON.stringify({ $plist_type: "integer", decimal: "9007199254740993" });
|
||||
expect(exact || precisionNote).toBe(true);
|
||||
// If a literal count is reported, opaque content must not inflate it.
|
||||
const countNote = result.limitations.find((note) =>
|
||||
/integer literal\(s\)/u.test(note),
|
||||
);
|
||||
if (countNote !== undefined)
|
||||
expect(countNote).toMatch(/^1 observed integer literal/u);
|
||||
} else {
|
||||
expect(fields.real).toBe(9007199254740992);
|
||||
}
|
||||
};
|
||||
it.each(scenarios)(
|
||||
"filesystem retains numeric meaning for %s",
|
||||
async (scenario) => {
|
||||
const item = await fixture(scenario);
|
||||
const result = await inspectBundleKeyedArchive({
|
||||
bundlePath: item.root,
|
||||
targetSha256: item.digest,
|
||||
parameters: { path: "archive.plist", limit: 100 },
|
||||
});
|
||||
verify(result, scenario, item.digest);
|
||||
},
|
||||
);
|
||||
cliTest.for(scenarios)(
|
||||
"built CLI retains numeric meaning for %s",
|
||||
async (scenario, { cli }) => {
|
||||
const item = await fixture(scenario);
|
||||
const output = await cli.run({
|
||||
arguments: ["inspect-keyed-archive", item.path, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
|
||||
});
|
||||
expect(output.exitCode).toBe(0);
|
||||
verify(
|
||||
keyedArchiveResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
),
|
||||
scenario,
|
||||
item.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
keyedArchiveIntegerFormats,
|
||||
keyedArchiveIntegerCases,
|
||||
keyedArchiveIntegerFixture,
|
||||
expectKeyedArchiveIntegerCase,
|
||||
} from "../../fixtures/keyedArchiveIntegers.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const cases = keyedArchiveIntegerFormats.flatMap((format) =>
|
||||
keyedArchiveIntegerCases.map((selectedCase) => ({ format, selectedCase })),
|
||||
);
|
||||
// Selected by the existing required native keyed-archive verifier, not Linux skips.
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"Foundation archive integer precision",
|
||||
() => {
|
||||
it.each(cases)(
|
||||
"filesystem preserves $format $selectedCase numeric meaning",
|
||||
async ({ format, selectedCase }) => {
|
||||
const fixture = await keyedArchiveIntegerFixture(format);
|
||||
const result = await inspectBundleKeyedArchive({
|
||||
bundlePath: fixture.root,
|
||||
targetSha256: fixture.digest,
|
||||
parameters: { path: fixture.selected.filename, limit: 100 },
|
||||
});
|
||||
expectKeyedArchiveIntegerCase(
|
||||
result,
|
||||
selectedCase,
|
||||
fixture.selected,
|
||||
fixture.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
cliTest.for(cases)(
|
||||
"built CLI preserves $format $selectedCase numeric meaning",
|
||||
async ({ format, selectedCase }, { cli }) => {
|
||||
const fixture = await keyedArchiveIntegerFixture(format);
|
||||
const output = await cli.run({
|
||||
arguments: ["inspect-keyed-archive", fixture.archivePath, "--json"],
|
||||
environment: {
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
},
|
||||
});
|
||||
expect(output.exitCode).toBe(0);
|
||||
const result = keyedArchiveResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
);
|
||||
expectKeyedArchiveIntegerCase(
|
||||
result,
|
||||
selectedCase,
|
||||
fixture.selected,
|
||||
fixture.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { it } from "vitest";
|
||||
|
||||
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
archiveNumberMetadataCases,
|
||||
expectKeyedArchiveNumberMetadata,
|
||||
keyedArchiveNumberMetadataFixture,
|
||||
} from "../../fixtures/keyedArchiveNumberMetadata.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
it.each(archiveNumberMetadataCases)(
|
||||
"filesystem preserves numeric meaning for $name",
|
||||
async (item) => {
|
||||
const fixture = await keyedArchiveNumberMetadataFixture(item);
|
||||
const result = await inspectBundleKeyedArchive({
|
||||
bundlePath: fixture.root,
|
||||
targetSha256: fixture.digest,
|
||||
parameters: { path: "archive.plist" },
|
||||
});
|
||||
expectKeyedArchiveNumberMetadata(result, item, fixture.digest);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(archiveNumberMetadataCases)(
|
||||
"built CLI preserves numeric meaning for $name",
|
||||
async (item, { cli }) => {
|
||||
const fixture = await keyedArchiveNumberMetadataFixture(item);
|
||||
const output = await cli.run({
|
||||
arguments: ["inspect-keyed-archive", fixture.path, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
|
||||
});
|
||||
expectKeyedArchiveNumberMetadata(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
item,
|
||||
fixture.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { build } from "plist";
|
||||
import { expect, it } from "vitest";
|
||||
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const cases = ["unsafe-integer", "integral-real"] as const;
|
||||
const fixture = async (kind: (typeof cases)[number]) => {
|
||||
const root = await createTestTempDirectory("rea-integer-scalar-");
|
||||
const xml = build({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: ["$null", "__VALUE__"],
|
||||
$top: { root: { CF$UID: 1 } },
|
||||
}).replace(
|
||||
"<string>__VALUE__</string>",
|
||||
kind === "unsafe-integer"
|
||||
? "<integer>9007199254740993</integer>"
|
||||
: "<real>9007199254740992</real>",
|
||||
);
|
||||
const bytes = Buffer.from(xml);
|
||||
const path = join(root, "archive.plist");
|
||||
await writeFile(path, bytes);
|
||||
return {
|
||||
root,
|
||||
path,
|
||||
digest: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
};
|
||||
const verify = (
|
||||
result: ReturnType<typeof keyedArchiveResultSchema.parse>,
|
||||
kind: (typeof cases)[number],
|
||||
digest: string,
|
||||
) => {
|
||||
expect(result.archive_sha256).toBe(digest);
|
||||
expect(result.roots).toEqual({ root: { CF$UID: 1 } });
|
||||
expect(
|
||||
result.objects.map(({ id, kind: objectKind, status }) => ({
|
||||
id,
|
||||
kind: objectKind,
|
||||
status,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: 0, kind: "nil", status: "nil" },
|
||||
{ id: 1, kind: "scalar", status: "observed" },
|
||||
]);
|
||||
expect(result.references).toEqual([
|
||||
{
|
||||
source: null,
|
||||
path: ["root"],
|
||||
target: 1,
|
||||
status: "resolved",
|
||||
raw: { CF$UID: 1 },
|
||||
},
|
||||
]);
|
||||
expect(result.total_objects).toBe(2);
|
||||
expect(result.total_references).toBe(1);
|
||||
expect(result.offset).toBe(0);
|
||||
expect(result.next_offset).toBeNull();
|
||||
expect(result.truncated).toBe(false);
|
||||
const value = result.objects.find(({ id }) => id === 1)?.value;
|
||||
expect(value).toBeDefined();
|
||||
if (kind === "integral-real") expect(value).toBe(9007199254740992);
|
||||
else {
|
||||
const exact =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
JSON.stringify(value) ===
|
||||
JSON.stringify({ $plist_type: "integer", decimal: "9007199254740993" });
|
||||
const disclosed = result.limitations.some(
|
||||
(note) =>
|
||||
/integer|number/iu.test(note) &&
|
||||
/precision|exact range|rounded/iu.test(note),
|
||||
);
|
||||
expect(exact || disclosed).toBe(true);
|
||||
}
|
||||
};
|
||||
it.each(cases)(
|
||||
"filesystem preserves primitive scalar graph for %s",
|
||||
async (kind) => {
|
||||
const item = await fixture(kind);
|
||||
verify(
|
||||
await inspectBundleKeyedArchive({
|
||||
bundlePath: item.root,
|
||||
targetSha256: item.digest,
|
||||
parameters: { path: "archive.plist", limit: 100 },
|
||||
}),
|
||||
kind,
|
||||
item.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
cliTest.for(cases)(
|
||||
"built CLI preserves primitive scalar graph for %s",
|
||||
async (kind, { cli }) => {
|
||||
const item = await fixture(kind);
|
||||
const output = await cli.run({
|
||||
arguments: ["inspect-keyed-archive", item.path, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
|
||||
});
|
||||
expect(output.exitCode).toBe(0);
|
||||
verify(
|
||||
keyedArchiveResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
),
|
||||
kind,
|
||||
item.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
archiveNumberMetadataCases,
|
||||
expectKeyedArchiveNumberMetadata,
|
||||
keyedArchiveNumberMetadataFixture,
|
||||
} from "../../fixtures/keyedArchiveNumberMetadata.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
|
||||
mcpTest.for(archiveNumberMetadataCases)(
|
||||
"MCP preserves numeric meaning for $name",
|
||||
async (item, { mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchiveNumberMetadataFixture(item);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(configured.value);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(createServer(session, session));
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
||||
expectKeyedArchiveNumberMetadata(
|
||||
z
|
||||
.object({ result: keyedArchiveResultSchema })
|
||||
.parse(result.structuredContent).result,
|
||||
item,
|
||||
fixture.digest,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// Foundation writes the archive. XMLParser only observes inert plist element types.
|
||||
final class PlistElements: NSObject, XMLParserDelegate {
|
||||
struct Frame {
|
||||
let kind: String
|
||||
var values: [Any] = []
|
||||
var fields: [String: Any] = [:]
|
||||
var key: String?
|
||||
var text = ""
|
||||
}
|
||||
var frames: [Frame] = []
|
||||
var value: Any?
|
||||
func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
frames.append(Frame(kind: name))
|
||||
}
|
||||
func parser(_ parser: XMLParser, foundCharacters text: String) {
|
||||
if !frames.isEmpty { frames[frames.count - 1].text += text }
|
||||
}
|
||||
func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
let frame = frames.removeLast()
|
||||
if name == "key" {
|
||||
frames[frames.count - 1].key = frame.text
|
||||
return
|
||||
}
|
||||
let item: Any
|
||||
switch name {
|
||||
case "dict": item = frame.fields
|
||||
case "array": item = frame.values
|
||||
case "integer", "real":
|
||||
item = ["numberKind": name, "literal": frame.text.trimmingCharacters(in: .whitespacesAndNewlines)]
|
||||
case "string": item = frame.text
|
||||
case "true": item = true
|
||||
case "false": item = false
|
||||
case "plist": value = frame.values.first; return
|
||||
default: parser.abortParsing(); return
|
||||
}
|
||||
if frames.last?.kind == "dict" {
|
||||
guard let key = frames.last?.key else { parser.abortParsing(); return }
|
||||
frames[frames.count - 1].fields[key] = item
|
||||
frames[frames.count - 1].key = nil
|
||||
} else if !frames.isEmpty {
|
||||
frames[frames.count - 1].values.append(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
func elements(_ bytes: Data) throws -> [String: Any] {
|
||||
let delegate = PlistElements()
|
||||
let parser = XMLParser(data: bytes)
|
||||
parser.shouldResolveExternalEntities = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse(), let value = delegate.value as? [String: Any] else {
|
||||
throw NSError(domain: "REA.IntegerOracle", code: 1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
func rows(_ value: Any, path: [Any] = []) -> [[String: Any]] {
|
||||
if let fields = value as? [String: Any] {
|
||||
// Reference markers identify objects; their UID is not an archived numeric value.
|
||||
if fields.count == 1 && fields["CF$UID"] != nil { return [] }
|
||||
if let kind = fields["numberKind"] as? String, let literal = fields["literal"] as? String {
|
||||
return [["path": path, "kind": kind, "literal": literal]]
|
||||
}
|
||||
return fields.keys.sorted().flatMap { rows(fields[$0]!, path: path + [$0]) }
|
||||
}
|
||||
if let values = value as? [Any] {
|
||||
return values.enumerated().flatMap { rows($0.element, path: path + [$0.offset]) }
|
||||
}
|
||||
return []
|
||||
}
|
||||
func literal(_ value: Any?, kind: String) throws -> String {
|
||||
guard let fields = value as? [String: String], fields["numberKind"] == kind, let text = fields["literal"] else {
|
||||
throw NSError(domain: "REA.IntegerOracle", code: 2)
|
||||
}
|
||||
return text
|
||||
}
|
||||
let output = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let positive: Int64 = 9_007_199_254_740_993
|
||||
let negative: Int64 = Int64.min
|
||||
let unsigned: UInt64 = 9_007_199_254_740_993
|
||||
let real: Double = 9_007_199_254_740_992
|
||||
var archives: [String: Any] = [:]
|
||||
for format in ["xml", "binary"] {
|
||||
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
|
||||
archiver.outputFormat = format == "xml" ? .xml : .binary
|
||||
archiver.encode(positive, forKey: "unsafeSigned")
|
||||
archiver.encode(negative, forKey: "unsafeNegative")
|
||||
archiver.encode(Int64(9_007_199_254_740_991), forKey: "safeInteger")
|
||||
archiver.encode(Int64(-42), forKey: "safeNegative")
|
||||
archiver.encode(real, forKey: "integralReal")
|
||||
archiver.encode(NSNumber(value: unsigned), forKey: "boxedUnsigned")
|
||||
archiver.encode(NSNumber(value: real), forKey: "boxedReal")
|
||||
archiver.finishEncoding()
|
||||
let bytes = archiver.encodedData
|
||||
let native = try PropertyListSerialization.propertyList(from: bytes, options: [], format: nil)
|
||||
let xml = try PropertyListSerialization.data(fromPropertyList: native, format: .xml, options: 0)
|
||||
let parsed = try elements(xml)
|
||||
guard let top = parsed["$top"] as? [String: Any], let table = parsed["$objects"] as? [Any] else {
|
||||
throw NSError(domain: "REA.IntegerOracle", code: 3)
|
||||
}
|
||||
guard try literal(top["unsafeSigned"], kind: "integer") == String(positive),
|
||||
try literal(top["unsafeNegative"], kind: "integer") == String(negative),
|
||||
try literal(top["safeInteger"], kind: "integer") == "9007199254740991",
|
||||
try literal(top["safeNegative"], kind: "integer") == "-42",
|
||||
Double(try literal(top["integralReal"], kind: "real")) == real else {
|
||||
throw NSError(domain: "REA.IntegerOracle", code: 4)
|
||||
}
|
||||
var selectedRows: [[String: Any]] = []
|
||||
for key in ["unsafeSigned", "unsafeNegative", "safeInteger", "safeNegative", "integralReal"] {
|
||||
for row in rows(top[key]!) {
|
||||
selectedRows.append(row.merging(["container": "roots", "path": [key], "root": key]) { _, rhs in rhs })
|
||||
}
|
||||
}
|
||||
for key in ["boxedUnsigned", "boxedReal"] {
|
||||
guard let reference = top[key] as? [String: Any],
|
||||
let uid = Int(try literal(reference["CF$UID"], kind: "integer")), uid > 0, uid < table.count else {
|
||||
throw NSError(domain: "REA.IntegerOracle", code: 5)
|
||||
}
|
||||
let numericRows = rows(table[uid])
|
||||
let expectedKind = key == "boxedUnsigned" ? "integer" : "real"
|
||||
guard numericRows.contains(where: { row in
|
||||
guard row["kind"] as? String == expectedKind, let text = row["literal"] as? String else { return false }
|
||||
return key == "boxedUnsigned" ? text == String(unsigned) : Double(text) == real
|
||||
}) else { throw NSError(domain: "REA.IntegerOracle", code: 6) }
|
||||
for row in numericRows {
|
||||
selectedRows.append(row.merging(["container": "objects", "objectId": uid, "root": key]) { _, rhs in rhs })
|
||||
}
|
||||
}
|
||||
let filename = "numbers.\(format).plist"
|
||||
try bytes.write(to: output.appendingPathComponent(filename))
|
||||
archives[format] = ["filename": filename, "sha256": SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), "rows": selectedRows]
|
||||
}
|
||||
try JSONSerialization.data(withJSONObject: archives, options: [.sortedKeys]).write(to: output.appendingPathComponent("oracle.json"))
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { keyedArchiveResultSchema } from "../../src/domain/apple/keyedArchive.js";
|
||||
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
||||
|
||||
const rowSchema = z.object({
|
||||
container: z.enum(["roots", "objects"]),
|
||||
root: z.string(),
|
||||
objectId: z.number().int().nonnegative().optional(),
|
||||
path: z.array(z.union([z.string(), z.number().int().nonnegative()])),
|
||||
kind: z.enum(["integer", "real"]),
|
||||
literal: z.string(),
|
||||
});
|
||||
const archiveSchema = z.object({
|
||||
filename: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
rows: z.array(rowSchema),
|
||||
});
|
||||
const oracleSchema = z.object({ xml: archiveSchema, binary: archiveSchema });
|
||||
export const keyedArchiveIntegerFormats = ["xml", "binary"] as const;
|
||||
export const keyedArchiveIntegerCases = ["controls", "unsafe"] as const;
|
||||
type Format = (typeof keyedArchiveIntegerFormats)[number];
|
||||
type Case = (typeof keyedArchiveIntegerCases)[number];
|
||||
type Row = z.infer<typeof rowSchema>;
|
||||
export const keyedArchiveIntegerFixture = async (format: Format) => {
|
||||
const root = await createTestTempDirectory("rea-keyed-integers-");
|
||||
await promisify(execFile)(
|
||||
"/usr/bin/xcrun",
|
||||
[
|
||||
"swift",
|
||||
"-module-cache-path",
|
||||
join(root, "modules"),
|
||||
"tests/conformance/native/keyed-archive-integers.swift",
|
||||
root,
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const oracle = oracleSchema.parse(
|
||||
JSON.parse(await readFile(join(root, "oracle.json"), "utf8")),
|
||||
);
|
||||
for (const entry of [oracle.xml, oracle.binary]) {
|
||||
expect(entry.rows).toContainEqual(
|
||||
expect.objectContaining({
|
||||
root: "unsafeSigned",
|
||||
kind: "integer",
|
||||
literal: "9007199254740993",
|
||||
}),
|
||||
);
|
||||
expect(entry.rows).toContainEqual(
|
||||
expect.objectContaining({
|
||||
root: "unsafeNegative",
|
||||
kind: "integer",
|
||||
literal: "-9223372036854775808",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
entry.rows.some(
|
||||
(row) =>
|
||||
row.root === "boxedUnsigned" &&
|
||||
row.kind === "integer" &&
|
||||
row.literal === "9007199254740993",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entry.rows.some(
|
||||
(row) =>
|
||||
row.root === "boxedReal" &&
|
||||
row.kind === "real" &&
|
||||
Number(row.literal) === 9007199254740992,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
const selected = oracle[format];
|
||||
const archivePath = join(root, selected.filename);
|
||||
const bytes = await readFile(archivePath);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
expect(digest).toBe(selected.sha256);
|
||||
return { root, selected, archivePath, digest };
|
||||
};
|
||||
const at = (value: unknown, path: Row["path"]): unknown => {
|
||||
for (const key of path) {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
value = (value as Record<string | number, unknown>)[key];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
export const expectKeyedArchiveIntegerCase = (
|
||||
result: z.infer<typeof keyedArchiveResultSchema>,
|
||||
selectedCase: Case,
|
||||
selected: z.infer<typeof archiveSchema>,
|
||||
digest: string,
|
||||
) => {
|
||||
expect(result.archive_sha256).toBe(digest);
|
||||
const unsafe = (row: Row) =>
|
||||
row.kind === "integer" &&
|
||||
(BigInt(row.literal) > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
BigInt(row.literal) < BigInt(Number.MIN_SAFE_INTEGER));
|
||||
const rows = selected.rows.filter((row) =>
|
||||
selectedCase === "unsafe" ? unsafe(row) : !unsafe(row),
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const row of rows) {
|
||||
const value = at(
|
||||
row.container === "roots"
|
||||
? result.roots
|
||||
: result.objects.find((object) => object.id === row.objectId)?.value,
|
||||
row.path,
|
||||
);
|
||||
expect(
|
||||
value,
|
||||
`${row.root} numeric slot remains present`,
|
||||
).not.toBeUndefined();
|
||||
if (unsafe(row)) {
|
||||
const exact =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as Record<string, unknown>).$plist_type === "integer" &&
|
||||
(value as Record<string, unknown>).decimal ===
|
||||
BigInt(row.literal).toString();
|
||||
// The accepted alternate contract must explicitly disclose numeric precision loss.
|
||||
const disclosed = result.limitations.some(
|
||||
(reason) =>
|
||||
/integer|number/iu.test(reason) &&
|
||||
/precision|exact range|rounded/iu.test(reason),
|
||||
);
|
||||
expect(
|
||||
exact || disclosed,
|
||||
`${row.root} ${row.literal} must be exact or disclose numeric loss`,
|
||||
).toBe(true);
|
||||
} else {
|
||||
expect(typeof value, `${row.root} retains its numeric type`).toBe(
|
||||
"number",
|
||||
);
|
||||
expect(value).toBe(Number(row.literal));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { build } from "plist";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { keyedArchiveResultSchema } from "../../src/domain/apple/keyedArchive.js";
|
||||
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
||||
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
||||
|
||||
const EXACT_INTEGER = { $plist_type: "integer", decimal: "9007199254740993" };
|
||||
const ROUNDED = 9007199254740992;
|
||||
|
||||
/** Accepted decoder representations whose supplemental metadata needs care. */
|
||||
export const mixedArchiveNumberElements = [
|
||||
"<real>9007199254740992<![CDATA[0]]></real>",
|
||||
"<real><![CDATA[1]]>9007199254740992</real>",
|
||||
"<integer>9007199254740992<!-- split -->3</integer>",
|
||||
"<integer>9007199254740992<![CDATA[3]]></integer>",
|
||||
"<integer><![CDATA[9007199254740992]]>3</integer>",
|
||||
"<integer><![CDATA[90071992547409923]]></integer>",
|
||||
"<integer>9007199254740992<?rea split?>3</integer>",
|
||||
"<integer>9007199254740992<string>3</string></integer>",
|
||||
"<integer>9007199254740992x</integer>",
|
||||
] as const;
|
||||
|
||||
/** Ordinary dictionaries and malformed numeric references at the same boundary. */
|
||||
export const archiveNumberMetadataCases = [
|
||||
...mixedArchiveNumberElements.map((element) => ({
|
||||
name: element,
|
||||
source: { first: "__INTEGER__", other: "__OTHER__" },
|
||||
element,
|
||||
expected: { first: ROUNDED, other: ROUNDED },
|
||||
incomplete: true,
|
||||
malformedReference: false,
|
||||
})),
|
||||
...(["UID", "CF$UID"] as const).flatMap((key) => [
|
||||
{
|
||||
name: `${key} dictionary data`,
|
||||
source: { [key]: { amount: "__INTEGER__" } },
|
||||
element: "",
|
||||
expected: { [key]: { amount: EXACT_INTEGER } },
|
||||
incomplete: false,
|
||||
malformedReference: false,
|
||||
},
|
||||
{
|
||||
name: `${key} array data`,
|
||||
source: { [key]: ["__INTEGER__"] },
|
||||
element: "",
|
||||
expected: { [key]: [EXACT_INTEGER] },
|
||||
incomplete: false,
|
||||
malformedReference: false,
|
||||
},
|
||||
{
|
||||
name: `${key} malformed numeric reference`,
|
||||
source: { [key]: "__INTEGER__" },
|
||||
element: "",
|
||||
expected: { [key]: ROUNDED },
|
||||
incomplete: false,
|
||||
malformedReference: true,
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: "entity-encoded integer text",
|
||||
source: { amount: "__INTEGER__" },
|
||||
element: "",
|
||||
expected: { amount: EXACT_INTEGER },
|
||||
incomplete: false,
|
||||
malformedReference: false,
|
||||
integerElement: "<integer>9007199254740993</integer>",
|
||||
},
|
||||
] satisfies readonly {
|
||||
name: string;
|
||||
source: JsonValue;
|
||||
element: string;
|
||||
expected: JsonValue;
|
||||
incomplete: boolean;
|
||||
malformedReference: boolean;
|
||||
integerElement?: string;
|
||||
}[];
|
||||
|
||||
type MetadataCase = (typeof archiveNumberMetadataCases)[number];
|
||||
|
||||
/** Write one inert archive and retain the original byte digest for assertions. */
|
||||
export const keyedArchiveNumberMetadataFixture = async (item: MetadataCase) => {
|
||||
const root = await createTestTempDirectory("rea-keyed-number-metadata-");
|
||||
const xml = build({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: ["$null", item.source],
|
||||
$top: { root: { CF$UID: 1 } },
|
||||
})
|
||||
.replace(
|
||||
"<string>__INTEGER__</string>",
|
||||
"integerElement" in item
|
||||
? item.integerElement
|
||||
: "<integer>9007199254740993</integer>",
|
||||
)
|
||||
.replace("<string>__OTHER__</string>", item.element);
|
||||
const bytes = Buffer.from(xml);
|
||||
const path = join(root, "archive.plist");
|
||||
await writeFile(path, bytes);
|
||||
return {
|
||||
root,
|
||||
path,
|
||||
digest: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
};
|
||||
|
||||
/** Require exact ordinary values and honest limitations for incomplete metadata. */
|
||||
export const expectKeyedArchiveNumberMetadata = (
|
||||
value: unknown,
|
||||
item: MetadataCase,
|
||||
digest: string,
|
||||
) => {
|
||||
const result = keyedArchiveResultSchema.parse(value);
|
||||
expect(result.archive_sha256).toBe(digest);
|
||||
expect(result.roots).toEqual({ root: { CF$UID: 1 } });
|
||||
expect(result.objects).toHaveLength(2);
|
||||
expect(result.objects[1]).toMatchObject({
|
||||
id: 1,
|
||||
kind: "dictionary",
|
||||
value: item.expected,
|
||||
});
|
||||
expect(result.references).toContainEqual({
|
||||
source: null,
|
||||
path: ["root"],
|
||||
target: 1,
|
||||
status: "resolved",
|
||||
raw: { CF$UID: 1 },
|
||||
});
|
||||
if (item.incomplete) {
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringMatching(/metadata.*completely observed.*precision/u),
|
||||
);
|
||||
expect(
|
||||
result.limitations.some((note) => note.includes("unambiguous integer")),
|
||||
).toBe(false);
|
||||
} else if (item.malformedReference) {
|
||||
expect(result.references).toContainEqual({
|
||||
source: 1,
|
||||
path: [],
|
||||
target: null,
|
||||
status: "malformed",
|
||||
raw: item.expected,
|
||||
});
|
||||
} else {
|
||||
expect(result.references).toHaveLength(1);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringMatching(/^1 unambiguous integer value/u),
|
||||
);
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue