fix(native): decode plist sources within result capacity

Derive ordinary plist depth from existing JSON result capacity while preserving archive limits. Decode supported XML and safe JSON directly from owned source bytes; retain conversion for OpenStep, unsafe numeric classification and unsupported encodings. Preserve typed scalar facts, truthful provenance, cleanup ownership and explicit capacity recovery.
This commit is contained in:
morluto committed 2026-10-24 10:17:23 +08:00
1 parent 2f2b07f750
commit cc18e9ad0d
23 files changed
+862 -196

No files matched your search

+29 -11
View File
@@ -453,17 +453,35 @@ markup, including UTF-8 and BOM-marked UTF-16 sources. Unidentified text and
OpenStep plists, including a data-only value such as `<deadbeef>`, report
`unknown` even when plutil decodes their values through an XML conversion.
Binary plist inspection reads the original bytes through an owned file descriptor,
so conversion cannot erase control characters, fractional dates, negative zero,
or the high word of a 128-bit integer. It accepts at most 64 MiB of input and
reserves an estimated 64 MiB decode budget before parsing; this estimate is not
a hard process-memory bound. A refused input reports non-retryable
`resource_constraint` with `reported_limits.maximum_input_bytes`,
`maximum_decoded_bytes` or, for nesting deeper than 128 levels,
`maximum_nesting_depth`. These are fixed allowances: worker limits and host
memory do not raise them, and the unchanged request fails the same way.
Binary-source provenance records the `file` classification without claiming a
`plutil` conversion.
Plist inspection reads bounded original bytes through an owned file descriptor.
Binary and UTF-8/BOM-marked UTF-16 XML sources decode directly, retaining scalar
facts without a mandatory `plutil` conversion. UTF-8 JSON containers also decode
directly when their number facts need no further classification; explicitly
identified JSON sources can have scalar roots. Unidentified primitive text keeps
its existing conversion route, so OpenStep `123` remains a string. OpenStep,
unsupported XML encodings such as UTF-32 and declared legacy encodings, and JSON
numbers requiring XML type facts retain the `plutil` route. Invalid UTF-8 and
encoding-declaration mismatches remain explicit failures. Direct-source
provenance records `file` without claiming conversion; conversion limitations
and invocations appear only when conversion actually ran.
Malformed XML structure remains an input failure; malformed scalar literals
retain their exact source text, reason and counted unknown-scalar limitation,
using the same scalar interpretation as archive readers.
Source input is limited to 64 MiB. Binary and direct XML decoding reserve an
estimated 64 MiB decode budget before parsing; this is not a hard process-memory
bound. JSON decode memory has not been measured and has no equivalent decode
estimate. A refused input reports non-retryable `resource_constraint` with
`reported_limits.maximum_input_bytes` or `maximum_decoded_bytes` as applicable.
Ordinary direct-source inspection admits 998 source nesting levels, derived from
the existing 1000-level JSON result capacity by reserving one level for typed
scalar fields and one for the result's `value` field. Deeper input reports
`resource_constraint` with `reported_limits.maximum_nesting_depth: 998` and names
the fixed nesting allowance. Compatibility conversions can encounter an earlier
native utility refusal; their diagnostics remain explicit. Archive and Interface
Builder decoding retain their separate 128-level allowance. Worker limits and
host memory do not raise these fixed allowances, and the unchanged request fails
the same way.
A plist explicitly selected through the path input or as the active plist target
reports an unreadable or malformed selection as `invalid_request`. A missing or
@@ -11,7 +11,7 @@ import {
inspectBundleKeyedArchive,
} from "./KeyedArchiveReader.js";
import {
plistNestingLimitMessage,
PlistNestingLimitError,
unknownDateLimitation,
} from "../../domain/apple/plistValue.js";
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
@@ -795,7 +795,7 @@ describe("XML plist nesting preflight", () => {
Buffer.from(deeplyNestedXmlPlist(depth)),
selection,
),
).toThrow(new RangeError(plistNestingLimitMessage()));
).toThrow(new PlistNestingLimitError(128));
},
);
@@ -812,7 +812,7 @@ describe("XML plist nesting preflight", () => {
it("rejects one nesting level past the limit before decoding", () => {
expect(() =>
decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection),
).toThrow(new RangeError(plistNestingLimitMessage()));
).toThrow(new PlistNestingLimitError(128));
});
});
@@ -155,6 +155,8 @@ const propertyListDecodeBudgetFailure = (
/** How a caller names and reports binary plist expansion failures. */
export interface BinaryPlistExpansionOptions {
/** Source-value depth; archive consumers retain the default 128-level allowance. */
readonly maximumDepth?: number;
/** Include original number metadata and exact-integer attribute projection. */
readonly numberProjection?: boolean;
/** Retain typed UID locations for Foundation archive interpretation. */
@@ -247,6 +249,7 @@ export const estimateBinaryPlistExpansion = (
bounds = "strict",
numberProjection = false,
uidProjection = false,
maximumDepth = MAX_BINARY_PLIST_NESTING_DEPTH,
}: BinaryPlistExpansionOptions = {},
): PlistDecodePreflight => {
if (bytes.length < 40)
@@ -380,7 +383,7 @@ export const estimateBinaryPlistExpansion = (
continue;
}
pendingObjectCount -= 1;
if (entry.depth > MAX_BINARY_PLIST_NESTING_DEPTH)
if (entry.depth > maximumDepth)
throw fail("depth", `binary plist nesting exceeds ${budget}`);
if (active.has(entry.object))
throw fail("cycle", `binary plist reference cycle exceeds ${budget}`);
+17 -7
View File
@@ -13,15 +13,25 @@ const jsonScalarSchema = z.union([
]);
/**
* Maximum plist value nesting the JSON projection supports. The XML decode
* preflight shares this limit so oversized documents are rejected before any
* recursive pass rather than during one.
* Default source-value nesting for archive consumers. Ordinary plist inspection
* supplies its result-capacity allowance to both decode and projection.
*/
export const MAX_PLIST_DEPTH = 128;
/** Agent-visible refusal for nesting past the JSON projection allowance. */
export const plistNestingLimitMessage = (): string =>
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels. This release has no runtime override for this ${String(MAX_PLIST_DEPTH)}-level nesting allowance.`;
export const plistNestingLimitMessage = (
maximumDepth = MAX_PLIST_DEPTH,
): string =>
`Plist exceeds ${String(maximumDepth)} nesting levels. This release has no runtime override for this ${String(maximumDepth)}-level nesting allowance.`;
/** A source-value depth refusal, distinct from malformed decoder output. */
export class PlistNestingLimitError extends RangeError {
readonly _tag = "PlistNestingLimitError";
constructor(readonly maximumDepth: number) {
super(plistNestingLimitMessage(maximumDepth));
}
}
/** A decoded plist projected into JSON, with values JSON cannot express. */
export interface ProjectedPlistValue {
@@ -72,6 +82,7 @@ export const projectPlistValue = (
value: unknown,
/** Observe generated wrappers before consumers classify the JSON value tree. */
onWrappedScalar?: (value: Record<string, JsonValue>) => void,
maximumDepth = MAX_PLIST_DEPTH,
): ProjectedPlistValue => {
let nonFiniteRealCount = 0;
let negativeZeroRealCount = 0;
@@ -82,8 +93,7 @@ export const projectPlistValue = (
return value;
};
const project = (item: unknown, depth: number): JsonValue => {
if (depth > MAX_PLIST_DEPTH)
throw new RangeError(plistNestingLimitMessage());
if (depth > maximumDepth) throw new PlistNestingLimitError(maximumDepth);
if (item !== null && typeof item === "object" && types.isProxy(item))
throw new TypeError("Plist values cannot contain proxies");
if (item instanceof Uint8Array)
+18 -4
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { parse } from "plist";
import { plistNestingLimitMessage } from "./apple/plistValue.js";
import {
PlistNestingLimitError,
plistNestingLimitMessage,
} from "./apple/plistValue.js";
import {
omitPrototypeKeys,
parseXmlPropertyList as parseWithNumbers,
@@ -240,11 +243,22 @@ it("rejects XML nesting beyond the projection limit with the limit diagnostic",
// `plist` and the prototype-key traversal recurse once per level, so the
// depth check must report the same bounded-resource error as the projection
// instead of letting those recursive passes exhaust the call stack.
const limit = new RangeError(plistNestingLimitMessage());
const limit = new PlistNestingLimitError(128);
expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow(limit);
try {
parseXmlPropertyList(nestedDictionaries(129));
throw new Error("Expected default nesting refusal");
} catch (cause: unknown) {
expect(cause).toBeInstanceOf(RangeError);
expect(cause).toMatchObject({
_tag: "PlistNestingLimitError",
maximumDepth: 128,
message: plistNestingLimitMessage(),
});
}
});
it("decodes XML property lists exactly at the projection nesting limit", () => {
@@ -281,7 +295,7 @@ it.each(['"', "'"])(
opening.repeat(3000) + "<string>x</string>" + "</dict>".repeat(3000),
);
expect(() => parseXmlPropertyList(text)).toThrow(
new RangeError(plistNestingLimitMessage()),
new PlistNestingLimitError(128),
);
expect(
parseXmlPropertyList(
@@ -309,7 +323,7 @@ it("bounds nested plist wrappers before recursive decoding", () => {
const text =
"<plist>".repeat(3000) + "<string>x</string>" + "</plist>".repeat(3000);
expect(() => parseXmlPropertyList(text)).toThrow(
new RangeError(plistNestingLimitMessage()),
new PlistNestingLimitError(128),
);
expect(
parseXmlPropertyList(plist("<plist><string>x</string></plist>")).value,
+10 -10
View File
@@ -8,10 +8,7 @@ import {
} from "./apple/plistNumbers.js";
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
import {
MAX_PLIST_DEPTH,
plistNestingLimitMessage,
} from "./apple/plistValue.js";
import { MAX_PLIST_DEPTH, PlistNestingLimitError } from "./apple/plistValue.js";
/** The dictionary key REA results cannot carry: validation strips it. */
const PROTOTYPE_KEY = "__proto__";
@@ -66,7 +63,10 @@ export const omitPrototypeKeys = (
* Decode one validated XML DOM into values and numeric source facts.
* Entries keyed `__proto__` are omitted and counted at the same boundary.
*/
export const parseXmlPropertyList = (text: string): ParsedPropertyList => {
export const parseXmlPropertyList = (
text: string,
maximumDepth = MAX_PLIST_DEPTH,
): ParsedPropertyList => {
// Preserve the pinned decoder's existing OpenStep autodetection, also used
// by app Info.plist resolution.
const first = text[skipOpenStepTrivia(text, 0)];
@@ -74,14 +74,14 @@ export const parseXmlPropertyList = (text: string): ParsedPropertyList => {
// The pinned decoder assigns a `__proto__` key to the object prototype, so
// the entry is gone before the decoded value can be counted. Count keys in
// the source instead; the rebuild still drops any prototype it replaced.
const prototypeKeys = scanOpenStep(text);
const prototypeKeys = scanOpenStep(text, maximumDepth);
return {
value: omitPrototypeKeys(parseOpenStep(text)).value,
numberLiterals: createPlistNumberCollector().result(true),
omittedPrototypeKeys: prototypeKeys,
};
}
const decoded = decodeXmlPropertyList(text);
const decoded = decodeXmlPropertyList(text, maximumDepth);
return {
...omitPrototypeKeys(decoded.value),
numberLiterals: decoded.numberLiterals,
@@ -123,7 +123,7 @@ const admitsOpenStepCloser = (
* Bound OpenStep value nesting before the pinned decoder recursively reads it,
* and count the dictionary keys that decode to `__proto__`.
*/
const scanOpenStep = (text: string): number => {
const scanOpenStep = (text: string, maximumDepth: number): number => {
let prototypeKeys = 0;
const containers: OpenStepContainer[] = [];
const completeValue = (): void => {
@@ -137,8 +137,8 @@ const scanOpenStep = (text: string): number => {
: "separator";
};
const assertDepth = (): void => {
if (containers.length > MAX_PLIST_DEPTH)
throw new RangeError(plistNestingLimitMessage());
if (containers.length > maximumDepth)
throw new PlistNestingLimitError(maximumDepth);
};
let cursor = 0;
while (cursor < text.length) {
+6 -2
View File
@@ -5,6 +5,7 @@ import { readXmlPlistSource } from "./propertyListXmlSource.js";
import { createPlistNumberCollector } from "./apple/plistNumbers.js";
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import {
decodeXmlPlistDateLiteral,
parseXmlPlistIntegerLiteral,
@@ -12,8 +13,11 @@ import {
} from "./propertyListXmlScalar.js";
/** Own source interpretation, depth, values and numeric facts together. */
export const decodeXmlPropertyList = (text: string) => {
const source = readXmlPlistSource(text);
export const decodeXmlPropertyList = (
text: string,
maximumDepth = MAX_PLIST_DEPTH,
) => {
const source = readXmlPlistSource(text, maximumDepth);
const document = source.document;
const root = document.documentElement;
if (root?.nodeName !== "plist")
+9 -8
View File
@@ -1,8 +1,5 @@
import type { Document, Node } from "@xmldom/xmldom";
import {
MAX_PLIST_DEPTH,
plistNestingLimitMessage,
} from "./apple/plistValue.js";
import { MAX_PLIST_DEPTH, PlistNestingLimitError } from "./apple/plistValue.js";
import { err, ok, type Result } from "./result.js";
import { parseXmlDocument, XML_LINE_ENDING } from "./xmlDocument.js";
import {
@@ -24,7 +21,10 @@ export class XmlPlistSourceError extends TypeError {
}
/** One source owner binds lexical scalar values to their validated DOM nodes. */
export const readXmlPlistSource = (text: string) => {
export const readXmlPlistSource = (
text: string,
maximumDepth = MAX_PLIST_DEPTH,
) => {
const normalized = text.replace(XML_LINE_ENDING, "\n");
const parsed = parseXmlDocument(normalized, {
doctype: "allow",
@@ -58,7 +58,7 @@ export const readXmlPlistSource = (text: string) => {
? err("source-location")
: ok({ start: openingEnd + 1, end, selfClosing });
};
const spans = collectXmlPlistScalarSpans(document, readSpan);
const spans = collectXmlPlistScalarSpans(document, readSpan, maximumDepth);
return {
document,
scalarLiteral(node: Node): Result<string, XmlPlistSourceError> {
@@ -162,6 +162,7 @@ const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
const collectXmlPlistScalarSpans = (
document: Document,
readSpan: (node: Node) => Result<ScalarSpan, "source-location">,
maximumDepth: number,
): WeakMap<Node, Result<ScalarSpan, "source-location">> => {
const spans = new WeakMap<Node, Result<ScalarSpan, "source-location">>();
const root = document.documentElement;
@@ -178,8 +179,8 @@ const collectXmlPlistScalarSpans = (
if (node.nextSibling !== null)
pending.push({ node: node.nextSibling, depth });
if (node.nodeType !== node.ELEMENT_NODE) continue;
if (node.nodeName !== "key" && depth > MAX_PLIST_DEPTH)
throw new RangeError(plistNestingLimitMessage());
if (node.nodeName !== "key" && depth > maximumDepth)
throw new PlistNestingLimitError(maximumDepth);
if (PLIST_LEXICAL_SCALARS.has(node.nodeName))
spans.set(node, readSpan(node));
if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null)
+16
View File
@@ -6,6 +6,22 @@ import {
} from "./propertyListXmlText.js";
it.each([
[
Buffer.from(
'<?xml version="1.0" encoding="ISO-8859-1"?><plist><string>caf\xe9</string></plist>',
"latin1",
),
"unsupported-encoding",
],
[
Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?><plist><string>\xff</string></plist>',
"latin1",
),
"invalid-bytes",
],
[Buffer.from([0xff, 0xfe, 0, 0, 0x3c, 0, 0, 0]), "unsupported-encoding"],
[Buffer.from([0, 0, 0xfe, 0xff, 0, 0, 0, 0x3c]), "unsupported-encoding"],
[
Buffer.from('<?xml version="1.0" encoding="UTF-16"?><plist/>'),
"encoding-mismatch",
+52 -4
View File
@@ -15,12 +15,30 @@ export class XmlPlistTextDecodeError extends TypeError {
/** Decode BOM-aware XML plist text and verify any declaration against its bytes. */
export const decodeXmlPlistText = (bytes: Uint8Array): string => {
if (
(bytes[0] === 0xff &&
bytes[1] === 0xfe &&
bytes[2] === 0 &&
bytes[3] === 0) ||
(bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 0xfe && bytes[3] === 0xff)
)
throw new XmlPlistTextDecodeError(
"unsupported-encoding",
"Unsupported XML encoding: UTF-32. Supported encodings are UTF-8 and BOM-marked UTF-16 in either byte order.",
"UTF-32",
);
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
? "utf-16le"
: bytes[0] === 0xfe && bytes[1] === 0xff
? "utf-16be"
: "utf-8";
// An ASCII declaration can identify an unsupported legacy encoding before
// its body is incorrectly subjected to UTF-8 validation.
const prolog = encoding === "utf-8" ? asciiXmlDeclaration(bytes) : undefined;
const declaration =
prolog === undefined ? undefined : declaredEncoding(prolog);
if (prolog !== undefined) verifyEncodingDeclaration(declaration, encoding);
let text: string;
try {
text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
@@ -32,9 +50,40 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => {
{ cause },
);
}
const declaration = /^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u.exec(
text,
)?.[1];
if (prolog === undefined)
verifyEncodingDeclaration(declaredEncoding(text), encoding);
return text;
};
const declaredEncoding = (text: string): string | undefined =>
/^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u.exec(text)?.[1];
/** Only a complete ASCII XML declaration is interpretable before body decoding. */
const asciiXmlDeclaration = (bytes: Uint8Array): string | undefined => {
const start =
bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;
if (
bytes[start] !== 0x3c ||
bytes[start + 1] !== 0x3f ||
bytes[start + 2] !== 0x78 ||
bytes[start + 3] !== 0x6d ||
bytes[start + 4] !== 0x6c
)
return undefined;
for (let index = start + 5; index < bytes.length; index += 1) {
if (bytes[index]! > 0x7f) return undefined;
if (bytes[index] === 0x3f && bytes[index + 1] === 0x3e)
return new TextDecoder("utf-8", { fatal: true }).decode(
bytes.subarray(start, index + 2),
);
}
return undefined;
};
const verifyEncodingDeclaration = (
declaration: string | undefined,
encoding: string,
): void => {
const declared = declaration?.toLowerCase();
if (
declared !== undefined &&
@@ -55,5 +104,4 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => {
"XML encoding declaration disagrees with its bytes",
declaration,
);
return text;
};
+76 -17
View File
@@ -59,6 +59,7 @@ import {
AnalysisInputError,
AnalysisOutputError,
AnalysisUnsupportedTargetError,
type AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import {
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
@@ -113,10 +114,13 @@ import { parseDemangledSymbols } from "./parsers/demangle.js";
import { parseLipoCapture } from "./parsers/lipo.js";
import {
readPlistJson,
readPlistJsonSource,
parsePlistBinary,
parsePlistXml,
parsePlistXmlSource,
type ParsedPlist,
} from "./parsers/plist.js";
import { XmlPlistTextDecodeError } from "../domain/propertyListXmlText.js";
import {
architectureLocations,
inspectNativeMacho,
@@ -1011,7 +1015,22 @@ class NativeMacOSClient implements AnalysisClient {
classified.value,
);
if (binary !== undefined) return binary;
return await this.#inspectConvertedPlist(fileInput, classified.value);
const format = await plistSourceFormat(
classified.value.stdout,
fileInput,
);
if (!format.ok) return format;
const source = await this.#inspectTextPlist(
fileInput,
classified.value,
format.value,
);
if (source !== undefined) return source;
return await this.#inspectConvertedPlist(
fileInput,
classified.value,
format.value,
);
} catch (cause: unknown) {
// Producer failures stay opaque; only scope admission failures reach the file mapper.
return err(nativeOutputFailure("inspect_plist", cause));
@@ -1063,9 +1082,60 @@ class NativeMacOSClient implements AnalysisClient {
);
}
async #inspectTextPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
format: z.infer<typeof inspectPlistSchema>["format"],
): Promise<Result<NativeObservation, AnalysisError> | undefined> {
const source = await readNativePlistBytes(fileInput);
if (!source.ok) return source;
let parsed: ReturnType<typeof parsePlistXmlSource>;
if (format === "xml") {
parsed = parsePlistXmlSource(source.value);
if (
!parsed.ok &&
parsed.error instanceof XmlPlistTextDecodeError &&
parsed.error.reason === "unsupported-encoding"
)
return undefined;
} else {
const json = readPlistJsonSource(source.value);
if (
json === undefined ||
json.rootKind === "invalid" ||
(json.rootKind !== "container" && format !== "json") ||
json.needsNumberTypes ||
json.nonFiniteNumberObserved
)
return undefined;
parsed = json.project();
}
if (!parsed.ok)
return parsed.error instanceof AnalysisOutputError ||
parsed.error instanceof XmlPlistTextDecodeError
? err(
unreadablePlist(fileInput.selection, parsed.error.message, {
cause: parsed.error,
}),
)
: err(parsed.error);
return ok(
nativePlistObservation({
parsed: parsed.value,
format,
path: fileInput.selection.path,
resourceRoot: this.bundleResourceRoot,
provenance: [
invocation(classified, fileInput.selection.path, "$PLIST"),
],
}),
);
}
async #inspectConvertedPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
format: z.infer<typeof inspectPlistSchema>["format"],
): Promise<Result<NativeObservation, AnalysisError>> {
const { path } = fileInput.selection;
const { signal, timeoutMs } = fileInput;
@@ -1084,7 +1154,10 @@ class NativeMacOSClient implements AnalysisClient {
? readPlistJson(json.value.stdout)
: undefined;
let xml: NativeCommandCapture | undefined;
let parsed: Result<ParsedPlist, AnalysisOutputError>;
let parsed: Result<
ParsedPlist,
AnalysisOutputError | AnalysisResourceConstraintError
>;
if (jsonAttempt === undefined || jsonAttempt.needsNumberTypes) {
const converted = await this.#run(
"inspect_plist",
@@ -1127,24 +1200,10 @@ class NativeMacOSClient implements AnalysisClient {
json.value,
...(xml === undefined ? [] : [xml]),
].map((item) => invocation(item, path, "$PLIST"));
const converted = xml ?? 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,
format,
path,
resourceRoot: this.bundleResourceRoot,
provenance,
+9 -44
View File
@@ -9,7 +9,6 @@ import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExec
import type {
AnalysisError,
AnalysisErrorOptions,
AnalysisCapturedOutput,
} from "../domain/analysisErrorBase.js";
import {
AnalysisArtifactChangedError,
@@ -138,14 +137,12 @@ interface PlistFileInput {
readonly selection: NativePlistSelection;
readonly resources: ArtifactResourceScope;
readonly signal: AbortSignal | undefined;
readonly capturedOutput?: AnalysisCapturedOutput;
}
/** Translate only physical file outcomes, never a later signal or arbitrary abort value. */
export const nativePlistFailure = (
cause: unknown,
selection: NativePlistSelection | undefined,
capturedOutput?: AnalysisCapturedOutput,
): AnalysisError => {
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
const physical =
@@ -154,66 +151,41 @@ export const nativePlistFailure = (
: undefined;
const previous =
physical?.outcome.kind === "failed"
? nativePlistPrimaryFailure(
artifactFilePrimaryFacts(cause),
selection,
capturedOutput,
)
? nativePlistPrimaryFailure(artifactFilePrimaryFacts(cause), selection)
: physical?.completionCancellation === undefined
? undefined
: new AnalysisCancelledError("inspect_plist", {
cause: physical.completionCancellation.reason,
capturedOutput,
});
const previousProjection =
previous === undefined ? null : projectAnalysisError(previous);
// The top-level capture owns the output once; this snapshot keeps the primary reason.
const previousDetails = previousProjection?.details;
const { captured_output: _capture, ...reasonDetails } =
previousDetails ?? {};
const previousError =
previousProjection === null
? null
: {
...previousProjection,
...(previousDetails === undefined
? {}
: { details: reasonDetails }),
};
const failure = new ProviderCleanupError(
IDENTITY.id,
cause.cleanup.resources,
{
reason: cause.cleanup.reason,
previous_error: previousError,
previous_error: previousProjection,
},
{
operation: "inspect_plist",
cause,
cleanupPrimary: previous,
capturedOutput,
},
);
bindArtifactFileCleanup(cause, failure);
return failure;
}
return nativePlistPrimaryFailure(
artifactFilePrimaryFacts(cause),
selection,
capturedOutput,
);
return nativePlistPrimaryFailure(artifactFilePrimaryFacts(cause), selection);
};
const nativePlistPrimaryFailure = (
{ cause, origin }: ArtifactFilePrimaryFacts,
selection: NativePlistSelection | undefined,
capturedOutput: AnalysisCapturedOutput | undefined,
): AnalysisError => {
if (origin === "owned-or-classified") {
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
return new AnalysisCancelledError("inspect_plist", {
cause: artifactFileCancellationCause(cause),
capturedOutput,
});
if (
cause instanceof ArtifactReaderFailure &&
@@ -223,7 +195,7 @@ const nativePlistPrimaryFailure = (
IDENTITY.id,
"inspect_plist",
cause.message,
{ cause, capturedOutput },
{ cause },
);
if (
selection !== undefined &&
@@ -234,7 +206,7 @@ const nativePlistPrimaryFailure = (
"inspect_plist",
selection.path,
cause.message,
{ cause, capturedOutput },
{ cause },
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") {
const resource = cause.boundary ?? "memory";
@@ -246,7 +218,6 @@ const nativePlistPrimaryFailure = (
{ maximum_input_bytes: MAX_NATIVE_PLIST_BYTES },
{
cause,
capturedOutput,
...fixedAllowanceOptions(NATIVE_PLIST_INPUT_ALLOWANCE, "input"),
},
)
@@ -255,14 +226,13 @@ const nativePlistPrimaryFailure = (
resource,
cause.message,
null,
{ cause, capturedOutput, retryable: false },
{ cause, retryable: false },
);
}
}
if (selection === undefined)
return new ProviderAdapterError(IDENTITY.id, "inspect_plist", {
cause,
capturedOutput,
diagnostics: {
phase: "plist-scope",
reason: cause instanceof Error ? cause.message : String(cause),
@@ -274,12 +244,10 @@ const nativePlistPrimaryFailure = (
if (code === "ENOENT" || code === "ENOTDIR")
return unreadablePlist(selection, "no file exists there", {
cause,
capturedOutput,
});
if (code === "EISDIR" || code === "ENOTFILE")
return unreadablePlist(selection, "it is not a regular file", {
cause,
capturedOutput,
});
if (code === "ELOOP" || code === "ENAMETOOLONG")
return unreadablePlist(
@@ -287,17 +255,16 @@ const nativePlistPrimaryFailure = (
code === "ELOOP"
? "symbolic-link traversal looped or exceeded the host limit (ELOOP)"
: "the path exceeds the host path or component length limit (ENAMETOOLONG)",
{ cause, capturedOutput },
{ cause },
);
if (code === "EACCES" || code === "EPERM")
return new BinaryTargetError(
path,
`permission denied while reading plist (${code})`,
{ cause, capturedOutput },
{ cause },
);
return new ProviderAdapterError(IDENTITY.id, "inspect_plist", {
cause,
capturedOutput,
diagnostics: {
path,
phase: "plist-file",
@@ -321,9 +288,7 @@ const readPlistFile = async <Value>(
),
);
} catch (cause: unknown) {
return err(
nativePlistFailure(cause, input.selection, input.capturedOutput),
);
return err(nativePlistFailure(cause, input.selection));
}
};
+100 -2
View File
@@ -1,17 +1,106 @@
import { expect, it } from "vitest";
import { buildBinary } from "plist";
import { buildBinary, type PlistValue } from "plist";
import { MAX_JSON_DEPTH, jsonValueSchema } from "../../domain/jsonValue.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
UnsupportedBinaryPlistUidError,
} from "../../artifacts/apple/PlistScalars.js";
import { parsePlistBinary, readPlistJson, parsePlistXml } from "./plist.js";
import {
parsePlistBinary,
readPlistJson,
parsePlistXml,
parsePlistXmlSource,
} from "./plist.js";
import { estimateBinaryPlistExpansion } from "../../artifacts/apple/PropertyListDecodeBudget.js";
import {
binaryPlistObjectTable,
binaryPlistWithOverlappingDateAndData,
} from "../../../tests/fixtures/binaryPlistObjectTable.js";
it.each([
"binary data",
"XML data",
"JSON integer",
"JSON unsafe integer",
] as const)(
"bounds ordinary %s by the projected JSON capacity, including scalar wrappers",
(encoding) => {
// One level for typed scalar fields and one for normalized_result.value.
const maximum = MAX_JSON_DEPTH - 2;
for (const depth of [maximum, maximum + 1]) {
let binary: PlistValue = Buffer.from([0, 1]);
if (encoding === "binary data")
for (let level = 0; level < depth; level += 1) binary = [binary];
const parsed =
encoding === "binary data"
? parsePlistBinary(Buffer.from(buildBinary(binary)))
: encoding === "XML data"
? parsePlistXml(
`<plist>${"<array>".repeat(depth)}<data>AAE=</data>${"</array>".repeat(depth)}</plist>`,
)
: readPlistJson(
"[".repeat(depth) +
(encoding === "JSON unsafe integer"
? "9007199254740993"
: "1") +
"]".repeat(depth),
).project(
encoding === "JSON unsafe integer"
? `<plist>${"<array>".repeat(depth)}<integer>9007199254740993</integer>${"</array>".repeat(depth)}</plist>`
: undefined,
);
if (depth > maximum) {
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected nesting refusal");
if (parsed.error instanceof UnsupportedBinaryPlistUidError)
throw parsed.error;
const failure = projectAnalysisError(parsed.error);
expect(failure).toMatchObject({
code: "resource_constraint",
retryable: false,
details: { reported_limits: { maximum_nesting_depth: maximum } },
});
expect(failure.remediation.action).toContain(
`${maximum}-level native plist nesting allowance (no runtime override)`,
);
} else {
if (!parsed.ok) throw parsed.error;
expect(() =>
jsonValueSchema.parse({ value: parsed.value.value }),
).not.toThrow();
let leaf: unknown = parsed.value.value;
for (let level = 0; level < depth; level += 1) {
expect(Array.isArray(leaf)).toBe(true);
leaf = (leaf as unknown[])[0];
}
expect(leaf).toEqual(
encoding === "JSON unsafe integer"
? { $plist_type: "integer", decimal: "9007199254740993" }
: encoding === "JSON integer"
? 1
: { $plist_type: "data", base64: "AAE=" },
);
}
}
},
);
it.each(["<bogus/>", "<plist><data>AAE=</data></plist>"])(
"keeps malformed XML conversion %s separate from nesting refusal",
(source) => {
const parsed = parsePlistXml(source);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected malformed conversion failure");
expect(projectAnalysisError(parsed.error)).toMatchObject({
code: "unreadable_output",
details: { reason: "plutil XML conversion was not a readable plist" },
});
},
);
const parseWithoutMutatingSource = (bytes: Buffer) => {
const original = Buffer.from(bytes);
const parsed = parsePlistBinary(bytes);
@@ -21,6 +110,15 @@ const parseWithoutMutatingSource = (bytes: Buffer) => {
return parsed.value;
};
it("keeps malformed XML source structure separate from retained unknown scalars", () => {
const parsed = parsePlistXmlSource(
Buffer.from("<plist><dict><key>missing</key></dict></plist>"),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected malformed source structure");
expect(parsed.error.message).toContain("Missing value for key");
});
const binaryPlistWithNegativeZero = (): Buffer => {
const bytes = Buffer.from(
buildBinary({
+151 -32
View File
@@ -6,7 +6,7 @@ import {
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../../domain/jsonValue.js";
import {
binaryDatePrecisionOverlapLimitation,
negativeZeroReal,
@@ -16,6 +16,7 @@ import {
unknownDateLimitation,
unknownScalarLimitation,
type ProjectedPlistValue,
PlistNestingLimitError,
} from "../../domain/apple/plistValue.js";
import {
omitPrototypeKeys,
@@ -36,10 +37,14 @@ import {
} from "../../artifacts/apple/PlistScalars.js";
import {
estimateBinaryPlistExpansion,
MAX_BINARY_PLIST_NESTING_DEPTH,
preflightPropertyListDecode,
PropertyListDecodeBudgetFailure,
} from "../../artifacts/apple/PropertyListDecodeBudget.js";
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
import {
decodeXmlPlistText,
XmlPlistTextDecodeError,
} from "../../domain/propertyListXmlText.js";
/**
* Input ceiling matches native command output; projection is bounded separately.
@@ -49,6 +54,9 @@ export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024;
/** Provisional decode allowance. This release has no runtime override. */
const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024;
// Reserve one level for typed scalar fields and one for normalized_result.value.
export const MAX_NATIVE_PLIST_NESTING_DEPTH = MAX_JSON_DEPTH - 2;
const mebibytes = (bytes: number): string =>
`${String(bytes / (1024 * 1024))} MiB`;
@@ -86,18 +94,39 @@ class PlistJsonIntegerLiteral {
/** One JSON parse attempt, retaining its facts or failure for final projection. */
export interface PlistJsonParseAttempt {
readonly rootKind: "container" | "scalar" | "invalid";
readonly nonFiniteNumberObserved: boolean;
/** Whether plutil should supply XML number types; false for malformed JSON. */
readonly needsNumberTypes: boolean;
project(xmlConversion?: string): Result<ParsedPlist, AnalysisOutputError>;
project(xmlConversion?: string): Result<ParsedPlist, PlistParseError>;
}
const plistJsonOutputError = (cause: unknown): AnalysisOutputError =>
new AnalysisOutputError(
type PlistParseError = AnalysisOutputError | AnalysisResourceConstraintError;
const plistNestingError = (cause: unknown): AnalysisResourceConstraintError =>
new AnalysisResourceConstraintError(
"inspect_plist",
"memory",
cause instanceof Error ? cause.message : String(cause),
{ cause },
{ maximum_nesting_depth: MAX_NATIVE_PLIST_NESTING_DEPTH },
{
cause,
...fixedAllowanceOptions(
`The ${String(MAX_NATIVE_PLIST_NESTING_DEPTH)}-level native plist nesting allowance (no runtime override)`,
"parser",
),
},
);
const plistOutputError = (cause: unknown, reason?: string): PlistParseError =>
cause instanceof PlistNestingLimitError
? plistNestingError(cause)
: new AnalysisOutputError(
"inspect_plist",
reason ?? (cause instanceof Error ? cause.message : String(cause)),
{ cause },
);
/**
* Read plutil JSON once, retaining exact unsafe integral literals. Optional XML
* classification and each independent output projection use these same facts.
@@ -105,6 +134,7 @@ const plistJsonOutputError = (cause: unknown): AnalysisOutputError =>
export const readPlistJson = (output: string): PlistJsonParseAttempt => {
let decoded: Result<unknown, unknown>;
let needsNumberTypes = false;
let nonFiniteNumberObserved = false;
try {
// plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright.
const text = output.replace(/^\uFEFF/u, "");
@@ -117,6 +147,8 @@ export const readPlistJson = (output: string): PlistJsonParseAttempt => {
context?: { readonly source?: string },
) => {
const source = context?.source;
if (typeof item === "number" && !Number.isFinite(item))
nonFiniteNumberObserved = true;
if (
typeof item !== "number" ||
Number.isSafeInteger(item) ||
@@ -134,20 +166,43 @@ export const readPlistJson = (output: string): PlistJsonParseAttempt => {
decoded = err(cause);
}
return {
rootKind: !decoded.ok
? "invalid"
: decoded.value !== null &&
typeof decoded.value === "object" &&
!(decoded.value instanceof PlistJsonIntegerLiteral)
? "container"
: "scalar",
needsNumberTypes: decoded.ok && needsNumberTypes,
nonFiniteNumberObserved: decoded.ok && nonFiniteNumberObserved,
project: (xmlConversion) => projectPlistJsonAttempt(decoded, xmlConversion),
};
};
/** Recognize JSON source using fatal UTF-8; other encodings retain conversion. */
export const readPlistJsonSource = (
bytes: Buffer,
): PlistJsonParseAttempt | undefined => {
try {
return readPlistJson(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
} catch {
return undefined;
}
};
const projectPlistJsonAttempt = (
attempt: Result<unknown, unknown>,
xmlConversion: string | undefined,
): Result<ParsedPlist, AnalysisOutputError> => {
): Result<ParsedPlist, PlistParseError> => {
let literals: PlistNumberLiterals | undefined;
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
let negativeZeroRealCount = 0;
const project = (item: unknown): unknown => {
const project = (item: unknown, depth: number): unknown => {
if (depth > MAX_NATIVE_PLIST_NESTING_DEPTH)
throw new PlistNestingLimitError(MAX_NATIVE_PLIST_NESTING_DEPTH);
// A plist integer has no signed zero, so -0 can only come from a real.
if (typeof item === "number" && Object.is(item, -0)) {
negativeZeroRealCount += 1;
@@ -168,10 +223,14 @@ const projectPlistJsonAttempt = (
ambiguousNumberCount += 1;
return item.value;
}
if (Array.isArray(item)) return item.map(project);
if (Array.isArray(item))
return item.map((child) => project(child, depth + 1));
if (item !== null && typeof item === "object")
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, project(value)]),
Object.entries(item).map(([key, value]) => [
key,
project(value, depth + 1),
]),
);
return item;
};
@@ -181,12 +240,13 @@ const projectPlistJsonAttempt = (
literals =
xmlConversion === undefined
? undefined
: parseXmlPropertyList(xmlConversion).numberLiterals;
if (!attempt.ok) return err(plistJsonOutputError(attempt.error));
: parseXmlPropertyList(xmlConversion, MAX_NATIVE_PLIST_NESTING_DEPTH)
.numberLiterals;
if (!attempt.ok) return err(plistOutputError(attempt.error));
// Numeric limitations also account for values under omitted source keys.
decoded = omitPrototypeKeys(project(attempt.value));
decoded = omitPrototypeKeys(project(attempt.value, 0));
} catch (cause: unknown) {
return err(plistJsonOutputError(cause));
return err(plistOutputError(cause));
}
const { value } = decoded;
return ok({
@@ -209,23 +269,76 @@ const projectPlistJsonAttempt = (
*/
export const parsePlistXml = (
output: string,
): Result<ParsedPlist, AnalysisOutputError> => {
): Result<ParsedPlist, PlistParseError> => {
const parsed = projectPlistXml(
output,
"plutil XML conversion was not a readable plist",
);
return !parsed.ok
? parsed
: ok({
...parsed.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": ... }.',
...parsed.value.limitations,
],
});
};
/** Decode original XML bytes with their own numeric types and no conversion claim. */
export const parsePlistXmlSource = (
bytes: Buffer,
): Result<ParsedPlist, PlistParseError | XmlPlistTextDecodeError> => {
try {
const text = decodeXmlPlistText(bytes);
preflightPropertyListDecode(bytes, MAX_NATIVE_PLIST_DECODE_BYTES, text, {
numberProjection: true,
budget: "the native plist decode budget",
});
return projectPlistXml(text);
} catch (cause: unknown) {
if (cause instanceof XmlPlistTextDecodeError) return err(cause);
if (cause instanceof PropertyListDecodeBudgetFailure)
return err(
new AnalysisResourceConstraintError(
"inspect_plist",
"memory",
cause.message,
{ maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES },
{
cause,
...fixedAllowanceOptions(
`The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} (${String(MAX_NATIVE_PLIST_DECODE_BYTES)}-byte) native XML plist decode allowance (no runtime override)`,
"parser",
),
},
),
);
return err(plistOutputError(cause));
}
};
const projectPlistXml = (
output: string,
failureReason?: string,
): Result<ParsedPlist, PlistParseError> => {
let projected: ProjectedPlistValue;
let omittedPrototypeKeys: number;
let literals: PlistNumberLiterals;
try {
const decoded = parseXmlPropertyList(output);
const decoded = parseXmlPropertyList(
output,
MAX_NATIVE_PLIST_NESTING_DEPTH,
);
omittedPrototypeKeys = decoded.omittedPrototypeKeys;
literals = decoded.numberLiterals;
projected = projectPlistValue(decoded.value);
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
"inspect_plist",
"plutil XML conversion was not a readable plist",
{ cause },
),
projected = projectPlistValue(
decoded.value,
undefined,
MAX_NATIVE_PLIST_NESTING_DEPTH,
);
} catch (cause: unknown) {
return err(plistOutputError(cause, failureReason));
}
// The XML decoder turns <integer> and <real> into numbers before REA sees
// them, rounding integers beyond the exact range.
@@ -251,7 +364,6 @@ export const parsePlistXml = (
value,
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": ... }.',
...projectedScalarLimitations(projected),
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
@@ -278,6 +390,7 @@ export const parsePlistBinary = (
{
budget: "the native plist decode budget",
numberProjection: true,
maximumDepth: MAX_NATIVE_PLIST_NESTING_DEPTH,
fail: (kind, message) =>
kind === "cycle"
? new TypeError(
@@ -290,7 +403,11 @@ export const parsePlistBinary = (
const decoded = parseBinary(
decodeBinaryPlistScalars(bytes, scalarMetadata, expansion),
);
const projected = projectPlistValue(decoded);
const projected = projectPlistValue(
decoded,
undefined,
MAX_NATIVE_PLIST_NESTING_DEPTH,
);
const numbers = createPlistNumberProjection(scalarMetadata.literals);
const value = numbers.project(projected.value);
return ok({
@@ -313,21 +430,23 @@ export const parsePlistBinary = (
});
} catch (cause: unknown) {
if (cause instanceof UnsupportedBinaryPlistUidError) return err(cause);
if (
cause instanceof PlistNestingLimitError ||
(cause instanceof PropertyListDecodeBudgetFailure &&
cause.kind === "depth")
)
return err(plistNestingError(cause));
if (cause instanceof PropertyListDecodeBudgetFailure)
return err(
new AnalysisResourceConstraintError(
"inspect_plist",
"memory",
cause.message,
cause.kind === "depth"
? { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH }
: { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES },
{ maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES },
{
cause,
...fixedAllowanceOptions(
cause.kind === "depth"
? `The ${String(MAX_BINARY_PLIST_NESTING_DEPTH)}-level native binary plist nesting allowance (no runtime override)`
: `The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} (${String(MAX_NATIVE_PLIST_DECODE_BYTES)}-byte) native binary plist decode allowance (no runtime override)`,
`The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} (${String(MAX_NATIVE_PLIST_DECODE_BYTES)}-byte) native binary plist decode allowance (no runtime override)`,
"parser",
),
},
@@ -171,12 +171,8 @@ describe.skipIf(process.platform !== "darwin")(
separators: "a\u0085b\u2028c\u2029d",
payload: { $plist_type: "data", base64: "AAEC" },
},
provenance: [
{ tool: "file" },
{ command: expect.arrayContaining(["json"]), exit: { code: 1 } },
{ command: expect.arrayContaining(["xml1"]), exit: { code: 0 } },
],
limitations: [expect.stringContaining("XML conversion")],
provenance: [{ tool: "file" }],
limitations: [],
});
expect(await readFile(binaryPath)).toEqual(binaryBytes);
expect(await readFile(xmlPath)).toEqual(xmlBytes);
@@ -0,0 +1,127 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { buildBinary, type PlistValue } from "plist";
import { describe, expect } from "vitest";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { inspectPlistSchema } from "../../../src/domain/native/nativeInspection.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
describe.skipIf(process.platform !== "darwin")(
"ordinary plist source nesting",
() => {
cliTest(
"retains depth-129 data and depth-200 integers through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-plist-nesting-");
const target = join(root, "target");
await writeFile(target, thinMach(0xfeedfacf, 0x0100000c));
const environment = {
HOME: root,
USERPROFILE: root,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
};
const { call } = await connectLocalToolsMcp(environment);
expect((await call("open_binary", { path: target })).isError).not.toBe(
true,
);
for (const fixture of [
{ format: "binary", depth: 129, data: true },
{ format: "xml", depth: 129, data: true },
{ format: "binary", depth: 200, data: false },
{ format: "json", depth: 200, data: false },
{ format: "binary", depth: 998, data: true },
{ format: "binary", depth: 999, data: true },
{ format: "xml", depth: 998, data: true },
{ format: "xml", depth: 999, data: true },
{ format: "json", depth: 998, data: false },
{ format: "json", depth: 999, data: false },
] as const) {
const { format, depth, data } = fixture;
const path = join(root, `${format}-${depth}.plist`);
let value: PlistValue = data ? Buffer.from([0, 1]) : 1;
for (let level = 0; level < depth; level += 1)
value = { level: value };
const bytes =
format === "binary"
? Buffer.from(buildBinary(value))
: Buffer.from(
format === "json"
? JSON.stringify(value)
: `<plist>${"<dict><key>level</key>".repeat(depth)}<data>AAE=</data>${"</dict>".repeat(depth)}</plist>`,
);
await writeFile(path, bytes);
const output = await cli.run({
arguments: ["inspect-plist", path, "--json"],
environment,
});
const response = await call("inspect_plist", { path });
expect(await readFile(path)).toEqual(bytes);
if (depth === 999) {
expect(output.exitCode, output.stderr).toBe(1);
const expectedFailure = {
code: "resource_constraint",
category: "resource_constraint",
retryable: false,
details: {
resource: "memory",
reported_limits: { maximum_nesting_depth: 998 },
},
remediation: {
action:
"The 998-level native plist nesting allowance (no runtime override) is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.",
},
};
expect(output.json).toMatchObject(expectedFailure);
expect(parseMcpToolError(response).error).toMatchObject(
expectedFailure,
);
continue;
}
expect(
output.exitCode,
JSON.stringify({
format,
depth,
stderr: output.stderr,
failure: output.exitCode === 0 ? undefined : output.json,
}),
).toBe(0);
const cliResult = inspectPlistSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcpResult = inspectPlistSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
expect(mcpResult).toEqual(cliResult);
// Generic JSON text remains unclassified; a JSON conversion alone
// does not establish a source-format observation.
expect(cliResult.format).toBe(format === "json" ? "unknown" : format);
expect(cliResult.provenance.map((item) => item.tool)).toEqual([
"file",
]);
let leaf: unknown = cliResult.value;
for (let level = 0; level < depth; level += 1) {
expect(leaf).toHaveProperty("level");
leaf = (leaf as Record<string, unknown>).level;
}
expect(leaf).toEqual(
data ? { $plist_type: "data", base64: "AAE=" } : 1,
);
expect(
cliResult.limitations.some((item) =>
item.includes("XML conversion"),
),
).toBe(false);
}
},
60_000,
);
},
);
+4 -26
View File
@@ -82,7 +82,7 @@ describe.skipIf(process.platform !== "darwin")(
"native plist CLI source formats",
() => {
cliTest(
"retains non-finite values through real plutil XML conversion",
"retains non-finite values from original XML bytes",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-plist-reals-");
const path = join(directory, "selected.plist");
@@ -281,27 +281,8 @@ describe.skipIf(process.platform !== "darwin")(
],
},
bundle: { identifier: "com.example.types", executable: "App" },
provenance:
format === "xml"
? [
{ tool: "file" },
{
command: expect.arrayContaining(["json"]),
exit: { code: 1 },
},
{
command: expect.arrayContaining(["xml1"]),
exit: { code: 0 },
},
]
: [{ tool: "file" }],
limitations:
format === "xml"
? [
expect.stringContaining("XML conversion"),
expect.stringContaining("2 non-finite real"),
]
: [expect.stringContaining("2 non-finite real")],
provenance: [{ tool: "file" }],
limitations: [expect.stringContaining("2 non-finite real")],
},
});
},
@@ -354,10 +335,7 @@ describe.skipIf(process.platform !== "darwin")(
Real: 9007199254740992,
Large: 1e20,
},
limitations: [
...(withData ? [expect.stringContaining("XML conversion")] : []),
expect.stringContaining("2 integer value(s)"),
],
limitations: [expect.stringContaining("2 integer value(s)")],
},
});
},
@@ -14,8 +14,9 @@ it.skipIf(process.platform !== "darwin")(
await writeFile(program, thinMach(0xfeedfacf, 0x100000c));
await mkdir(join(root, "Contents"));
const path = join(root, "Contents/Info.plist");
const source =
"<plist><dict><key>number</key><integer> 42 </integer></dict></plist>";
// Malformed OpenStep remains conversion-only; supported XML now reads its
// original source and keeps malformed scalar literals as explicit unknowns.
const source = "{ number = ;";
await writeFile(path, source);
const { call } = await connectLocalToolsMcp({
HOME: root,
@@ -14,7 +14,7 @@ import { ProviderAdapterError } from "../../../../src/domain/providerAdapterErro
import { buildBinary, type PlistValue } from "plist";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { MAX_NATIVE_PLIST_BYTES } from "../../../../src/native/parsers/plist.js";
import { MAX_BINARY_PLIST_NESTING_DEPTH } from "../../../../src/artifacts/apple/PropertyListDecodeBudget.js";
import { MAX_JSON_DEPTH } from "../../../../src/domain/jsonValue.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import {
NativeFixtureRunner,
@@ -96,11 +96,11 @@ it.each([
},
{
budget: "nesting",
value: nested(MAX_BINARY_PLIST_NESTING_DEPTH + 1),
value: nested(MAX_JSON_DEPTH - 1),
reason: "binary plist nesting exceeds the native plist decode budget",
limits: { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH },
limits: { maximum_nesting_depth: MAX_JSON_DEPTH - 2 },
allowance:
"The 128-level native binary plist nesting allowance (no runtime override)",
"The 998-level native plist nesting allowance (no runtime override)",
},
])(
"names the fixed binary plist $budget allowance instead of host memory",
@@ -25,7 +25,7 @@ const fixture = async (streams: (path: string) => Streams) => {
const path = join(root, "Contents/Info.plist");
await writeFile(path, "not a plist");
const output = streams(path);
const replay = new NativeFixtureRunner();
const replay = new NativeFixtureRunner({ file: "ASCII text\n" });
const runner: NativeCommandRunner = {
async run(tool, args) {
const capture = await replay.run(tool, args);
@@ -151,11 +151,12 @@ it.for(["path-input", "target"] as const)(
read.mockRestore();
await release?.();
});
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner({ file: "ASCII text", plutil: '"value"' }),
"darwin",
).createClient(
const runner = new NativeFixtureRunner({
file: "ASCII text",
plutil: '"value"',
});
const commands = vi.spyOn(runner, "run");
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
selection === "target"
? { kind: "artifact", format: "plist", path, sha256: "0".repeat(64) }
: nativeMachoTarget(join(root, "target")),
@@ -174,11 +175,9 @@ it.for(["path-input", "target"] as const)(
operation: "inspect_plist",
});
expect(result.error.cleanupResources).toContain(path);
expect(result.error.capturedOutput?.stdout).toBe('"value"');
expect(result.error.capturedOutput).toBeUndefined();
const projected = projectAnalysisError(result.error);
expect(JSON.stringify(projected).match(/captured_output/gu)).toHaveLength(
1,
);
expect(JSON.stringify(projected)).not.toContain("captured_output");
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
const failure = result.error.cause as ArtifactFileCleanupFailure;
expect(failure.fileCleanupFailure.outcome).toEqual({
@@ -194,9 +193,8 @@ it.for(["path-input", "target"] as const)(
expect(attempts).toBe(3);
expect(result.error.cleanupIncomplete).toBe(false);
expect(resolvedAnalysisError(result.error).cause).toBe(primary);
expect(resolvedAnalysisError(result.error).capturedOutput?.stdout).toBe(
'"value"',
);
expect(resolvedAnalysisError(result.error).capturedOutput).toBeUndefined();
expect(commands.mock.calls.map(([tool]) => tool)).toEqual(["file"]);
if (!first.ok) expect(first.error.cleanupIncomplete).toBe(false);
},
);
@@ -0,0 +1,196 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { inspectPlistSchema } from "../../../../src/domain/native/nativeInspection.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { ok } from "../../../../src/domain/result.js";
import {
NativeFixtureRunner,
nativeMachoTarget,
} from "../../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
const xml =
"<plist><dict><key>Data</key><data>AAE=</data><key>Count</key><integer>9007199254740993</integer></dict></plist>";
const xmlValue = {
Data: { $plist_type: "data", base64: "AAE=" },
Count: { $plist_type: "integer", decimal: "9007199254740993" },
};
const utf32Xml =
"<plist><dict><key>name</key><string>UTF32 control</string></dict></plist>";
const utf32Bytes = Buffer.alloc(4 + utf32Xml.length * 4);
utf32Bytes.writeUInt32LE(0xfeff, 0);
for (let index = 0; index < utf32Xml.length; index += 1)
utf32Bytes.writeUInt32LE(utf32Xml.charCodeAt(index), 4 + index * 4);
interface TextSourceCase {
readonly name: string;
readonly bytes: Buffer;
readonly description: string;
readonly format: string;
readonly value: unknown;
readonly limitations?: readonly unknown[];
readonly conversions: readonly {
readonly format: string;
readonly output: string;
readonly exitCode?: number;
}[];
}
it.for<TextSourceCase>([
{
name: "UTF-8 XML",
bytes: Buffer.from(xml),
description: "XML 1.0 document text",
format: "xml",
value: xmlValue,
conversions: [],
},
{
name: "UTF-16LE XML",
bytes: Buffer.from("\uFEFF" + xml, "utf16le"),
description: "Unicode text, UTF-16, little-endian text",
format: "xml",
value: xmlValue,
conversions: [],
},
{
name: "UTF-16BE XML",
bytes: Buffer.from("\uFEFF" + xml, "utf16le").swap16(),
description: "Unicode text, UTF-16, big-endian text",
format: "xml",
value: xmlValue,
conversions: [],
},
{
name: "unknown XML scalar",
bytes: Buffer.from("<plist><integer> 42 </integer></plist>"),
description: "XML 1.0 document text",
format: "xml",
value: {
$plist_type: "unknown",
element: "integer",
literal: " 42 ",
reason: "invalid-literal",
},
limitations: [
expect.stringContaining("1 XML scalar value(s) could not be established"),
],
conversions: [],
},
{
name: "unknown OpenStep primitive",
bytes: Buffer.from("123"),
description: "ASCII text",
format: "unknown",
value: "123",
conversions: [
{ format: "json", output: "", exitCode: 1 },
{ format: "xml1", output: "<plist><string>123</string></plist>" },
],
},
{
name: "unknown JSON container",
bytes: Buffer.from('{"count":123}'),
description: "ASCII text",
format: "unknown",
value: { count: 123 },
conversions: [],
},
{
name: "explicit JSON primitive",
bytes: Buffer.from("123"),
description: "JSON text data",
format: "json",
value: 123,
conversions: [],
},
{
name: "unsafe JSON container",
bytes: Buffer.from('{"count":9007199254740993}'),
description: "ASCII text",
format: "unknown",
value: { count: { $plist_type: "integer", decimal: "9007199254740993" } },
conversions: [
{ format: "json", output: '{"count":9007199254740993}' },
{
format: "xml1",
output:
"<plist><dict><key>count</key><integer>9007199254740993</integer></dict></plist>",
},
],
},
{
name: "legacy XML encoding",
bytes: Buffer.from(
'<?xml version="1.0" encoding="ISO-8859-1"?><plist><dict><key>name</key><string>caf\xe9</string></dict></plist>',
"latin1",
),
description: "XML 1.0 document text",
format: "xml",
value: { name: "café" },
conversions: [{ format: "json", output: '{"name":"café"}' }],
},
{
name: "uncertain XML encoding",
bytes: utf32Bytes,
description: "Unicode text, UTF-32, little-endian",
format: "unknown",
value: { name: "UTF32 control" },
conversions: [{ format: "json", output: '{"name":"UTF32 control"}' }],
},
])("owns $name interpretation before optional conversion", async (fixture) => {
const root = await createTestTempDirectory("rea-plist-text-source-");
const path = join(root, "selected.plist");
await writeFile(path, fixture.bytes);
const replay = new NativeFixtureRunner({ file: fixture.description });
const tools: string[] = [];
let conversionIndex = 0;
const client = new NativeMacOSProvider(
{},
{
async run(tool, args) {
tools.push(tool);
const result = await replay.run(tool, args);
if (tool !== "plutil" || !result.ok) return result;
const conversion = fixture.conversions[conversionIndex++];
if (conversion === undefined)
throw new Error("Unexpected conversion of directly supported source");
expect(args[1]).toBe(conversion.format);
return ok({
...result.value,
stdout: conversion.output,
stdoutBytes: Buffer.byteLength(conversion.output),
exitCode: conversion.exitCode ?? 0,
});
},
},
"darwin",
).createClient(nativeMachoTarget(join(root, "target")));
onTestFinished(async () => {
await client.close();
});
const inspected = await client.execute("inspect_plist", { path });
if (!inspected.ok) throw inspected.error;
const result = inspectPlistSchema.parse(inspected.value.result);
expect(result).toMatchObject({
format: fixture.format,
value: fixture.value,
source_path: path,
});
expect(result.provenance.map((item) => item.tool)).toEqual(tools);
expect(tools).toEqual(["file", ...fixture.conversions.map(() => "plutil")]);
expect(conversionIndex).toBe(fixture.conversions.length);
if (fixture.limitations !== undefined)
expect(result.limitations).toEqual(fixture.limitations);
expect(
result.limitations.some((item) => item.includes("XML conversion")),
).toBe(
fixture.conversions.some(
(item) => item.format === "json" && item.exitCode === 1,
),
);
expect(await readFile(path)).toEqual(fixture.bytes);
});
@@ -68,6 +68,7 @@ it.for<PlistSourceCase>([
contents: JSON.stringify(VALUE),
expected: "json",
xmlFallback: false,
expectedTools: ["file"],
},
...["<deadbeef>\n", "<de ad\nbe ef>\n", "<>\n"].map((contents) => ({
description: "ASCII text\n",
@@ -86,12 +87,16 @@ it.for<PlistSourceCase>([
contents: ` \n${XML}`,
expected: "xml",
xmlFallback: true,
expectedTools: ["file"],
expectedLimitations: [],
},
{
description: "ASCII text\n",
contents: `<!-- source plist -->\n${XML}`,
expected: "xml",
xmlFallback: true,
expectedTools: ["file"],
expectedLimitations: [],
},
...[false, true].map((bigEndian) => ({
description: `Unicode text, UTF-16, ${bigEndian ? "big" : "little"}-endian text\n`,
@@ -100,6 +105,8 @@ it.for<PlistSourceCase>([
: Buffer.from(`\uFEFF${XML}`, "utf16le"),
expected: "xml",
xmlFallback: true,
expectedTools: ["file"],
expectedLimitations: [],
})),
{ description: "ASCII text\n", expected: "unknown", xmlFallback: true },
{
@@ -109,14 +116,20 @@ it.for<PlistSourceCase>([
},
{
description: "XML 1.0 document text, ASCII text\n",
contents: XML,
expected: "xml",
xmlFallback: false,
expectedValue: { ...VALUE, Blob: DATA },
expectedTools: ["file"],
},
{
description:
"XML 1.0 document text, Unicode text, UTF-16, big-endian text\n",
contents: Buffer.from("\uFEFF" + XML, "utf16le").swap16(),
expected: "xml",
xmlFallback: true,
expectedTools: ["file"],
expectedLimitations: [],
},
{
description: "Apple binary property list\n",
@@ -149,6 +162,8 @@ it.for<PlistSourceCase>([
xmlFallback: true,
convertedXml:
"<plist><dict><key>nel</key><string>a\u0085b</string><key>ls</key><string>a\u2028b</string><key>ps</key><string>a\u2029b</string><key>lf</key><string>a\nb</string><key>payload</key><data>AA==</data></dict></plist>",
expectedTools: ["file"],
expectedLimitations: [],
expectedValue: {
nel: "a\u0085b",
ls: "a\u2028b",