fix(apple): preserve binary plist scalar source facts

Decode native binary plists from bounded owned source bytes before conversion can erase scalar values. Share scalar preflight and isolate overlapping decoder records while retaining exact integers, date uncertainty, and original bytes. Refuse numeric payloads that read structural table bytes, and avoid retaining projection metadata when callers only need structural validation.

Closes #5701
Closes #5703
Closes #5705
Closes #5782
Closes #5783
Closes #5792
This commit is contained in:
morluto committed 2026-10-21 14:09:29 +08:00
1 parent 25bea07b42
commit d672cc2399
27 files changed
+3232 -798

No files matched your search

+167 -93
View File
@@ -1,9 +1,12 @@
import {
plistSourceFormat,
isBinaryPlistDescription,
readNativePlistBytes,
requireRegularPlist,
nativePlistFailure,
resolveNativePlistSelection,
unreadablePlist,
type NativePlistSelection,
} from "./NativePlistSource.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
@@ -50,7 +53,6 @@ import type { EvidenceLocation } from "../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
@@ -105,8 +107,10 @@ import { parseDemangledSymbols } from "./parsers/demangle.js";
import { parseLipoOutput } from "./parsers/lipo.js";
import {
parsePlistJson,
parsePlistBinary,
parsePlistXml,
plistJsonNeedsNumberTypes,
type ParsedPlist,
} from "./parsers/plist.js";
import {
architectureLocations,
@@ -128,6 +132,12 @@ type SignatureRuntimeRootFactory = () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
>;
interface NativePlistReadContext {
readonly selection: NativePlistSelection;
readonly resources: ArtifactResourceScope;
readonly signal: AbortSignal | undefined;
}
/** Read-only semantic provider composed from Xcode command-line utilities. */
export class NativeMacOSProvider implements AnalysisProvider {
readonly #capabilities: readonly CapabilityDescriptor[];
@@ -887,7 +897,7 @@ class NativeMacOSClient implements AnalysisClient {
requested,
);
if (!plist.ok) return plist;
const fileInput = {
const fileInput: NativePlistReadContext = {
selection: plist.value,
resources: this.#plistResources,
signal,
@@ -901,98 +911,12 @@ class NativeMacOSClient implements AnalysisClient {
{ signal },
);
if (!classified.ok) return classified;
const json = await this.#run(
"inspect_plist",
"plutil",
["-convert", "json", "-o", "-", "--", plist.value.path],
{ signal, acceptNonZero: true },
);
if (!json.ok) return json;
// JSON cannot express data, dates, or non-finite reals; plutil rejects
// such plists, so decode its lossless XML conversion instead. JSON also
// prints an integral <real> like an <integer>, so a number beyond the
// exact JSON range takes its element type from the XML conversion.
const jsonDecoded = json.value.exitCode === 0;
const xml =
jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout)
? undefined
: await this.#run(
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", plist.value.path],
{ signal, acceptNonZero: !jsonDecoded },
);
if (xml !== undefined && !xml.ok) return xml;
if (xml !== undefined && xml.value.exitCode !== 0) {
const capture = failureCapture(xml.value, false);
return err(
unreadablePlist(
plist.value,
`plutil could not decode it as a property list (${plutilDiagnostic(capture, plist.value.path)})`,
{
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: capture.truncated,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
},
),
);
}
const parsed =
xml === undefined
? parsePlistJson(json.value.stdout)
: jsonDecoded
? parsePlistJson(json.value.stdout, xml.value.stdout)
: parsePlistXml(xml.value.stdout);
if (!parsed.ok) return parsed;
const provenance = [
const binary = await this.#inspectBinaryPlist(
fileInput,
classified.value,
json.value,
...(xml === undefined ? [] : [xml.value]),
].map((item) => invocation(item, plist.value.path, "$PLIST"));
const converted = xml?.value ?? json.value;
const format = await plistSourceFormat(classified.value.stdout, {
...fileInput,
capturedOutput: {
stdout: converted.stdout,
stderr: converted.stderr,
truncated: false,
stdout_bytes: converted.stdoutBytes,
stderr_bytes: converted.stderrBytes,
exit_code: converted.exitCode,
signal: converted.signal,
},
});
if (!format.ok) return format;
const result = inspectPlistSchema.parse({
format: format.value,
value: parsed.value.value,
bundle: parsed.value.bundle,
source_path: plist.value.path,
...(this.bundleResourceRoot === undefined
? {}
: {
resource_root: {
caller_path: this.bundleResourceRoot.callerPath,
canonical_path: this.bundleResourceRoot.canonicalPath,
info_plist_path: this.bundleResourceRoot.infoPlistPath,
layout: this.bundleResourceRoot.layout,
executable_sha256: this.bundleResourceRoot.executableSha256,
},
}),
provenance,
limitations: parsed.value.limitations,
});
return ok({
result: jsonValueSchema.parse(result),
limitations: parsed.value.limitations,
locations: [{ kind: "artifact-path", path: plist.value.path }],
});
);
if (binary !== undefined) return binary;
return await this.#inspectConvertedPlist(fileInput, classified.value);
} catch (cause: unknown) {
// Producer failures stay opaque; only scope admission failures reach the file mapper.
return err(nativeOutputFailure("inspect_plist", cause));
@@ -1003,6 +927,122 @@ class NativeMacOSClient implements AnalysisClient {
}
}
async #inspectBinaryPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
): Promise<Result<NativeObservation, AnalysisError> | undefined> {
if (!isBinaryPlistDescription(classified.stdout)) return undefined;
const source = await readNativePlistBytes(fileInput);
if (!source.ok) return source;
const parsed = parsePlistBinary(source.value);
if (!parsed.ok) {
return parsed.error instanceof AnalysisOutputError
? err(
unreadablePlist(
fileInput.selection,
"binary property list could not be decoded",
{ cause: parsed.error },
),
)
: parsed;
}
return ok(
nativePlistObservation({
parsed: parsed.value,
format: "binary",
path: fileInput.selection.path,
resourceRoot: this.bundleResourceRoot,
provenance: [
invocation(classified, fileInput.selection.path, "$PLIST"),
],
}),
);
}
async #inspectConvertedPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
): Promise<Result<NativeObservation, AnalysisError>> {
const { path } = fileInput.selection;
const { signal } = fileInput;
const json = await this.#run(
"inspect_plist",
"plutil",
["-convert", "json", "-o", "-", "--", path],
{ signal, acceptNonZero: true },
);
if (!json.ok) return json;
// JSON cannot express data, dates, or non-finite reals; plutil rejects
// such plists, so decode its XML conversion instead. JSON also prints an
// integral <real> like an <integer>, so XML identifies unsafe number types.
const jsonDecoded = json.value.exitCode === 0;
const xml =
jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout)
? undefined
: await this.#run(
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", path],
{ signal, acceptNonZero: !jsonDecoded },
);
if (xml !== undefined && !xml.ok) return xml;
if (xml !== undefined && xml.value.exitCode !== 0) {
const capture = failureCapture(xml.value, false);
return err(
unreadablePlist(
fileInput.selection,
`plutil could not decode it as a property list (${plutilDiagnostic(capture, path)})`,
{
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: capture.truncated,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
},
),
);
}
const parsed =
xml === undefined
? parsePlistJson(json.value.stdout)
: jsonDecoded
? parsePlistJson(json.value.stdout, xml.value.stdout)
: parsePlistXml(xml.value.stdout);
if (!parsed.ok) return parsed;
const provenance = [
classified,
json.value,
...(xml === undefined ? [] : [xml.value]),
].map((item) => invocation(item, path, "$PLIST"));
const converted = xml?.value ?? json.value;
const format = await plistSourceFormat(classified.stdout, {
...fileInput,
capturedOutput: {
stdout: converted.stdout,
stderr: converted.stderr,
truncated: false,
stdout_bytes: converted.stdoutBytes,
stderr_bytes: converted.stderrBytes,
exit_code: converted.exitCode,
signal: converted.signal,
},
});
if (!format.ok) return format;
return ok(
nativePlistObservation({
parsed: parsed.value,
format: format.value,
path,
resourceRoot: this.bundleResourceRoot,
provenance,
}),
);
}
async #inspectMacho(
signal?: AbortSignal,
): Promise<Result<NativeObservation, AnalysisError>> {
@@ -1432,6 +1472,40 @@ const invocation = (
stderr_bytes: capture.stderrBytes,
});
const nativePlistObservation = (input: {
readonly parsed: ParsedPlist;
readonly format: z.infer<typeof inspectPlistSchema>["format"];
readonly path: string;
readonly resourceRoot: ResolvedAppBundleResourceRoot | undefined;
readonly provenance: readonly NativeCommandInvocation[];
}): NativeObservation => {
const { parsed, format, path, resourceRoot, provenance } = input;
const result = inspectPlistSchema.parse({
format,
value: parsed.value,
bundle: parsed.bundle,
source_path: path,
...(resourceRoot === undefined
? {}
: {
resource_root: {
caller_path: resourceRoot.callerPath,
canonical_path: resourceRoot.canonicalPath,
info_plist_path: resourceRoot.infoPlistPath,
layout: resourceRoot.layout,
executable_sha256: resourceRoot.executableSha256,
},
}),
provenance,
limitations: parsed.limitations,
});
return {
result: jsonValueSchema.parse(result),
limitations: parsed.limitations,
locations: [{ kind: "artifact-path", path }],
};
};
const parseEntitlements = (
output: string,
): {
+76 -1
View File
@@ -11,9 +11,11 @@ import type {
AnalysisCapturedOutput,
} from "../domain/analysisErrorBase.js";
import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@@ -26,10 +28,12 @@ import {
artifactFilePrimaryFailure,
artifactFileCancellationCause,
bindArtifactFileCleanup,
assertArtifactReadActive,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js";
import { MAX_NATIVE_PLIST_BYTES } from "./parsers/plist.js";
/** Resolved lookup identity and selection intent, independent of file readiness. */
export interface NativePlistSelection {
@@ -197,6 +201,29 @@ export const nativePlistFailure = (
cause.message,
{ cause, capturedOutput },
);
if (
selection !== undefined &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "integrity"
)
return new AnalysisArtifactChangedError(
"inspect_plist",
selection.path,
cause.message,
{ cause, capturedOutput },
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") {
const resource = cause.boundary ?? "memory";
return new AnalysisResourceConstraintError(
"inspect_plist",
resource,
cause.message,
resource === "file-size"
? { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }
: null,
{ cause, capturedOutput, retryable: false },
);
}
if (selection === undefined)
return new ProviderAdapterError(IDENTITY.id, "inspect_plist", {
cause,
@@ -263,6 +290,51 @@ export const requireRegularPlist = (
): Promise<Result<null, AnalysisError>> =>
readPlistFile(input, async () => null);
/** Read bounded source bytes through the same retained descriptor owner. */
export const readNativePlistBytes = (
input: PlistFileInput,
): Promise<Result<Buffer, AnalysisError>> =>
readPlistFile(input, async (file) => {
const before = await file.stat({ bigint: true });
if (before.size > BigInt(MAX_NATIVE_PLIST_BYTES))
throw new ArtifactReaderFailure(
"limit",
`Selected plist exceeds ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}`,
{ boundary: "file-size" },
);
const bytes = Buffer.allocUnsafe(Number(before.size));
let position = 0;
while (position < bytes.length) {
assertArtifactReadActive(input.signal);
const length = Math.min(64 * 1024, bytes.length - position);
const { bytesRead } = await file.read(bytes, position, length, position);
if (bytesRead === 0) break;
position += bytesRead;
}
const after = await file.stat({ bigint: true });
if (after.size > BigInt(MAX_NATIVE_PLIST_BYTES))
throw new ArtifactReaderFailure(
"limit",
`Selected plist grew beyond ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}`,
{ boundary: "file-size" },
);
if (
!after.isFile() ||
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeNs !== after.mtimeNs ||
before.ctimeNs !== after.ctimeNs ||
BigInt(position) !== before.size
)
throw new ArtifactReaderFailure(
"integrity",
`Selected plist changed during read: ${input.selection.path}`,
);
assertArtifactReadActive(input.signal);
return bytes;
});
/** Bytes examined for leading markup when `file` reports only text. */
const PLIST_MARKUP_PROBE_BYTES = 4096;
@@ -273,7 +345,7 @@ export const plistSourceFormat = async (
): Promise<
Result<z.infer<typeof inspectPlistSchema>["format"], AnalysisError>
> => {
if (/binary property list/iu.test(description)) return ok("binary");
if (isBinaryPlistDescription(description)) return ok("binary");
if (/\bXML\b/iu.test(description)) return ok("xml");
if (/\bJSON\b/iu.test(description)) return ok("json");
if (!/\btext\b/iu.test(description)) return ok("unknown");
@@ -296,3 +368,6 @@ export const plistSourceFormat = async (
: "unknown";
});
};
export const isBinaryPlistDescription = (description: string): boolean =>
/binary property list/iu.test(description);
+428
View File
@@ -1,10 +1,438 @@
import { expect, it } from "vitest";
import { buildBinary } from "plist";
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
} from "../../artifacts/apple/PlistScalars.js";
import {
parsePlistBinary,
parsePlistJson,
parsePlistXml,
plistJsonNeedsNumberTypes,
} from "./plist.js";
import { estimateBinaryPlistExpansion } from "../../artifacts/apple/PropertyListDecodeBudget.js";
import {
binaryPlistObjectTable,
binaryPlistWithOverlappingDateAndData,
} from "../../../tests/fixtures/binaryPlistObjectTable.js";
const parseWithoutMutatingSource = (bytes: Buffer) => {
const original = Buffer.from(bytes);
const parsed = parsePlistBinary(bytes);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(bytes).toEqual(original);
return parsed.value;
};
const binaryPlistWithNegativeZero = (): Buffer => {
const bytes = Buffer.from(
buildBinary({
negative: -1.25,
positive: 0,
note: "a\u0001b",
payload: Buffer.from([0]),
fraction: new Date("2020-05-05T01:02:03.100Z"),
whole: new Date("2020-05-05T01:02:03.000Z"),
nel: "a\u0085b",
ls: "a\u2028b",
ps: "a\u2029b",
largeInteger: 9007199254740992,
}),
);
const negativeOneAndQuarter = Buffer.from([
0x23, 0xbf, 0xf4, 0, 0, 0, 0, 0, 0,
]);
const offset = bytes.indexOf(negativeOneAndQuarter);
if (offset < 0) throw new Error("binary plist fixture lost its real");
bytes.fill(0, offset + 1, offset + 9);
bytes[offset + 1] = 0x80;
const largeReal = Buffer.alloc(9);
largeReal[0] = 0x23;
largeReal.writeDoubleBE(9007199254740992, 1);
const largeOffset = bytes.indexOf(largeReal);
if (largeOffset < 0) throw new Error("binary plist fixture lost its integer");
bytes[largeOffset] = 0x13;
bytes.writeBigInt64BE(9007199254740992n, largeOffset + 1);
return bytes;
};
const binaryDate = (secondsSince2001: number): Buffer => {
const bytes = Buffer.from(
buildBinary({ date: new Date("2001-01-01T00:00:00.000Z") }),
);
const metadata = scanBinaryPlistScalars(bytes);
const date = metadata.dates[0];
if (date === undefined) throw new Error("binary plist fixture lost its date");
bytes.writeDoubleBE(secondsSince2001, date.payloadOffset);
return bytes;
};
const binarySharedSubmillisecondDate = (): Buffer => {
// Array object 0 references the same date object 1 twice.
const bytes = Buffer.alloc(66);
bytes[8] = 0xa2;
bytes[9] = 1;
bytes[10] = 1;
bytes[11] = 0x33;
bytes.writeDoubleBE(0.1005, 12);
return binaryPlistObjectTable(bytes, [8, 11], 20);
};
const binaryWithUnreachableMalformedDate = (): Buffer => {
const bytes = Buffer.alloc(54);
bytes.set([0x52, 0x6f, 0x6b, 0x30], 8); // Root "ok"; unused malformed date.
return binaryPlistObjectTable(bytes, [8, 11], 20);
};
const binaryWithReachableAndUnreachableDate = (): Buffer => {
const bytes = Buffer.alloc(67);
bytes.set([0xa1, 1, 0x33], 8);
bytes.writeDoubleBE(0.1005, 11);
bytes[19] = 0x30; // Unreachable malformed date marker.
return binaryPlistObjectTable(bytes, [8, 10, 19], 20);
};
const binaryWithExactOverlappingDateAndNull = (): Buffer => {
const bytes = Buffer.alloc(55);
bytes.set([0xa2, 1, 2, 0x33], 8);
bytes.writeDoubleBE(0, 12);
return binaryPlistObjectTable(bytes, [8, 11, 18], 20);
};
const binaryWithDateInsideData = (): Buffer =>
Buffer.from(
"62706c6973743030a201024a00333fb9ba5e353f7cee080b0d0000000000000101000000000000000300000000000000000000000000000016",
"hex",
);
const binaryWithOverlappingAndIndependentDates = (): Buffer => {
const bytes = Buffer.alloc(68);
bytes.set([0xa3, 1, 2, 3, 0x4a, 0, 0x33], 8);
bytes.writeDoubleBE(0.1005, 15);
bytes.set([0x33], 23);
bytes.writeDoubleBE(0.1, 24);
return binaryPlistObjectTable(bytes, [8, 12, 14, 23], 32);
};
const binaryWithOverlappingIntegerDate = (): Buffer =>
Buffer.from(
"62706c6973743030a20102333fb999999999102a080b120000000000000101000000000000000300000000000000000000000000000014",
"hex",
);
const binaryWithSharedOverlappingDate = (): Buffer => {
const bytes = Buffer.alloc(57);
bytes.set([0xa3, 1, 2, 3, 0x33], 8);
bytes.writeDoubleBE(0.1005, 13);
bytes[20] = 0x40;
return binaryPlistObjectTable(bytes, [8, 12, 12, 20], 21);
};
const binaryInt128 = (marker = 0x14): Buffer => {
const bytes = Buffer.alloc(58);
bytes[8] = marker;
bytes.writeBigUInt64BE(0n, 9);
bytes.writeBigUInt64BE(9007199254740993n, 17);
bytes[25] = 8;
return binaryPlistObjectTable(bytes, [8], 25);
};
const dateProjection = (secondsSince2001: number) => {
const source = binaryDate(secondsSince2001);
const metadata = scanBinaryPlistScalars(source);
const datePlan = estimateBinaryPlistExpansion(source, 1024 * 1024, {
numberProjection: true,
});
const decodedInput = decodeBinaryPlistScalars(source, metadata, datePlan);
const parsed = parsePlistBinary(source);
if (!parsed.ok) throw parsed.error;
return { source, decodedInput, metadata, parsed: parsed.value };
};
it.each([
[0, "2001-01-01T00:00:00.000Z"],
[0.1, "2001-01-01T00:00:00.100Z"],
[-0.1, "2000-12-31T23:59:59.900Z"],
[
(Date.parse("1900-01-01T00:00:00.900Z") -
Date.parse("2001-01-01T00:00:00.000Z")) /
1000,
"1900-01-01T00:00:00.900Z",
],
])(
"preserves binary dates exactly representable in milliseconds: %s",
(seconds, iso) => {
const result = dateProjection(seconds);
expect(result.parsed.value).toEqual({
date: { $plist_type: "date", iso8601: iso },
});
expect(result.decodedInput).toBe(result.source);
},
);
it.each([0.1005, -0.1005, 0.0005])(
"marks binary sub-millisecond source seconds unknown rather than rounding: %s",
(seconds) => {
const result = dateProjection(seconds);
expect(result.metadata.dates[0]?.secondsSince2001).toBe(seconds);
expect(result.decodedInput).not.toBe(result.source);
expect(
result.source.readDoubleBE(result.metadata.dates[0]!.payloadOffset),
).toBe(seconds);
expect(result.parsed.value).toEqual({
date: { $plist_type: "date", iso8601: null },
});
expect(result.parsed.limitations).toContainEqual(
expect.stringContaining("date value(s)"),
);
},
);
it("keeps distinct source dates separate when they round to the same millisecond", () => {
const bytes = Buffer.from(
buildBinary([
new Date("2001-01-01T00:00:00.100Z"),
new Date("2001-01-01T00:00:00.100Z"),
]),
);
const metadata = scanBinaryPlistScalars(bytes);
expect(metadata.dates).toHaveLength(2);
bytes.writeDoubleBE(0.1005, metadata.dates[1]!.payloadOffset);
const parsed = parsePlistBinary(bytes);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" },
{ $plist_type: "date", iso8601: null },
]);
});
it("projects a shared sub-millisecond date reference as unknown at each use", () => {
const source = binarySharedSubmillisecondDate();
const metadata = scanBinaryPlistScalars(source);
expect(metadata.dates).toHaveLength(1);
expect(metadata.dates[0]?.secondsSince2001).toBe(0.1005);
const parsed = parsePlistBinary(source);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([
{ $plist_type: "date", iso8601: null },
{ $plist_type: "date", iso8601: null },
]);
});
it("retains out-of-range date source seconds without rewriting them", () => {
const result = dateProjection(1e20);
expect(result.metadata.dates[0]?.secondsSince2001).toBe(1e20);
expect(result.decodedInput).toBe(result.source);
expect(result.parsed.value).toEqual({
date: { $plist_type: "date", iso8601: null },
});
});
it("does not treat a malformed date marker as a valid date scalar", () => {
const bytes = binaryDate(0.1005);
const valid = scanBinaryPlistScalars(bytes);
bytes[valid.dates[0]!.offset] = 0x30;
const metadata = scanBinaryPlistScalars(bytes);
expect(metadata.literals.incomplete).toBe(true);
expect(metadata.dates).toEqual([]);
expect(
decodeBinaryPlistScalars(bytes, metadata, {
reachableDateOffsets: [],
overlappingDateOffsets: [],
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
}),
).toBe(bytes);
});
it("rejects a reachable date with a malformed marker", () => {
const bytes = binaryDate(0.1);
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined) throw new Error("binary plist fixture lost its date");
bytes[date.offset] = 0x30;
const parsed = parsePlistBinary(bytes);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected malformed reachable date rejection");
expect(parsed.error.message).toContain("date marker 0x30 is invalid");
});
it("does not reject an unreachable malformed date object as a decoded value", () => {
const parsed = parsePlistBinary(binaryWithUnreachableMalformedDate());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toBe("ok");
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("number metadata could not be completely observed"),
);
});
it("keeps reachable date uncertainty when another object is malformed", () => {
const parsed = parsePlistBinary(binaryWithReachableAndUnreachableDate());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([{ $plist_type: "date", iso8601: null }]);
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("number metadata could not be completely observed"),
);
});
it("retains reachable date source facts when an unreachable offset is invalid", () => {
const bytes = binaryWithReachableAndUnreachableDate();
bytes[22] = 0xff;
const metadata = scanBinaryPlistScalars(bytes);
const parsed = parsePlistBinary(bytes);
expect(metadata.dates).toHaveLength(1);
expect(metadata.dates[0]?.secondsSince2001).toBe(0.1005);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([{ $plist_type: "date", iso8601: null }]);
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("number metadata could not be completely observed"),
);
});
it("does not rewrite shared bytes when a reachable date overlaps another object", () => {
const bytes = binaryPlistWithOverlappingDateAndData();
const parsed = parseWithoutMutatingSource(bytes);
expect(parsed.value).toEqual([
{ $plist_type: "date", iso8601: null },
{ $plist_type: "data", base64: "" },
]);
expect(parsed.limitations).toContainEqual(
expect.stringContaining("sub-millisecond source records overlap"),
);
});
it("keeps an exactly millisecond-representable overlapping date known", () => {
const parsed = parsePlistBinary(binaryWithExactOverlappingDateAndNull());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.000Z" },
null,
]);
expect(parsed.value.limitations).not.toContainEqual(
expect.stringContaining("sub-millisecond source records overlap"),
);
});
it("keeps a date unknown when its start is inside another reachable payload", () => {
const bytes = binaryWithDateInsideData();
const parsed = parseWithoutMutatingSource(bytes);
expect(parsed.value).toEqual([
{ $plist_type: "data", base64: "ADM/ubpeNT987g==" },
{ $plist_type: "date", iso8601: null },
]);
expect(parsed.limitations).toContainEqual(
expect.stringContaining("sub-millisecond source records overlap"),
);
});
it("isolates an overlapping date without losing an independent date", () => {
const parsed = parsePlistBinary(binaryWithOverlappingAndIndependentDates());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([
{ $plist_type: "data", base64: "ADM/ubpeNT987g==" },
{ $plist_type: "date", iso8601: null },
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" },
]);
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("sub-millisecond source records overlap"),
);
});
it("retains the integer sibling of a sub-millisecond overlapping date", () => {
const bytes = binaryWithOverlappingIntegerDate();
const parsed = parseWithoutMutatingSource(bytes);
expect(parsed.value).toEqual([{ $plist_type: "date", iso8601: null }, 42]);
expect(parsed.limitations).toContainEqual(
expect.stringContaining("sub-millisecond source records overlap"),
);
});
it("isolates a shared overlapping date once while preserving both references", () => {
const parsed = parsePlistBinary(binaryWithSharedOverlappingDate());
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual([
{ $plist_type: "date", iso8601: null },
{ $plist_type: "date", iso8601: null },
{ $plist_type: "data", base64: "" },
]);
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining(
"2 date value(s) are reported with unknown timestamps",
),
);
});
it("preserves valid 16-byte integers while rejecting unsupported numeric markers", () => {
const parsed = parsePlistBinary(binaryInt128());
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({
$plist_type: "integer",
decimal: "9007199254740993",
});
for (const [marker, message] of [
[0x15, "integer width is invalid"],
[0x20, "real width is invalid"],
[0x24, "real width is invalid"],
] as const) {
const rejected = parsePlistBinary(binaryInt128(marker));
expect(rejected.ok).toBe(false);
if (rejected.ok)
throw new Error("Expected unsupported numeric marker rejection");
expect(rejected.error.message).toContain(message);
}
});
it("keeps binary plist source values lost by XML conversion", () => {
const parsed = parsePlistBinary(binaryPlistWithNegativeZero());
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({
negative: { $plist_type: "real", value: "-0" },
positive: 0,
note: "a\u0001b",
payload: { $plist_type: "data", base64: "AA==" },
fraction: { $plist_type: "date", iso8601: "2020-05-05T01:02:03.100Z" },
whole: { $plist_type: "date", iso8601: "2020-05-05T01:02:03.000Z" },
nel: "a\u0085b",
ls: "a\u2028b",
ps: "a\u2029b",
largeInteger: { $plist_type: "integer", decimal: "9007199254740992" },
});
expect(parsed.value.limitations).toContain(
'1 negative-zero real value(s) are reported as { "$plist_type": "real", "value": "-0" } because a JSON number cannot carry the sign of zero.',
);
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("1 observed integer literal(s)"),
);
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("1 unambiguous integer value(s)"),
);
});
it("rejects a truncated binary plist source", () => {
const parsed = parsePlistBinary(Buffer.from("bplist00bad"));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected malformed binary plist");
expect(parsed.error.message).toContain("trailer is truncated");
});
it("retains complete segmented XML keys in native plist fallback evidence", () => {
const parsed = parsePlistXml(
+106 -11
View File
@@ -1,8 +1,13 @@
import { z } from "zod";
import { parseBinary } from "plist";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import {
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
binaryDatePrecisionOverlapLimitation,
negativeZeroReal,
negativeZeroRealLimitation,
nonFiniteRealLimitation,
@@ -20,8 +25,22 @@ import { err, ok, type Result } from "../../domain/result.js";
import {
incompletePlistNumberLimitation,
createPlistNumberProjection,
type PlistNumberLiterals,
} from "../../domain/apple/plistNumbers.js";
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
} from "../../artifacts/apple/PlistScalars.js";
import {
estimateBinaryPlistExpansion,
PropertyListDecodeBudgetFailure,
} from "../../artifacts/apple/PropertyListDecodeBudget.js";
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
/** Input ceiling matches native command output; projection is bounded separately. */
export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024;
const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024;
const plistObject = z.record(z.string(), z.unknown());
@@ -204,16 +223,7 @@ export const parsePlistXml = (
bundle: projectPlistBundle(value),
limitations: [
'plutil cannot express this plist as JSON, so it was decoded from plutil\'s XML conversion; data and date values are typed objects such as { "$plist_type": "data", "base64": ... }.',
...(projected.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...negativeZeroLimitations(projected.negativeZeroRealCount),
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
...(projected.unknownScalarCount === 0
? []
: [unknownScalarLimitation(projected.unknownScalarCount)]),
...projectedScalarLimitations(projected),
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(omittedPrototypeKeys),
@@ -221,9 +231,94 @@ export const parsePlistXml = (
});
};
/** Decode binary plist source bytes before conversion can erase scalar facts. */
export const parsePlistBinary = (
bytes: Buffer,
): Result<
ParsedPlist,
AnalysisOutputError | AnalysisResourceConstraintError
> => {
try {
if (!hasBinaryPlistSignature(bytes))
throw new TypeError("selected file does not have a binary plist header");
const expansion = estimateBinaryPlistExpansion(
bytes,
MAX_NATIVE_PLIST_DECODE_BYTES,
{
budget: "the native plist decode budget",
numberProjection: true,
fail: (kind, message) =>
kind === "cycle"
? new TypeError(
"binary plist object references form a cycle, which a property list cannot represent",
)
: new PropertyListDecodeBudgetFailure(kind, message),
},
);
const scalarMetadata = scanBinaryPlistScalars(bytes, expansion);
const decoded = parseBinary(
decodeBinaryPlistScalars(bytes, scalarMetadata, expansion),
);
const projected = projectPlistValue(decoded);
const numbers = createPlistNumberProjection(scalarMetadata.literals);
const value = numbers.project(projected.value);
return ok({
value,
bundle: projectPlistBundle(value),
limitations: [
...projectedScalarLimitations(projected),
...(expansion.datePrecisionOverlapCount === 0
? []
: [
binaryDatePrecisionOverlapLimitation(
expansion.datePrecisionOverlapCount,
),
]),
...numbers.limitations(),
...(expansion.binaryPrototypeKeyObserved
? [omittedPrototypeKeysLimitation(null)]
: []),
],
});
} catch (cause: unknown) {
if (cause instanceof PropertyListDecodeBudgetFailure)
return err(
new AnalysisResourceConstraintError(
"inspect_plist",
"memory",
cause.message,
{ maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES },
{ cause, retryable: false },
),
);
return err(
new AnalysisOutputError(
"inspect_plist",
cause instanceof Error ? cause.message : String(cause),
{ cause },
),
);
}
};
const prototypeKeyLimitations = (count: number): string[] =>
count === 0 ? [] : [omittedPrototypeKeysLimitation(count)];
const projectedScalarLimitations = (
projected: ProjectedPlistValue,
): string[] => [
...(projected.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...negativeZeroLimitations(projected.negativeZeroRealCount),
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
...(projected.unknownScalarCount === 0
? []
: [unknownScalarLimitation(projected.unknownScalarCount)]),
];
const negativeZeroLimitations = (count: number): string[] =>
count === 0 ? [] : [negativeZeroRealLimitation(count)];