Files
rea/src/domain/propertyListKeys.ts
T
ae09e3fe70 fix(native): recover macOS command deadlines (#6059)
* fix(native): recover macOS command deadlines

Promote native_command_timeout_ms (default 60000), native_ui_deadline_ms
(default 180000), and pwntools_timeout_ms (default 30000) as validated
per-call settings. Keep cpuSeconds, byte allowances, LLDB grace, and plist
probes fixed, and say those caps have no runtime override.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(native): report the deadline that actually expired

Pass the selected native command timeout through xcrun resolution, thread
the pwntools deadline into pwndbg failures, and state the selected UI
deadline apart from the fixed wait-step cap. SIGXFSZ and EFBIG recovery
names the file-size allowance.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(native): honor the selected deadline on helper stages

The UI helper compile and capture subprocesses used fixed 60s and 30s
exec timeouts, so a longer native_ui_deadline_ms still failed as a generic
provider error. Preparation timeouts reported the 60000 ms default instead
of the selected native_command_timeout_ms.

Memory resource-constraint recovery names memory again, and the Darwin
plist CLI expectation matches the byte-counted input allowance.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 02:59:27 +08:00

246 lines
8.6 KiB
TypeScript

import { parseOpenStep } from "plist";
import { decodeXmlPropertyList } from "./propertyListXml.js";
import {
createPlistNumberCollector,
type PlistNumberLiterals,
} from "./apple/plistNumbers.js";
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
import {
MAX_PLIST_DEPTH,
plistNestingLimitMessage,
} from "./apple/plistValue.js";
/** The dictionary key REA results cannot carry: validation strips it. */
const PROTOTYPE_KEY = "__proto__";
/** A decoded plist without `__proto__` entries, and how many were omitted. */
export interface PropertyListWithoutPrototypeKeys {
readonly value: unknown;
readonly omittedPrototypeKeys: number;
}
/** Value and numeric facts decoded together; OpenStep numeric facts remain unknown. */
export interface ParsedPropertyList extends PropertyListWithoutPrototypeKeys {
readonly numberLiterals: PlistNumberLiterals;
}
/** Limitation for results whose source plist held `__proto__` entries. */
export const omittedPrototypeKeysLimitation = (count: number | null): string =>
count === null
? "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key."
: `${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`;
/** Remove own `__proto__` entries, such as JSON.parse creates, and count them. */
export const omitPrototypeKeys = (
value: unknown,
): PropertyListWithoutPrototypeKeys => {
let omitted = 0;
const strip = (item: unknown): unknown => {
if (Array.isArray(item)) return item.map(strip);
if (
item === null ||
typeof item !== "object" ||
item instanceof Date ||
item instanceof Uint8Array ||
item instanceof UnknownPlistScalar
)
return item;
return Object.fromEntries(
Object.entries(item).flatMap(([name, entry]) => {
if (name !== PROTOTYPE_KEY) return [[name, strip(entry)]];
omitted += 1;
return [];
}),
);
};
const stripped = strip(value);
return { value: stripped, omittedPrototypeKeys: omitted };
};
/**
* 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 => {
// Preserve the pinned decoder's existing OpenStep autodetection, also used
// by app Info.plist resolution.
const first = text.trimStart()[0];
if (first === "{" || first === "(") {
// 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);
return {
value: omitPrototypeKeys(parseOpenStep(text)).value,
numberLiterals: createPlistNumberCollector().result(true),
omittedPrototypeKeys: prototypeKeys,
};
}
const decoded = decodeXmlPropertyList(text);
return {
...omitPrototypeKeys(decoded.value),
numberLiterals: decoded.numberLiterals,
};
};
interface OpenStepContainer {
end: string;
next: "key" | "equals" | "value" | "separator" | "after-value";
/** Source offset of a container that is itself a dictionary key. */
keyStart?: number;
}
/** Consume only the punctuation expected between the pinned decoder's values. */
const consumeOpenStepSeparator = (
parent: OpenStepContainer | undefined,
character: string,
): "consumed" | "invalid" | "none" => {
if (parent?.next === "after-value") {
parent.next = "key";
return character === ";" ? "consumed" : "none";
}
if (parent?.next !== "equals" && parent?.next !== "separator") return "none";
if (character !== (parent.next === "equals" ? "=" : ",")) return "invalid";
parent.next = "value";
return "consumed";
};
/** An array may close after a comma; a dictionary still needs its assigned value. */
const admitsOpenStepCloser = (
parent: OpenStepContainer | undefined,
character: string,
): boolean =>
parent?.end === character &&
parent.next !== "equals" &&
!(parent.end === "}" && parent.next === "value");
/**
* 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 => {
let prototypeKeys = 0;
const containers: OpenStepContainer[] = [];
const completeValue = (): void => {
const parent = containers.at(-1);
if (parent !== undefined)
parent.next =
parent.next === "key"
? "equals"
: parent.end === "}"
? "after-value"
: "separator";
};
const assertDepth = (): void => {
if (containers.length > MAX_PLIST_DEPTH)
throw new RangeError(plistNestingLimitMessage());
};
let cursor = 0;
while (cursor < text.length) {
const afterTrivia = skipOpenStepTrivia(text, cursor);
if (afterTrivia !== cursor) {
cursor = afterTrivia;
continue;
}
const character = text[cursor] ?? "";
const parent = containers.at(-1);
// Stop at the first impossible token: only the pinned decoder owns syntax
// diagnostics. A later deep suffix cannot change that failure into a limit.
if (character === "}" || character === ")") {
if (!admitsOpenStepCloser(parent, character)) return prototypeKeys;
const closed = containers.pop();
if (
closed?.keyStart !== undefined &&
isOpenStepPrototypeKey(text.slice(closed.keyStart, cursor + 1))
)
prototypeKeys += 1;
// parseOpenStep returns the first root value without reading trailing text.
if (containers.length === 0) return prototypeKeys;
completeValue();
cursor += 1;
continue;
}
const separator = consumeOpenStepSeparator(parent, character);
if (separator === "invalid") return prototypeKeys;
if (separator === "consumed") {
cursor += 1;
continue;
}
if (character === "{" || character === "(") {
assertDepth();
containers.push({
end: character === "{" ? "}" : ")",
next: character === "{" ? "key" : "value",
// A compound key is coerced to a string, e.g. `(__proto__)`.
...(parent?.next === "key" ? { keyStart: cursor } : {}),
});
cursor += 1;
continue;
}
const end = skipOpenStepScalar(text, cursor);
if (end === cursor) return prototypeKeys;
if (parent?.next === "key") {
if (isOpenStepPrototypeKey(text.slice(cursor, end))) prototypeKeys += 1;
} else assertDepth();
cursor = end;
completeValue();
}
return prototypeKeys;
};
/**
* Whether one OpenStep key, scalar or compound, becomes the property name
* `__proto__` when the decoder assigns `obj[key]`. Plain strings compare
* directly; any other key is decoded by the pinned decoder itself and
* coerced to a property name the same way that assignment does.
*/
const isOpenStepPrototypeKey = (token: string): boolean => {
if (!/^["({<]/u.test(token)) return token === PROTOTYPE_KEY;
if (token.startsWith('"') && !token.includes("\\"))
return token === `"${PROTOTYPE_KEY}"`;
try {
return String(parseOpenStep(token)) === PROTOTYPE_KEY;
} catch {
// An unterminated token is the decoder's syntax error to report.
return false;
}
};
/** Skip grammar trivia without interpreting delimiter examples inside comments. */
const skipOpenStepTrivia = (text: string, cursor: number): number => {
if (/\s/u.test(text[cursor] ?? "")) return cursor + 1;
if (!text.startsWith("//", cursor) && !text.startsWith("/*", cursor))
return cursor;
const line = text[cursor + 1] === "/";
const end = text.indexOf(line ? "\n" : "*/", cursor + 2);
return end === -1 ? text.length : end + (line ? 1 : 2);
};
/** Read the extent of one opaque scalar; the pinned decoder still owns its value. */
const skipOpenStepScalar = (text: string, start: number): number => {
let cursor = start;
const character = text[cursor];
if (character === '"') {
cursor += 1;
while (cursor < text.length) {
const next = text[cursor];
cursor += next === "\\" ? 2 : 1;
if (next === '"') break;
}
} else if (character === "<") {
const end = text.indexOf(">", cursor + 1);
cursor = end === -1 ? text.length : end + 1;
} else {
// Match the pinned OpenStep decoder's unquoted-string alphabet. Other
// syntax remains for the decoder to reject; preflight only owns depth.
while (
cursor < text.length &&
/[a-zA-Z0-9._/$:-]/u.test(text[cursor] ?? "")
)
cursor += 1;
}
return cursor;
};