chore: merge main into the streamable HTTP transport branch

Picks up upstream's test-runner configuration and the command-finalization
test fixes so the fork lane stops failing on unrelated flaky shards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Fable 5.1 committed 2026-10-18 11:58:12 -07:00
commit cfbf595437
135 files changed
+33233 -1087

No files matched your search

+32 -1
View File
@@ -23,6 +23,37 @@ const probe = (signal?: AbortSignal) =>
signal,
);
it("classifies the complete diagnostic before shortening its displayed reason", async () => {
const localPath = `/host/${"directory/".repeat(60)}payload.bin`;
const stderr = `adb: error: cannot create '${localPath}': Permission denied`;
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("local destination refused"), {
code: 1,
stdout: "",
stderr,
signal: null,
killed: false,
}),
);
await expect(
runAdbCapture(
"pull_adb_file",
{ device: "device", path: "/remote/file", localPath },
"/host/adb",
[],
{ timeoutMs: 100, maxOutputBytes: 2048 },
undefined,
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
path: localPath,
reason: stderr.slice(0, 512),
},
});
});
it("recognizes only the completed empty false result as absence", async () => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("test false"), {
@@ -161,7 +192,7 @@ it("keeps a physical failure even when the later abort reason is that same error
});
const result = await runAdbCapture(
"inspect_adb_device",
"device",
{ device: "device" },
"/host/adb",
[],
{ timeoutMs: 100, maxOutputBytes: 1024 },
+72 -17
View File
@@ -17,8 +17,26 @@ import {
type ExecFileOutputFailure,
} from "../process/ExecFileOutput.js";
import { packageChangeFailureReason } from "./AdbShellOutput.js";
import { isSuccessfulDaemonStartNotice } from "./AdbDeviceOutput.js";
const STDERR_EXCERPT_BYTES = 512;
const STDERR_EXCERPT_CHARACTERS = 512;
// Match the known host operand in adb's local-file diagnostics. The suffix is
// part of the format: opening/reading identify local failures explicitly.
const LOCAL_FILE_DIAGNOSTICS = [
["cannot create", ":"],
["cannot create file/directory", ":"],
["cannot stat", ":"],
["cannot open", ":"],
["cannot write", ":"],
["failed to stat local file", ":"],
["failed to read all of", ":"],
["failed to access", ":"],
["failed to stat", ":"],
["failed to open", ":"],
["opening", " locally failed:"],
["reading", " locally failed:"],
] as const;
/** Project parsed command-owner facts without reinterpreting the rejection. */
export const adbCommandCapturedOutput = (
@@ -70,10 +88,19 @@ export interface AdbRunLimits {
readonly maxOutputBytes: number;
}
/** Keep the selected device separate from the file addressed by the command. */
export interface AdbCommandTarget {
readonly device: string;
/** Caller-addressed file, such as a remote path or the original APK input. */
readonly path?: string;
/** Exact host operand adb consumes, including an owned input snapshot. */
readonly localPath?: string;
}
/** Run one bounded adb invocation and keep its exact failure shape. */
export const runAdbCapture = async (
operation: string,
targetPath: string,
target: AdbCommandTarget,
binary: string,
arguments_: readonly string[],
limits: AdbRunLimits,
@@ -115,33 +142,61 @@ export const runAdbCapture = async (
{ cause },
),
);
const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES);
if (/unauthorized/iu.test(excerpt))
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
const stderr = failure.stderr.trim();
const excerpt = stderr.slice(0, STDERR_EXCERPT_CHARACTERS);
// Device-state words inside a reported file name are not device verdicts.
// Only leading notices need line parsing. Do not expand a capture-sized
// stderr into an array whose entries can exceed the byte capture budget.
let diagnosticStart = 0;
while (diagnosticStart < stderr.length) {
const newline = stderr.indexOf("\n", diagnosticStart);
const line = stderr
.slice(diagnosticStart, newline === -1 ? stderr.length : newline)
.trimEnd();
if (
!isSuccessfulDaemonStartNotice(line) &&
!/^\* daemon not running(?:; starting now at [^\r\n]*|\. starting it now on port \d+ \*)$/u.test(
line,
)
)
break;
diagnosticStart = newline === -1 ? stderr.length : newline + 1;
}
const diagnostic = stderr
.slice(diagnosticStart)
.replace(
/^(?:adb:\s*)?(?:error:\s*)?(?:(?:failed to get feature set|connect failed):\s*)?/iu,
"",
);
if (
/device[^\n]*not found|device offline|still authorizing|more than one device/iu.test(
excerpt,
/^(?:unauthorized\b|device(?: '[^'\n]*')? (?:unauthorized\b|offline\b|not found\b|still authorizing\b)|still authorizing\b|more than one device\b)/iu.test(
diagnostic,
)
)
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
new AnalysisUnsupportedTargetError(operation, target.device, excerpt, {
cause,
}),
);
if (
/No such file or directory|Permission denied|read: Permission denied/iu.test(
excerpt,
diagnostic,
)
)
) {
const localPath = target.localPath;
const path =
localPath !== undefined &&
LOCAL_FILE_DIAGNOSTICS.some(([verb, suffix]) =>
[localPath, `'${localPath}'`, `"${localPath}"`].some((operand) =>
diagnostic.startsWith(`${verb} ${operand}${suffix}`),
),
)
? localPath
: (target.path ?? target.device);
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
new AnalysisUnsupportedTargetError(operation, path, excerpt, { cause }),
);
}
if (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
@@ -210,7 +265,7 @@ export const probeAdbPathExistence = async (
): Promise<Result<boolean, AnalysisError>> => {
const probed = await runAdbCapture(
operation,
path,
{ device: serial, path },
binary,
adbArguments(serial, ["shell", "test", "-e", path]),
limits,
+1 -1
View File
@@ -32,7 +32,7 @@ const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
const normalizeAdbOutputLine = (rawLine: string): string => rawLine.trimEnd();
const isSuccessfulDaemonStartNotice = (line: string): boolean =>
export const isSuccessfulDaemonStartNotice = (line: string): boolean =>
line === "* daemon started successfully" ||
line === "* daemon started successfully *";
+136 -2
View File
@@ -568,7 +568,7 @@ it.each([
async (_, stderr, excerpt) => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ]; then printf '%s\\n' "${stderr.replace(/'/gu, "'\\''")}" >&2; exit 1; fi`,
if [ "$1" = "-s" ]; then printf '%s\\n' "${stderr}" >&2; exit 1; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_device",
@@ -873,6 +873,137 @@ exit 1`,
});
});
it.each([
[
"pull_adb_file",
"adb: error: failed to stat remote object /sdcard/unauthorized.bin: No such file or directory",
"/sdcard/unauthorized.bin",
],
[
"pull_adb_file",
"adb: error: failed to open /sdcard/unauthorized.bin: Permission denied",
"/sdcard/unauthorized.bin",
],
[
"pull_adb_file",
"* daemon not running; starting now at tcp:5037\n* daemon started successfully\nadb: error: device offline",
"emulator-5554",
],
["pull_adb_file", "error: device unauthorized", "emulator-5554"],
[
"pull_adb_file",
"* daemon not running. starting it now on port 5037 *\r\n* daemon started successfully *\r\nerror: device offline",
"emulator-5554",
],
[
"pull_adb_file",
"* daemon started successfully *\r\nerror: device 'emulator-5554' not found",
"emulator-5554",
],
[
"push_adb_file",
"adb: error: connect failed: device offline",
"emulator-5554",
],
["push_adb_file", "error: device unauthorized", "emulator-5554"],
["push_adb_file", "Permission denied", "/sdcard/unauthorized.bin"],
] as const)(
"attributes %s refusal %s to %s",
async (operation, reason, path) => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" != "-s" ] || [ "$2" != "emulator-5554" ]; then exit 91; fi
if [ "$3" = "shell" ] && [ "$4" = "test" ] && [ "$6" = "/sdcard/unauthorized.bin" ]; then exit 1; fi
if [ "$3:$4" = "pull:/sdcard/unauthorized.bin" ] || [ "$3:$5" = "push:/sdcard/unauthorized.bin" ]; then
printf '%s\\n' '${reason.replaceAll("'", "'\\''")}' >&2
exit 1
fi
exit 91`,
);
const localPath = join(root, "payload.bin");
await writeFile(localPath, "payload");
const input = {
serial: "emulator-5554",
device_path: "/sdcard/unauthorized.bin",
};
const projected = await refusedBy(
providerOver(binary),
operation === "pull_adb_file"
? {
operation,
input: { ...input, output_directory: join(root, "pulls") },
}
: {
operation,
input: { ...input, local_path: localPath, overwrite: false },
},
);
expect(projected).toMatchObject({
code: "unsupported_target",
details: { operation, path, reason },
message: `Unsupported target for ${operation} at ${path}: ${reason}`,
});
},
);
it.each([
["pull_adb_file", "cannot create '%s': Permission denied"],
["pull_adb_file", "cannot create file/directory '%s': Permission denied"],
["pull_adb_file", "cannot write '%s': Permission denied"],
["push_adb_file", "cannot stat '%s': No such file or directory"],
["push_adb_file", "opening '%s' locally failed: Permission denied"],
["push_adb_file", "reading '%s' locally failed: Permission denied"],
["push_adb_file", "failed to read all of '%s': Permission denied"],
] as const)(
"keeps the actual host file identity for %s refusal %s",
async (operation, diagnostic) => {
const { binary, root } = await writeAdbStub(
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" != "-s" ] || [ "$2" != "emulator-5554" ]; then exit 91; fi
if [ "$3" = "shell" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$3" = "pull" ]; then
printf '%s' "$5" > '${stubRoot}/host-path'
printf "adb: error: ${diagnostic}\\n" "$5" >&2
exit 1
fi
if [ "$3" = "push" ]; then
printf '%s' "$4" > '${stubRoot}/host-path'
printf "adb: error: ${diagnostic}\\n" "$4" >&2
exit 1
fi
exit 91`,
);
const localPath = join(root, "payload.bin");
await writeFile(localPath, "payload");
const input = {
serial: "emulator-5554",
device_path: "/sdcard/sample.bin",
};
const projected = await refusedBy(
providerOver(binary),
operation === "pull_adb_file"
? {
operation,
input: { ...input, output_directory: join(root, "pulls") },
}
: {
operation,
input: { ...input, local_path: localPath, overwrite: false },
},
);
const hostPath = await readFile(join(root, "host-path"), "utf8");
expect(projected).toMatchObject({
code: "unsupported_target",
details: {
operation,
path: hostPath,
reason: expect.stringContaining(hostPath),
},
});
expect(projected.message).toContain(`at ${hostPath}:`);
},
);
it.each([
["/data/local/tmp/..", "dot_segment"],
["/data/local/tmp/", "empty_file_name"],
@@ -1113,7 +1244,10 @@ if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\
},
});
// Existence is unknown, so nothing is claimed about the path and no push runs.
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected).toMatchObject({
code: "unsupported_target",
details: { path: "emulator-5554", reason: stderr },
});
expect(projected.message).toContain(state);
expect(projected.message).not.toContain("already exists");
expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n");
+30 -16
View File
@@ -1318,7 +1318,9 @@ export class AdbProvider {
const shell = (arguments_: readonly string[], limits: AdbRunLimits) =>
runAdb(
operation,
"serial" in request.input ? request.input.serial : operation,
{
device: "serial" in request.input ? request.input.serial : operation,
},
selection.binary,
adbArguments(
"serial" in request.input ? request.input.serial : undefined,
@@ -1338,7 +1340,7 @@ export class AdbProvider {
case "list_adb_devices": {
const ran = await runAdb(
operation,
"adb-devices",
{ device: "adb-devices" },
selection.binary,
["devices", "-l"],
{
@@ -1374,7 +1376,7 @@ export class AdbProvider {
case "inspect_adb_device": {
const ran = await runAdb(
operation,
request.input.serial,
{ device: request.input.serial },
selection.binary,
adbArguments(request.input.serial, ["shell", "getprop"]),
{
@@ -1615,7 +1617,7 @@ export class AdbProvider {
case "list_adb_directory": {
const ran = await runAdbCapture(
operation,
request.input.serial,
{ device: request.input.serial, path: request.input.device_path },
selection.binary,
adbArguments(request.input.serial, [
"shell",
@@ -1909,7 +1911,7 @@ export class AdbProvider {
const { serial, package: packageName } = input;
const paths = await runAdb(
operation,
serial,
{ device: serial },
selection.binary,
adbArguments(serial, ["shell", "pm", "path", packageName]),
{
@@ -2129,7 +2131,11 @@ export class AdbProvider {
const localPath = join(packageDirectory, entry.file_name);
const pulled = await runAdb(
operation,
serial,
{
device: serial,
path: entry.device_path,
localPath,
},
selection.binary,
adbArguments(serial, ["pull", entry.device_path, localPath]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
@@ -2302,7 +2308,11 @@ export class AdbProvider {
if (existing !== undefined) return err(existing);
const pulled = await runAdb(
operation,
input.serial,
{
device: input.serial,
path: input.device_path,
localPath,
},
selection.binary,
adbArguments(input.serial, ["pull", input.device_path, localPath]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
@@ -2423,7 +2433,11 @@ export class AdbProvider {
);
const pushed = await runAdb(
operation,
input.device_path,
{
device: input.serial,
path: input.device_path,
localPath: digest.path,
},
selection.binary,
adbArguments(input.serial, ["push", digest.path, input.device_path]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
@@ -2457,7 +2471,7 @@ export class AdbProvider {
);
const deviceDigest = await runAdb(
operation,
input.device_path,
{ device: input.serial, path: input.device_path },
selection.binary,
adbArguments(input.serial, ["shell", "sha256sum", input.device_path]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: 1024 },
@@ -2563,7 +2577,7 @@ export class AdbProvider {
);
const installed = await runAdb(
operation,
input.serial,
{ device: input.serial, path: apkPath, localPath: digest.path },
selection.binary,
adbArguments(input.serial, [
"install",
@@ -2621,7 +2635,7 @@ export class AdbProvider {
): Promise<Result<AnalysisExecution, AnalysisError>> {
const uninstalled = await runAdb(
operation,
input.serial,
{ device: input.serial },
selection.binary,
adbArguments(input.serial, [
"shell",
@@ -2678,7 +2692,7 @@ export class AdbProvider {
): Promise<Result<AnalysisExecution, AnalysisError>> {
const resolved = await runAdb(
operation,
input.serial,
{ device: input.serial },
selection.binary,
adbArguments(input.serial, [
"shell",
@@ -2816,7 +2830,7 @@ export class AdbProvider {
): Promise<Result<AnalysisExecution, AnalysisError>> {
const started = await runAdb(
operation,
serial,
{ device: serial },
selection.binary,
adbArguments(serial, ["shell", "am", "start", "-W", ...arguments_]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
@@ -2885,7 +2899,7 @@ export class AdbProvider {
): Promise<Result<AnalysisExecution, AnalysisError>> {
const stopped = await runAdb(
operation,
input.serial,
{ device: input.serial },
selection.binary,
adbArguments(input.serial, ["shell", "am", "force-stop", input.package]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
@@ -2927,7 +2941,7 @@ export class AdbProvider {
if (existing !== undefined) return err(existing);
const captured = await runAdbCapture(
operation,
input.serial,
{ device: input.serial },
selection.binary,
adbArguments(input.serial, ["exec-out", "screencap", "-p"]),
{ timeoutMs: SCREEN_TIMEOUT_MS, maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES },
@@ -3077,7 +3091,7 @@ export class AdbProvider {
};
const collected = await runAdb(
operation,
input.serial,
{ device: input.serial },
selection.binary,
adbArguments(input.serial, ["bugreport", stagingDirectory]),
{
+32
View File
@@ -169,6 +169,38 @@ const apkFixture = async (
};
};
it("returns table-only facts for a single selected primary table", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const provider = providerOver(command);
onTestFinished(() => provider.close());
const outcome = await new ApktoolResourceAnalysisService(provider).execute(
"decode_android_resources",
{ path: apk.path, string_tables_only: true },
);
if (!outcome.ok) throw outcome.error;
expect(outcome.value).toMatchObject({
parameters: { path: apk.path, string_tables_only: true },
subject: { digest: { sha256: apk.sha256 } },
normalized_result: {
strings: [],
string_tables: [
{
locale: null,
status: "present",
strings: [
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
],
total_count: 2,
unparsed_count: 0,
coverage: "complete",
},
],
},
});
});
it.each([
[false, false],
[true, false],
+1 -1
View File
@@ -660,7 +660,7 @@ export class ApktoolProvider {
: "complete";
completedDecode.strings = [...projectedStrings.strings];
if (input.locales !== undefined)
if (input.locales !== undefined || input.string_tables_only === true)
completedDecode.string_tables = [...projectedStrings.tables];
completedDecode.coverage = coverage;
if (!projection.ok) throw projection.error.cause;
+121 -64
View File
@@ -18,30 +18,66 @@ const textRead = (text: string) => ({
sourceBytes: Buffer.byteLength(text, "utf8"),
});
describe("physical Apktool string facts", () => {
it("reads one physical table for two caller-selected aliases", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(1));
},
it("charges table-only output without reserving a duplicate primary copy", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(MAX_STRINGS));
},
"decoded",
{ include_strings: true, locale: "pt-BR", locales: ["pt-rBR"] },
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths).toEqual([
join("decoded", "res", "values-pt-rBR", "strings.xml"),
]);
expect(result.tables.map((table) => table.locale)).toEqual([
"pt-BR",
"pt-rBR",
]);
expect(result.tables.map((table) => table.total_count)).toEqual([1, 1]);
});
},
"decoded",
{
include_strings: true,
locales: ["de", "es", "it", "fr"],
string_tables_only: true,
},
);
if (!outcome.ok) throw outcome.error.cause;
expect(paths).toHaveLength(5);
expect(outcome.value.strings).toEqual([]);
expect(outcome.value.tables.map((table) => table.status)).toEqual(
Array.from({ length: 5 }, () => "present"),
);
expect(
outcome.value.tables.reduce((sum, table) => sum + table.strings.length, 0),
).toBe(MAX_AGGREGATE_STRINGS);
expect(outcome.value.coverage).toBe("complete");
});
describe("physical Apktool string facts", () => {
it.each([false, true])(
"reads one physical table for two caller-selected aliases (table-only: %s)",
async (tablesOnly) => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(1));
},
},
"decoded",
{
include_strings: true,
locale: "pt-BR",
locales: ["pt-rBR"],
string_tables_only: tablesOnly,
},
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths).toEqual([
join("decoded", "res", "values-pt-rBR", "strings.xml"),
]);
expect(result.tables.map((table) => table.locale)).toEqual([
"pt-BR",
"pt-rBR",
]);
expect(result.tables.map((table) => table.total_count)).toEqual([1, 1]);
},
);
it("counts complete source entries beyond the retained table prefix", async () => {
const outcome = await projectApktoolStringTables(
@@ -138,50 +174,71 @@ describe("physical Apktool string facts", () => {
});
describe("Apktool string projection admission", () => {
it("retains completed tables when cancellation arrives before the next selection", async () => {
const controller = new AbortController();
const reason = { selected: "caller" };
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
controller.abort(reason);
return textRead(xml(1));
it.each([false, true])(
"retains completed tables when cancellation arrives before the next selection (table-only: %s)",
async (tablesOnly) => {
const controller = new AbortController();
const reason = { selected: "caller" };
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
controller.abort(reason);
return textRead(xml(1));
},
},
},
"decoded",
{ include_strings: true, locales: ["de", "fr"] },
controller.signal,
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toHaveProperty("cause", reason);
expect(paths).toHaveLength(1);
expect(result.error.partial).toMatchObject({
coverage: "partial",
tables: [{ locale: null, total_count: 1 }],
});
expect(result.error.partial.limitations).toContain(
'String projection stopped at locale "de"; this selection and 1 remaining selections were not projected',
);
});
"decoded",
{
include_strings: true,
locales: ["de", "fr"],
string_tables_only: tablesOnly,
},
controller.signal,
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toHaveProperty("cause", reason);
expect(paths).toHaveLength(1);
expect(result.error.partial).toMatchObject({
coverage: "partial",
tables: [{ locale: null, total_count: 1 }],
});
expect(result.error.partial.strings).toHaveLength(tablesOnly ? 0 : 1);
expect(result.error.partial.limitations).toContain(
'String projection stopped at locale "de"; this selection and 1 remaining selections were not projected',
);
},
);
it("honors omitted strings without reading any selected table", async () => {
const outcome = await projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
it.each([false, true])(
"honors omitted strings without reading any selected table (table-only: %s)",
async (tablesOnly) => {
const outcome = await projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
},
},
},
"decoded",
{ include_strings: false, locale: "de", locales: ["fr"] },
);
expect(outcome).toEqual({
ok: true,
value: { strings: [], tables: [], limitations: [], coverage: "complete" },
});
});
"decoded",
{
include_strings: false,
locale: "de",
locales: ["fr"],
string_tables_only: tablesOnly,
},
);
expect(outcome).toEqual({
ok: true,
value: {
strings: [],
tables: [],
limitations: [],
coverage: "complete",
},
});
},
);
it("preserves the exact physical read failure", async () => {
const cause = new Error("Physical read and close failed");
+11 -5
View File
@@ -19,7 +19,7 @@ import type { ApktoolWorkspace } from "./ApktoolWorkspace.js";
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
type Selection = Pick<
Extract<ApktoolRequest, { operation: "decode_android_resources" }>["input"],
"include_strings" | "locale" | "locales"
"include_strings" | "locale" | "locales" | "string_tables_only"
>;
type PresentTable = {
readonly status: "present";
@@ -152,7 +152,8 @@ export const projectApktoolStringTables = async (
"Aggregate string table projection stopped due to resource limits",
);
return {
strings: tables[0]?.strings ?? [],
strings:
input.string_tables_only === true ? [] : (tables[0]?.strings ?? []),
tables,
limitations: [...limitations],
coverage: tables.some((table) => table.coverage === "partial")
@@ -223,9 +224,14 @@ export const projectApktoolStringTables = async (
);
continue;
}
// Batch output includes the primary strings twice, in the legacy field and
// in string_tables. Charge that representation before selecting entries.
const copies = index === 0 && input.locales !== undefined ? 2 : 1;
// The default batch shape repeats primary entries in strings and tables;
// table-only output charges one copy. Account before selecting entries.
const copies =
index === 0 &&
input.locales !== undefined &&
input.string_tables_only !== true
? 2
: 1;
const remaining = Math.max(0, MAX_AGGREGATE_STRINGS - outputEntries);
const selected = fact.entries.slice(0, Math.floor(remaining / copies));
outputEntries += selected.length * copies;
@@ -4,8 +4,35 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
it.each([
["macOS", ["Contents"], ["Contents", "MacOS"]],
["iOS", [], []],
] as const)(
"resolves the %s bundle layout when Info.plist contains processing instructions",
async (_kind, plistDirectory, programDirectory) => {
const root = await createTestTempDirectory("rea-app-plist-pi-");
const bundle = join(root, "Fixture.app");
const plistPath = join(bundle, ...plistDirectory, "Info.plist");
const executable = join(bundle, ...programDirectory, "Fixture");
await mkdir(join(bundle, ...plistDirectory), { recursive: true });
await mkdir(join(bundle, ...programDirectory), { recursive: true });
await writeFile(
plistPath,
'<plist version="1.0"><?rea ignored?><dict><?rea ignored?><key>CFBundleExecutable</key><?rea ignored?><string>Fixture</string><?rea ignored?></dict><?rea ignored?></plist>',
);
await writeFile(executable, "inert fixture; never executed");
const result = await resolveAppBundleExecutable(bundle);
expect(result).toEqual({
ok: true,
value: { executable, infoPlist: plistPath },
});
},
);
describe("app bundle Info.plist magic", () => {
it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-"));
@@ -8,6 +8,14 @@ import {
const registration = { command: "rea", args: ["mcp"] };
const serializedText = (
...input: Parameters<typeof serializeClientConfiguration>
): string => {
const serialized = serializeClientConfiguration(...input);
if (!serialized.ok) throw new Error("TOML serialization was refused");
return serialized.value;
};
describe("TOML configuration source capacity", () => {
it.each(["\n", "\r\n"])(
"retains adjacent assignments and multiline values with %j breaks",
@@ -29,18 +37,14 @@ describe("TOML configuration source capacity", () => {
...parsed.servers,
rea: registration,
});
const configured = serializeClientConfiguration(
desired,
"toml",
original,
);
const configured = serializedText(desired, "toml", original);
expect(
configured.startsWith("\uFEFF" + prefix.replaceAll("\n", newline)),
).toBe(true);
expect(parseClientConfiguration(configured, "toml").document).toEqual(
desired,
);
const removed = serializeClientConfiguration(
const removed = serializedText(
withClientServers(parsed, {}),
"toml",
configured,
@@ -61,16 +65,12 @@ describe("TOML configuration source capacity", () => {
...parsed.servers,
rea: registration,
});
const configured = serializeClientConfiguration(desired, "toml", original);
const configured = serializedText(desired, "toml", original);
expect(configured).toContain(`NOTE="""${note}"""`);
expect(parseClientConfiguration(configured, "toml").document).toEqual(
desired,
);
const removed = serializeClientConfiguration(
parsed.document,
"toml",
configured,
);
const removed = serializedText(parsed.document, "toml", configured);
expect(removed).toContain(`NOTE="""${note}"""`);
expect(parseClientConfiguration(removed, "toml").document).toEqual(
parsed.document,
@@ -87,7 +87,7 @@ describe("TOML configuration source capacity", () => {
...parsed.servers,
rea: registration,
});
const configured = serializeClientConfiguration(desired, "grok", original);
const configured = serializedText(desired, "grok", original);
const result = parseClientConfiguration(configured, "grok");
expect(result.document.disabled_mcp_servers).toEqual(
Array<string>(500_000).fill("other"),
+143 -54
View File
@@ -15,16 +15,18 @@ import {
import {
applyEdits,
createScanner,
findNodeAtLocation,
modify,
format as formatJson,
parse as parseJsonc,
parseTree,
printParseErrorCode,
type Edit,
type Node,
type ParseError,
type SyntaxKind,
} from "jsonc-parser";
import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -1312,6 +1314,37 @@ const nextJsoncToken = (
return { kind, offset: scanner.getTokenOffset() };
};
const isJsonMember = (property: Node, key: string): boolean =>
property.children?.length === 2 && property.children[0]?.value === key;
/**
* The property a client reads for `key`. `JSON.parse` and JSONC `parse` keep
* the last duplicate member, while jsonc-parser's `findNodeAtLocation` and
* `modify` select the first, so JSON text edits locate members only here.
*/
const effectiveJsonProperty = (object: Node, key: string): Node | undefined =>
object.children?.findLast((property) => isJsonMember(property, key));
/** Apply one edit and format the lines it touched, as `modify` does. */
const applyFormattedJsonEdit = (text: string, edit: Edit): string => {
const edited = applyEdits(text, [edit]);
let begin = edit.offset;
let end = edit.offset + edit.content.length;
if (edit.length === 0) {
while (begin > 0 && !"\r\n".includes(edited.charAt(begin - 1))) begin -= 1;
while (end < edited.length && !"\r\n".includes(edited.charAt(end)))
end += 1;
}
return applyEdits(
edited,
formatJson(
edited,
{ offset: begin, length: end - begin },
{ insertSpaces: true, tabSize: 2, eol: "\n", keepLines: false },
),
);
};
/**
* Delete a property by its JSONC AST span and the separator needed to keep its
* parent object valid. All whitespace and comments outside those tokens stay
@@ -1319,80 +1352,136 @@ const nextJsoncToken = (
*/
const deleteJsonProperty = (
text: string,
path: readonly string[],
): string | undefined => {
const root = parseTree(text, [], { allowTrailingComma: true });
const value =
root === undefined ? undefined : findNodeAtLocation(root, [...path]);
const property = value?.parent;
const parent = property?.parent;
object: Node,
property: Node,
): string => {
const siblings = object.children ?? [];
const propertyIndex = siblings.indexOf(property);
const previous = siblings[propertyIndex - 1];
const next = siblings[propertyIndex + 1];
const comma = nextJsoncToken(
text,
previous === undefined
? property.offset + property.length
: previous.offset + previous.length,
);
if (
property === undefined ||
parent?.type !== "object" ||
property.type !== "property" ||
parent.children === undefined
propertyIndex < 0 ||
(comma.kind !== JSON_COMMA &&
(previous !== undefined || next !== undefined))
)
return undefined;
const propertyIndex = parent.children.indexOf(property);
if (propertyIndex < 0) return undefined;
const previous = parent.children[propertyIndex - 1];
const next = parent.children[propertyIndex + 1];
let commaOffset: number | undefined;
if (previous !== undefined) {
const comma = nextJsoncToken(text, previous.offset + previous.length);
if (comma.kind !== JSON_COMMA) return undefined;
commaOffset = comma.offset;
} else {
const token = nextJsoncToken(text, property.offset + property.length);
if (token.kind === JSON_COMMA) commaOffset = token.offset;
else if (next !== undefined) return undefined;
}
throw new TypeError("JSON property separator was not found");
const edits = [
{ offset: property.offset, length: property.length, content: "" },
...(commaOffset === undefined
? []
: [{ offset: commaOffset, length: 1, content: "" }]),
...(comma.kind === JSON_COMMA
? [{ offset: comma.offset, length: 1, content: "" }]
: []),
];
return applyEdits(text, edits);
};
/** Serialize a validated client document, retaining JSONC and TOML comments. */
/** A JSON edit refused because it would activate a shadowed duplicate member. */
export interface ClientConfigurationShadowedMember {
readonly kind: "shadowed_member";
/** Key path of the effective member whose removal was refused. */
readonly path: readonly string[];
}
/**
* Set or delete the member a client reads at `path`, keeping every byte
* outside that member. Shadowed duplicates stay in place, and deleting a
* member that shadows one is refused so the earlier member never becomes
* active, whatever its ownership.
*/
const editJsonMember = (
text: string,
path: readonly string[],
value: unknown,
): Result<string, ClientConfigurationShadowedMember> => {
let object = parseTree(text, [], { allowTrailingComma: true });
for (const [index, key] of path.entries()) {
if (object?.type !== "object")
throw new TypeError("JSON edit parent is not an object");
const property = effectiveJsonProperty(object, key);
const member = property?.children?.[1];
if (property === undefined || member === undefined) {
if (value === undefined) return ok(text);
const content = `${JSON.stringify(key)}: ${JSON.stringify(
path
.slice(index + 1)
.reduceRight<unknown>((nested, name) => ({ [name]: nested }), value),
)}`;
const previous = object.children?.at(-1);
return ok(
applyFormattedJsonEdit(
text,
previous === undefined
? { offset: object.offset + 1, length: 0, content }
: {
offset: previous.offset + previous.length,
length: 0,
content: `,${content}`,
},
),
);
}
if (index < path.length - 1) {
object = member;
continue;
}
if (value !== undefined)
return ok(
applyFormattedJsonEdit(text, {
offset: member.offset,
length: member.length,
content: JSON.stringify(value),
}),
);
if (
object.children?.some(
(sibling) => sibling !== property && isJsonMember(sibling, key),
)
)
return err({ kind: "shadowed_member", path });
return ok(deleteJsonProperty(text, object, property));
}
throw new TypeError("JSON edit path is empty");
};
/**
* Serialize a validated client document, retaining JSONC and TOML comments.
* JSON edits fail only when they would activate a shadowed duplicate member.
*/
export const serializeClientConfiguration = (
document: Record<string, unknown>,
format: ClientConfigurationFormat | undefined,
originalText?: string,
editedPaths?: readonly (readonly string[])[],
): string => {
): Result<string, ClientConfigurationShadowedMember> => {
if (format === undefined || format === "unsupported")
throw new TypeError("client does not have a supported MCP config format");
if (format === "toml")
return serializeCodexConfiguration(document, originalText);
return ok(serializeCodexConfiguration(document, originalText));
if (format === "grok")
return serializeGrokConfiguration(document, originalText);
return ok(serializeGrokConfiguration(document, originalText));
if (format === "hermes")
return serializeHermesConfiguration(document, originalText);
if (format === "dsh") return serializeDshDocument(document);
return ok(serializeHermesConfiguration(document, originalText));
if (format === "dsh") return ok(serializeDshDocument(document));
// An empty original holds no comments or settings to preserve. Writing a
// fresh document also keeps a lone BOM from shifting past the closing brace.
if (
originalText !== undefined &&
!isEmptyClientConfigurationText(originalText) &&
editedPaths !== undefined
originalText === undefined ||
isEmptyClientConfigurationText(originalText) ||
editedPaths === undefined
)
return editedPaths.reduce((text, path) => {
const value = valueAt(document, path);
if (value === undefined) {
const preserved = deleteJsonProperty(text, path);
if (preserved !== undefined) return preserved;
}
return applyEdits(
text,
modify(text, [...path], value, {
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
}),
);
}, originalText);
return `${JSON.stringify(document, null, 2)}\n`;
return ok(`${JSON.stringify(document, null, 2)}\n`);
let text = originalText;
for (const path of editedPaths) {
const edited = editJsonMember(text, path, valueAt(document, path));
if (!edited.ok) return edited;
text = edited.value;
}
return ok(text);
};
/** Key path of a named registration in the server table. */
+40
View File
@@ -28,6 +28,7 @@ import {
import type { VerifiedLinuxHopperPackage } from "./LinuxHopperPublisher.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import * as nativeCommands from "../process/ExecFileOutput.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe("Linux package-manager command display", () => {
@@ -123,6 +124,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
installPackage = (
family: LinuxPackageFamily,
archive: string,
_options?: { readonly signal?: AbortSignal },
): Promise<LinuxPackageInstallOutcome> => {
if (!this.verifiedBeforeInstall)
throw new Error("Package manager ran before publisher verification");
@@ -260,6 +262,24 @@ describe("Linux Hopper installation", () => {
});
describe("Linux Hopper package installation", () => {
it("keeps runtime inspection failure distinct from package-manager failure and cleans the archive", async () => {
const host = new RecordingLinuxHost();
const failure = new pythonRuntime.LinuxHopperPythonError(
"configuration_unknown",
"Fixture unknown runtime",
);
host.installPackage = async () => ({
status: "runtime_unavailable",
failure,
});
expect(await installLinuxHopper(host)).toMatchObject({
status: "failed",
reason: "runtime_dependencies",
pythonRuntimeFailure: failure,
});
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
it.each(["deb", "rpm", "arch"] as const)(
"downloads, verifies, installs, reads back, and cleans a %s package",
async (family) => {
@@ -426,10 +446,30 @@ describe("Linux Hopper cancellation", () => {
).toEqual({
status: "failed",
reason: "cancelled",
cancellationReason: controller.signal.reason,
});
expect(host.downloads).toEqual([]);
expect(host.installed).toEqual([]);
});
it("forwards cancellation to package stages and retains its original cause through cleanup", async () => {
const host = new RecordingLinuxHost();
const controller = new AbortController();
const cause = new Error("caller cancelled between package transactions");
host.installPackage = async (_family, _archive, options) => {
expect(options?.signal).toBe(controller.signal);
expect(host.verifiedBeforeInstall).toBe(true);
controller.abort(cause);
return { status: "cancelled", cause };
};
const result = await installLinuxHopper(host, {
signal: controller.signal,
});
expect(result).toMatchObject({ status: "failed", reason: "cancelled" });
if (result.status === "failed")
expect(result.cancellationReason).toBe(cause);
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
});
describe("Linux Hopper temporary cleanup ownership", () => {
+178 -41
View File
@@ -1,11 +1,16 @@
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, posix } from "node:path";
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { downloadHopperPackage } from "./HopperDownload.js";
import {
inspectLinuxHopperPythonRuntime,
linuxHopperPythonUnavailable,
LinuxHopperPythonError,
} from "./LinuxHopperPython.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { err, type Result } from "../domain/result.js";
import {
@@ -29,7 +34,7 @@ export type { LinuxPackageFamily } from "./HopperReleaseFeed.js";
/** Package-manager output retained per stream without stopping the command. */
const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024;
/** Exact stable external effects disclosed before a Linux Hopper installation. */
/** Package mutations disclosed before a Linux Hopper installation. */
export interface LinuxHopperInstallDisclosure {
readonly networkOrigins: readonly string[];
readonly integrity: string;
@@ -48,11 +53,21 @@ export const linuxHopperInstallDisclosure = (
HOPPER_DOWNLOAD_PREFIX,
],
integrity: `Detached PGP signature · primary ${HOPPER_LINUX_PRIMARY_FINGERPRINT} · package byte identity and installed launcher SHA-256 from the official feed`,
commands: linuxPackageManagerCommands(
family,
`<verified-hopper.${family}>`,
isRoot,
).map(({ executable, args }) => formatCommandForDisplay(executable, args)),
commands: [
...linuxPackageManagerCommands(
family,
`<verified-hopper.${family}>`,
isRoot,
),
...(family === "deb"
? [
debianPythonLibraryCommand(
"<libpython matching /usr/bin/python3, if not loadable>",
isRoot,
),
]
: []),
].map(({ executable, args }) => formatCommandForDisplay(executable, args)),
};
};
@@ -108,13 +123,21 @@ export interface LinuxPackageInstallFailure {
}
/** Whether the package-manager step installed the verified package. */
export type LinuxPackageInstallOutcome =
type LinuxPackageCommandOutcome =
| { readonly status: "installed" }
| {
readonly status: "failed";
readonly failure: LinuxPackageInstallFailure;
};
export type LinuxPackageInstallOutcome =
| LinuxPackageCommandOutcome
| { readonly status: "cancelled"; readonly cause: unknown }
| {
readonly status: "runtime_unavailable";
readonly failure: LinuxHopperPythonError;
};
/** External effects required to install the official Linux package. */
export interface LinuxHopperInstallHost {
distribution(): Promise<LinuxDistribution | undefined>;
@@ -131,6 +154,7 @@ export interface LinuxHopperInstallHost {
installPackage(
family: LinuxPackageFamily,
archive: string,
options?: { readonly signal?: AbortSignal },
): Promise<LinuxPackageInstallOutcome>;
verifyArchive(
family: LinuxPackageFamily,
@@ -179,6 +203,10 @@ export type LinuxHopperInstallResult =
readonly packageFailure?: LinuxPackageInstallFailure;
/** Publisher-owned failure, retained for the public setup diagnostic. */
readonly verificationFailure?: HopperReleaseError;
/** System-interpreter inspection, kept separate from package-manager output. */
readonly pythonRuntimeFailure?: LinuxHopperPythonError;
/** Original caller cancellation, without flattening its value into text. */
readonly cancellationReason?: unknown;
} & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure });
/** Failed removal of an owned temporary install directory. */
@@ -282,8 +310,8 @@ export const installLinuxHopper = async (
readonly signal?: AbortSignal;
} = {},
): Promise<LinuxHopperInstallResult> => {
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
if (isAborted(options.signal))
return cancelledInstall(options.signal?.reason);
const distribution = await host.distribution();
if (distribution?.hopperSetupSupported !== true)
return { status: "failed", reason: "unsupported_host" };
@@ -339,18 +367,27 @@ export const installLinuxHopper = async (
? "verification_unavailable"
: retained.error.reason,
};
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
if (isAborted(options.signal))
return cancelledInstall(options.signal?.reason);
const install = await host.installPackage(
distribution.packageFamily,
archive,
options,
);
if (install.status === "cancelled")
return cancelledInstall(install.cause);
if (install.status === "failed")
return {
status: "failed",
reason: "authorization_or_package_manager",
packageFailure: install.failure,
};
if (install.status === "runtime_unavailable")
return {
status: "failed",
reason: "runtime_dependencies",
pythonRuntimeFailure: install.failure,
};
const launcherPath = "/opt/hopper/bin/Hopper";
const launcherStatus = await host.launcherStatus(
launcherPath,
@@ -367,9 +404,18 @@ export const installLinuxHopper = async (
} catch (cause: unknown) {
result = {
status: "failed",
reason: isAbortCause(cause) ? "cancelled" : "download",
reason:
isAborted(options.signal) || isAbortCause(cause)
? "cancelled"
: "download",
};
}
if (
result.status === "failed" &&
result.reason === "cancelled" &&
isAborted(options.signal)
)
result = { ...result, cancellationReason: options.signal?.reason };
if (temporary === undefined) return result;
try {
await host.cleanup(temporary);
@@ -418,8 +464,8 @@ export const systemLinuxHopperInstallHost = (
download: downloadHopperPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: (family, archive) =>
installSystemPackage(family, archive, environment),
installPackage: (family, archive, options) =>
installSystemPackage(family, archive, environment, options?.signal),
verifyArchive: (family, release, archive, signal) =>
publisher.verifyArchive(family, release, archive, signal),
retainVerifiedArchive: (verified, archive, signal) =>
@@ -503,36 +549,130 @@ const installSystemPackage = async (
family: LinuxPackageFamily,
archive: string,
environment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
): Promise<LinuxPackageInstallOutcome> => {
for (const command of linuxPackageManagerCommands(
family,
archive,
process.getuid?.() === 0,
)) {
const output = await execFileBoundedOutput(
command.executable,
command.args,
{
env: environment,
limit: PACKAGE_MANAGER_OUTPUT_BYTES,
},
);
if (output.exitCode !== 0) {
return {
const cancelled = (): LinuxPackageInstallOutcome => ({
status: "cancelled",
cause: signal?.reason,
});
const isRoot = process.getuid?.() === 0;
for (const command of linuxPackageManagerCommands(family, archive, isRoot)) {
if (isAborted(signal)) return cancelled();
// Let a running package transaction settle; do not start another after
// the caller cancels. Interrupting dpkg can leave configured state partial.
const result = await runPackageManagerCommand(command, environment);
if (result.status === "failed") return result;
}
if (isAborted(signal)) return cancelled();
if (family !== "deb") return { status: "installed" };
// Ubuntu splits the executable/stdlib from the embedding library. Resolve
// the installed distribution interpreter only after python3 is configured.
const python = await inspectLinuxHopperPythonRuntime(environment, signal);
if (isAborted(signal)) return cancelled();
if (!python.ok) return pythonInspectionFailure(python.error);
if (python.value.library.status === "loadable")
return { status: "installed" };
if (python.value.library.status === "unknown")
return {
status: "runtime_unavailable",
failure: linuxHopperPythonUnavailable(python.value.library),
};
const packageName = "libpython" + python.value.version.join(".");
const libraryPath = python.value.library.path;
// Only the standard Ubuntu shared ABI/layout establishes this APT mapping.
// Preserve alternate prefixes/SONAMEs as unknown instead of guessing a package.
if (
posix.basename(libraryPath) !== packageName + ".so.1.0" ||
!/^\/usr\/lib\/[a-z0-9_]+-linux-gnu[a-z0-9_]*$/u.test(
posix.dirname(libraryPath),
)
)
return {
status: "runtime_unavailable",
failure: new LinuxHopperPythonError(
"configuration_unknown",
"Cannot determine the matching APT runtime package for " +
libraryPath +
". Repair this interpreter's shared runtime before retrying.",
),
};
if (isAborted(signal)) return cancelled();
const installed = await runPackageManagerCommand(
debianPythonLibraryCommand(packageName, isRoot),
environment,
);
if (installed.status === "failed") return installed;
if (isAborted(signal)) return cancelled();
// A successful APT exit is not proof that the selected runtime can load.
const verified = await inspectLinuxHopperPythonRuntime(environment, signal);
if (isAborted(signal)) return cancelled();
if (!verified.ok) return pythonInspectionFailure(verified.error);
if (verified.value.library.status !== "loadable")
return {
status: "runtime_unavailable",
failure: linuxHopperPythonUnavailable(verified.value.library),
};
return { status: "installed" };
};
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
const cancelledInstall = (cause: unknown): LinuxHopperInstallResult => ({
status: "failed",
reason: "cancelled",
cancellationReason: cause,
});
const pythonInspectionFailure = (
failure: LinuxHopperPythonError,
): LinuxPackageInstallOutcome =>
failure.reason === "cancelled"
? { status: "cancelled", cause: failure.cause }
: { status: "runtime_unavailable", failure };
interface PackageCommand {
readonly executable: string;
readonly args: readonly string[];
}
const withPackagePrivilege = (
executable: string,
args: readonly string[],
isRoot: boolean,
): PackageCommand =>
isRoot
? { executable, args }
: { executable: "pkexec", args: [executable, ...args] };
const debianPythonLibraryCommand = (
packageName: string,
isRoot: boolean,
): PackageCommand =>
withPackagePrivilege("apt-get", ["install", "-y", packageName], isRoot);
const runPackageManagerCommand = async (
command: PackageCommand,
environment: Readonly<NodeJS.ProcessEnv>,
): Promise<LinuxPackageCommandOutcome> => {
const output = await execFileBoundedOutput(command.executable, command.args, {
env: environment,
limit: PACKAGE_MANAGER_OUTPUT_BYTES,
});
return output.exitCode === 0
? { status: "installed" }
: {
status: "failed",
failure: {
command: formatCommandForDisplay(command.executable, command.args),
exitCode: output.exitCode,
// The streams stay separate; their original interleaved order
// is not recoverable after capture.
// Preserve the separately captured streams and truncation marker.
stdout: output.stdout,
stderr: output.stderr,
outputTruncated: output.outputTruncated,
},
};
}
}
return { status: "installed" };
};
/** Preserve argument boundaries in a command shown to the user. */
@@ -551,10 +691,7 @@ export const linuxPackageManagerCommands = (
family: LinuxPackageFamily,
archive: string,
isRoot: boolean,
): readonly {
readonly executable: string;
readonly args: readonly string[];
}[] => {
): readonly PackageCommand[] => {
const runtimeDependencies =
family === "deb"
? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]
@@ -577,9 +714,9 @@ export const linuxPackageManagerCommands = (
["pacman", "-U", "--noconfirm", archive],
];
return commands.map((command) => {
const executable = isRoot ? command[0] : "pkexec";
const executable = command[0];
if (executable === undefined) throw new Error("package command is empty");
return { executable, args: isRoot ? command.slice(1) : command };
return withPackagePrivilege(executable, command.slice(1), isRoot);
});
};
@@ -0,0 +1,264 @@
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import * as boundedCommands from "../process/ExecFileBoundedOutput.js";
import { err, ok } from "../domain/result.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import {
linuxHopperInstallDisclosure,
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
const missingPython = {
interpreter: "/usr/bin/python3",
version: [3, 14],
library: {
status: "unavailable",
path: "/usr/lib/x86_64-linux-gnu/libpython3.14.so.1.0",
reason: "No such file",
},
} satisfies pythonRuntime.LinuxHopperPythonRuntime;
const readyPython = {
...missingPython,
library: {
status: "loadable",
path: "/usr/lib/x86_64-linux-gnu/libpython3.14.so.1.0",
},
} satisfies pythonRuntime.LinuxHopperPythonRuntime;
const commandSuccess = {
exitCode: 0,
stdout: "",
stderr: "",
outputTruncated: false,
};
describe("Linux Hopper package runtime", () => {
it.each([
{
name: "installs and verifies the matching future Python library",
inspections: [ok(missingPython), ok(readyPython)],
packageCalls: 2,
status: "installed",
},
{
name: "skips the extra APT command for an already loadable library",
inspections: [ok(readyPython)],
packageCalls: 1,
status: "installed",
},
{
name: "does not guess a package for an unknown library location",
inspections: [
ok({
...missingPython,
library: { status: "unknown", reason: "No INSTSONAME" },
} satisfies pythonRuntime.LinuxHopperPythonRuntime),
],
packageCalls: 1,
status: "runtime_unavailable",
},
{
name: "rejects APT success when the library still cannot load",
inspections: [ok(missingPython), ok(missingPython)],
packageCalls: 2,
status: "runtime_unavailable",
},
{
name: "preserves an interpreter inspection failure",
inspections: [
err(
new pythonRuntime.LinuxHopperPythonError(
"query_failed",
"Fixture interpreter unavailable",
),
),
],
packageCalls: 1,
status: "runtime_unavailable",
},
])("$name", async ({ inspections, packageCalls, status }) => {
const directory = await createTestTempDirectory(
"rea-linux-python-install-",
);
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValue(commandSuccess);
const inspect = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockRejectedValue(new Error("Unexpected extra Python inspection"));
for (const result of inspections) inspect.mockResolvedValueOnce(result);
try {
expect(
await host.installPackage("deb", "/tmp/verified-hopper.deb"),
).toMatchObject({ status });
expect(command).toHaveBeenCalledTimes(packageCalls);
expect(inspect).toHaveBeenCalledTimes(inspections.length);
if (packageCalls === 2) {
const isRoot = process.getuid?.() === 0;
expect(command.mock.calls[1]?.slice(0, 2)).toEqual([
isRoot ? "apt-get" : "pkexec",
[...(isRoot ? [] : ["apt-get"]), "install", "-y", "libpython3.14"],
]);
}
} finally {
try {
await host.close?.();
} finally {
inspect.mockRestore();
command.mockRestore();
}
}
});
it("preserves a failed runtime APT command and does not verify it as installed", async () => {
const directory = await createTestTempDirectory(
"rea-linux-python-failure-",
);
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const failed = {
exitCode: 67,
stdout: "original package output",
stderr: "original package error",
outputTruncated: true,
};
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValueOnce(commandSuccess)
.mockResolvedValueOnce(failed);
const inspect = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockResolvedValue(ok(missingPython));
try {
expect(
await host.installPackage("deb", "/tmp/verified-hopper.deb"),
).toMatchObject({ status: "failed", failure: failed });
expect(command).toHaveBeenCalledTimes(2);
expect(inspect).toHaveBeenCalledTimes(1);
} finally {
try {
await host.close?.();
} finally {
inspect.mockRestore();
command.mockRestore();
}
}
});
});
describe("Linux Hopper package mapping", () => {
it.each([
"/custom/libpython3.14.so.1.0",
"/usr/lib/x86_64-linux-gnu/libpython3.14d.so.1.0",
"/usr/lib/x86_64-linux-gnu/libpython3.13.so.1.0",
])("keeps an unproven APT mapping unknown: %s", async (path) => {
const directory = await createTestTempDirectory("rea-python-package-map-");
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValue(commandSuccess);
const inspect = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockResolvedValue(
ok({
...missingPython,
library: { ...missingPython.library, path },
}),
);
try {
expect(
await host.installPackage("deb", "/tmp/verified-hopper.deb"),
).toMatchObject({
status: "runtime_unavailable",
failure: {
reason: "configuration_unknown",
message: expect.stringContaining(path),
},
});
expect(command).toHaveBeenCalledTimes(1);
expect(inspect).toHaveBeenCalledTimes(1);
} finally {
try {
await host.close?.();
} finally {
inspect.mockRestore();
command.mockRestore();
}
}
});
});
describe("Linux Hopper package cancellation and disclosure", () => {
it.each([
{ phase: "before", commands: 0, inspections: 0 },
{ phase: "first transaction", commands: 1, inspections: 0 },
{ phase: "query", commands: 1, inspections: 1 },
{ phase: "runtime transaction", commands: 2, inspections: 1 },
{ phase: "verification", commands: 2, inspections: 2 },
])(
"honors cancellation at $phase without starting the next stage",
async ({ phase, commands, inspections }) => {
const directory = await createTestTempDirectory(
"rea-python-install-cancel-",
);
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
const controller = new AbortController();
const cause = new Error("caller cancelled at " + phase);
let commandCount = 0;
let inspectionCount = 0;
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockImplementation(async () => {
commandCount++;
if (
(phase === "first transaction" && commandCount === 1) ||
(phase === "runtime transaction" && commandCount === 2)
)
controller.abort(cause);
return commandSuccess;
});
const inspect = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockImplementation(async (_environment, signal) => {
expect(signal).toBe(controller.signal);
inspectionCount++;
if (
phase === "query" ||
(phase === "verification" && inspectionCount === 2)
)
controller.abort(cause);
return ok(inspectionCount === 1 ? missingPython : readyPython);
});
if (phase === "before") controller.abort(cause);
try {
const result = await host.installPackage(
"deb",
"/tmp/verified-hopper.deb",
{
signal: controller.signal,
},
);
expect(result).toEqual({ status: "cancelled", cause });
if (result.status === "cancelled") expect(result.cause).toBe(cause);
expect(command).toHaveBeenCalledTimes(commands);
expect(inspect).toHaveBeenCalledTimes(inspections);
} finally {
try {
await host.close?.();
} finally {
inspect.mockRestore();
command.mockRestore();
}
}
},
);
it("discloses the conditional privileged library install without returning probe code", () => {
const disclosure = linuxHopperInstallDisclosure("deb", false);
expect(disclosure.commands).toHaveLength(2);
expect(disclosure.commands[1]).toBe(
'pkexec apt-get install -y "<libpython matching /usr/bin/python3, if not loadable>"',
);
expect(linuxHopperInstallDisclosure("rpm", false).commands).toHaveLength(1);
});
});
+257
View File
@@ -0,0 +1,257 @@
import { existsSync } from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { inspectLinuxHopperPythonRuntime } from "./LinuxHopperPython.js";
const response = (value: unknown) => {
const stdout = JSON.stringify(value);
return {
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
};
};
const available = {
version: [3, 14],
library: { status: "loadable", path: "/usr/lib/libpython3.14.so.1.0" },
};
describe("distribution Python runtime inspection", () => {
it("preserves version and library identity without assuming Python 3.12", async () => {
const execute = vi
.fn<typeof execFileOutput>()
.mockResolvedValue(response(available));
const environment = {
PYTHONHOME: "/caller/python",
PYTHONPATH: "/caller/modules",
};
const result = await inspectLinuxHopperPythonRuntime(
environment,
undefined,
execute,
);
expect(result).toEqual({
ok: true,
value: { ...available, interpreter: "/usr/bin/python3" },
});
expect(execute).toHaveBeenCalledWith(
"/usr/bin/python3",
expect.arrayContaining(["-I", "-S", "-B"]),
expect.objectContaining({
env: environment,
timeout: 10_000,
maxBuffer: 64 * 1024,
stopSignal: "SIGTERM",
}),
);
});
it.each([
{ version: [2, 7], library: available.library },
{ version: [3, "14"], library: available.library },
{
version: [3, 14],
library: { status: "loadable", path: "relative/libpython.so" },
},
{
version: [3, 14],
library: { status: "loadable", path: "C:\\not-a-linux-path\\python.dll" },
},
{
version: [3, 14],
library: { status: "loadable", path: "/usr/lib/python\0.so" },
},
{
version: [3, 14],
library: { status: "unavailable", path: "/lib/python.so" },
},
null,
])(
"refuses malformed facts without reporting library absence: %j",
async (value) => {
const execute = vi
.fn<typeof execFileOutput>()
.mockResolvedValue(response(value));
const result = await inspectLinuxHopperPythonRuntime(
{},
undefined,
execute,
);
expect(result).toMatchObject({
ok: false,
error: { reason: "invalid_response" },
});
},
);
it("retains unknown configuration and observes a later repaired library afresh", async () => {
const unknown = {
version: [3, 14],
library: { status: "unknown", reason: "No INSTSONAME" },
};
const execute = vi
.fn<typeof execFileOutput>()
.mockResolvedValueOnce(response(unknown))
.mockResolvedValueOnce(response(available));
expect(
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
).toMatchObject({ ok: true, value: unknown });
expect(
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
).toMatchObject({ ok: true, value: available });
expect(execute).toHaveBeenCalledTimes(2);
});
it("distinguishes execution and JSON failures from an unavailable library", async () => {
const execute = vi
.fn<typeof execFileOutput>()
.mockRejectedValueOnce(new Error("fixture execution failure"))
.mockResolvedValueOnce({
stdout: "{",
stderr: "",
stdoutBytes: 1,
stderrBytes: 0,
});
expect(
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
).toMatchObject({ ok: false, error: { reason: "query_failed" } });
expect(
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
).toMatchObject({ ok: false, error: { reason: "invalid_response" } });
});
});
describe("distribution Python process cleanup", () => {
it.skipIf(process.platform !== "linux" || !existsSync("/usr/bin/python3"))(
"waits for a delayed Python exit before completing cancellation",
async () => {
const directory = await createTestTempDirectory("rea-python-stop-");
const ready = join(directory, "ready");
const stopping = join(directory, "stopping");
const release = join(directory, "release");
const stopped = join(directory, "stopped");
const program = `from pathlib import Path
import signal, sys, time
def stop(signum, frame):
Path(${JSON.stringify(stopping)}).write_text(str(signum))
while not Path(${JSON.stringify(release)}).exists():
time.sleep(0.01)
Path(${JSON.stringify(stopped)}).write_text(str(signum))
sys.exit(0)
signal.signal(signal.SIGTERM, stop)
Path(${JSON.stringify(ready)}).write_text("ready")
while True:
time.sleep(0.1)
`;
const controller = new AbortController();
const reason = { request: "stop owned Python" };
let settled = false;
const execution = inspectLinuxHopperPythonRuntime(
process.env,
controller.signal,
(command, _arguments, options) =>
execFileOutput(command, ["-I", "-S", "-B", "-c", program], options),
).then((result) => {
settled = true;
return result;
});
try {
await vi.waitFor(() => access(ready), { timeout: 5_000, interval: 10 });
controller.abort(reason);
await vi.waitFor(() => access(stopping), {
timeout: 3_000,
interval: 10,
});
// The child cannot exit until released; early settlement loses ownership.
expect(settled).toBe(false);
} finally {
try {
await writeFile(release, "release");
} finally {
controller.abort(reason);
await execution;
}
await vi.waitFor(() => access(stopped), {
timeout: 5_000,
interval: 10,
});
}
expect(await readFile(stopped, "utf8")).toBe("15");
const result = await execution;
expect(result).toMatchObject({
ok: false,
error: { reason: "cancelled" },
});
if (!result.ok) expect(result.error.cause).toBe(reason);
},
);
});
describe("distribution Python isolation and cancellation", () => {
it("preserves cancellation and starts no interpreter for an already cancelled query", async () => {
const controller = new AbortController();
const reason = new Error("caller cancellation");
controller.abort(reason);
const execute = vi.fn<typeof execFileOutput>();
const result = await inspectLinuxHopperPythonRuntime(
{},
controller.signal,
execute,
);
expect(result).toMatchObject({
ok: false,
error: { reason: "cancelled", cause: reason },
});
expect(execute).not.toHaveBeenCalled();
});
it("observes cancellation that arrives during interpreter execution", async () => {
const controller = new AbortController();
const reason = new Error("cancelled during query");
const execute = vi
.fn<typeof execFileOutput>()
.mockImplementation(async () => {
controller.abort(reason);
return response(available);
});
const result = await inspectLinuxHopperPythonRuntime(
{},
controller.signal,
execute,
);
expect(result).toMatchObject({
ok: false,
error: { reason: "cancelled", cause: reason },
});
});
it.skipIf(process.platform !== "linux" || !existsSync("/usr/bin/python3"))(
"runs the real producer without loading caller Python modules or PYTHONHOME",
async () => {
const directory = await createTestTempDirectory("rea-system-python-");
for (const name of ["ctypes.py", "sysconfig.py", "sitecustomize.py"])
await writeFile(
join(directory, name),
"raise RuntimeError('caller module was loaded')\n",
);
const result = await inspectLinuxHopperPythonRuntime({
...process.env,
PYTHONPATH: directory,
PYTHONHOME: join(directory, "missing-python-home"),
});
expect(result).toMatchObject({
ok: true,
value: {
interpreter: "/usr/bin/python3",
version: [3, expect.any(Number)],
},
});
},
);
});
+157
View File
@@ -0,0 +1,157 @@
import { posix } from "node:path";
import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
const libraryPath = z
.string()
.refine((value) => posix.isAbsolute(value) && !value.includes("\0"));
const runtimeSchema = z.object({
version: z.tuple([z.literal(3), z.number().int().nonnegative()]),
library: z.discriminatedUnion("status", [
z.object({ status: z.literal("loadable"), path: libraryPath }),
z.object({
status: z.literal("unavailable"),
path: libraryPath,
reason: z.string().min(1),
}),
z.object({ status: z.literal("unknown"), reason: z.string().min(1) }),
]),
});
/** Facts reported by the isolated system interpreter, without a GUI launch. */
export type LinuxHopperPythonRuntime = z.infer<typeof runtimeSchema> & {
readonly interpreter: "/usr/bin/python3";
};
/** A failed inspection does not establish that a shared library is absent. */
export class LinuxHopperPythonError extends Error {
readonly _tag = "LinuxHopperPythonError";
constructor(
readonly reason:
| "query_failed"
| "invalid_response"
| "configuration_unknown"
| "runtime_unavailable"
| "cancelled",
message: string,
cause?: unknown,
) {
super(message, { cause });
}
}
// Isolate caller Python paths/site hooks and disable bytecode writes. Inspect
// the distribution interpreter that the package manager installs, not PATH.
const query = `import ctypes, json, os, sys, sysconfig
directory = sysconfig.get_config_var("LIBDIR")
name = sysconfig.get_config_var("INSTSONAME")
if not isinstance(directory, str) or not os.path.isabs(directory) or not isinstance(name, str) or not name:
library = {"status": "unknown", "reason": "Python did not report an absolute shared-library location"}
else:
path = os.path.join(directory, name)
try:
ctypes.CDLL(path)
library = {"status": "loadable", "path": path}
except OSError as error:
library = {"status": "unavailable", "path": path, "reason": str(error)}
print(json.dumps({"version": list(sys.version_info[:2]), "library": library}))
`;
const runtimeCommand = {
executable: "/usr/bin/python3",
args: ["-I", "-S", "-B", "-c", query],
} as const;
// AbortSignal changes while an awaited process runs, despite its readonly type.
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
/** Fresh, bounded inspection; package or interpreter changes must be observable. */
export const inspectLinuxHopperPythonRuntime = async (
environment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
execute: typeof execFileOutput = execFileOutput,
): Promise<Result<LinuxHopperPythonRuntime, LinuxHopperPythonError>> => {
const cancelled = () =>
err(
new LinuxHopperPythonError(
"cancelled",
"Python runtime inspection was cancelled.",
signal?.reason,
),
);
if (isAborted(signal)) return cancelled();
let output: string;
try {
const result = await execute(
runtimeCommand.executable,
runtimeCommand.args,
{
env: environment,
signal,
stopSignal: "SIGTERM",
timeout: 10_000,
maxBuffer: 64 * 1024,
},
);
output = result.stdout;
} catch (cause: unknown) {
if (isAborted(signal)) return cancelled();
return err(
new LinuxHopperPythonError(
"query_failed",
"Could not inspect the shared runtime of /usr/bin/python3.",
cause,
),
);
}
if (isAborted(signal)) return cancelled();
let value: unknown;
try {
value = JSON.parse(output);
} catch (cause: unknown) {
return err(
new LinuxHopperPythonError(
"invalid_response",
"System Python returned an invalid shared-runtime description.",
cause,
),
);
}
const parsed = runtimeSchema.safeParse(value);
if (!parsed.success)
return err(
new LinuxHopperPythonError(
"invalid_response",
"System Python returned an invalid shared-runtime description.",
parsed.error,
),
);
return ok({
...parsed.data,
interpreter: runtimeCommand.executable,
});
};
/** Explain a known failed load separately from an unknown configuration. */
export const linuxHopperPythonUnavailable = (
library: Exclude<
LinuxHopperPythonRuntime["library"],
{ readonly status: "loadable" }
>,
): LinuxHopperPythonError =>
new LinuxHopperPythonError(
library.status === "unknown"
? "configuration_unknown"
: "runtime_unavailable",
library.status === "unavailable"
? "The system Python shared runtime cannot load " +
library.path +
": " +
library.reason
: "System Python did not identify its shared runtime: " + library.reason,
);
+32 -18
View File
@@ -250,16 +250,6 @@ const persistClientConfiguration = async ({
};
if (target.value) return { status: "failed", reason: "target_changed" };
}
let recoveryBackupPath: string | undefined;
if (original !== undefined) {
const preserved = await preserveCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
transactionPath,
original,
);
if (!preserved.ok) return { status: "failed", reason: "backup" };
recoveryBackupPath = preserved.value;
}
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
// otherwise conflict with it.
const legacyPath = legacyClientServerPath(
@@ -292,19 +282,43 @@ const persistClientConfiguration = async ({
),
}
: registered;
let serialized: ReturnType<typeof serializeClientConfiguration>;
try {
serialized = serializeClientConfiguration(document, format, original, [
...Object.keys(fallbackServers)
.filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey)
.map((name) => clientServerPath(parsed, name)),
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
...(legacyPath === undefined ? [] : [legacyPath]),
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
]);
} catch (cause: unknown) {
// An unserializable edit is reported before any file is written.
void cause;
return { status: "failed", reason: "write" };
}
if (!serialized.ok)
return {
status: "failed",
reason: "shadowed_member",
path: serialized.error.path,
};
let recoveryBackupPath: string | undefined;
if (original !== undefined) {
const preserved = await preserveCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
transactionPath,
original,
);
if (!preserved.ok) return { status: "failed", reason: "backup" };
recoveryBackupPath = preserved.value;
}
let publication: ClientConfigurationPublication;
try {
await mkdir(dirname(client.configPath), { recursive: true });
publication = await ClientConfigurationPublication.write(
transactionPath,
serializeClientConfiguration(document, format, original, [
...Object.keys(fallbackServers)
.filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey)
.map((name) => clientServerPath(parsed, name)),
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
...(legacyPath === undefined ? [] : [legacyPath]),
...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
]),
serialized.value,
original,
0o600,
);
+2
View File
@@ -20,6 +20,8 @@ export const failedConfigurationMessage = (
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
case "write":
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
case "shadowed_member":
return `Agent configuration repeats the member at ${JSON.stringify(result.path)}; removing the member the client reads would activate an earlier duplicate, so no change was made. Remove the duplicate members, then rerun setup.`;
case "rollback":
return result.backupPath === undefined
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
+5 -2
View File
@@ -222,10 +222,13 @@ export const systemSetupHost = (
result.packageFailure,
"linux",
);
return result.verificationFailure !== undefined
const detail =
result.verificationFailure?.message ??
result.pythonRuntimeFailure?.message;
return detail !== undefined
? {
...failure,
remediation: `${result.verificationFailure.message} ${failure.remediation}`,
remediation: `${detail} ${failure.remediation}`,
}
: failure;
}
+2 -2
View File
@@ -136,7 +136,7 @@ export const setupInstallFailure = (
status: "failed",
code: "runtime_dependency_unavailable",
remediation:
"Hopper was installed but required shared libraries are missing. Rerun rea setup, then apply the hopper-demo-runtime remediation from rea doctor.",
"Hopper's required runtime could not be verified. Inspect rea doctor --provider hopper; on a supported system use rea setup --install-hopper to repair its dependencies.",
};
case "unsupported_hopper_build":
return {
@@ -150,7 +150,7 @@ export const setupInstallFailure = (
status: "failed",
code: "setup_cancelled",
remediation:
"Setup was cancelled before Hopper was installed. Rerun rea setup when ready.",
"Setup was cancelled. Run rea doctor to inspect the current state before retrying.",
};
case "destination_exists":
return {
+1 -1
View File
@@ -301,7 +301,7 @@ const setupPlan = (input: {
: "system package manager",
detail:
input.platform === "linux"
? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.`
? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. Install Hopper plus its Xvfb demo-session dependencies${input.linuxPackageFamily === "deb" ? ", including the shared library matching the distribution Python interpreter" : ""}. REA uses a private display and selects Hopper's offered demo mode for each analysis session.`
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
operation: "install" as const,
+6
View File
@@ -37,6 +37,12 @@ export type ClientConfigurationResult =
readonly status: "failed";
readonly reason: "path" | "target_changed" | "backup" | "write";
}
| {
readonly status: "failed";
/** Removing the member a client reads would activate an earlier duplicate. */
readonly reason: "shadowed_member";
readonly path: readonly string[];
}
| {
readonly status: "failed";
readonly reason: "readback";
+22 -6
View File
@@ -551,6 +551,27 @@ const removeClient = async (
...(removeNative ? [clientServerPath(parsed, name)] : []),
...(legacyPath === undefined ? [] : [legacyPath]),
];
let serialized: ReturnType<typeof serializeClientConfiguration>;
try {
serialized = serializeClientConfiguration(
document,
client.format,
original,
editedPaths,
);
} catch {
return item(
client.name,
"failed",
"Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
);
}
if (!serialized.ok)
return item(
client.name,
"failed",
`Configuration repeats the member at ${JSON.stringify(serialized.error.path)}; removing the registration the client reads would activate an earlier duplicate, so no change was made. Remove the duplicate members manually, then rerun uninstall.`,
);
const backupPath = await backupClientConfiguration(
client,
transactionPath,
@@ -573,12 +594,7 @@ const removeClient = async (
try {
publication = await fileSystem.publishConfiguration(
transactionPath,
serializeClientConfiguration(
document,
client.format,
original,
editedPaths,
),
serialized.value,
original,
);
} catch (cause: unknown) {
@@ -25,6 +25,36 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { err, ok } from "../../domain/result.js";
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
it.each(["layout", "javascript"])(
"carries the retained %s source snapshot into repeatable shared views",
(kind) => {
const parent =
kind === "layout"
? analysisViewLayoutEvidence()
: analysisViewJavaScriptEvidence();
const ledger = new EvidenceLedger();
const recorded = ledger.record(parent);
if (!recorded.ok) throw recorded.error;
const borrowed = ledger.forAnalysis(parent.evidence_id)!;
const original = JSON.stringify(borrowed);
const input = {
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
view: { kind: "summary" },
} as const;
const first = inspectAnalysisView(input, (id) => ledger.forAnalysis(id));
if (!first.ok) throw first.error;
const repeated = inspectAnalysisView(input, (id) => ledger.forAnalysis(id));
if (!repeated.ok) throw repeated.error;
expect(repeated.value.normalized_result).toEqual(
first.value.normalized_result,
);
expect(isImmutableJsonSnapshot(borrowed.normalized_result)).toBe(true);
expect(JSON.stringify(borrowed)).toBe(original);
expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed);
},
);
it.each(["/fixtures/unknown.exe", ""])(
"preserves recorded empty native identity fields: path=%j",
@@ -21,7 +21,10 @@ import {
type InspectAnalysisViewInput,
} from "../../domain/analysisView/analysisView.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
import {
immutableJsonSnapshotMember,
isImmutableJsonSnapshot,
} from "../../domain/immutableJson.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js";
@@ -70,7 +73,9 @@ export const inspectAnalysisViewValidated = (
{
evidenceId: parent.evidence_id,
operation: parent.operation,
normalizedResult: parent.normalized_result,
normalizedResult:
immutableJsonSnapshotMember(parent, "normalized_result") ??
parent.normalized_result,
// Sealing an Evidence root deep-freezes it; only the root is registered.
sealedResult: isImmutableJsonSnapshot(parent),
subject: parent.subject,
@@ -32,6 +32,7 @@ const refusedPort: ApktoolResourceAnalysisPort = {
it.each([
[{ path: "/targets/app.apk", include_strings: "yes" }],
[{ path: "/targets/app.apk", string_tables_only: "yes" }],
[{ path: "/targets/app.apk", locale: "not a locale" }],
[{ path: "/targets/app.apk", locales: ["not a locale"] }],
[{ path: "/targets/app.apk", locales: [] }],
@@ -10,6 +10,7 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import {
projectPlistValue,
unknownDateLimitation,
unknownScalarLimitation,
} from "../../domain/apple/plistValue.js";
import {
omittedPrototypeKeysLimitation,
@@ -67,6 +68,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
const unresolvedConnections = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number | null>();
const unknownDates = new Map<string, number>();
const unknownScalars = new Map<string, number>();
let omitted = 0;
let attempted = 0;
let aggregateInputBytes = 0;
@@ -148,6 +150,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
value: raw,
omittedPrototypeKeys,
unknownDateCount,
unknownScalarCount,
numberLiterals,
} = nib === null
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved)
@@ -155,6 +158,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
value: nib.raw,
omittedPrototypeKeys: 0,
unknownDateCount: 0,
unknownScalarCount: 0,
numberLiterals: undefined,
};
if (nib !== null && nib.omitted > 0)
@@ -165,6 +169,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
if (unknownDateCount !== 0)
unknownDates.set(entry.path, unknownDateCount);
if (unknownScalarCount !== 0)
unknownScalars.set(entry.path, unknownScalarCount);
const documentHash = createHash("sha256").update(bytes).digest("hex");
documents.push({
relativePath: entry.path,
@@ -207,6 +213,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
unresolvedConnections,
prototypeKeyOmissions,
unknownDates,
unknownScalars,
invalid,
omittedArchives,
unreportedOmittedArchives,
@@ -276,6 +283,7 @@ const finalizeHierarchyCoverage = (
unresolvedConnections: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
unknownDates: ReadonlyMap<string, number>;
unknownScalars: ReadonlyMap<string, number>;
invalid: readonly string[];
omittedArchives: readonly { path: string; reason: string }[];
unreportedOmittedArchives: number;
@@ -288,6 +296,7 @@ const finalizeHierarchyCoverage = (
unresolvedConnections,
prototypeKeyOmissions,
unknownDates,
unknownScalars,
invalid,
omittedArchives,
unreportedOmittedArchives,
@@ -376,6 +385,9 @@ const finalizeHierarchyCoverage = (
...[...unknownDates].map(
([path, count]) => `${path}: ${unknownDateLimitation(count)}`,
),
...[...unknownScalars].map(
([path, count]) => `${path}: ${unknownScalarLimitation(count)}`,
),
...omittedArchives.map(
({ path, reason }) =>
`${path}: omitted because ${reason.replaceAll("_", " ")}.`,
@@ -746,6 +758,7 @@ const decodePlist = (
readonly value: JsonValue;
readonly omittedPrototypeKeys: number | null;
readonly unknownDateCount: number;
readonly unknownScalarCount: number;
readonly numberLiterals: PlistNumberLiterals;
} => {
const binary = hasBinaryPlistSignature(bytes);
@@ -766,6 +779,7 @@ const decodePlist = (
value: projected.value,
omittedPrototypeKeys,
unknownDateCount: projected.unknownDateCount,
unknownScalarCount: projected.unknownScalarCount,
numberLiterals,
};
};
+5 -5
View File
@@ -10,7 +10,7 @@ import {
negativeZeroRealLimitation,
nonFiniteRealLimitation,
unknownDateLimitation,
unknownRealLimitation,
unknownScalarLimitation,
} from "../../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import {
@@ -89,9 +89,9 @@ export const decodeKeyedArchiveBytes = (
: parseXmlPropertyList(xmlText ?? "");
const {
nonFiniteRealCount,
unknownRealCount,
negativeZeroRealCount,
unknownDateCount,
unknownScalarCount,
...graph
} = projectKeyedArchive(parsed.value, selection);
const numbers = createPlistNumberProjection(parsed.numberLiterals);
@@ -121,15 +121,15 @@ export const decodeKeyedArchiveBytes = (
...(nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(nonFiniteRealCount)]),
...(unknownRealCount === 0
? []
: [unknownRealLimitation(unknownRealCount)]),
...(negativeZeroRealCount === 0
? []
: [negativeZeroRealLimitation(negativeZeroRealCount)]),
...(unknownDateCount === 0
? []
: [unknownDateLimitation(unknownDateCount)]),
...(unknownScalarCount === 0
? []
: [unknownScalarLimitation(unknownScalarCount)]),
],
};
};
+5
View File
@@ -55,6 +55,8 @@ export const registerApktoolCommands = (
// `b+zh+Hans+CN` form at the CLI boundary.
options: z.object({
locale: apktoolInputSchemas.decode_android_resources.shape.locale,
"string-tables-only":
apktoolInputSchemas.decode_android_resources.shape.string_tables_only,
locales:
apktoolInputSchemas.decode_android_resources.shape.locales.describe(
"Repeat to project multiple localized string tables, e.g. --locales de --locales pt-BR",
@@ -74,6 +76,9 @@ export const registerApktoolCommands = (
execute(CLI_COMMANDS.decodeAndroidResources, "decode_android_resources", {
path: args.path,
include_strings: options.strings,
...(options["string-tables-only"] === undefined
? {}
: { string_tables_only: options["string-tables-only"] }),
...(options.locale === undefined ? {} : { locale: options.locale }),
...(options.locales === undefined ? {} : { locales: options.locales }),
}),
@@ -39,7 +39,11 @@ export const APKTOOL_TOOL_CONTRACTS = [
},
{
title: "Decode multiple locale string tables in one pass",
input: { path: "/targets/Example.apk", locales: ["de", "fr"] },
input: {
path: "/targets/Example.apk",
locales: ["de", "fr"],
string_tables_only: true,
},
},
],
},
+190 -1
View File
@@ -1,13 +1,202 @@
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { expect, it as test, vi } from "vitest";
import { createSystemDoctorHost } from "./doctorRuntime.js";
import { DOCTOR_PROVIDER_IDS } from "./application/DoctorProviderIds.js";
import { providerRegistrationEnvironment } from "./application/SetupRegistrationEnvironment.js";
import { createTestTempDirectory } from "../tests/fixtures/temporaryDirectory.js";
import { writeJadxJarInventory } from "../tests/fixtures/android/jadxJar.js";
import * as privateDisplay from "./hopper/LinuxPrivateDisplayProbe.js";
import * as pythonRuntime from "./application/LinuxHopperPython.js";
import { err, ok } from "./domain/result.js";
import { AnalysisError } from "./domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "./domain/analysisErrorCore.js";
import { createDoctorHostFixture } from "./application/Doctor.fixture.js";
import { runDoctorOwned } from "./application/Doctor.js";
const readyDisplay = {
ok: true,
strategy: "direct",
diagnostic: {
component: "hopper_private_display",
operation: "probe",
reason: "ready",
socket_directory: "/tmp/.X11-unix",
socket_directory_mode: "1777",
mount_read_only: false,
effective_socket_directory_mode: "1777",
effective_mount_read_only: false,
wsl: false,
strategy: "direct",
fallback_reason: null,
xvfb_stderr_bytes: 0,
status: "ready",
failure_code: null,
},
} as const satisfies privateDisplay.LinuxPrivateDisplaySelection;
test.each([undefined, "controlled close refusal"])(
"propagates runtime-probe cancellation through owned doctor cleanup: %s",
async (cleanupFailure) => {
const controller = new AbortController();
const reason = { request: "cancel Python runtime inspection" };
const display = vi
.spyOn(privateDisplay, "selectLinuxPrivateDisplayStrategy")
.mockResolvedValue(readyDisplay);
const python = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockImplementation(async (_environment, signal) => {
expect(signal).toBe(controller.signal);
controller.abort(reason);
return err(
new pythonRuntime.LinuxHopperPythonError(
"cancelled",
"Controlled runtime cancellation",
reason,
),
);
});
const systemHost = createSystemDoctorHost({}, controller.signal);
const close = vi.fn(async () => {
await systemHost.close?.();
return cleanupFailure;
});
try {
const failure = await runDoctorOwned(
undefined,
createDoctorHostFixture({
platform: "linux",
configuredHopperPath: "/fixture/Hopper",
signal: controller.signal,
linuxDemoRuntimeCheck: systemHost.linuxDemoRuntimeCheck,
close,
}),
).then(
() => undefined,
(cause: unknown) => cause,
);
if (!(failure instanceof AnalysisError))
throw new Error("Expected a doctor cancellation failure");
const primary = failure.cleanupPrimary ?? failure;
expect(primary).toBeInstanceOf(AnalysisCancelledError);
expect(primary.cause).toBe(reason);
expect(primary.diagnostics).toMatchObject({
hopper_demo_runtime: {
private_display: readyDisplay.diagnostic,
embedded_python: { status: "unknown", reason: "cancelled" },
},
});
expect(close).toHaveBeenCalledTimes(1);
if (cleanupFailure === undefined) expect(failure).toBe(primary);
else expect(failure.cleanupIncomplete).toBe(true);
} finally {
try {
await systemHost.close?.();
} finally {
python.mockRestore();
display.mockRestore();
}
}
},
);
test.each(["unavailable", "unknown"] as const)(
"does not report a healthy Hopper runtime when Python is %s",
async (state) => {
const display = vi
.spyOn(privateDisplay, "selectLinuxPrivateDisplayStrategy")
.mockResolvedValue(readyDisplay);
const python = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockResolvedValue(
state === "unavailable"
? ok({
interpreter: "/usr/bin/python3",
version: [3, 14],
library: {
status: "unavailable",
path: "/usr/lib/libpython3.14.so.1.0",
reason: "No such file",
},
})
: err(
new pythonRuntime.LinuxHopperPythonError(
"query_failed",
"Fixture query failed",
),
),
);
try {
const check = await createSystemDoctorHost({}).linuxDemoRuntimeCheck?.();
expect(check).toMatchObject({
name: "hopper-demo-runtime",
ok: false,
classification:
state === "unavailable" ? "missing_dependency" : "unknown",
details: {
private_display: readyDisplay.diagnostic,
embedded_python: {
scope: "system_interpreter",
hopper_interpreter: "unknown",
...(state === "unavailable"
? { status: "observed", library: { status: "unavailable" } }
: { status: "unknown", reason: "query_failed" }),
},
},
});
expect(check?.remediation).toContain(
state === "unavailable"
? "rea setup --install-hopper"
: "Retry rea doctor",
);
} finally {
python.mockRestore();
display.mockRestore();
}
},
);
test("preserves both X11 and Python failures and their independent remediation", async () => {
const display = vi
.spyOn(privateDisplay, "selectLinuxPrivateDisplayStrategy")
.mockResolvedValue({
ok: false,
strategy: "unavailable",
exitCode: 80,
diagnostic: {
...readyDisplay.diagnostic,
status: "error",
failure_code: "x11_socket_directory_unusable",
},
});
const python = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockResolvedValue(
ok({
interpreter: "/usr/bin/python3",
version: [3, 14],
library: { status: "unknown", reason: "No INSTSONAME" },
}),
);
try {
const check = await createSystemDoctorHost({}).linuxDemoRuntimeCheck?.();
expect(check).toMatchObject({
ok: false,
classification: "config_drift",
details: {
private_display: { failure_code: "x11_socket_directory_unusable" },
embedded_python: { status: "observed", library: { status: "unknown" } },
},
});
expect(check?.remediation).toContain("user and mount namespaces");
expect(check?.remediation).toContain("shared-runtime configuration");
} finally {
python.mockRestore();
display.mockRestore();
}
});
const it = test.skipIf(process.platform === "win32");
+68 -13
View File
@@ -12,6 +12,11 @@ import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js";
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
import { ok } from "./domain/result.js";
import { AnalysisCancelledError } from "./domain/analysisErrorCore.js";
import {
inspectLinuxHopperPythonRuntime,
linuxHopperPythonUnavailable,
} from "./application/LinuxHopperPython.js";
/** Compose provider diagnostics at the outer CLI adapter boundary. */
export const createSystemDoctorHost = (
@@ -60,7 +65,8 @@ export const createSystemDoctorHost = (
fileCloseResources,
}),
],
linuxDemoRuntimeCheck: () => inspectLinuxPrivateDisplay(environment),
linuxDemoRuntimeCheck: () =>
inspectLinuxHopperDemoRuntime(environment, signal),
});
};
@@ -68,13 +74,31 @@ const HOPPER_DEMO_HELPER_PATH = fileURLToPath(
new URL("../scripts/hopper-demo-x11.py", import.meta.url),
);
const inspectLinuxPrivateDisplay = async (
const inspectLinuxHopperDemoRuntime = async (
environment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
): Promise<DoctorCheck> => {
const selection = await selectLinuxPrivateDisplayStrategy({
helperPath: HOPPER_DEMO_HELPER_PATH,
environment,
});
const python = await inspectLinuxHopperPythonRuntime(environment, signal);
const pythonFailure = !python.ok
? python.error
: python.value.library.status !== "loadable"
? linuxHopperPythonUnavailable(python.value.library)
: undefined;
const pythonDiagnostic = {
scope: "system_interpreter",
hopper_interpreter: "unknown",
...(python.ok
? { status: "observed", ...python.value }
: {
status: "unknown",
reason: python.error.reason,
message: python.error.message,
}),
};
const value = selection.diagnostic;
const detail = [
`strategy=${selection.strategy}`,
@@ -84,29 +108,60 @@ const inspectLinuxPrivateDisplay = async (
`effective_mode=${value.effective_socket_directory_mode ?? "unknown"}`,
`effective_mount_read_only=${String(value.effective_mount_read_only)}`,
`wsl=${String(value.wsl)}`,
`system_python=${python.ok ? python.value.library.status : "unknown"}`,
].join("; ");
if (selection.ok)
const details = {
private_display: { ...value },
embedded_python: pythonDiagnostic,
};
if (!python.ok && python.error.reason === "cancelled")
throw new AnalysisCancelledError("doctor", {
cause: python.error.cause,
diagnostics: { hopper_demo_runtime: details },
});
if (selection.ok && pythonFailure === undefined)
return {
name: "hopper-demo-runtime",
ok: true,
classification: "healthy",
detail,
details: { private_display: { ...value } },
details,
};
const missingDependency =
value.failure_code === "runtime_dependency_unavailable" ||
value.failure_code === "x11_authorization_failed";
value.failure_code === "x11_authorization_failed" ||
(python.ok && python.value.library.status === "unavailable");
const pythonRemediation =
pythonFailure === undefined
? undefined
: pythonFailure.reason === "runtime_unavailable"
? pythonFailure.message +
" On a supported system run rea setup --install-hopper, then rerun rea doctor --provider hopper. This checks the system interpreter; a custom Hopper Python interpreter needs its own matching shared runtime."
: pythonFailure.reason === "configuration_unknown"
? pythonFailure.message +
" Inspect this interpreter's shared-runtime configuration, then rerun rea doctor --provider hopper."
: pythonFailure.message +
" Retry rea doctor --provider hopper and inspect /usr/bin/python3 if its runtime remains unknown.";
const displayRemediation = selection.ok
? undefined
: value.failure_code === "x11_socket_directory_unusable"
? "Enable unprivileged user and mount namespaces, or provide a writable mode-1777 /tmp/.X11-unix directory. Do not mutate the current host mount with sudo; then rerun rea doctor --provider hopper."
: value.failure_code === "runtime_dependency_unavailable" ||
value.failure_code === "x11_authorization_failed"
? "Install Xvfb, xauth, Python 3, libX11, and libXtst, then rerun rea doctor --provider hopper."
: "Inspect the private-display diagnostic, correct the reported local condition, and rerun rea doctor --provider hopper.";
return {
name: "hopper-demo-runtime",
ok: false,
classification: missingDependency ? "missing_dependency" : "config_drift",
classification: missingDependency
? "missing_dependency"
: selection.ok
? "unknown"
: "config_drift",
detail,
details: { private_display: { ...value } },
remediation:
value.failure_code === "x11_socket_directory_unusable"
? "Enable unprivileged user and mount namespaces, or provide a writable mode-1777 /tmp/.X11-unix directory. Do not mutate the current host mount with sudo; then rerun rea doctor --provider hopper."
: missingDependency
? "Install Xvfb, xauth, Python 3, libX11, and libXtst, then rerun rea doctor --provider hopper."
: "Inspect the private-display diagnostic, correct the reported local condition, and rerun rea doctor --provider hopper.",
details,
remediation: [pythonRemediation, displayRemediation]
.filter((message) => message !== undefined)
.join(" "),
};
};
+6 -5
View File
@@ -79,6 +79,7 @@ export abstract class AnalysisError extends Error {
cleanupIncomplete: boolean;
#ownedCleanupResources: readonly string[];
#cleanupResourceOwner: AnalysisCleanupResourceOwner | undefined;
#secondaryFailures: AnalysisError[] = [];
constructor(message: string, options?: AnalysisErrorOptions) {
super(message, options);
this.#cleanupPrimary = options?.cleanupPrimary;
@@ -104,6 +105,10 @@ export abstract class AnalysisError extends Error {
get cleanupPrimary(): AnalysisError | undefined {
return this.#cleanupPrimary;
}
/** Later typed failures retained without changing the primary error cause. */
get secondaryFailures(): readonly AnalysisError[] {
return this.#secondaryFailures;
}
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
/** Live dependencies own current resource attribution; cleanup keeps history. */
@@ -142,12 +147,8 @@ export abstract class AnalysisError extends Error {
// the primary error's live cleanup status and original output.
Object.assign(this, {
diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }),
cause: new AggregateError(
this.cause === undefined ? [failure] : [this.cause, failure],
"Analysis failed and a later lifecycle operation also failed.",
{ cause: this.cause },
),
});
this.#secondaryFailures.push(failure);
return this;
}
@@ -839,7 +839,9 @@ describe("analysis error projection: evidence files", () => {
});
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
const callerReason = { caller: "stop" };
const primary = new AnalysisCancelledError("execute_rizin_command", {
cause: callerReason,
capturedOutput: {
stdout: "partial",
stderr: "diagnostic",
@@ -855,6 +857,8 @@ it("retains the primary failure and both cleanup reports after a secondary failu
});
expect(enriched).toBe(primary);
expect(enriched._tag).toBe("AnalysisCancelledError");
expect(enriched.cause).toBe(callerReason);
expect(enriched.secondaryFailures).toEqual([secondary]);
expect(projectAnalysisError(enriched)).toMatchObject({
code: "cleanup_incomplete",
details: {
@@ -867,6 +871,16 @@ it("retains the primary failure and both cleanup reports after a secondary failu
});
});
it("retains secondary failures when the primary error has no cause", () => {
const primary = new AnalysisCancelledError("execute_rizin_command");
const secondary = new AnalysisOutputError("open_binary", "cannot rebind");
primary.retainSecondaryFailure(secondary, {});
expect(primary.cause).toBeUndefined();
expect(primary.secondaryFailures).toEqual([secondary]);
});
it.each([false, true])(
"preserves producer capability recovery with retryable=%s",
(retryable) => {
+5 -8
View File
@@ -10,10 +10,7 @@ import {
} from "./javascriptFactsView.js";
import { javaScriptSourceSelectionSchema } from "../javascript/javascriptSourceSelection.js";
import {
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../analysisErrorCore.js";
import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
import {
INSPECT_ANALYSIS_VIEW_OPERATION,
analysisViewInputError,
@@ -24,7 +21,7 @@ import { evidenceSchema, type Evidence } from "../evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { binaryLayoutSchema } from "../native/binaryLayout.js";
import { safeParseOwnedBinaryLayout } from "../native/binaryLayout.js";
import { functionDossierSchema } from "../hopperValues.js";
import {
artifactGraphManifestSchema,
@@ -741,8 +738,8 @@ const parseJavaScriptParent = (
input: unknown,
): Result<JavaScriptApplicationAnalysisResult, AnalysisError> => {
try {
// Only the existing realm-private graph proofs permit reuse. Imported or
// merely frozen graphs still receive full canonical validation.
// The format owner authenticates imported results completely before reuse;
// mutable or merely frozen inputs still receive canonical validation.
const steps = parseOwnedJavaScriptApplicationAnalysisSteps(input);
let step = steps.next();
while (!step.done) step = steps.next();
@@ -841,7 +838,7 @@ export const projectAnalysisView = (
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
if (parent.operation === "inspect_binary_layout") {
const layout = binaryLayoutSchema.safeParse(parent.normalizedResult);
const layout = safeParseOwnedBinaryLayout(parent.normalizedResult);
if (!layout.success)
return err(
analysisViewInputError([
+22 -7
View File
@@ -69,6 +69,12 @@ export const apktoolInputSchemas = {
.describe(
`Project res/values/strings.xml entries (bounded to ${String(MAX_STRINGS)} entries)`,
),
string_tables_only: z
.boolean()
.optional()
.describe(
"Return selected strings only in string_tables, leaving top-level strings empty. Omit to preserve the primary strings field.",
),
locale: androidLocaleQualifierSchema
.optional()
.describe(
@@ -178,15 +184,24 @@ export const apktoolResultSchemas = {
package_name: z.string().nullable(),
}),
manifest: z.string().nullable(),
strings: z.array(
z.strictObject({
name: z.string().min(1),
value: z.string(),
}),
),
strings: z
.array(
z.strictObject({
name: z.string().min(1),
value: z.string(),
}),
)
.describe(
"Primary string entries; empty when include_strings is false or string_tables_only is true. An unrequested field does not establish an empty table.",
),
locale: z.string().nullable(),
locales: z.array(z.string().min(1)),
string_tables: z.array(stringTableSchema).optional(),
string_tables: z
.array(stringTableSchema)
.optional()
.describe(
"Selected table facts, included when locales is supplied or string_tables_only is true",
),
decoded_file_count: z.number().int().nonnegative(),
coverage: z.enum(["complete", "partial"]),
}),
+41
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { MAX_PLIST_DEPTH } from "./plistValue.js";
import { projectKeyedArchive } from "./keyedArchive.js";
import { UnknownPlistScalar } from "./unknownPlistScalar.js";
const deepValue = (depth: number): unknown => {
let value: unknown = 1;
@@ -86,3 +87,43 @@ describe("projectKeyedArchive root reference projection", () => {
).toThrow(RangeError);
});
});
it("keeps generated unknown scalar wrappers scalar and lookalike dictionaries ordinary", () => {
const result = projectKeyedArchive(
{
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
new UnknownPlistScalar(
"false",
"false<!-- value -->",
"unsupported-markup",
),
{
$plist_type: "unknown",
element: "false",
literal: "false<!-- value -->",
reason: "unsupported-markup",
},
],
$top: { root: { UID: 1 } },
},
{ offset: 0, limit: 3 },
);
expect(result.unknownScalarCount).toBe(1);
expect(result.objects[1]).toMatchObject({
kind: "scalar",
status: "observed",
value: {
$plist_type: "unknown",
element: "false",
literal: "false<!-- value -->",
reason: "unsupported-markup",
},
});
expect(result.objects[2]).toMatchObject({
kind: "dictionary",
status: "observed",
value: { $plist_type: "unknown" },
});
});
+1 -1
View File
@@ -248,9 +248,9 @@ export const projectKeyedArchive = (
const ids = new Set(page.map(({ id }) => id));
return {
nonFiniteRealCount: projected.nonFiniteRealCount,
unknownRealCount: projected.unknownRealCount,
negativeZeroRealCount: projected.negativeZeroRealCount,
unknownDateCount: projected.unknownDateCount,
unknownScalarCount: projected.unknownScalarCount,
roots,
objects: page,
references: references.filter(
+1 -45
View File
@@ -1,18 +1,5 @@
import type { JsonValue } from "../jsonValue.js";
/** XML numeric decoding could not establish a representable value identity. */
export class UnknownPlistReal {}
/** Exact decimal or signed hexadecimal XML integer text. */
export const parsePlistIntegerLiteral = (text: string): bigint | undefined => {
const match = /^([+-]?)(?:0[xX]([\da-fA-F]+)|(\d+))$/u.exec(text.trim());
if (match === null) return undefined;
const magnitude = BigInt(
match[2] === undefined ? match[3]! : `0x${match[2]}`,
);
return match[1] === "-" ? -magnitude : magnitude;
};
/** Source element types needed to distinguish large integers from integral reals. */
export interface PlistNumberLiterals {
readonly integers: ReadonlyMap<number, ReadonlySet<string>>;
@@ -31,7 +18,6 @@ export const createPlistNumberCollector = () => {
const integers = new Map<number, Set<string>>();
const reals = new Set<number>();
let unsafeIntegerLiterals = 0;
let incompleteMetadata = false;
return {
integer: (exact: bigint) => {
if (
@@ -49,14 +35,11 @@ export const createPlistNumberCollector = () => {
real: (value: number) => {
reals.add(value);
},
markIncomplete: () => {
incompleteMetadata = true;
},
result: (incomplete = false): PlistNumberLiterals => ({
integers,
reals,
unsafeIntegerLiterals,
incomplete: incomplete || incompleteMetadata,
incomplete,
}),
};
};
@@ -136,30 +119,3 @@ export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
],
};
};
/**
* Read a whole `<real>` text as one of CFPropertyList's non-finite spellings.
* Its parser compares the complete text case-insensitively and also accepts
* `infinity`, `inf`, `+inf` and `-inf`; its writer emits `nan`, `+infinity`
* and `-infinity`. Any other text, including surrounding whitespace, is not
* a spelled non-finite real.
*/
export const parsePlistNonFiniteRealLiteral = (
text: string,
): number | undefined => {
if (text.length < 3 || text.length > 9) return undefined;
switch (text.toLowerCase()) {
case "nan":
return Number.NaN;
case "+infinity":
case "infinity":
case "+inf":
case "inf":
return Number.POSITIVE_INFINITY;
case "-infinity":
case "-inf":
return Number.NEGATIVE_INFINITY;
default:
return undefined;
}
};
+18 -10
View File
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { parseBinary } from "plist";
import { projectPlistValue } from "./plistValue.js";
import { UnknownPlistReal } from "./plistNumbers.js";
import { UnknownPlistScalar } from "./unknownPlistScalar.js";
// Binary decoders build dates from seconds; beyond the Date range they are invalid.
it("projects an unrepresentable date as unknown and counts it", () => {
@@ -14,9 +14,9 @@ it("projects an unrepresentable date as unknown and counts it", () => {
near: { $plist_type: "date", iso8601: "1970-01-01T00:00:00.000Z" },
},
nonFiniteRealCount: 0,
unknownRealCount: 0,
negativeZeroRealCount: 0,
unknownDateCount: 1,
unknownScalarCount: 0,
});
});
@@ -27,7 +27,6 @@ it("keeps a negative-zero real distinct from positive zero and counts it", () =>
(wrapper) => observed.push(wrapper),
);
expect(projected.negativeZeroRealCount).toBe(1);
expect(projected.unknownRealCount).toBe(0);
expect(Array.isArray(projected.value)).toBe(true);
if (!Array.isArray(projected.value)) throw new Error("Expected array");
expect(projected.value[0]).toEqual({ $plist_type: "real", value: "-0" });
@@ -120,7 +119,6 @@ it("keeps retained integer scalars distinct from lookalike dictionaries", () =>
[BigInt(decimal), { $plist_type: "integer", decimal }],
(value) => observed.push(value),
);
expect(projected.unknownRealCount).toBe(0);
expect(projected.unknownDateCount).toBe(0);
expect(Array.isArray(projected.value)).toBe(true);
if (!Array.isArray(projected.value)) throw new Error("Expected array");
@@ -130,30 +128,41 @@ it("keeps retained integer scalars distinct from lookalike dictionaries", () =>
expect(observed[0]).not.toBe(projected.value[1]);
});
it("projects known non-finite numbers while retaining explicit XML unknowns", () => {
it("projects known non-finite and unknown XML scalars without wrapping lookalikes", () => {
const observed: object[] = [];
const projected = projectPlistValue(
[
Infinity,
-Infinity,
NaN,
new UnknownPlistReal(),
new UnknownPlistReal(),
new UnknownPlistScalar("real", "1e999", "unrepresentable"),
new UnknownPlistScalar(
"integer",
"12<!-- gap -->3",
"unsupported-markup",
),
{ $plist_type: "real", value: "nan" },
{
$plist_type: "unknown",
element: "integer",
literal: "12<!-- gap -->3",
reason: "unsupported-markup",
},
],
(wrapper) => observed.push(wrapper),
);
expect(projected.nonFiniteRealCount).toBe(3);
expect(projected.unknownRealCount).toBe(2);
expect(projected.unknownScalarCount).toBe(2);
expect(projected.negativeZeroRealCount).toBe(0);
expect(JSON.stringify(projected.value)).toBe(
'[{"$plist_type":"real","value":"+infinity"},{"$plist_type":"real","value":"-infinity"},{"$plist_type":"real","value":"nan"},{"$plist_type":"real","value":null},{"$plist_type":"real","value":null},{"$plist_type":"real","value":"nan"}]',
'[{"$plist_type":"real","value":"+infinity"},{"$plist_type":"real","value":"-infinity"},{"$plist_type":"real","value":"nan"},{"$plist_type":"unknown","element":"real","literal":"1e999","reason":"unrepresentable"},{"$plist_type":"unknown","element":"integer","literal":"12<!-- gap -->3","reason":"unsupported-markup"},{"$plist_type":"real","value":"nan"},{"$plist_type":"unknown","element":"integer","literal":"12<!-- gap -->3","reason":"unsupported-markup"}]',
);
expect(Array.isArray(projected.value)).toBe(true);
if (!Array.isArray(projected.value)) throw new Error("Expected array");
// Only generated wrappers are reported; the lookalike stays a dictionary.
expect(observed).toEqual(projected.value.slice(0, 5));
expect(observed).not.toContain(projected.value[5]);
expect(observed).not.toContain(projected.value[6]);
});
it.each([
@@ -226,7 +235,6 @@ it.each([
]);
expect(projected.nonFiniteRealCount).toBe(4);
expect(projected.negativeZeroRealCount).toBe(1);
expect(projected.unknownRealCount).toBe(0);
expect(observed).toHaveLength(5);
expect(bytes).toEqual(original);
});
+18 -13
View File
@@ -2,7 +2,7 @@ import { types } from "node:util";
import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
import { UnknownPlistReal } from "./plistNumbers.js";
import { UnknownPlistScalar } from "./unknownPlistScalar.js";
/**
* Maximum plist value nesting the JSON projection supports. The XML decode
@@ -16,26 +16,26 @@ export interface ProjectedPlistValue {
readonly value: JsonValue;
/** Known non-finite values, rendered with canonical numeric labels. */
readonly nonFiniteRealCount: number;
/** XML numeric values whose identity could not be established. */
readonly unknownRealCount: number;
/** Negative-zero reals, which a JSON number would report as positive zero. */
readonly negativeZeroRealCount: number;
/** Dates the decoder could not represent, reported without a timestamp. */
readonly unknownDateCount: number;
/** XML scalar literals retained explicitly because their values are unknown. */
readonly unknownScalarCount: number;
}
/** Limitation for results whose plist held dates the decoder could not represent. */
export const unknownDateLimitation = (count: number): string =>
`${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent them, such as XML dates with years beyond 9999 or negative years, or dates outside the JavaScript date range.`;
`${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent their timestamps.`;
/** Limitation for XML scalar literals whose values were not established. */
export const unknownScalarLimitation = (count: number): string =>
`${String(count)} XML scalar value(s) could not be established; their exact literals and reasons are retained in { "$plist_type": "unknown", "element": ..., "literal": ..., "reason": ... } wrappers.`;
/** Limitation for known non-finite values; the labels do not imply XML provenance. */
export const nonFiniteRealLimitation = (count: number): string =>
`${String(count)} non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.`;
/** Limitation for XML numeric values the decoder could not establish. */
export const unknownRealLimitation = (count: number): string =>
`${String(count)} numeric value(s) are reported as { "$plist_type": "real", "value": null } because XML numeric decoding could not establish their value identity.`;
/** The typed scalar that keeps a negative-zero real distinct from positive zero. */
export const negativeZeroReal = (): Record<string, JsonValue> => ({
$plist_type: "real",
@@ -58,9 +58,9 @@ export const projectPlistValue = (
onWrappedScalar?: (value: Record<string, JsonValue>) => void,
): ProjectedPlistValue => {
let nonFiniteRealCount = 0;
let unknownRealCount = 0;
let negativeZeroRealCount = 0;
let unknownDateCount = 0;
let unknownScalarCount = 0;
const scalar = (value: Record<string, JsonValue>): JsonValue => {
onWrappedScalar?.(value);
return value;
@@ -77,9 +77,14 @@ export const projectPlistValue = (
$plist_type: "data",
base64: Buffer.from(item).toString("base64"),
});
if (item instanceof UnknownPlistReal) {
unknownRealCount += 1;
return scalar({ $plist_type: "real", value: null });
if (item instanceof UnknownPlistScalar) {
unknownScalarCount += 1;
return scalar({
$plist_type: "unknown",
element: item.element,
literal: item.rawLiteral,
reason: item.reason,
});
}
if (item instanceof Date) {
if (!Number.isNaN(Date.prototype.getTime.call(item)))
@@ -137,8 +142,8 @@ export const projectPlistValue = (
return {
value: projected,
nonFiniteRealCount,
unknownRealCount,
negativeZeroRealCount,
unknownDateCount,
unknownScalarCount,
};
};
+11
View File
@@ -0,0 +1,11 @@
/** An XML plist scalar whose value cannot be established without guessing. */
export class UnknownPlistScalar {
constructor(
readonly element: "integer" | "real" | "date" | "true" | "false",
readonly rawLiteral: string,
readonly reason:
| "invalid-literal"
| "unsupported-markup"
| "unrepresentable",
) {}
}
+206 -1
View File
@@ -1,12 +1,55 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { JsonValue } from "./jsonValue.js";
import {
freezeJsonSnapshot,
canBorrowParsedJsonSnapshot,
immutableJsonSnapshotMember,
isImmutableJsonSnapshot,
} from "./immutableJson.js";
describe("owned immutable JSON snapshots", () => {
it("borrows only the selected own child of a completed sealed root", () => {
const value = freezeJsonSnapshot({
selected: { values: [1, 2] },
other: { value: 3 },
absent: null,
});
expect(isImmutableJsonSnapshot(value.selected)).toBe(false);
expect(immutableJsonSnapshotMember(value, "selected")).toBe(value.selected);
expect(isImmutableJsonSnapshot(value.selected)).toBe(true);
expect(isImmutableJsonSnapshot(value.other)).toBe(false);
expect(immutableJsonSnapshotMember(value.selected, "values")).toBe(
value.selected.values,
);
expect(isImmutableJsonSnapshot(value.selected.values)).toBe(true);
expect(immutableJsonSnapshotMember(value, "absent")).toBeNull();
expect(immutableJsonSnapshotMember(value, "missing")).toBeUndefined();
expect(immutableJsonSnapshotMember(value, "toString")).toBeUndefined();
expect(Reflect.set(value.selected.values, 0, 9)).toBe(false);
});
it("does not borrow children from unowned roots or invoke their accessors", () => {
let accessed = false;
const child = Object.freeze({ value: 1 });
const root = Object.freeze({ child });
expect(immutableJsonSnapshotMember(root, "child")).toBeUndefined();
expect(isImmutableJsonSnapshot(child)).toBe(false);
expect(
immutableJsonSnapshotMember(
{
get child() {
accessed = true;
return child;
},
},
"child",
),
).toBeUndefined();
expect(accessed).toBe(false);
expect(immutableJsonSnapshotMember(null, "child")).toBeUndefined();
});
it("seals reachable data and preserves repeated references", () => {
const shared = { values: [1, "observed"] };
const value = { left: shared, right: shared };
@@ -61,4 +104,166 @@ describe("owned immutable JSON snapshots", () => {
"JSON values",
);
});
it("rejects proxy snapshots before reflecting on their mutable behavior", () => {
let reflected = false;
const proxy = new Proxy(
{ value: 1 },
{
getPrototypeOf() {
reflected = true;
throw new Error("must not reflect on a proxy");
},
},
);
const root = { child: proxy };
for (const input of [proxy, root]) {
expect(() => freezeJsonSnapshot(input)).toThrow("proxies");
expect(isImmutableJsonSnapshot(input)).toBe(false);
}
expect(Object.isFrozen(root)).toBe(false);
expect(reflected).toBe(false);
});
});
describe("borrowing a parsed JSON snapshot", () => {
it("accepts reordered object fields but preserves arrays and exact scalars", () => {
const source = freezeJsonSnapshot({ second: [true, null], first: -0 });
expect(
canBorrowParsedJsonSnapshot(source, { first: -0, second: [true, null] }),
).toBe(true);
expect(
canBorrowParsedJsonSnapshot(source, { first: 0, second: [true, null] }),
).toBe(false);
expect(
canBorrowParsedJsonSnapshot(source, { first: -0, second: [null, true] }),
).toBe(false);
expect(
canBorrowParsedJsonSnapshot(Object.freeze({ value: 1 }), { value: 1 }),
).toBe(false);
});
it("preserves parser defaults, hidden-field removal and enumerability changes", () => {
const source = freezeJsonSnapshot({ observed: 1 });
expect(
canBorrowParsedJsonSnapshot(source, { observed: 1, defaulted: 0 }),
).toBe(false);
const hidden = { observed: 1 };
Object.defineProperty(hidden, "unmodeled", { value: 2, enumerable: false });
freezeJsonSnapshot(hidden);
expect(canBorrowParsedJsonSnapshot(hidden, { observed: 1 })).toBe(false);
const nonenumerable = { observed: 1 };
Object.defineProperty(nonenumerable, "observed", { enumerable: false });
freezeJsonSnapshot(nonenumerable);
expect(canBorrowParsedJsonSnapshot(nonenumerable, { observed: 1 })).toBe(
false,
);
});
it("does not evaluate accessors introduced by a parsed representation", () => {
let accessed = false;
const source = freezeJsonSnapshot({ observed: 1 });
expect(
canBorrowParsedJsonSnapshot(source, {
get observed() {
accessed = true;
return 1;
},
}),
).toBe(false);
expect(accessed).toBe(false);
});
it("declines parser-introduced proxies without reflecting on them", () => {
let reflected = false;
const parsed = new Proxy(
{ value: 1 },
{
getPrototypeOf() {
reflected = true;
throw new Error("must not reflect on a proxy");
},
},
);
expect(
canBorrowParsedJsonSnapshot(freezeJsonSnapshot({ value: 1 }), parsed),
).toBe(false);
expect(reflected).toBe(false);
});
it("compares deeply nested snapshots without depending on the call stack", () => {
let source: JsonValue = 1;
let parsed: JsonValue = 1;
for (let depth = 0; depth < 6_000; depth += 1) {
source = { child: source };
parsed = { child: parsed };
}
freezeJsonSnapshot(source);
expect(canBorrowParsedJsonSnapshot(source, parsed)).toBe(true);
});
it("compares shared snapshot graphs without repeatedly expanding their paths", () => {
let source: JsonValue = { value: 1 };
for (let depth = 0; depth < 26; depth += 1)
source = { left: source, right: source };
const parsed = structuredClone(source);
freezeJsonSnapshot(source);
const ownKeys = Reflect.ownKeys;
let inspections = 0;
const observed = vi
.spyOn(Reflect, "ownKeys")
.mockImplementation((value) => {
// Fail deterministically instead of consuming exponential CPU on regressions.
if (++inspections > 10_000)
throw new Error("Shared JSON comparison exceeded inspection budget");
return ownKeys(value);
});
let borrowed: boolean;
try {
borrowed = canBorrowParsedJsonSnapshot(source, parsed);
} finally {
observed.mockRestore();
}
expect(borrowed).toBe(true);
});
it("keeps the parsed result when a parser splits shared source nodes", () => {
const shared = { value: 1 };
const source = freezeJsonSnapshot({ left: shared, right: shared });
expect(
canBorrowParsedJsonSnapshot(source, {
left: { value: 1 },
right: { value: 1 },
}),
).toBe(false);
});
it.each(["direct", "nested"])(
"detects shared-node splits across a %s preserved subtree",
(kind) => {
const shared = { value: 1 };
const source = freezeJsonSnapshot({
preserved: kind === "direct" ? shared : { nested: shared },
cloned: shared,
});
expect(
canBorrowParsedJsonSnapshot(source, {
preserved: source.preserved,
cloned: { value: 1 },
}),
).toBe(false);
expect(canBorrowParsedJsonSnapshot(source, source)).toBe(true);
},
);
it("keeps the parsed result when a parser merges distinct source nodes", () => {
const source = freezeJsonSnapshot({
left: { value: 1 },
right: { value: 1 },
});
const shared = { value: 1 };
expect(
canBorrowParsedJsonSnapshot(source, { left: shared, right: shared }),
).toBe(false);
});
});
+85
View File
@@ -1,3 +1,5 @@
import { isProxy } from "node:util/types";
import type { JsonValue } from "./jsonValue.js";
const immutableSnapshots = new WeakSet<object>();
@@ -14,6 +16,87 @@ export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue =>
(typeof value === "number" && Number.isFinite(value)) ||
(typeof value === "object" && immutableSnapshots.has(value));
/** Borrow one own member of a completed snapshot, carrying its sealing proof. */
export const immutableJsonSnapshotMember = (
snapshot: unknown,
key: string,
): JsonValue | undefined => {
if (
typeof snapshot !== "object" ||
snapshot === null ||
!immutableSnapshots.has(snapshot)
)
return undefined;
const descriptor = Object.getOwnPropertyDescriptor(snapshot, key);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return undefined;
// Completed sealing checked and froze every reachable member. Register only
// the selected child; do not traverse it again or add per-node weak entries.
const member = descriptor.value as JsonValue;
if (typeof member === "object" && member !== null)
immutableSnapshots.add(member);
return member;
};
/** Match a completed snapshot to its parsed representation without recursion. */
export const canBorrowParsedJsonSnapshot = <Parsed extends JsonValue>(
source: unknown,
parsed: Parsed,
): source is Parsed => {
if (!isImmutableJsonSnapshot(source)) return false;
if (Object.is(source, parsed)) return true;
const pending: unknown[] = [source, parsed];
const compared = new Map<object, object>();
const sources = new Map<object, object>();
while (pending.length > 0) {
const right = pending.pop();
const left = pending.pop();
if (
typeof left !== "object" ||
left === null ||
typeof right !== "object" ||
right === null
) {
if (!Object.is(left, right)) return false;
continue;
}
if (
isProxy(right) ||
Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)
)
return false;
const previous = compared.get(left);
if (previous !== undefined) {
if (previous !== right) return false;
continue;
}
if (sources.has(right)) return false;
// Preserve sharing in both directions, including descendants of an
// identical subtree that may also appear in a separately parsed field.
compared.set(left, right);
sources.set(right, left);
const keys = Reflect.ownKeys(left);
if (keys.length !== Reflect.ownKeys(right).length) return false;
for (const key of keys) {
// A parser may drop hidden fields or normalize enumerability. Generic
// deep equality omits these differences and can borrow unparsed data.
if (typeof key !== "string") return false;
const a = Object.getOwnPropertyDescriptor(left, key);
const b = Object.getOwnPropertyDescriptor(right, key);
if (
a === undefined ||
b === undefined ||
!Object.hasOwn(a, "value") ||
!Object.hasOwn(b, "value") ||
a.enumerable !== b.enumerable
)
return false;
pending.push(a.value as unknown, b.value as unknown);
}
}
return true;
};
/** Seal an already parsed, owned JSON snapshot for immutable zero-copy reuse. */
export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
const pending: SnapshotEntry[] = [{ value, exiting: false }];
@@ -104,6 +187,8 @@ export function* freezeOwnedJsonSnapshotSteps<T extends JsonValue>(
}
const pushSnapshotChildren = (item: object, pending: SnapshotEntry[]): void => {
if (isProxy(item))
throw new TypeError("Immutable JSON snapshots cannot contain proxies");
const prototype: unknown = Object.getPrototypeOf(item);
if (
!Array.isArray(item) &&
+25
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
freezeOwnedJsonSnapshotSteps,
immutableJsonSnapshotMember,
isImmutableJsonSnapshot,
} from "./immutableJson.js";
import { jsonValueValidationIssue, type JsonValue } from "./jsonValue.js";
@@ -23,6 +24,7 @@ describe("cooperative owned snapshot sealing", () => {
expect(steps.next().done).toBe(false);
expect(Object.isFrozen(value)).toBe(true);
expect(isImmutableJsonSnapshot(value)).toBe(false);
expect(immutableJsonSnapshotMember(value, "deferred")).toBeUndefined();
const introduced = { value: 2 };
deferred.child = introduced;
expect(complete(steps)).toBe(value);
@@ -49,6 +51,7 @@ describe("cooperative owned snapshot sealing", () => {
expect(interrupted.next().done).toBe(false);
interrupted.return(cancelled);
expect(isImmutableJsonSnapshot(cancelled)).toBe(false);
expect(immutableJsonSnapshotMember(cancelled, "values")).toBeUndefined();
});
it("preserves aliases and rejects accessors, sparse values and cycles", () => {
@@ -80,4 +83,26 @@ describe("cooperative owned snapshot sealing", () => {
value.child.value = Number.NaN;
expect(jsonValueValidationIssue(value)).toBe("JSON numbers must be finite");
});
it("never authenticates roots containing proxies or invokes their traps", () => {
let reflected = false;
const proxy = new Proxy(
{ value: 1 },
{
getPrototypeOf() {
reflected = true;
throw new Error("must not reflect on a proxy");
},
},
);
const root = { child: proxy };
for (const input of [proxy, root]) {
expect(() => complete(freezeOwnedJsonSnapshotSteps(input))).toThrow(
"proxies",
);
expect(isImmutableJsonSnapshot(input)).toBe(false);
expect(immutableJsonSnapshotMember(input, "child")).toBeUndefined();
}
expect(reflected).toBe(false);
});
});
@@ -1,4 +1,5 @@
import { z } from "zod";
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
import {
javascriptApplicationGraphSchema,
@@ -172,21 +173,43 @@ const ownedAnalysisResultShape = applicationAnalysisResultShape.extend({
),
});
// One successful interpretation per exact immutable source. Weak keys retain
// neither a second graph nor a source beyond its Evidence lifetime.
const validatedImmutableResults = new WeakSet<object>();
const isValidatedImmutableResult = (
input: unknown,
): input is JavaScriptApplicationAnalysisResult =>
typeof input === "object" &&
input !== null &&
validatedImmutableResults.has(input);
/** Reuse exact owned graphs while validating metadata and cross-graph bindings. */
export function* parseOwnedJavaScriptApplicationAnalysisSteps(
input: unknown,
): Generator<void, JavaScriptApplicationAnalysisResult> {
if (isValidatedImmutableResult(input)) return input;
const prepared = ownedAnalysisResultShape.safeParse(input);
if (!prepared.success)
return javascriptApplicationAnalysisResultSchema.parse(input);
const issues: BindingIssue[] = [];
yield* checkApplicationBindingsSteps(prepared.data, {
addIssue: (issue) => {
issues.push(issue);
},
});
if (issues.length > 0) throw new z.ZodError(issues);
return prepared.data;
if (prepared.success) {
const issues: BindingIssue[] = [];
yield* checkApplicationBindingsSteps(prepared.data, {
addIssue: (issue) => {
issues.push(issue);
},
});
if (issues.length > 0) throw new z.ZodError(issues);
// Keep this existing fast path and its metadata normalization. Rewalking
// already owned graphs just to borrow the wrapper would add full-graph work.
return prepared.data;
}
const result = javascriptApplicationAnalysisResultSchema.parse(input);
// Borrow only if parsing preserved the exact representation. A future schema
// default/normalization must still return its parsed value, without caching.
// Admission follows full validation, including completion of yielding checks.
if (canBorrowParsedJsonSnapshot(input, result)) {
validatedImmutableResults.add(input);
return input;
}
return result;
}
/** Parsed public JavaScript application analysis request. */
@@ -56,6 +56,101 @@ const issuesFrom = (action: () => unknown) => {
throw new Error("Expected complete result validation to reject the input");
};
describe("immutable JavaScript result reuse", () => {
it("preserves parser copies shared across owned graphs and result metadata", () => {
const owned = ownedExample();
const input = freezeJsonSnapshot({
...owned,
limitations: owned.graph.limitations,
});
for (let repeat = 0; repeat < 2; repeat += 1) {
const parsed = complete(
parseOwnedJavaScriptApplicationAnalysisSteps(input),
);
expect(parsed).not.toBe(input);
expect(parsed.graph).toBe(input.graph);
expect(parsed.limitations).toEqual(input.graph.limitations);
expect(parsed.limitations).not.toBe(parsed.graph.limitations);
}
});
it.each(["hidden-extra", "changed-enumerability"])(
"preserves parsed results when the source has %s normalization",
(kind) => {
const input = example();
if (kind === "hidden-extra")
Object.defineProperty(input, "not_in_parsed_result", {
value: { unvalidated: true },
enumerable: false,
});
else Object.defineProperty(input, "input_path", { enumerable: false });
freezeJsonSnapshot(input);
const expected = javascriptApplicationAnalysisResultSchema.parse(input);
for (let repeat = 0; repeat < 2; repeat += 1) {
const parsed = complete(
parseOwnedJavaScriptApplicationAnalysisSteps(input),
);
expect(parsed).not.toBe(input);
expect(Object.hasOwn(parsed, "not_in_parsed_result")).toBe(false);
expect(
Object.getOwnPropertyDescriptor(parsed, "input_path")?.enumerable,
).toBe(
Object.getOwnPropertyDescriptor(expected, "input_path")?.enumerable,
);
}
},
);
it("borrows imported immutable results only after complete format validation", () => {
const input = example();
delete input.statistics.skipped_javascript_files;
freezeJsonSnapshot(input);
const result = complete(
parseOwnedJavaScriptApplicationAnalysisSteps(input),
);
expect(result).toBe(input);
expect(result).toEqual(
javascriptApplicationAnalysisResultSchema.parse(input),
);
expect(result.statistics).not.toHaveProperty("skipped_javascript_files");
expect(complete(parseOwnedJavaScriptApplicationAnalysisSteps(input))).toBe(
input,
);
expect(Reflect.set(input.statistics, "findings", -1)).toBe(false);
});
it("preserves a normalized representation instead of borrowing changed input", () => {
const input = example();
Object.setPrototypeOf(input, null);
freezeJsonSnapshot(input);
for (let repeat = 0; repeat < 2; repeat += 1) {
const result = complete(
parseOwnedJavaScriptApplicationAnalysisSteps(input),
);
expect(result).not.toBe(input);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result).toEqual(
javascriptApplicationAnalysisResultSchema.parse(input),
);
}
});
it.each(["mutable", "shallow-frozen"])(
"rechecks changed %s metadata despite authenticated nested graphs",
(kind) => {
const input = ownedExample();
if (kind === "shallow-frozen") Object.freeze(input);
expect(
complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)),
).not.toBe(input);
input.statistics.findings = -1;
expect(() =>
complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)),
).toThrow(z.ZodError);
},
);
});
describe("owned JavaScript graph validation", () => {
it("reuses exact sealed graphs and preserves all public result fields", () => {
const input = ownedExample();
@@ -239,6 +334,7 @@ describe("owned JavaScript result bindings", () => {
input.semantic_graph = complete(
createImmutableJavaScriptSemanticGraphSteps(semantic),
);
freezeJsonSnapshot(input);
const steps = parseOwnedJavaScriptApplicationAnalysisSteps(input);
expect(steps.next().done).toBe(false);
steps.return(input);
+55 -1
View File
@@ -1,5 +1,10 @@
import { expect, it } from "vitest";
import { binaryLayoutSchema, type BinaryLayout } from "./binaryLayout.js";
import {
binaryLayoutSchema,
safeParseOwnedBinaryLayout,
type BinaryLayout,
} from "./binaryLayout.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
const report = () => ({
artifact: { path: "/fixture.elf", sha256: "a".repeat(64), bytes: 128 },
@@ -60,6 +65,55 @@ const report = () => ({
limitations: [],
});
it("reuses an exact validated immutable layout without a second retained copy", () => {
const value = freezeJsonSnapshot(report());
const parsed = safeParseOwnedBinaryLayout(value);
expect(parsed.success).toBe(true);
if (!parsed.success) throw parsed.error;
expect(parsed.data).toBe(value);
expect(safeParseOwnedBinaryLayout(value)).toEqual(parsed);
expect(parsed.data).toEqual(binaryLayoutSchema.parse(value));
expect(Reflect.set(value.sections[0]!, "index", 5)).toBe(false);
});
it.each(["mutable", "shallow-frozen"])(
"revalidates a changed %s layout instead of caching its former validity",
(kind) => {
const value = report();
if (kind === "shallow-frozen") Object.freeze(value);
const first = safeParseOwnedBinaryLayout(value);
if (!first.success) throw first.error;
expect(first.data).not.toBe(value);
value.sections[0]!.header_location.offset = "0x80";
const invalid = safeParseOwnedBinaryLayout(value);
expect(invalid.success).toBe(false);
expect(invalid).toEqual(binaryLayoutSchema.safeParse(value));
},
);
it("never treats sealing as format validity or caches a refused layout", () => {
const value = report();
value.sections[0]!.index = 10;
freezeJsonSnapshot(value);
expect(safeParseOwnedBinaryLayout(value).success).toBe(false);
expect(safeParseOwnedBinaryLayout(value).success).toBe(false);
});
it("does not borrow hidden fields that the format parser removed", () => {
const value = report();
Object.defineProperty(value, "not_in_parsed_result", {
value: { unvalidated: true },
enumerable: false,
});
freezeJsonSnapshot(value);
for (let repeat = 0; repeat < 2; repeat += 1) {
const parsed = safeParseOwnedBinaryLayout(value);
if (!parsed.success) throw parsed.error;
expect(parsed.data).not.toBe(value);
expect(Object.hasOwn(parsed.data, "not_in_parsed_result")).toBe(false);
}
});
it("retains high linked values and NOBITS without inventing file backing", () => {
const value = binaryLayoutSchema.parse(report());
expect(value.entry_point.reported_value).toBe("0x20000000000001");
+23
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
import { localPathStringSchema } from "../localPath.js";
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
@@ -316,6 +317,28 @@ export type InspectBinaryLayoutInput = z.infer<
>;
export type BinaryLayout = z.infer<typeof binaryLayoutSchema>;
const validatedImmutableLayouts = new WeakSet<object>();
/** Validate once per exact immutable source; mutable inputs always revalidate. */
export const safeParseOwnedBinaryLayout = (
input: unknown,
): z.ZodSafeParseResult<BinaryLayout> => {
if (
typeof input === "object" &&
input !== null &&
validatedImmutableLayouts.has(input)
)
return { success: true, data: input as BinaryLayout };
const parsed = binaryLayoutSchema.safeParse(input);
if (parsed.success && canBorrowParsedJsonSnapshot(input, parsed.data)) {
// Preserve normalization if the schema changes: only unchanged, completely
// validated data can be borrowed without retaining a duplicate layout.
validatedImmutableLayouts.add(input);
return { success: true, data: input };
}
return parsed;
};
const validateRelocationReferences = (
value: z.output<typeof binaryLayoutObjectSchema>,
context: z.RefinementCtx,
-10
View File
@@ -2,20 +2,10 @@ import { describe, expect, it } from "vitest";
import { parse } from "plist";
import {
decodeXmlText,
omitPrototypeKeys,
parseXmlPropertyList as parseWithNumbers,
} from "./propertyListKeys.js";
it("preserves unknown named entities that collide with object prototype properties", () => {
expect(
decodeXmlText("&constructor; &toString; &valueOf; &hasOwnProperty;"),
).toBe("&constructor; &toString; &valueOf; &hasOwnProperty;");
expect(decodeXmlText("&amp;&lt;&gt;&quot;&apos;&#65;&#x42;")).toBe(
"&<>\"'AB",
);
});
// Existing key/omission controls assert those facts exactly; numeric facts
// have separate source-type and ambiguity controls below.
const parseXmlPropertyList = (text: string) => {
+3 -89
View File
@@ -1,15 +1,13 @@
import { normalizeLineEndings, type Document, type Node } from "@xmldom/xmldom";
import { parseOpenStep } from "plist";
import { decodeXmlPropertyList } from "./propertyListXml.js";
import {
UnknownPlistReal,
createPlistNumberCollector,
type PlistNumberLiterals,
} from "./apple/plistNumbers.js";
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import { parseXmlDocument } from "./xmlDocument.js";
/** The dictionary key REA results cannot carry: validation strips it. */
const PROTOTYPE_KEY = "__proto__";
@@ -43,7 +41,7 @@ export const omitPrototypeKeys = (
typeof item !== "object" ||
item instanceof Date ||
item instanceof Uint8Array ||
item instanceof UnknownPlistReal
item instanceof UnknownPlistScalar
)
return item;
return Object.fromEntries(
@@ -58,37 +56,6 @@ export const omitPrototypeKeys = (
return { value: stripped, omittedPrototypeKeys: omitted };
};
const XML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
quot: '"',
};
/**
* Expand XML character and predefined entity references. A reference beyond
* Unicode stays as written: comments and CDATA may hold such text literally,
* and the XML decoder rejects it anywhere else.
*/
export const decodeXmlText = (text: string): string =>
text.replace(
/&(?:#x([\da-f]+)|#(\d+)|([a-z]+));/giu,
(entity, hex?: string, decimal?: string, name?: string) => {
const codePoint =
hex !== undefined
? parseInt(hex, 16)
: decimal !== undefined
? parseInt(decimal, 10)
: undefined;
if (codePoint !== undefined)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
return name !== undefined && Object.hasOwn(XML_ENTITIES, name)
? XML_ENTITIES[name]!
: entity;
},
);
/**
* Decode one validated XML DOM into values and numeric source facts.
* Entries keyed `__proto__` are omitted and counted at the same boundary.
@@ -108,20 +75,7 @@ export const parseXmlPropertyList = (text: string): ParsedPropertyList => {
omittedPrototypeKeys: prototypeKeys,
};
}
// Normalize only the literal line endings the pinned decoder normalizes.
// Decode this DOM directly so character references are never reserialized
// into literal line endings and normalized a second time.
const normalized = normalizeLineEndings(text);
// Strict syntax owns well-formedness before value interpretation. Reuse the
// same DOM for depth, keys, values and number facts.
const parsed = parseXmlDocument(normalized, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok) throw parsed.error;
const document = parsed.value;
assertXmlPlistDepthWithinLimit(document);
const decoded = decodeXmlPropertyList(document);
const decoded = decodeXmlPropertyList(text);
return {
...omitPrototypeKeys(decoded.value),
numberLiterals: decoded.numberLiterals,
@@ -288,43 +242,3 @@ const skipOpenStepScalar = (text: string, start: number): number => {
}
return cursor;
};
/** Elements whose children the plist value decoder visits recursively. */
const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
/**
* Reject XML plists nested deeper than the projection supports before any
* recursive value pass runs. Value decoding and prototype omission recurse once
* per nesting level, so a deeply nested document exhausts the call stack
* before the projection limit can report the resource bound. The failure
* matches `projectPlistValue` so callers classify it as a limit failure.
*
* Depth counts values the way the projection does: the root value is level 0
* and every container member is one level deeper. A dictionary key names a
* member instead of nesting one, and a self-closing element is the value at
* its own level. The outer plist envelope is unwrapped by the decoder;
* nested plist elements decode as arrays and must count toward the bound.
*/
const assertXmlPlistDepthWithinLimit = (document: Document): void => {
const root = document.documentElement;
if (root === null) return;
const pending: Array<{ node: Node; depth: number }> = [
{ node: root, depth: -1 },
];
while (pending.length > 0) {
const entry = pending.pop();
if (entry === undefined) break;
const { node, depth } = entry;
// Schedule only the next sibling, keeping traversal storage proportional
// to depth rather than the number of members in a wide container.
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(
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
);
if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null)
pending.push({ node: node.firstChild, depth: depth + 1 });
}
};
+209 -37
View File
@@ -1,16 +1,85 @@
import { expect, it } from "vitest";
import { projectPlistValue } from "./apple/plistValue.js";
import { UnknownPlistReal } from "./apple/plistNumbers.js";
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
import { parseXmlPropertyList } from "./propertyListKeys.js";
it.each([
[
"plist",
"<plist><?dict ignored?><string>demo</string><?rea ignored?></plist>",
"demo",
],
[
"array",
"<plist><array><?rea ignored?><string>before</string><?integer 9007199254740993?><integer>42</integer><?rea ignored?></array></plist>",
["before", 42],
],
[
"dict",
"<plist><dict><?key ignored?><key>name</key><?string ignored?><string>demo</string><?rea ignored?><key>count</key><integer>42</integer><?rea ignored?></dict></plist>",
{ name: "demo", count: 42 },
],
] as const)(
"ignores processing instructions between %s members",
(_kind, xml, value) => {
const parsed = parseXmlPropertyList(xml);
expect(parsed.value).toEqual(value);
expect(parsed.numberLiterals.incomplete).toBe(false);
expect(parsed.numberLiterals.unsafeIntegerLiterals).toBe(0);
},
);
it("keeps document-level processing instructions outside plist members", () => {
const parsed = parseXmlPropertyList(
'<?xml version="1.0"?><?rea before?><plist><string>demo</string></plist><?rea after?>',
);
expect(parsed.value).toBe("demo");
expect(parsed.numberLiterals.incomplete).toBe(false);
});
it.each([
["CDATA", "<![CDATA[before<?rea content?>after]]>"],
["escaped text", "before&lt;?rea content?&gt;after"],
])(
"preserves literal processing-instruction text from %s",
(_kind, content) => {
expect(
parseXmlPropertyList(`<plist><string>${content}</string></plist>`).value,
).toBe("before<?rea content?>after");
},
);
it("keeps processing instructions inside numeric content subject to scalar rules", () => {
for (const markup of ["<?rea ignored?>", "<!-- ignored -->"]) {
const parsed = parseXmlPropertyList(
`<plist><integer>4${markup}2</integer></plist>`,
);
expect(parsed.value).toEqual(
new UnknownPlistScalar("integer", `4${markup}2`, "unsupported-markup"),
);
expect(parsed.numberLiterals.incomplete).toBe(false);
}
});
it("still rejects an unterminated processing instruction", () => {
expect(() =>
parseXmlPropertyList("<plist><?rea ignored<string>demo</string></plist>"),
).toThrow();
});
it("still rejects an unsupported element beside a processing instruction", () => {
expect(() =>
parseXmlPropertyList("<plist><?rea ignored?><unsupported/></plist>"),
).toThrow("Invalid PLIST tag unsupported");
});
it.each([
["0x2A", 42],
["0X2a", 42],
["+0x2A", 42],
["-0x2A", -42],
["&#48;x2A", 42],
["\n0x2A\n", 42],
["\n0x2A", 42],
["42", 42],
])(
"decodes exact integer text %s at the shared boundary",
@@ -24,6 +93,20 @@ it.each([
},
);
it.each([
["AQID", [1, 2, 3]],
[" A Q I D\n", [1, 2, 3]],
["AQI", []],
["AQ!ID", [1, 2, 3]],
["AQ==", [1]],
["AQI=", [1, 2]],
["AQ= =\n", [1]],
["", []],
])("decodes data text %j as CFPropertyList does", (text, expected) => {
const parsed = parseXmlPropertyList(`<plist><data>${text}</data></plist>`);
expect(parsed.value).toEqual(Uint8Array.from(expected));
});
it("collects source integers and CDATA reals before rounding and duplicate assignment", () => {
const parsed = parseXmlPropertyList(
"<plist><dict><key>a</key><integer>0x20000000000001</integer><key>a</key><integer>9007199254740992</integer><key>b</key><real>9007199254<![CDATA[740992]]></real></dict></plist>",
@@ -47,12 +130,24 @@ it("does not invent numeric facts from ignored scalar markup", () => {
expect(parsed.numberLiterals.incomplete).toBe(false);
});
it("retains partial numeric values with explicit incomplete metadata for unsupported markup", () => {
it("keeps unsupported numeric markup out of numeric source facts", () => {
const parsed = parseXmlPropertyList(
"<plist><array><integer>0x2A<!-- unsupported --></integer><real>1<string>ignored</string>.25</real></array></plist>",
);
expect(parsed.value).toEqual([0, 1.25]);
expect(parsed.numberLiterals.incomplete).toBe(true);
expect(parsed.value).toEqual([
new UnknownPlistScalar(
"integer",
"0x2A<!-- unsupported -->",
"unsupported-markup",
),
new UnknownPlistScalar(
"real",
"1<string>ignored</string>.25",
"unsupported-markup",
),
]);
expect(parsed.numberLiterals.incomplete).toBe(false);
expect(parsed.numberLiterals.reals.size).toBe(0);
});
it("decodes a signed integer zero as zero and a negative-zero real with its sign", () => {
@@ -67,7 +162,16 @@ it("decodes a signed integer zero as zero and a negative-zero real with its sign
true,
false,
]);
expect(parsed.value).toEqual([0, 0, -0, 0]);
expect(parsed.value).toEqual([
0,
new UnknownPlistScalar(
"integer",
"-0<!-- partial -->",
"unsupported-markup",
),
-0,
0,
]);
});
it("interprets failures in omitted entries before reporting partial evidence", () => {
@@ -110,38 +214,106 @@ it.each([
},
);
it("keeps XML numeric overflow and unreadable text explicitly unknown", () => {
const parsed = parseXmlPropertyList(
"<plist><array><real>1e999</real><real>-1e999</real><real> nan </real><real>-nan</real><real>infinite</real></array></plist>",
);
expect(parsed.value).toEqual(
Array.from({ length: 5 }, () => new UnknownPlistReal()),
);
expect(parsed.numberLiterals.incomplete).toBe(false);
});
it("does not infer a spelled real from incomplete numeric markup", () => {
const parsed = parseXmlPropertyList(
"<plist><real>-inf<!-- unsupported --></real></plist>",
);
expect(parsed.value).toBeInstanceOf(UnknownPlistReal);
expect(parsed.numberLiterals.incomplete).toBe(true);
});
it.each([
["integer", "unreadable", "invalid-literal"],
["integer", `${"9".repeat(400)}invalid`, "invalid-literal"],
["integer", "&#48;x2A", "invalid-literal"],
["integer", "42\n", "invalid-literal"],
["integer", "", "invalid-literal"],
["real", "", "invalid-literal"],
["real", "1e999", "unrepresentable"],
["real", "-1e999", "unrepresentable"],
["real", " nan ", "invalid-literal"],
["real", "-nan", "invalid-literal"],
["real", "infinite", "invalid-literal"],
["real", "Infinitysuffix", "invalid-literal"],
["real", "nan<!-- unsupported -->", "unsupported-markup"],
["date", "2026-10-17T12:00:00+02:00", "invalid-literal"],
["date", "2026-10-17T12:00:00.5Z", "invalid-literal"],
["date", "2026-10-17T12:00:00", "invalid-literal"],
["date", "2026-13-17T12:00:00Z", "unrepresentable"],
["date", "2026-02-31T12:00:00Z", "unrepresentable"],
["date", "<![CDATA[2026-10-17T12:00:00Z]]>", "unsupported-markup"],
["true", "x", "invalid-literal"],
["false", " ", "invalid-literal"],
["true", "<!-- empty -->", "unsupported-markup"],
] as const)(
"keeps unresolved <%s> %j identity explicit",
(element, literal, reason) => {
const parsed = parseXmlPropertyList(
`<plist><${element}>${literal}</${element}></plist>`,
);
expect(parsed.value).toEqual(
new UnknownPlistScalar(element, literal, reason),
);
const projected = projectPlistValue(parsed.value);
expect(projected.value).toEqual({
$plist_type: "unknown",
element,
literal,
reason,
});
expect(projected.unknownScalarCount).toBe(1);
expect(projected.nonFiniteRealCount).toBe(0);
expect(parsed.numberLiterals.incomplete).toBe(false);
},
);
it.each([
["integer", "unreadable"],
["integer", `${"9".repeat(400)}invalid`],
["real", "1e999"],
["real", "-1e999"],
["real", "Infinitysuffix"],
["real", "nan<!-- unsupported -->"],
])("keeps unresolved <%s> numeric identity explicit", (tag, text) => {
"4<!-- </integer> -->2",
"4<?rea </integer> ?>2",
"4<![CDATA[</integer>]]>2",
'4<integer note=">">2<integer/></integer>3',
])(
"retains complete source markup %j without treating apparent closing tags as structure",
(literal) => {
expect(
parseXmlPropertyList(`<plist><integer>${literal}</integer></plist>`)
.value,
).toEqual(new UnknownPlistScalar("integer", literal, "unsupported-markup"));
},
);
it("binds raw literals to their nodes across normalized line endings and UTF-16 columns", () => {
const parsed = parseXmlPropertyList(
`<plist><${tag}>${text}</${tag}></plist>`,
'<plist><array><string>💡</string>\r\n<integer note=">">42\r\nabc</integer>\u0085<date>2026-10-17T12:00:00&#90;</date>\u2028<false>\r\n</false></array></plist>',
);
expect(parsed.value).toBeInstanceOf(UnknownPlistReal);
const projected = projectPlistValue(parsed.value);
expect(projected.value).toEqual({ $plist_type: "real", value: null });
expect(projected.unknownRealCount).toBe(1);
expect(projected.nonFiniteRealCount).toBe(0);
expect(parsed.value).toEqual([
"💡",
new UnknownPlistScalar("integer", "42\r\nabc", "invalid-literal"),
new UnknownPlistScalar(
"date",
"2026-10-17T12:00:00&#90;",
"invalid-literal",
),
new UnknownPlistScalar("false", "\r\n", "invalid-literal"),
]);
});
it.each(["\u0085", "\u2028", "\u2029"])(
"does not normalize unsupported leading whitespace %j into an observed scalar",
(prefix) => {
const parsed = parseXmlPropertyList(
`<plist><array><integer>${prefix}42</integer><real>${prefix}1.5</real></array></plist>`,
);
expect(parsed.value).toEqual([
new UnknownPlistScalar("integer", `${prefix}42`, "invalid-literal"),
prefix === "\u0085"
? 1.5
: new UnknownPlistScalar("real", `${prefix}1.5`, "invalid-literal"),
]);
},
);
it("expands real references outside CDATA without changing scalar source policy", () => {
expect(
parseXmlPropertyList(
"<plist><array><real>&#49;<![CDATA[.5]]></real><real><![CDATA[&#49;.5]]></real><true/><false></false></array></plist>",
).value,
).toEqual([
1.5,
new UnknownPlistScalar("real", "<![CDATA[&#49;.5]]>", "invalid-literal"),
true,
false,
]);
});
+79 -58
View File
@@ -1,51 +1,58 @@
import type { Document, Node } from "@xmldom/xmldom";
import type { Node } from "@xmldom/xmldom";
import { readXmlPlistSource } from "./propertyListXmlSource.js";
import { createPlistNumberCollector } from "./apple/plistNumbers.js";
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
import {
UnknownPlistReal,
createPlistNumberCollector,
parsePlistIntegerLiteral,
parsePlistNonFiniteRealLiteral,
} from "./apple/plistNumbers.js";
decodeXmlPlistDateLiteral,
parseXmlPlistIntegerLiteral,
parseXmlPlistRealLiteral,
} from "./propertyListXmlScalar.js";
/** Interpret an already validated, depth-bounded DOM, owning values and number facts together. */
export const decodeXmlPropertyList = (document: Document) => {
/** Own source interpretation, depth, values and numeric facts together. */
export const decodeXmlPropertyList = (text: string) => {
const source = readXmlPlistSource(text);
const document = source.document;
const root = document.documentElement;
if (root?.nodeName !== "plist")
throw new TypeError("malformed document. First element should be <plist>");
const numbers = createPlistNumberCollector();
const requireContent = (node: Node): Node => {
if (node.firstChild === null)
throw new TypeError(`Cannot parse "" as ${node.nodeName}`);
return node.firstChild;
};
const textNode = (node: Node) => node.nodeType === node.TEXT_NODE;
const characterNode = (node: Node) =>
textNode(node) || node.nodeType === node.CDATA_SECTION_NODE;
const content = (node: Node, accepts: (child: Node) => boolean) => {
let text = "";
let complete = true;
for (
let child = node.firstChild;
child !== null;
child = child.nextSibling
) {
if (accepts(child)) text += child.nodeValue ?? "";
else complete = false;
}
return { text, complete };
return text;
};
const hasUnsupportedMarkup = (
node: Node,
accepts: (child: Node) => boolean,
): boolean => {
for (let child = node.firstChild; child !== null; child = child.nextSibling)
if (!accepts(child)) return true;
return false;
};
const dictionaryKey = (node: Node): string => {
for (let child = node.firstChild; child !== null; child = child.nextSibling)
if (child.nodeType === child.ELEMENT_NODE)
return String(node.firstChild?.nodeValue ?? null);
return content(node, characterNode).text;
return content(node, characterNode);
};
const members = function* (node: Node) {
for (let child = node.firstChild; child !== null; child = child.nextSibling)
if (
child.nodeType !== child.TEXT_NODE &&
child.nodeType !== child.CDATA_SECTION_NODE &&
child.nodeType !== child.COMMENT_NODE
child.nodeType !== child.COMMENT_NODE &&
child.nodeType !== child.PROCESSING_INSTRUCTION_NODE
)
yield child;
};
@@ -91,61 +98,75 @@ export const decodeXmlPropertyList = (document: Document) => {
return value;
}
case "string":
return content(node, characterNode).text;
return content(node, characterNode);
case "integer": {
const first = requireContent(node);
const literal = content(node, textNode);
const exact = literal.complete
? parsePlistIntegerLiteral(literal.text)
const raw = source.scalarLiteral(node);
if (!raw.ok) throw raw.error;
const unsupportedMarkup = hasUnsupportedMarkup(node, textNode);
const exact = !unsupportedMarkup
? parseXmlPlistIntegerLiteral(raw.value)
: undefined;
if (exact === undefined) {
numbers.markIncomplete();
const partial = Number.parseInt(first.nodeValue ?? "", 10);
// An integer has no signed zero; keep -0 from reading as a real.
return !Number.isFinite(partial)
? new UnknownPlistReal()
: partial === 0
? 0
: partial;
}
if (exact === undefined)
return new UnknownPlistScalar(
"integer",
raw.value,
unsupportedMarkup ? "unsupported-markup" : "invalid-literal",
);
numbers.integer(exact);
const decoded = Number(exact);
// Keep a finite integer's identity when it cannot fit even a Number
// approximation; otherwise it would be mislabeled as a non-finite real.
// Arbitrary-precision integers are a REA extension to CF's range.
return Number.isFinite(decoded) ? decoded : exact;
}
case "real": {
requireContent(node);
const literal = content(node, characterNode);
if (!literal.complete) numbers.markIncomplete();
// CFPropertyList spells NaN and the infinities; parseFloat would read
// the lowercase spellings as NaN and lose the kind and sign.
const known = literal.complete
? parsePlistNonFiniteRealLiteral(literal.text)
: undefined;
if (known !== undefined) {
numbers.real(known);
return known;
}
const value = Number.parseFloat(literal.text);
if (!Number.isFinite(value)) return new UnknownPlistReal();
if (literal.complete) numbers.real(value);
return value;
const raw = source.scalarLiteral(node);
if (!raw.ok) throw raw.error;
if (hasUnsupportedMarkup(node, characterNode))
return new UnknownPlistScalar(
"real",
raw.value,
"unsupported-markup",
);
const value = parseXmlPlistRealLiteral(raw.value);
if (!value.ok)
return new UnknownPlistScalar("real", raw.value, value.error);
numbers.real(value.value);
return value.value;
}
case "data": {
const binary = atob(content(node, textNode).text.replace(/\s+/gu, ""));
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
const value = source.dataValue(node);
if (!value.ok) throw value.error;
return value.value;
}
case "date": {
const value = requireContent(node).nodeValue;
return new Date(value === null ? 0 : value);
const raw = source.scalarLiteral(node);
if (!raw.ok) throw raw.error;
if (hasUnsupportedMarkup(node, textNode))
return new UnknownPlistScalar(
"date",
raw.value,
"unsupported-markup",
);
const date = decodeXmlPlistDateLiteral(raw.value);
return date.ok
? date.value
: new UnknownPlistScalar("date", raw.value, date.error);
}
case "null":
return null;
case "true":
return true;
case "false":
return false;
case "false": {
const raw = source.scalarLiteral(node);
if (!raw.ok) throw raw.error;
return raw.value === ""
? node.nodeName === "true"
: new UnknownPlistScalar(
node.nodeName,
raw.value,
hasUnsupportedMarkup(node, textNode)
? "unsupported-markup"
: "invalid-literal",
);
}
default:
throw new TypeError(`Invalid PLIST tag ${node.nodeName}`);
}
+78
View File
@@ -0,0 +1,78 @@
import { expect, it } from "vitest";
import { parseXmlPropertyList } from "./propertyListKeys.js";
// Expected bytes were independently read from plutil's binary conversion.
it.each([
["&#65;QID", "eb9408"],
["&amp;AQID", "6a6a40"],
["AQ&#10;ID", "010d74"],
["&#xE9;AQID", "c44f40"],
["====", "00"],
["=AQI", "000408"],
["AQI=Z", "0102"],
["AQ=!==", "0100"],
])(
"retains CFPropertyList data bytes from source spelling %j",
(literal, hex) => {
const parsed = parseXmlPropertyList(
`<plist><data>${literal}</data></plist>`,
);
expect(parsed.value).toEqual(Uint8Array.from(Buffer.from(hex, "hex")));
},
);
it.each(["é", "😀", "\u00a0", "\u0085", "\u2028", "\u2029"])(
"rejects non-ASCII data before source normalization can hide %j",
(character) => {
expect(() =>
parseXmlPropertyList(`<plist><data>AQ${character}ID</data></plist>`),
).toThrow(
expect.objectContaining({
_tag: "XmlPlistDataError",
reason: "non-ascii",
characterOffset: 2,
}),
);
},
);
it.each([
"A<!-- omitted -->QID",
"<![CDATA[AQID]]>",
"A<?rea ignored?>QID",
"<string>AQID</string>",
])(
"rejects data markup instead of silently decoding a different source: %j",
(literal) => {
expect(() =>
parseXmlPropertyList(`<plist><data>${literal}</data></plist>`),
).toThrow(expect.objectContaining({ reason: "markup" }));
},
);
it.each(["", ' note=">"', " note='>/'"])(
"distinguishes explicit empty data from a self-closing element with attributes %j",
(attributes) => {
expect(
parseXmlPropertyList(`<plist><data${attributes}></data></plist>`).value,
).toEqual(new Uint8Array());
expect(() =>
parseXmlPropertyList(`<plist><data${attributes}/></plist>`),
).toThrow(expect.objectContaining({ reason: "empty-element" }));
},
);
it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])(
"binds raw data after astral text, entities and line ending %j to the right node",
(ending) => {
const parsed = parseXmlPropertyList(
`<plist><array><string note=">">π😀&#10;${ending}before</string>${ending}` +
"<data>&#65;QID</data><data>AQID</data></array></plist>",
);
expect(parsed.value).toEqual([
expect.any(String),
Uint8Array.from([235, 148, 8]),
Uint8Array.from([1, 2, 3]),
]);
},
);
+65
View File
@@ -0,0 +1,65 @@
import { err, ok, type Result } from "./result.js";
export class XmlPlistDataError extends TypeError {
readonly _tag = "XmlPlistDataError";
constructor(
readonly reason:
| "non-ascii"
| "markup"
| "empty-element"
| "source-location",
/** UTF-16 offset within the raw data literal, when known. */
readonly characterOffset: number | null = null,
) {
super(`Cannot decode XML plist <data>: ${reason}`);
this.name = "XmlPlistDataError";
}
}
const BASE64_DIGITS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const C_WHITESPACE = new Set([" ", "\t", "\n", "\v", "\f", "\r"]);
/** Interpret each completed quartet once per pass, including CF's padding resets. */
const visitDataQuartets = (
text: string,
visit: (value: number, byteCount: 1 | 2 | 3) => void,
): Result<void, XmlPlistDataError> => {
let quartet = 0;
let digits = 0;
let padding = 0;
for (let index = 0; index < text.length; index += 1) {
if (text.charCodeAt(index) >= 128)
return err(new XmlPlistDataError("non-ascii", index));
const character = text[index]!;
if (character === "=") padding += 1;
else if (!C_WHITESPACE.has(character)) padding = 0;
const digit = character === "=" ? 0 : BASE64_DIGITS.indexOf(character);
if (digit < 0) continue;
quartet = ((quartet << 6) | digit) & 0xffffff;
digits += 1;
if (digits % 4 === 0)
visit(quartet, padding >= 2 ? 1 : padding === 1 ? 2 : 3);
}
return ok(undefined);
};
/** CFPropertyList reads raw ASCII, skips nonalphabet bytes and emits complete quartets. */
export const decodeXmlPlistData = (
text: string,
): Result<Uint8Array, XmlPlistDataError> => {
let length = 0;
const counted = visitDataQuartets(text, (_value, byteCount) => {
length += byteCount;
});
if (!counted.ok) return counted;
// Count actual output before allocation, including padding in every quartet.
const bytes = new Uint8Array(length);
let offset = 0;
const decoded = visitDataQuartets(text, (value, byteCount) => {
bytes[offset++] = value >> 16;
if (byteCount >= 2) bytes[offset++] = value >> 8;
if (byteCount === 3) bytes[offset++] = value;
});
return decoded.ok ? ok(bytes) : decoded;
};
+139
View File
@@ -0,0 +1,139 @@
import { expect, it } from "vitest";
import {
decodeXmlPlistDateLiteral,
parseXmlPlistIntegerLiteral,
parseXmlPlistRealLiteral,
} from "./propertyListXmlScalar.js";
it.each([
["42", 42n],
[" + 42", 42n],
["\n- 0x2a", -42n],
["+0XAF", 175n],
[`${"9".repeat(80)}`, BigInt("9".repeat(80))],
])("parses complete XML plist integer text %j", (raw, expected) => {
expect(parseXmlPlistIntegerLiteral(raw)).toBe(expected);
});
it.each(["", "42 ", "42\n", "+", "0x", "0 x2a", "42.0", "42&foo;"])(
"rejects incomplete or trailing XML plist integer text %j",
(raw) => {
expect(parseXmlPlistIntegerLiteral(raw)).toBeUndefined();
},
);
it.each([
[" 42.5", 42.5],
["\t-1.25e2", -125],
[".5", 0.5],
["1.", 1],
["NaN", Number.NaN],
["+inf", Infinity],
["-infinity", -Infinity],
])("parses complete XML plist real text %j", (raw, expected) => {
expect(parseXmlPlistRealLiteral(raw)).toEqual({ ok: true, value: expected });
});
it.each([
"",
"42.5 ",
"42.5\n",
"1e",
"0x1p2",
"1,25",
"1suffix",
"nan ",
"infinite",
"-NaN",
])("rejects incomplete or unsupported XML plist real text %j", (raw) => {
expect(parseXmlPlistRealLiteral(raw)).toEqual({
ok: false,
error: "invalid-literal",
});
});
it.each([
["2001-02-03T04:05:06Z", Date.UTC(2001, 1, 3, 4, 5, 6)],
["0000-02-29T00:00:00Z", -62162121600000],
])("decodes XML plist date text %s", (raw, expected) => {
const date = decodeXmlPlistDateLiteral(raw);
expect(date).toEqual({ ok: true, value: new Date(expected) });
});
it.each([
"2001-2-03T04:05:06Z",
"2001-02-03t04:05:06Z",
"2001-02-03T04:05:06+00:00",
" 2001-02-03T04:05:06Z",
"2001-02-03T04:05:06Z ",
"2001-02-03T04:05:06Z&amp;",
])("rejects malformed XML plist date text %j", (raw) => {
expect(decodeXmlPlistDateLiteral(raw)).toEqual({
ok: false,
error: "invalid-literal",
});
});
it.each([
"2001-02-29T04:05:06Z",
"2000-13-01T00:00:00Z",
"2000-01-01T24:00:00Z",
"2000-01-01T00:00:60Z",
"275761-01-01T00:00:00Z",
])(
"keeps unsupported XML plist date %s distinct from malformed text",
(raw) => {
const date = decodeXmlPlistDateLiteral(raw);
expect(date).toEqual({ ok: false, error: "unrepresentable" });
},
);
it("distinguishes finite-spelling overflow from a known infinity", () => {
expect(parseXmlPlistRealLiteral("1e999")).toEqual({
ok: false,
error: "unrepresentable",
});
expect(parseXmlPlistRealLiteral("-1e999")).toEqual({
ok: false,
error: "unrepresentable",
});
});
it.each([
["\u0085", false, true],
["\u00a0", false, true],
["\u1680", false, false],
["\u2003", true, true],
["\u200b", true, true],
["\u2028", false, false],
["\u202f", false, false],
["\u205f", true, false],
["\ufeff", false, true],
] as const)(
"preserves distinct integer and real leading whitespace rules for %j",
(prefix, integerAccepted, realAccepted) => {
expect(parseXmlPlistIntegerLiteral(`${prefix}42`)).toBe(
integerAccepted ? 42n : undefined,
);
expect(parseXmlPlistRealLiteral(`${prefix}42`)).toEqual(
realAccepted
? { ok: true, value: 42 }
: { ok: false, error: "invalid-literal" },
);
},
);
it.each([
["1-01-01T00:00:00Z", "0001-01-01T00:00:00.000Z"],
["-0001-01-01T00:00:00Z", "-000001-01-01T00:00:00.000Z"],
["10000-01-01T00:00:00Z", "+010000-01-01T00:00:00.000Z"],
])(
"interprets year fields as UTC without host string-date inference: %s",
(literal, iso8601) => {
expect(decodeXmlPlistDateLiteral(literal)).toEqual({
ok: true,
value: new Date(iso8601),
});
},
);
+139
View File
@@ -0,0 +1,139 @@
import { err, ok, type Result } from "./result.js";
// Integer parsing examines UTF-8 bytes; real scanning examines CFString
// characters. Native plutil controls distinguish their leading-whitespace sets;
// notably, this host rejects U+202F despite its inclusion in older CF source.
const INTEGER_WHITESPACE =
"[\\u0000-\\u0020\\u007f\\u2000-\\u200b\\u205f\\u3000]*";
const REAL_WHITESPACE =
"[\\u0000-\\u0020\\u007f-\\u00a0\\u2000-\\u200b\\u3000\\ufeff]*";
const INTEGER_LITERAL = new RegExp(
`^${INTEGER_WHITESPACE}(?:([+-])${INTEGER_WHITESPACE})?(?:0[xX]([\\da-fA-F]+)|(\\d+))$`,
"u",
);
const REAL_LEADING_WHITESPACE = new RegExp(`^${REAL_WHITESPACE}`, "u");
/** Parse CF-style decimal or hexadecimal integer text without losing precision. */
export const parseXmlPlistIntegerLiteral = (
raw: string,
): bigint | undefined => {
const match = INTEGER_LITERAL.exec(raw);
if (match === null) return undefined;
const magnitude = BigInt(
match[2] === undefined ? match[3]! : `0x${match[2]}`,
);
return match[1] === "-" ? -magnitude : magnitude;
};
/**
* Parse UTC fields without JavaScript string-date parsing or calendar rollover.
* Syntax failures and unsupported date representations retain distinct reasons.
*/
export const decodeXmlPlistDateLiteral = (
raw: string,
): Result<Date, "invalid-literal" | "unrepresentable"> => {
const match = /^(-?)(\d+)-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/u.exec(
raw,
);
if (match === null) return err("invalid-literal");
const yearMagnitude = Number(match[2]);
const year = match[1] === "-" ? -yearMagnitude : yearMagnitude;
const month = Number(match[3]);
const day = Number(match[4]);
const hour = Number(match[5]);
const minute = Number(match[6]);
const second = Number(match[7]);
if (
!Number.isSafeInteger(year) ||
month < 1 ||
month > 12 ||
hour > 23 ||
minute > 59 ||
second > 59
)
return err("unrepresentable");
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
const daysInMonth = [
31,
leapYear ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
][month - 1]!;
if (day < 1 || day > daysInMonth) return err("unrepresentable");
const date = new Date(0);
date.setUTCFullYear(year, month - 1, day);
date.setUTCHours(hour, minute, second, 0);
return Number.isNaN(date.getTime()) ? err("unrepresentable") : ok(date);
};
/** Parse a complete decimal or CF-spelled non-finite real literal. */
export const parseXmlPlistRealLiteral = (
raw: string,
): Result<number, "invalid-literal" | "unrepresentable"> => {
// This content has passed XML well-formedness and character-node checks.
// Expand references only outside CDATA; retain original line endings, which
// the DOM's compatibility normalization would otherwise erase.
const text = raw.replace(
/<!\[CDATA\[([\s\S]*?)\]\]>|&#(?:x([\da-fA-F]+)|(\d+));|&(amp|lt|gt|apos|quot);/gu,
(
_token,
cdata: string | undefined,
hex: string | undefined,
decimal: string | undefined,
name: string | undefined,
) => {
if (cdata !== undefined) return cdata;
if (hex !== undefined)
return String.fromCodePoint(Number.parseInt(hex, 16));
if (decimal !== undefined)
return String.fromCodePoint(Number.parseInt(decimal, 10));
return name === "amp"
? "&"
: name === "lt"
? "<"
: name === "gt"
? ">"
: name === "apos"
? "'"
: '"';
},
);
// CF compares complete non-finite spellings before scanning a decimal.
if (text.length <= 9) {
switch (text.toLowerCase()) {
case "nan":
return ok(Number.NaN);
case "+infinity":
case "infinity":
case "+inf":
case "inf":
return ok(Number.POSITIVE_INFINITY);
case "-infinity":
case "-inf":
return ok(Number.NEGATIVE_INFINITY);
}
}
const numericText = text.replace(REAL_LEADING_WHITESPACE, "");
if (
!/^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$/u.test(
numericText,
)
)
return err("invalid-literal");
const value = Number(numericText);
return Number.isFinite(value) ? ok(value) : err("unrepresentable");
};
+191
View File
@@ -0,0 +1,191 @@
import type { Document, Node } from "@xmldom/xmldom";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import { err, ok, type Result } from "./result.js";
import { parseXmlDocument } from "./xmlDocument.js";
import {
decodeXmlPlistData,
XmlPlistDataError,
} from "./propertyListXmlData.js";
// Preserve DOM compatibility normalization while retaining raw scalar spans.
const LINE_ENDING = /\r[\n\u0085]|[\r\n\u0085\u2028\u2029]/gu;
interface ScalarSpan {
readonly start: number;
readonly end: number;
readonly selfClosing: boolean;
}
export class XmlPlistSourceError extends TypeError {
readonly _tag = "XmlPlistSourceError";
constructor() {
super("Cannot locate XML plist scalar in its original source");
}
}
/** One source owner binds lexical scalar values to their validated DOM nodes. */
export const readXmlPlistSource = (text: string) => {
const normalized = text.replace(LINE_ENDING, "\n");
const parsed = parseXmlDocument(normalized, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok) throw parsed.error;
const document = parsed.value;
const lineEndings = new RegExp(LINE_ENDING);
let line = 1;
let lineStart = 0;
// The depth walk visits elements in source order, so location lookup is
// linear without an index entry for every line in a potentially large value.
const readSpan = (node: Node): Result<ScalarSpan, "source-location"> => {
if (node.lineNumber === undefined || node.columnNumber === undefined)
return err("source-location");
while (line < node.lineNumber) {
const ending = lineEndings.exec(text);
if (ending === null) return err("source-location");
lineStart = ending.index + ending[0].length;
line += 1;
}
if (line !== node.lineNumber) return err("source-location");
const start = lineStart + node.columnNumber - 1;
const openingEnd = openingTagEnd(text, start);
if (openingEnd < 0 || text[start] !== "<") return err("source-location");
const selfClosing = text[openingEnd - 1] === "/";
const end = selfClosing
? openingEnd + 1
: closingTagStart(text, openingEnd + 1);
return end < 0
? err("source-location")
: ok({ start: openingEnd + 1, end, selfClosing });
};
const spans = collectXmlPlistScalarSpans(document, readSpan);
return {
document,
scalarLiteral(node: Node): Result<string, XmlPlistSourceError> {
const span = spans.get(node);
return span?.ok === true
? ok(text.slice(span.value.start, span.value.end))
: err(new XmlPlistSourceError());
},
dataValue(node: Node): Result<Uint8Array, XmlPlistDataError> {
const span = spans.get(node);
if (node.nodeName !== "data" || span === undefined)
return err(new XmlPlistDataError("source-location"));
if (!span.ok) return err(new XmlPlistDataError(span.error));
for (
let child = node.firstChild;
child !== null;
child = child.nextSibling
)
if (child.nodeType !== child.TEXT_NODE)
return err(new XmlPlistDataError("markup"));
if (span.value.selfClosing)
return err(new XmlPlistDataError("empty-element"));
return decodeXmlPlistData(text.slice(span.value.start, span.value.end));
},
};
};
/** Scan validated tag syntax without treating a quoted > as its end. */
const openingTagEnd = (source: string, start: number): number => {
let quote: string | undefined;
for (let cursor = start; cursor < source.length; cursor += 1) {
const character = source[cursor];
if (quote !== undefined) {
if (character === quote) quote = undefined;
} else if (character === '"' || character === "'") quote = character;
else if (character === ">") return cursor;
}
return -1;
};
/**
* Locate an element's closing tag in validated XML. Comments, CDATA and PIs
* can contain apparent tags; nested markup is retained as source, not decoded
* or recursively interpreted as additional plist values.
*/
const closingTagStart = (source: string, contentStart: number): number => {
let depth = 1;
let cursor = contentStart;
while (cursor < source.length) {
const start = source.indexOf("<", cursor);
if (start < 0) return -1;
const delimiter = source.startsWith("<!--", start)
? "-->"
: source.startsWith("<![CDATA[", start)
? "]]>"
: source.startsWith("<?", start)
? "?>"
: undefined;
if (delimiter !== undefined) {
const end = source.indexOf(delimiter, start + 2);
if (end < 0) return -1;
cursor = end + delimiter.length;
continue;
}
const end = openingTagEnd(source, start);
if (end < 0) return -1;
if (source[start + 1] === "/") {
depth -= 1;
if (depth === 0) return start;
} else if (source[end - 1] !== "/") depth += 1;
cursor = end + 1;
}
return -1;
};
const PLIST_LEXICAL_SCALARS = new Set([
"data",
"integer",
"real",
"date",
"true",
"false",
]);
/** Elements whose children the plist value decoder visits recursively. */
const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
/**
* Reject XML plists nested deeper than the projection supports before any
* recursive value pass runs. Value decoding and prototype omission recurse once
* per nesting level, so a deeply nested document exhausts the call stack
* before the projection limit can report the resource bound. The failure
* matches `projectPlistValue` so callers classify it as a limit failure.
*
* Depth counts values the way the projection does: the root value is level 0
* and every container member is one level deeper. A dictionary key names a
* member instead of nesting one, and a self-closing element is the value at
* its own level. The outer plist envelope is unwrapped by the decoder;
* nested plist elements decode as arrays and must count toward the bound.
*/
const collectXmlPlistScalarSpans = (
document: Document,
readSpan: (node: Node) => Result<ScalarSpan, "source-location">,
): WeakMap<Node, Result<ScalarSpan, "source-location">> => {
const spans = new WeakMap<Node, Result<ScalarSpan, "source-location">>();
const root = document.documentElement;
if (root === null) return spans;
const pending: Array<{ node: Node; depth: number }> = [
{ node: root, depth: -1 },
];
while (pending.length > 0) {
const entry = pending.pop();
if (entry === undefined) break;
const { node, depth } = entry;
// Schedule only the next sibling, keeping traversal storage proportional
// to depth rather than the number of members in a wide container.
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(
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
);
if (PLIST_LEXICAL_SCALARS.has(node.nodeName))
spans.set(node, readSpan(node));
if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null)
pending.push({ node: node.firstChild, depth: depth + 1 });
}
return spans;
};
+1 -1
View File
@@ -1,7 +1,7 @@
/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "6.5.0", // x-release-please-version
version: "6.7.0", // x-release-please-version
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
+201 -101
View File
@@ -143,6 +143,14 @@ export interface CapturedOutput {
readonly complete: boolean;
}
type CapturedOutputRead =
| { readonly ok: true; readonly value: CapturedOutput }
| {
readonly ok: false;
readonly error: unknown;
readonly partial?: CapturedOutput;
};
/** One completed LLDB observation, before projection into the result contract. */
export interface NativeCallTrace {
readonly debugger: ResolvedTool & { readonly version: string | null };
@@ -231,7 +239,7 @@ export class LldbCallTracer implements NativeCallTracer {
try {
result = await this.#run(lldb.value, owner, request, signal);
} catch (cause: unknown) {
const partialObservation = await partialNativeObservation({
const partial = await partialNativeObservation({
request,
files: owner.files,
pidPath: paths.pid,
@@ -247,21 +255,20 @@ export class LldbCallTracer implements NativeCallTracer {
owner.files.hasPending
)
this.#pendingCleanup = owner;
result = err(
new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { reason: errorReason(cause) },
partialObservation,
...(this.#pendingCleanup === owner
? {
cleanup: {
reason: "LLDB trace failed while owned resources remain",
resources: lldbCleanupResources(owner),
},
}
: {}),
}),
);
const failure = new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { reason: errorReason(cause) },
partialObservation: partial.observation,
...(this.#pendingCleanup === owner
? {
cleanup: {
reason: "LLDB trace failed while owned resources remain",
resources: lldbCleanupResources(owner),
},
}
: {}),
});
result = err(retainPartialReadFailures(failure, partial.readFailures));
}
if (result.ok && signal?.aborted)
result = err(
@@ -497,10 +504,11 @@ export class LldbCallTracer implements NativeCallTracer {
retained.processStopped =
exited.cleanupOwner === undefined && exited.cleanupFailure === undefined;
retained.targetStopped = !exited.targetLaunched;
const output =
const readback =
exited.kind === "exited"
? await readTracerOutput(paths.result, retained.files)
: undefined;
const output = readback?.ok ? readback.value : undefined;
// An explicit bridge observation can prove no process was created or that
// the created process exited. A missing PID alone remains ambiguous.
const bridgeObservedExit =
@@ -548,7 +556,7 @@ export class LldbCallTracer implements NativeCallTracer {
: "tracer-failure";
const getPartialObservation = (
reason: NativeCallPartialObservation["coverage"]["reason"] = partialReason,
): Promise<NativeCallPartialObservation> =>
): Promise<PartialNativeObservationRead> =>
partialNativeObservation({
request,
files: retained.files,
@@ -559,84 +567,115 @@ export class LldbCallTracer implements NativeCallTracer {
version: output?.status === "traced" ? output.version : null,
reason,
});
if (exited.kind === "cancelled")
const failureWithPartial = async <Failure extends AnalysisError>(
createFailure: (observation: NativeCallPartialObservation) => Failure,
reason?: NativeCallPartialObservation["coverage"]["reason"],
): Promise<Result<NativeCallTrace, AnalysisError>> => {
const partial = await getPartialObservation(reason);
return err(
new AnalysisCancelledError(OPERATION, {
cause: signal?.reason,
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
partialObservation: await getPartialObservation(),
}),
retainPartialReadFailures(
createFailure(partial.observation),
partial.readFailures,
),
);
if (exited.kind === "timeout")
return err(
new AnalysisTimeoutError(
OPERATION,
input.duration_ms + DEADLINE_GRACE_MS,
{
};
if (exited.kind === "cancelled")
return failureWithPartial(
(partialObservation) =>
new AnalysisCancelledError(OPERATION, {
cause: signal?.reason,
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
partialObservation: await getPartialObservation(),
},
),
partialObservation,
}),
);
if (exited.kind === "timeout")
return failureWithPartial(
(partialObservation) =>
new AnalysisTimeoutError(
OPERATION,
input.duration_ms + DEADLINE_GRACE_MS,
{
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
partialObservation,
},
),
);
if (output !== undefined && output.status !== "traced")
return err(
tracerFailure(output, lifecycleCleanup, await getPartialObservation()),
return failureWithPartial((partialObservation) =>
tracerFailure(output, lifecycleCleanup, partialObservation),
);
if (exited.cleanupFailure !== undefined)
return err(
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB process-group cleanup could not be verified",
cleanup_failure: exited.cleanupFailure,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation: await getPartialObservation(),
},
),
const processCleanupFailure = exited.cleanupFailure;
if (processCleanupFailure !== undefined)
return failureWithPartial(
(partialObservation) =>
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB process-group cleanup could not be verified",
cleanup_failure: processCleanupFailure,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation,
},
),
);
if (!terminated)
return err(
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB target process termination could not be verified",
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation: await getPartialObservation(),
},
),
return failureWithPartial(
(partialObservation) =>
new ProviderCleanupError(
PROVIDER,
lldbCleanupResources(retained),
{
reason: "LLDB target process termination could not be verified",
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
{
operation: OPERATION,
partialObservation,
},
),
);
if (output === undefined)
return err(
new ProviderAdapterError(PROVIDER, OPERATION, {
diagnostics: {
reason: "LLDB exited without a tracer result",
exit_code: exited.exitCode,
lldb_output: exited.output,
return failureWithPartial(
(partialObservation) =>
new ProviderAdapterError(PROVIDER, OPERATION, {
...(readback !== undefined && !readback.ok
? { cause: readback.error }
: {}),
diagnostics: {
reason: "LLDB exited without a usable tracer result",
...(readback !== undefined && !readback.ok
? {
result_failure:
readback.error instanceof z.ZodError
? `Tracer result failed schema validation (${readback.error.issues.length} issues)`
: readback.error instanceof Error
? readback.error.message
: String(readback.error),
}
: {}),
exit_code: exited.exitCode,
lldb_output: exited.output,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
partialObservation,
...(lifecycleCleanup === undefined
? {}
: { target_cleanup: lifecycleCleanup }),
},
partialObservation: await getPartialObservation(),
...(lifecycleCleanup === undefined
? {}
: { cleanup: lifecycleCleanup }),
}),
: { cleanup: lifecycleCleanup }),
}),
);
const stdout = await capturedOutput(
paths.stdoutCapture,
@@ -672,8 +711,8 @@ export class LldbCallTracer implements NativeCallTracer {
{
debugger: { ...lldb, version: output.version },
run: output,
stdout: stdout.ok ? stdout.value : null,
stderr: stderr.ok ? stderr.value : null,
stdout: stdout.ok ? stdout.value : (stdout.partial ?? null),
stderr: stderr.ok ? stderr.value : (stderr.partial ?? null),
terminated,
},
"capture-failure",
@@ -770,6 +809,31 @@ const tracerFailure = (
);
};
interface PartialNativeObservationRead {
readonly observation: NativeCallPartialObservation;
readonly readFailures: readonly unknown[];
}
const retainPartialReadFailures = <Failure extends AnalysisError>(
failure: Failure,
readFailures: readonly unknown[],
): Failure => {
if (readFailures.length === 0) return failure;
const readbackFailure = new ProviderAdapterError(PROVIDER, OPERATION, {
cause:
readFailures.length === 1
? readFailures[0]
: new AggregateError(readFailures, "LLDB partial readback failed"),
diagnostics: {
reason: "LLDB could not read all available partial observation data",
read_failures: readFailures.map(errorReason),
},
});
return failure.retainSecondaryFailure(readbackFailure, {
partial_readback_failure: projectAnalysisError(readbackFailure),
});
};
const partialNativeObservation = async (options: {
readonly request: Parameters<NativeCallTracer["trace"]>[0];
readonly files: LldbReadbackFiles;
@@ -779,14 +843,14 @@ const partialNativeObservation = async (options: {
readonly observationPath: string;
readonly version: string | null;
readonly reason: NativeCallPartialObservation["coverage"]["reason"];
}): Promise<NativeCallPartialObservation> => {
}): Promise<PartialNativeObservationRead> => {
const [journal, pid, stdout, stderr] = await Promise.all([
readLldbObservationJournal(options.observationPath, options.files),
options.files.readTargetPid(options.pidPath),
readPartialCapture(options.stdoutPath, options.files),
readPartialCapture(options.stderrPath, options.files),
]);
return projectLldbPartialObservation({
const observation = projectLldbPartialObservation({
target: {
path: options.request.executable,
sha256: options.request.expectedSha256,
@@ -821,6 +885,14 @@ const partialNativeObservation = async (options: {
],
reason: options.reason,
});
const readFailures = [journal, stdout, stderr].flatMap((read) =>
!read.ok
? [read.error]
: read.value.status === "read-failure"
? [read.value.readFailure]
: [],
);
return { observation, readFailures };
};
const partialObservationFromTrace = (
@@ -855,37 +927,65 @@ const lldbRuntimePaths = (directory: string) => ({
const readTracerOutput = async (
path: string,
files: LldbReadbackFiles,
): Promise<z.infer<typeof tracerOutputSchema> | undefined> => {
const text = await files.read(path, (file) => file.readFile("utf8"));
if (!text.ok) return undefined;
const parsed = safeParseJson(text.value);
if (!parsed.ok) return undefined;
): Promise<Result<z.infer<typeof tracerOutputSchema>, unknown>> => {
const bytes = await files.read(path, (file) => file.readFile());
if (!bytes.ok) return bytes;
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.value,
);
} catch (cause: unknown) {
return err(cause);
}
const parsed = safeParseJson(text);
if (!parsed.ok) return err(parsed.cause);
const output = tracerOutputSchema.safeParse(parsed.value);
return output.success ? output.data : undefined;
return output.success ? ok(output.data) : err(output.error);
};
const capturedOutput = (
const capturedOutput = async (
path: string,
observedBytes: number,
truncated: boolean,
complete: boolean,
files: LldbReadbackFiles,
): Promise<Result<CapturedOutput, unknown>> =>
files.read(path, async (handle) => {
): Promise<CapturedOutputRead> => {
const readback = await files.read(path, async (handle) => {
const { size } = await handle.stat();
const { buffer, bytesRead } = await readBoundedPrefix(
const read = await readBoundedPrefix(
Math.min(size, MAX_OUTPUT_BYTES),
async (target, offset, length, position) =>
(await handle.read(target, offset, length, position)).bytesRead,
);
const { buffer, bytesRead } = read;
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
return {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: observedBytes,
truncated: truncated || size > MAX_OUTPUT_BYTES,
complete:
complete && bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES),
status: read.status,
capture: {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: observedBytes,
truncated:
truncated ||
size > MAX_OUTPUT_BYTES ||
read.status === "read-failure",
complete:
read.status !== "read-failure" &&
complete &&
bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES),
},
...(read.status === "read-failure" ? { readFailure: read.cause } : {}),
};
});
if (!readback.ok) return readback;
if (readback.value.status === "read-failure")
return {
ok: false,
error: readback.value.readFailure,
partial: readback.value.capture,
};
return ok(readback.value.capture);
};
interface RetainedLldbResources {
readonly root: PrivateRuntimeRoot;
+25 -4
View File
@@ -7,6 +7,9 @@ import {
} from "../artifacts/ArtifactFileOperation.js";
import type { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
/** PID publication is decimal; allow the largest safe PID and a CRLF ending. */
const TARGET_PID_FILE_MAX_BYTES = String(Number.MAX_SAFE_INTEGER).length + 2;
/** One trace's readback descriptors, separate from the facts already read. */
export class LldbReadbackFiles {
readonly #resources = new ArtifactResourceScope();
@@ -76,10 +79,28 @@ export class LldbReadbackFiles {
async readTargetPid(path: string): Promise<number | undefined> {
const known = this.#targetPids.get(path);
if (known !== undefined) return known;
const contents = await this.read(path, async (file) =>
(await file.readFile("utf8")).trim(),
);
if (!contents.ok || !/^[1-9]\d*$/u.test(contents.value)) return undefined;
const contents = await this.read(path, async (file) => {
const bytes = Buffer.alloc(TARGET_PID_FILE_MAX_BYTES + 1);
let length = 0;
while (length < bytes.length) {
const { bytesRead } = await file.read(
bytes,
length,
bytes.length - length,
length,
);
if (bytesRead === 0) break;
length += bytesRead;
}
if (length > TARGET_PID_FILE_MAX_BYTES) return undefined;
return bytes.subarray(0, length).toString("utf8").trim();
});
if (
!contents.ok ||
contents.value === undefined ||
!/^[1-9]\d*$/u.test(contents.value)
)
return undefined;
const pid = Number(contents.value);
if (!Number.isSafeInteger(pid)) return undefined;
this.#targetPids.set(path, pid);
+92 -31
View File
@@ -15,13 +15,47 @@ export interface ParsedLldbJournal {
readonly limitations: string[];
}
export interface PartialCaptureRead {
interface LldbObservationJournalValue {
readonly parsed: ParsedLldbJournal;
readonly limitations: readonly string[];
}
export type LldbObservationJournalRead =
| (LldbObservationJournalValue & {
readonly status: "complete" | "eof";
})
| (LldbObservationJournalValue & {
readonly status: "read-failure";
readonly readFailure: unknown;
});
interface PartialCaptureValue {
readonly capture: NonNullable<
NativeCallPartialObservation["process"]["stdout"]
>;
readonly limitations: readonly string[];
}
export type PartialCaptureRead =
| (PartialCaptureValue & { readonly status: "complete" | "eof" })
| (PartialCaptureValue & {
readonly status: "read-failure";
readonly readFailure: unknown;
});
export type BoundedPrefixRead =
| {
readonly status: "complete" | "eof";
readonly buffer: Buffer;
readonly bytesRead: number;
}
| {
readonly status: "read-failure";
readonly buffer: Buffer;
readonly bytesRead: number;
readonly cause: unknown;
};
/** Join safely retained journal and process evidence into its failure contract. */
export const projectLldbPartialObservation = (input: {
readonly target: NativeCallPartialObservation["target"];
@@ -66,20 +100,19 @@ export const projectLldbPartialObservation = (input: {
export const parseLldbObservationJournal = (
bytes: Uint8Array,
): ParsedLldbJournal => {
const completeText = Buffer.from(bytes).toString("utf8");
const newline = completeText.lastIndexOf("\n");
const hasTornTail = newline !== completeText.length - 1;
const hasTornTail = bytes.length > 0 && bytes[bytes.length - 1] !== 0x0a;
const events: ParsedLldbJournal["events"] = [];
const otherStops: string[] = [];
const limitations: string[] = [];
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let malformedCount = 0;
let firstMalformedRow: number | undefined;
let start = 0;
let rowNumber = 0;
while (start <= newline && newline >= 0) {
const end = completeText.indexOf("\n", start);
if (end < 0 || end > newline) break;
const line = completeText.slice(start, end);
while (start < bytes.length) {
const end = bytes.indexOf(0x0a, start);
if (end < 0) break;
const line = bytes.subarray(start, end);
start = end + 1;
if (line.length === 0) continue;
rowNumber += 1;
@@ -89,7 +122,7 @@ export const parseLldbObservationJournal = (
};
let row: unknown;
try {
row = JSON.parse(line);
row = JSON.parse(decoder.decode(line));
} catch {
markMalformed();
continue;
@@ -137,22 +170,31 @@ export const readBoundedPrefix = async (
length: number,
position: number,
) => Promise<number>,
): Promise<{ readonly buffer: Buffer; readonly bytesRead: number }> => {
): Promise<BoundedPrefixRead> => {
const buffer = Buffer.alloc(requestedBytes);
let bytesRead = 0;
while (bytesRead < requestedBytes) {
const count = await readChunk(
buffer,
bytesRead,
requestedBytes - bytesRead,
bytesRead,
);
let count: number;
try {
count = await readChunk(
buffer,
bytesRead,
requestedBytes - bytesRead,
bytesRead,
);
} catch (cause: unknown) {
return { status: "read-failure", buffer, bytesRead, cause };
}
if (count === 0) break;
if (count > requestedBytes - bytesRead)
throw new Error("The bounded read returned more bytes than requested");
bytesRead += count;
}
return { buffer, bytesRead };
return {
status: bytesRead < requestedBytes ? "eof" : "complete",
buffer,
bytesRead,
};
};
const readPrefix = (handle: FileHandle, requestedBytes: number) =>
@@ -166,17 +208,14 @@ const readPrefix = (handle: FileHandle, requestedBytes: number) =>
export const readLldbObservationJournal = (
path: string,
files: LldbReadbackFiles,
): Promise<
Result<
{ readonly parsed: ParsedLldbJournal; readonly limitations: string[] },
unknown
>
> =>
): Promise<Result<LldbObservationJournalRead, unknown>> =>
files.read(path, async (handle) => {
const { size } = await handle.stat();
const length = Math.min(size, LLDB_OBSERVATION_JOURNAL_MAX_BYTES);
const { buffer, bytesRead } = await readPrefix(handle, length);
return {
const read = await readPrefix(handle, length);
const { buffer, bytesRead } = read;
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
const value: LldbObservationJournalValue = {
parsed: parseLldbObservationJournal(buffer.subarray(0, bytesRead)),
limitations: [
...(size > LLDB_OBSERVATION_JOURNAL_MAX_BYTES
@@ -184,13 +223,21 @@ export const readLldbObservationJournal = (
"The observation journal exceeded 8 MiB; only its bounded prefix was read.",
]
: []),
...(bytesRead < length
...(read.status === "eof"
? [
"The observation journal became shorter while being read; the available prefix was parsed.",
]
: []),
...(read.status === "read-failure"
? [
`The observation journal read failed after ${String(bytesRead)} bytes: ${errorReason(read.cause)}`,
]
: []),
],
};
return read.status === "read-failure"
? { ...value, status: read.status, readFailure: read.cause }
: { ...value, status: read.status };
});
/** Retain an available output prefix without claiming an unreported total. */
@@ -201,19 +248,33 @@ export const readPartialCapture = (
files.read(path, async (handle) => {
const { size } = await handle.stat();
const length = Math.min(size, MAX_CAPTURE_BYTES);
const { buffer, bytesRead } = await readPrefix(handle, length);
return {
const read = await readPrefix(handle, length);
const { buffer, bytesRead } = read;
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
const value: PartialCaptureValue = {
capture: {
text: buffer.subarray(0, bytesRead).toString("utf8"),
bytes: Math.max(size, bytesRead),
truncated: true,
complete: false,
},
limitations:
bytesRead < length
limitations: [
...(read.status === "eof"
? [
"A target output capture became shorter while being read; its retained prefix may be incomplete.",
]
: [],
: []),
...(read.status === "read-failure"
? [
`A target output capture read failed after ${String(bytesRead)} bytes: ${errorReason(read.cause)}`,
]
: []),
],
};
return read.status === "read-failure"
? { ...value, status: read.status, readFailure: read.cause }
: { ...value, status: read.status };
});
const errorReason = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
+54 -53
View File
@@ -64,9 +64,9 @@ import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
projectPlistValue,
nonFiniteRealLimitation,
unknownRealLimitation,
negativeZeroRealLimitation,
unknownDateLimitation,
unknownScalarLimitation,
} from "../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../domain/apple/plistNumbers.js";
import type { NativeUiObservationResult } from "../domain/native/nativeUiObservation.js";
@@ -118,6 +118,7 @@ import {
createNativeUiHelperRuntime,
type NativeUiHelperRuntime,
} from "./NativeUiHelperRuntime.js";
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
type SignatureRuntimeRootFactory = () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
@@ -362,57 +363,57 @@ class NativeMacOSClient implements AnalysisClient {
this.#nativeUiCleanupFailure(earlierCleanupFailures, operation),
);
const runtime = createNativeUiHelperRuntime(this.environment);
const observationSignal =
signal === undefined
? runtime.signal
: AbortSignal.any([signal, runtime.signal]);
this.#pendingNativeUiRuntimes.add(runtime);
let result: Awaited<ReturnType<typeof observeNativeUi>>;
const interruption = new NativeUiInterruptionControl(
signal,
runtime.signal,
);
try {
result = await observeNativeUi(this.target, operation, parameters, {
environment: this.environment,
signal: observationSignal,
runtime,
});
} catch (cause: unknown) {
result = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(IDENTITY.id, operation, { cause }),
);
this.#pendingNativeUiRuntimes.add(runtime);
let result: Awaited<ReturnType<typeof observeNativeUi>>;
try {
result = await observeNativeUi(this.target, operation, parameters, {
environment: this.environment,
runtime,
interruption,
});
} catch (cause: unknown) {
result = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(IDENTITY.id, operation, { cause }),
);
}
try {
await runtime.close();
this.#pendingNativeUiRuntimes.delete(runtime);
} catch (cause: unknown) {
result = finishNativeUiObservation(result, operation, interruption);
const cleanup = this.#nativeUiCleanupFailure(
[{ runtime, cause }],
operation,
result.ok ? result.value : undefined,
);
return err(
result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
result = finishNativeUiObservation(result, operation, interruption);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
limitations: result.value.limitations,
}),
)
: result;
} finally {
interruption.dispose();
}
try {
await runtime.close();
this.#pendingNativeUiRuntimes.delete(runtime);
} catch (cause: unknown) {
result = finishNativeUiObservation(
result,
operation,
observationSignal,
);
const cleanup = this.#nativeUiCleanupFailure(
[{ runtime, cause }],
operation,
result.ok ? result.value : undefined,
);
return err(
result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
result = finishNativeUiObservation(result, operation, observationSignal);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
limitations: result.value.limitations,
}),
)
: result;
});
}
@@ -1243,15 +1244,15 @@ const parseEntitlements = (
...(projected.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...(projected.unknownRealCount === 0
? []
: [unknownRealLimitation(projected.unknownRealCount)]),
...(projected.negativeZeroRealCount === 0
? []
: [negativeZeroRealLimitation(projected.negativeZeroRealCount)]),
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
...(projected.unknownScalarCount === 0
? []
: [unknownScalarLimitation(projected.unknownScalarCount)]),
],
};
};
+2 -1
View File
@@ -10,6 +10,7 @@ import { execFileOutput } from "../process/ExecFileOutput.js";
import { safeParseJson } from "../domain/safeJson.js";
import type { NativeUiHelper } from "./NativeUiObservation.js";
import { NATIVE_UI_HELPER_MAX_BUFFER } from "./NativeUiOutputBudget.js";
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
export interface NativeUiHelperRuntime {
readonly invoke: NativeUiHelper;
@@ -24,7 +25,7 @@ export const createNativeUiHelperRuntime = (
environment: Readonly<NodeJS.ProcessEnv>,
): NativeUiHelperRuntime => {
const selectedEnvironment = snapshotEnvironment(environment);
const deadlineSignal = AbortSignal.timeout(180_000);
const deadlineSignal = AbortSignal.timeout(NATIVE_UI_DEADLINE_MS);
const operations = new ArtifactResourceScope();
let root: string | undefined;
let preparation: Promise<string> | undefined;
@@ -0,0 +1,57 @@
import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { expect, it } from "vitest";
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
it("gives a pre-aborted caller priority over a pre-aborted deadline", () => {
const reason = { phase: "shared" };
const caller = new AbortController();
const deadline = new AbortController();
caller.abort(reason);
deadline.abort(reason);
const control = new NativeUiInterruptionControl(
caller.signal,
deadline.signal,
);
expect(control.error("observe_native_ui")).toBeInstanceOf(
AnalysisCancelledError,
);
expect(control.error("observe_native_ui")?.cause).toBe(reason);
control.dispose();
});
it("keeps the first observed source when caller and deadline share a reason", () => {
const reason = { phase: "shared" };
const caller = new AbortController();
const deadline = new AbortController();
const control = new NativeUiInterruptionControl(
caller.signal,
deadline.signal,
);
deadline.abort(reason);
caller.abort(reason);
expect(control.error("observe_native_ui")).toBeInstanceOf(
AnalysisTimeoutError,
);
expect(control.error("observe_native_ui")?.cause).toBe(reason);
control.dispose();
});
it("records a caller interruption before a later deadline", () => {
const reason = { phase: "shared" };
const caller = new AbortController();
const deadline = new AbortController();
const control = new NativeUiInterruptionControl(
caller.signal,
deadline.signal,
);
caller.abort(reason);
deadline.abort(reason);
expect(control.error("observe_native_ui")).toBeInstanceOf(
AnalysisCancelledError,
);
expect(control.error("observe_native_ui")?.cause).toBe(reason);
control.dispose();
});
+70
View File
@@ -0,0 +1,70 @@
import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
export const NATIVE_UI_DEADLINE_MS = 180_000;
type Interruption =
| { readonly kind: "caller"; readonly reason: unknown }
| { readonly kind: "deadline"; readonly reason: unknown };
/** Tracks whether the caller or the owned operation deadline stopped UI work. */
export class NativeUiInterruptionControl {
readonly signal: AbortSignal;
#controller = new AbortController();
#interruption: Interruption | undefined;
#caller: AbortSignal | undefined;
#deadline: AbortSignal;
#onCallerAbort = (): void => {
this.#interrupt("caller", this.#caller?.reason);
};
#onDeadlineAbort = (): void => {
this.#interrupt("deadline", this.#deadline.reason);
};
constructor(caller: AbortSignal | undefined, deadline: AbortSignal) {
this.#caller = caller;
this.#deadline = deadline;
this.signal = this.#controller.signal;
// Match AbortSignal.any's iterable priority when both are already aborted.
if (caller?.aborted) this.#interrupt("caller", caller.reason);
else if (deadline.aborted) this.#interrupt("deadline", deadline.reason);
else {
caller?.addEventListener("abort", this.#onCallerAbort, { once: true });
deadline.addEventListener("abort", this.#onDeadlineAbort, {
once: true,
});
}
}
error(
operation: string,
partialObservation?: AnalysisPartialObservation,
): AnalysisCancelledError | AnalysisTimeoutError | undefined {
const interruption = this.#interruption;
if (interruption === undefined) return undefined;
return interruption.kind === "deadline"
? new AnalysisTimeoutError(operation, NATIVE_UI_DEADLINE_MS, {
cause: interruption.reason,
partialObservation,
})
: new AnalysisCancelledError(operation, {
cause: interruption.reason,
partialObservation,
});
}
dispose(): void {
this.#caller?.removeEventListener("abort", this.#onCallerAbort);
this.#deadline.removeEventListener("abort", this.#onDeadlineAbort);
}
#interrupt(kind: Interruption["kind"], reason: unknown): void {
if (this.#interruption !== undefined) return;
this.#interruption = { kind, reason };
this.#controller.abort(reason);
}
}
+182
View File
@@ -2,6 +2,12 @@ import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { observeNativeUi } from "./NativeUiObservation.js";
import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
const target: BinaryTarget = {
@@ -268,6 +274,182 @@ describe("native UI capture selection and budgets", () => {
});
});
describe("native UI deadline execution", () => {
it("reports the owned deadline as a timeout during helper execution", async () => {
const deadline = new AbortController();
const reason = new DOMException("deadline elapsed", "TimeoutError");
const result = await observeNativeUi(target, "observe_native_ui", scope, {
environment: {},
runtime: {
signal: deadline.signal,
invoke: async () => ({ ok: true, result: snapshot }),
cleanupLease: new AnalysisCleanupLease(async () => undefined),
close: async () => undefined,
resources: () => [],
},
invoke: async () => {
deadline.abort(reason);
throw new ExecFileOutputCancelledError(reason);
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected deadline timeout");
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
expect(result.error.cause).toBe(reason);
});
it("preserves an independent producer cancellation when the deadline fires", async () => {
const deadline = new AbortController();
const producerReason = { phase: "producer" };
const result = await observeNativeUi(target, "observe_native_ui", scope, {
environment: {},
runtime: {
signal: deadline.signal,
invoke: async () => ({ ok: true, result: snapshot }),
cleanupLease: new AnalysisCleanupLease(async () => undefined),
close: async () => undefined,
resources: () => [],
},
invoke: async () => {
deadline.abort({ phase: "late-deadline" });
throw new ExecFileOutputCancelledError(producerReason);
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected producer cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(producerReason);
});
it("keeps deadline failure in a scenario report as failed, not cancelled", async () => {
const deadline = new AbortController();
const reason = { phase: "scenario-deadline" };
let calls = 0;
const result = await observeNativeUi(
target,
"capture_native_ui_scenario",
{ ...scope, steps: [{ kind: "click", path: [1] }] },
{
environment: {},
runtime: {
signal: deadline.signal,
invoke: async () => ({ ok: true, result: snapshot }),
cleanupLease: new AnalysisCleanupLease(async () => undefined),
close: async () => undefined,
resources: () => [],
},
invoke: async () => {
if (++calls === 1) {
deadline.abort(reason);
return { ok: true, result: snapshot };
}
throw new Error("unexpected second capture");
},
},
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected deadline timeout");
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
steps: [{ outcome: "failed", after: null }],
});
expect(calls).toBe(1);
});
});
describe("native UI interruption provenance", () => {
it("reports an owned deadline during a scenario wait as a failed step", async () => {
const deadline = new AbortController();
const reason = { phase: "wait-deadline" };
const timer = setTimeout(() => deadline.abort(reason), 0);
const result = await observeNativeUi(
target,
"capture_native_ui_scenario",
{ ...scope, steps: [{ kind: "wait", milliseconds: 100 }] },
{
environment: {},
runtime: {
signal: deadline.signal,
invoke: async () => ({ ok: true, result: snapshot }),
cleanupLease: new AnalysisCleanupLease(async () => undefined),
close: async () => undefined,
resources: () => [],
},
invoke: async () => ({ ok: true, result: snapshot }),
},
);
clearTimeout(timer);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected deadline timeout");
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
steps: [{ outcome: "failed", reason: expect.any(String) }],
});
});
it("treats a caller TimeoutError-shaped reason as caller cancellation", async () => {
const caller = new AbortController();
const reason = new DOMException("caller stopped", "TimeoutError");
caller.abort(reason);
const result = await observeNativeUi(target, "observe_native_ui", scope, {
environment: {},
signal: caller.signal,
invoke: async () => ({ ok: true, result: snapshot }),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected caller cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
it("preserves a physical helper failure even if the owned deadline fires", async () => {
const deadline = new AbortController();
const failure = new Error("physical helper failure");
const result = await observeNativeUi(target, "observe_native_ui", scope, {
environment: {},
runtime: {
signal: deadline.signal,
invoke: async () => ({ ok: true, result: snapshot }),
cleanupLease: new AnalysisCleanupLease(async () => undefined),
close: async () => undefined,
resources: () => [],
},
invoke: async () => {
deadline.abort({ phase: "late-deadline" });
throw failure;
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected helper failure");
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cause).toBe(failure);
});
it("preserves schema failure even if the owned deadline fires", async () => {
const deadline = new AbortController();
const result = await observeNativeUi(target, "observe_native_ui", scope, {
environment: {},
runtime: {
signal: deadline.signal,
invoke: async () => ({ ok: true, result: snapshot }),
cleanupLease: new AnalysisCleanupLease(async () => undefined),
close: async () => undefined,
resources: () => [],
},
invoke: async () => {
deadline.abort({ phase: "late-deadline" });
return { ok: true, result: { malformed: true } };
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected schema failure");
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cause).toMatchObject({ name: "ZodError" });
});
});
describe("native UI target and cancellation failures", () => {
it.each([
"accessibility-denied",
+60 -82
View File
@@ -30,6 +30,7 @@ import {
NATIVE_UI_OUTPUT_BUDGET_BYTES,
NATIVE_UI_OUTPUT_WEIGHT,
} from "./NativeUiOutputBudget.js";
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
const helper = fileURLToPath(
new URL("../../bridge/native/ReaNativeUI.swift", import.meta.url),
@@ -61,56 +62,64 @@ export const observeNativeUi = async (
signal?: AbortSignal | undefined;
invoke?: NativeUiHelper;
runtime?: NativeUiHelperRuntime;
interruption?: NativeUiInterruptionControl;
},
): Promise<Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>> => {
const runtime =
options.runtime ?? createNativeUiHelperRuntime(options.environment);
const signal =
options.signal === undefined
? runtime.signal
: AbortSignal.any([options.signal, runtime.signal]);
const interruption =
options.interruption ??
new NativeUiInterruptionControl(options.signal, runtime.signal);
const ownsInterruption = options.interruption === undefined;
let result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>;
try {
result = await observeWithHelper(target, operation, parameters, {
signal,
invoke: options.invoke ?? runtime.invoke,
});
} catch (cause: unknown) {
result = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError("native-macos", operation, { cause }),
);
}
if (options.runtime === undefined) {
try {
await runtime.close();
result = await observeWithHelper(target, operation, parameters, {
interruption,
invoke: options.invoke ?? runtime.invoke,
});
} catch (cause: unknown) {
result = finishNativeUiObservation(result, operation, signal);
const cleanup = new NativeUiCleanupError(
[{ runtime, cause }],
operation,
result.ok ? result.value : undefined,
result = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError("native-macos", operation, { cause }),
);
return result.ok
? err(cleanup)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
if (options.runtime === undefined) {
try {
await runtime.close();
} catch (cause: unknown) {
result = finishNativeUiObservation(result, operation, interruption);
const cleanup = new NativeUiCleanupError(
[{ runtime, cause }],
operation,
result.ok ? result.value : undefined,
);
return result.ok
? err(cleanup)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
}
return finishNativeUiObservation(result, operation, interruption);
} finally {
if (ownsInterruption) interruption.dispose();
}
return finishNativeUiObservation(result, operation, signal);
};
const observeWithHelper = async (
target: BinaryTarget,
operation: "observe_native_ui" | "capture_native_ui_scenario",
parameters: unknown,
options: { signal: AbortSignal; invoke: NativeUiHelper },
options: {
interruption: NativeUiInterruptionControl;
invoke: NativeUiHelper;
},
): Promise<Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>> => {
const parsed = (
operation === "observe_native_ui"
@@ -135,17 +144,16 @@ const observeWithHelper = async (
}),
);
const steps = input.steps;
const signal = options.signal;
const interruption = options.interruption;
const signal = interruption.signal;
const invoke = options.invoke;
let launchTime: number | undefined;
let outputBytes = 0;
const take = async (
action?: unknown,
): Promise<Result<z.infer<typeof nativeUiSnapshotSchema>, AnalysisError>> => {
if (signal.aborted)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
const interrupted = interruption.error(operation);
if (interrupted !== undefined) return err(interrupted);
try {
const response = responseSchema.parse(
await invoke(
@@ -202,7 +210,10 @@ const observeWithHelper = async (
if (cause instanceof AnalysisError) return err(cause);
return err(
cause instanceof ExecFileOutputCancelledError
? new AnalysisCancelledError(operation, { cause: cause.cause })
? ((signal.aborted && cause.cause === signal.reason
? interruption.error(operation)
: undefined) ??
new AnalysisCancelledError(operation, { cause: cause.cause }))
: new ProviderAdapterError("native-macos", operation, {
cause,
diagnostics: {
@@ -221,37 +232,8 @@ const observeWithHelper = async (
let before = initial.value;
let terminalFailure: AnalysisError | undefined;
for (const [index, step] of steps.entries()) {
if (signal.aborted) {
terminalFailure = new AnalysisCancelledError(operation, {
cause: signal.reason,
});
results.push({
index,
kind: step.kind,
before,
after: null,
outcome: "cancelled",
reason: "Scenario cancelled before action",
});
break;
}
if (
step.kind === "wait" &&
(await waitForAbortableDelay(step.milliseconds, signal)) === "aborted"
) {
terminalFailure = new AnalysisCancelledError(operation, {
cause: signal.reason,
});
results.push({
index,
kind: step.kind,
before,
after: null,
outcome: "cancelled",
reason: "Scenario cancelled during wait",
});
break;
}
if (step.kind === "wait")
await waitForAbortableDelay(step.milliseconds, signal);
const after = await take(step.kind === "wait" ? undefined : step);
if (!after.ok) {
terminalFailure = after.error;
@@ -288,11 +270,11 @@ const observeWithHelper = async (
"Click uses AXPress; scroll uses AXIncrement/AXDecrement; key-entry sets the selected element's AXValue. Unsupported elements fail without global event fallback.",
"Each completed action is followed immediately by capture; delayed UI changes require explicit wait steps. A failed after-capture can follow a completed action, so failure does not prove absence of application effects.",
"Scenarios can change app data, cause app network activity and persist changes. The caller explicitly chooses leave-as-is; automatic restoration is unsupported.",
"Window matching uses exact PID and window ID for screenshots and unique accessibility geometry. Accessibility paths can change as the UI changes; stale or ambiguous paths fail. Screenshots are scaled to at most 2048 pixels; scenario output is budgeted at 64 MiB and execution is cancelled after 180 seconds.",
"Window matching uses exact PID and window ID for screenshots and unique accessibility geometry. Accessibility paths can change as the UI changes; stale or ambiguous paths fail. Screenshots are scaled to at most 2048 pixels; scenario output is budgeted at 64 MiB and execution has a 180-second deadline.",
],
});
return terminalFailure === undefined
? finishNativeUiObservation(ok(report), operation, signal)
? finishNativeUiObservation(ok(report), operation, interruption)
: err(terminalFailure.retainPartialObservation(report));
};
@@ -300,13 +282,9 @@ const observeWithHelper = async (
export const finishNativeUiObservation = (
result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>,
operation: string,
signal?: AbortSignal,
): Result<z.infer<typeof nativeUiResultSchema>, AnalysisError> =>
result.ok && signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
partialObservation: result.value,
}),
)
: result;
interruption: NativeUiInterruptionControl,
): Result<z.infer<typeof nativeUiResultSchema>, AnalysisError> => {
if (!result.ok) return result;
const error = interruption.error(operation, result.value);
return error === undefined ? result : err(error);
};
+62 -6
View File
@@ -223,7 +223,7 @@ it("waits for an in-flight signature binding before provider close", async () =>
await expect(stat(roots[0]!)).rejects.toMatchObject({ code: "ENOENT" });
});
it("tracks mixed snapshot descriptor and root cleanup through partial release and retry", async () => {
it("releases retained descriptors before retrying their containing root", async () => {
const target = await createTarget("observed", "expected");
const resources = new ArtifactResourceScope();
const original = OwnedFileHandle.prototype.close;
@@ -265,15 +265,16 @@ it("tracks mixed snapshot descriptor and root cleanup through partial release an
createRoot,
);
expect(retried.ok).toBe(false);
if (retried.ok) throw new Error("Expected retained descriptor retry failure");
if (retried.ok) throw new Error("Expected retained root retry failure");
expect(first.error.cleanupIncomplete).toBe(true);
expect(first.error.cleanupResources).toEqual([snapshot]);
expect(retried.error.cleanupResources).toEqual([snapshot]);
expect(first.error.cleanupResources).toEqual([roots[0]!]);
expect(retried.error.cleanupResources).toEqual([roots[0]!]);
expect(roots).toHaveLength(1);
expect(inspect).not.toHaveBeenCalled();
await expect(stat(roots[0]!)).rejects.toMatchObject({ code: "ENOENT" });
expect((await stat(roots[0]!)).isDirectory()).toBe(true);
await resources.close();
expect(attempts).toBe(3);
await expect(stat(roots[0]!)).rejects.toMatchObject({ code: "ENOENT" });
for (const failure of [first.error, retried.error]) {
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupResources).toEqual([]);
@@ -283,6 +284,57 @@ it("tracks mixed snapshot descriptor and root cleanup through partial release an
);
});
it("retains the snapshot root until its failed descriptor owner confirms release", async () => {
const target = await createTarget("observed", "expected");
const resources = new ArtifactResourceScope();
const original = OwnedFileHandle.prototype.close;
let blocked: OwnedFileHandle | undefined;
let allowClose = false;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
blocked ??= this;
if (this === blocked && !allowClose)
return Promise.reject(new Error("snapshot descriptor remains open"));
return original.call(this);
});
const root = await createRoot();
const removeRoot = vi.fn(root.close);
onTestFinished(async () => {
allowClose = true;
await resources.close();
close.mockRestore();
});
const result = await withSignatureTarget(
target,
resources,
unusedInspection,
undefined,
async () => ({ path: root.path, close: removeRoot }),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected retained snapshot descriptor");
expect(removeRoot).not.toHaveBeenCalled();
expect((await stat(root.path)).isDirectory()).toBe(true);
const retry = await resources.close().then(
() => undefined,
(cause: unknown) => cause,
);
expect(retry).toBeDefined();
expect(removeRoot).not.toHaveBeenCalled();
expect((await stat(root.path)).isDirectory()).toBe(true);
expect(result.error.cleanupIncomplete).toBe(true);
allowClose = true;
await resources.close();
expect(removeRoot).toHaveBeenCalledTimes(1);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(resolvedAnalysisError(result.error)._tag).toBe(
"AnalysisArtifactChangedError",
);
await expect(stat(root.path)).rejects.toMatchObject({ code: "ENOENT" });
});
const opaqueReasons = [
null,
0,
@@ -339,12 +391,15 @@ for (const ownerIndex of [0, 1])
const reason = { stage: "descriptor-close" };
const original = OwnedFileHandle.prototype.close;
let calls = 0;
let blocked: OwnedFileHandle | undefined;
let allowClose = false;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
const cancels = calls++ === ownerIndex;
if (cancels && cleanupFails) blocked = this;
try {
if (cancels && cleanupFails)
if (this === blocked && !allowClose)
throw new Error("controlled close failure");
await original.call(this);
} finally {
@@ -368,6 +423,7 @@ for (const ownerIndex of [0, 1])
throw new Error("Expected descriptor finalization cancellation");
expect(inspect).not.toHaveBeenCalled();
expect(result.error.cleanupIncomplete).toBe(cleanupFails);
allowClose = true;
await resources.close();
expect(resolvedAnalysisError(result.error)).toMatchObject({
_tag: "AnalysisCancelledError",
+25 -9
View File
@@ -118,9 +118,20 @@ export const withSignatureTarget = async (
: snapshotFailure(target, "create", cause),
);
}
const children = new ArtifactResourceScope();
const owner: SignatureOwner = {
kind: "lease",
lease: new AnalysisCleanupLease(() => root.close(), [root.path]),
lease: new AnalysisCleanupLease(
async () => {
await children.close();
await root.close();
},
{
get cleanupResources() {
return [...children.cleanupResources, root.path];
},
},
),
resource: root.path,
};
let result: Result<InspectSignature, AnalysisError>;
@@ -128,7 +139,7 @@ export const withSignatureTarget = async (
const binding = await bindSignatureTarget(
target,
join(root.path, basename(target.path)),
resources,
children,
signal,
);
if (!binding.ok) result = binding;
@@ -173,11 +184,19 @@ export const withSignatureTarget = async (
cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
? cause.cleanup
: undefined;
if (retained !== undefined)
if (retained !== undefined) {
const failure = signatureCleanupFailure(
target,
retained.resources,
cause,
"retry",
);
bindArtifactFileCleanup(cause, failure);
return combineCleanupFailure(
err(signatureReadFailure(target, cause)),
signatureCleanupFailure(target, retained.resources, cause, "retry"),
failure,
);
}
return err(signatureReadFailure(target, cause));
}
};
@@ -202,8 +221,8 @@ const signatureCleanupFailure = (
resources: readonly string[],
cause: unknown,
phase: string,
): AnalysisError => {
const failure = new ProviderCleanupError(
): AnalysisError =>
new ProviderCleanupError(
NATIVE_MACOS_PROVIDER_IDENTITY.id,
resources,
{
@@ -213,9 +232,6 @@ const signatureCleanupFailure = (
},
{ operation: "inspect_signature", cause },
);
bindArtifactFileCleanup(cause, failure);
return failure;
};
const snapshotFailure = (
target: BinaryTarget,
+2 -2
View File
@@ -190,11 +190,11 @@ it("keeps CFPropertyList's non-finite real spellings in XML evidence", () => {
);
if (!parsed.ok) throw parsed.error;
expect(JSON.stringify(parsed.value.value)).toBe(
'{"Up":{"$plist_type":"real","value":"+infinity"},"Down":{"$plist_type":"real","value":"-infinity"},"Missing":{"$plist_type":"real","value":"nan"},"Overflow":{"$plist_type":"real","value":null},"Ratio":1.25}',
'{"Up":{"$plist_type":"real","value":"+infinity"},"Down":{"$plist_type":"real","value":"-infinity"},"Missing":{"$plist_type":"real","value":"nan"},"Overflow":{"$plist_type":"unknown","element":"real","literal":"1e999","reason":"unrepresentable"},"Ratio":1.25}',
);
expect(parsed.value.limitations).toEqual([
expect.stringContaining("XML conversion"),
'3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.',
'1 numeric value(s) are reported as { "$plist_type": "real", "value": null } because XML numeric decoding could not establish their value identity.',
expect.stringContaining("1 XML scalar value(s) could not be established"),
]);
});
+4 -4
View File
@@ -8,7 +8,7 @@ import {
nonFiniteRealLimitation,
projectPlistValue,
unknownDateLimitation,
unknownRealLimitation,
unknownScalarLimitation,
type ProjectedPlistValue,
} from "../../domain/apple/plistValue.js";
import {
@@ -207,13 +207,13 @@ export const parsePlistXml = (
...(projected.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...(projected.unknownRealCount === 0
? []
: [unknownRealLimitation(projected.unknownRealCount)]),
...negativeZeroLimitations(projected.negativeZeroRealCount),
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
...(projected.unknownScalarCount === 0
? []
: [unknownScalarLimitation(projected.unknownScalarCount)]),
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(omittedPrototypeKeys),