mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
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:
commit
cfbf595437
135 files changed
+33233
-1087
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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]),
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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)]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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(" "),
|
||||
};
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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"]),
|
||||
}),
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("&<>"'AB")).toBe(
|
||||
"&<>\"'AB",
|
||||
);
|
||||
});
|
||||
|
||||
// Existing key/omission controls assert those facts exactly; numeric facts
|
||||
// have separate source-type and ambiguity controls below.
|
||||
const parseXmlPropertyList = (text: string) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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<?rea content?>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],
|
||||
["0x2A", 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", "0x2A", "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:00Z</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:00Z",
|
||||
"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>1<![CDATA[.5]]></real><real><![CDATA[1.5]]></real><true/><false></false></array></plist>",
|
||||
).value,
|
||||
).toEqual([
|
||||
1.5,
|
||||
new UnknownPlistScalar("real", "<![CDATA[1.5]]>", "invalid-literal"),
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
["AQID", "eb9408"],
|
||||
["&AQID", "6a6a40"],
|
||||
["AQ ID", "010d74"],
|
||||
["é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=">">π😀 ${ending}before</string>${ending}` +
|
||||
"<data>AQID</data><data>AQID</data></array></plist>",
|
||||
);
|
||||
expect(parsed.value).toEqual([
|
||||
expect.any(String),
|
||||
Uint8Array.from([235, 148, 8]),
|
||||
Uint8Array.from([1, 2, 3]),
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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&",
|
||||
])("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),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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)]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user