From 00266efe13af902bfe9d4030e407ddaaba502339 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:42:25 +0800
Subject: [PATCH 01/45] fix(plist): preserve Unicode separators during XML
normalization
Use one XML 1.0 line-ending rule in the shared XML boundary and plist source-span owner. Normalize literal CRLF and CR while retaining NEL, line separator and paragraph separator as observed characters. Keep character references, raw scalar locations and prototype-key omission intact.
Validation: 229 XML/plist source tests, 60 adjacent Ghidra/Apktool/SAZ source tests, and 33 compiled CLI/provider tests passed. Full check:ci passed. Real plutil XML and binary fixtures reproduced the baseline loss and passed CLI plus SDK-validated stdio MCP checks with unchanged input digests. Independent source review found no blocker. Binary conversion losses tracked by #5701, #5703 and #5705 remain separate work.
Closes #5702
---
src/domain/propertyListKeys.test.ts | 17 ++++++++++++++++-
src/domain/propertyListXml.test.ts | 7 +++++++
src/domain/propertyListXmlSource.ts | 9 +++------
src/domain/xmlDocument.test.ts | 14 ++++++++++++++
src/domain/xmlDocument.ts | 12 +++++-------
tests/boundary/cli/nativePlistCli.test.ts | 2 ++
.../providers/native/plistSourceFormat.test.ts | 14 ++++++++++++++
7 files changed, 61 insertions(+), 14 deletions(-)
diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts
index f29fc1c305..cf771ced15 100644
--- a/src/domain/propertyListKeys.test.ts
+++ b/src/domain/propertyListKeys.test.ts
@@ -315,7 +315,7 @@ it("bounds nested plist wrappers before recursive decoding", () => {
).toEqual(["x"]);
});
-it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])(
+it.each(["\n", "\r\n", "\r"])(
"preserves unrelated source values around %j line endings",
(newline) => {
const text = plist(
@@ -390,3 +390,18 @@ describe("OpenStep plist nesting preflight", () => {
});
});
});
+
+it.each(["\u0085", "\u2028", "\u2029"])(
+ "preserves key identity and omission around inter-element separator %j",
+ (separator) => {
+ const parsed = parseXmlPropertyList(
+ plist(
+ `${separator}kept${separator}keya${separator}b${separator}__proto__omitted`,
+ ),
+ );
+ expect(parsed).toEqual({
+ value: { [`kept${separator}key`]: `a${separator}b` },
+ omittedPrototypeKeys: 1,
+ });
+ },
+);
diff --git a/src/domain/propertyListXml.test.ts b/src/domain/propertyListXml.test.ts
index b349cec112..4de709e4be 100644
--- a/src/domain/propertyListXml.test.ts
+++ b/src/domain/propertyListXml.test.ts
@@ -290,6 +290,13 @@ it("binds raw literals to their nodes across normalized line endings and UTF-16
]);
});
+it("preserves Unicode line separators in plist string values", () => {
+ const value = "a\u0085b\u2028c\u2029d";
+ expect(
+ parseXmlPropertyList(`${value}`).value,
+ ).toBe(value);
+});
+
it.each(["\u0085", "\u2028", "\u2029"])(
"does not normalize unsupported leading whitespace %j into an observed scalar",
(prefix) => {
diff --git a/src/domain/propertyListXmlSource.ts b/src/domain/propertyListXmlSource.ts
index 26e29ab92c..cc1f13575d 100644
--- a/src/domain/propertyListXmlSource.ts
+++ b/src/domain/propertyListXmlSource.ts
@@ -1,15 +1,12 @@
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 { parseXmlDocument, XML_LINE_ENDING } 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;
@@ -25,14 +22,14 @@ export class XmlPlistSourceError extends TypeError {
/** 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 normalized = text.replace(XML_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);
+ const lineEndings = new RegExp(XML_LINE_ENDING);
let line = 1;
let lineStart = 0;
// The depth walk visits elements in source order, so location lookup is
diff --git a/src/domain/xmlDocument.test.ts b/src/domain/xmlDocument.test.ts
index c8ad068ab6..469bc31600 100644
--- a/src/domain/xmlDocument.test.ts
+++ b/src/domain/xmlDocument.test.ts
@@ -73,3 +73,17 @@ it("applies XML line-ending normalization only when the consumer selects it", ()
expect(parsed.value.documentElement?.getAttribute("attr")).toBe("a b");
expect(parsed.value.documentElement?.textContent).toBe("\nx");
});
+
+it("preserves Unicode separators under XML 1.0 normalization", () => {
+ const parsed = parseXmlDocument(
+ 'a\r\u0085b\r\u2028c\r\n
d\u2029',
+ { doctype: "allow", lineEndings: "normalize" },
+ );
+ if (!parsed.ok) throw parsed.error;
+ expect(parsed.value.documentElement?.getAttribute("attr")).toBe(
+ "a\u0085b\u2028c\u2029d",
+ );
+ expect(parsed.value.documentElement?.textContent).toBe(
+ "a\n\u0085b\n\u2028c\n\rd\u2029",
+ );
+});
diff --git a/src/domain/xmlDocument.ts b/src/domain/xmlDocument.ts
index af4f636cc1..3022f4c6cd 100644
--- a/src/domain/xmlDocument.ts
+++ b/src/domain/xmlDocument.ts
@@ -1,12 +1,10 @@
-import {
- DOMParser,
- normalizeLineEndings,
- onErrorStopParsing,
- type Document,
-} from "@xmldom/xmldom";
+import { DOMParser, onErrorStopParsing, type Document } from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import { err, ok, type Result } from "./result.js";
+/** XML 1.0 literal line endings; Unicode separators remain source characters. */
+export const XML_LINE_ENDING = /\r\n?|\n/gu;
+
export class XmlDocumentParseError extends Error {
readonly _tag = "XmlDocumentParseError";
constructor(
@@ -48,7 +46,7 @@ export const parseXmlDocument = (
normalizeLineEndings:
policy.lineEndings === "preserve"
? (text) => text
- : normalizeLineEndings,
+ : (text) => text.replace(XML_LINE_ENDING, "\n"),
onError: onErrorStopParsing,
}).parseFromString(source, "application/xml"),
);
diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts
index 3c6f69dff6..5e0b6f3bbe 100644
--- a/tests/boundary/cli/nativePlistCli.test.ts
+++ b/tests/boundary/cli/nativePlistCli.test.ts
@@ -228,6 +228,7 @@ describe.skipIf(process.platform !== "darwin")(
"CFBundleExecutableApp",
"CFBundleIdentifiercom.example.types",
"BlobAAEC",
+ "Separatorsa\u0085b\u2028c\u2029d",
"Built2020-01-01T00:00:00Z",
// Parse the unsigned extended year as a UTC field, without JS string-date inference.
"Far11507-08-17T05:20:00Z",
@@ -261,6 +262,7 @@ describe.skipIf(process.platform !== "darwin")(
value: {
CFBundleIdentifier: "com.example.types",
Blob: { $plist_type: "data", base64: "AAEC" },
+ Separators: "a\u0085b\u2028c\u2029d",
Built: {
$plist_type: "date",
iso8601: "2020-01-01T00:00:00.000Z",
diff --git a/tests/boundary/providers/native/plistSourceFormat.test.ts b/tests/boundary/providers/native/plistSourceFormat.test.ts
index ab267b5768..0cbd27fa06 100644
--- a/tests/boundary/providers/native/plistSourceFormat.test.ts
+++ b/tests/boundary/providers/native/plistSourceFormat.test.ts
@@ -113,6 +113,20 @@ it.for([
expected: "binary",
xmlFallback: true,
},
+ {
+ description: "Apple binary property list\n",
+ expected: "binary",
+ xmlFallback: true,
+ convertedXml:
+ "nela\u0085blsa\u2028bpsa\u2029blfa\nbpayloadAA==",
+ expectedValue: {
+ nel: "a\u0085b",
+ ls: "a\u2028b",
+ ps: "a\u2029b",
+ lf: "a\nb",
+ payload: { $plist_type: "data", base64: "AA==" },
+ },
+ },
])(
"keeps source format $expected for $description (XML fallback: $xmlFallback)",
async (
From df8b4d8a5a8c218ac9f49dd75e014bd910209d36 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:43:17 +0800
Subject: [PATCH 02/45] fix(process): token-check identityless exit-refresh
replacements (#5675)
* fix(process): token-check identityless exit-refresh replacements
A launcher-exit refresh could drop a reparented grandchild that was absent
from the prior snapshot and report cleanup complete, or publish a verified
empty lineage, without reading its run token.
Fixes #5206
Co-authored-by: morluto
* fix(process): re-read identityless refresh rows when relations change
Lineage refresh classified every pre-existing process as a newcomer, and it
skipped a row after caching its first parent and group. Seed the exclusion
set from the initial snapshot and read a row again when that relation changes.
Co-authored-by: morluto
* style: format files oxfmt rejects on main
Repo-wide format:check fails on these two files at current main. Align them
so the process-ownership fix can pass the required static check.
Co-authored-by: morluto
* fix(process): keep owned groups found while confirming exit
An unreadable exit-refresh row used to hide the confirmation table, and
revalidation dropped groups discovered after the plan was built. Both now
stay in the same cleanup, and confirmation reads are bounded.
Co-authored-by: morluto
* fix(process): reclassify lineage rows when tree membership changes
Initial outsiders and cached refresh rows are read again when a parent,
group, or ancestor move changes launcher-tree membership. Unchanged
initial rows stay unread.
Co-authored-by: morluto
* fix(process): re-read cached identityless lineage tokens
Parent and group do not identify a process generation. A row whose token
was read is read again on the next refresh, so a reused PID cannot keep
a verified descendant.
Co-authored-by: morluto
* fix(process): revalidate an owned group that reappears
A group discovered after it was already processed is queued once more.
Another appearance stays unresolved instead of reporting cleanup complete.
Co-authored-by: morluto
* fix(process): recheck a helper that changes groups during confirmation
The confirmation table can move a token-matched member into a new group.
That row is read again, and cleanup signals the group it belongs to now.
Co-authored-by: morluto
* fix(process): publish one lineage row when a descendant returns
A refreshed descendant replaces the initial row for the same PID, including
when its group moves away and then returns to the original relation.
Co-authored-by: morluto
* fix(process): re-read foreign PIDs on the exit confirmation table
A readable foreign token does not identify a process that later reuses
that PID. The confirmation table reads those newcomers again.
Co-authored-by: morluto
* fix(process): recheck a pre-exit PID whose relation changes
An unchanged pre-existing row stays unread. A prior PID whose parent or
group changed is token-checked, so a reused owned process is not skipped.
Co-authored-by: morluto
* fix(process): recheck an initial descendant after its token matches
A successful read in the first lineage pass marks that PID token-checked.
A later refresh reads it again, so a reused PID cannot stay verified.
Co-authored-by: morluto
* fix(process): recheck the launcher on each lineage refresh
A later refresh reads the launcher PID again. A missing or unowned
launcher is unavailable instead of rooting a verified lineage.
Co-authored-by: morluto
* fix(process): publish lineage from the latest refresh table
A descendant missing from the latest refresh is omitted. A refreshed
launcher whose group or command no longer matches stays unavailable.
Co-authored-by: morluto
* test(process): cover a refreshed launcher parent mismatch
A same-token launcher whose parent no longer matches the committed
expected parent is unavailable, same as a group or command change.
Co-authored-by: morluto
* fix(process): recheck a token-read helper that returns to its group
A confirmation table dropped a previously matched PID once its parent and
group matched the pre-exit row, so cleanup could signal only the group it
had joined and report success while that helper survived.
Co-authored-by: morluto
---------
Co-authored-by: Cursor Agent
Co-authored-by: morluto
---
docs/process-capture.md | 28 +-
docs/testing.md | 18 +-
src/process/ProcessOwnership.part3.test.ts | 593 +++++++++++++++++-
src/process/ProcessOwnership.ts | 274 +++++++-
.../ProcessOwnership.validation.test.ts | 571 +++++++++++++++++
src/process/ProcessOwnershipObservation.ts | 212 ++++++-
6 files changed, 1669 insertions(+), 27 deletions(-)
diff --git a/docs/process-capture.md b/docs/process-capture.md
index 9096b494fa..67f81c085f 100644
--- a/docs/process-capture.md
+++ b/docs/process-capture.md
@@ -461,7 +461,33 @@ selected roots; it is not a per-file allowance. File contents are read in
Ownership inspections bind run-token reads to native start identities and retain
related generations through reparenting, group changes, omitted rows, and cleanup
-retries. The captured run keeps one evidence owner through settlement and cleanup.
+retries. A host without native identity reads still token-checks process-table
+rows that appear only in a launcher-exit refresh before reporting that group
+clean or publishing an empty lineage. A matching token authorizes a signal only
+for a group whose leader carries the same token. A matching token whose leader
+is absent, or an unreadable token for a still-live new row, stays unresolved. A
+readable foreign token is not signaled. An unreadable row that has exited by
+the confirmation read does not hide a new row in that confirmation table, and a
+group proven while revalidating another owned group is signaled in the same
+cleanup. A matched row that changes parent or group on the confirmation read
+is token-checked again. A PID whose token was already read is read again
+on the next confirmation even when its parent and group return to the
+pre-exit values. A PID whose first token was foreign is read again
+on that table, because the PID alone does not identify the process. A PID
+from the pre-exit table is read again when its parent or group changes, and
+an unchanged pre-existing row that was never token-read stays unread. Lineage
+publishes one row for each PID. Confirmation reads are bounded; a table that keeps producing unreadable
+replacements stays unresolved. That refresh does not retain a ledger
+for a later attempt that never observes the launcher. Lineage reads a row again
+when its parent, group, or launcher-tree membership changes, and it reads a
+token again on the next refresh even when those fields are unchanged. The
+launcher is read on every lineage refresh; a missing, unowned, or
+relation-changed launcher PID is not published as a verified lineage. A
+descendant absent from the latest refresh is omitted. An
+ancestor move cannot leave a foreign descendant inside a verified result. A
+group that reappears after cleanup already processed it is revalidated once;
+another appearance stays unresolved. The captured run keeps
+one evidence owner through settlement and cleanup.
An absent or changed token leaves a previously related generation unresolved;
current token proof is still required before signaling. Swept cleanup verifies the
remaining family after its group signals. Coverage is bounded by the native
diff --git a/docs/testing.md b/docs/testing.md
index 5ff3c4712e..e0b3f39a77 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -272,7 +272,23 @@ termination. Simulated Windows controls do not establish native Windows behavior
Native process-ownership controls distinguish start identity, zombie lifecycle,
current parent/group relation, and token-read evidence. Retain generation-bound
candidates through smaller lineage observations, table omissions, reparenting,
-group changes, and failed cleanup retries. A readable token with unavailable
+group changes, and failed cleanup retries. Identityless exit-refresh controls
+cover a replacement row that is not linked to the prior descendant set: a
+matching token on its own group is signaled, a foreign or absent token is not,
+an unreadable live token stays unresolved, a replacement that appears only in
+the confirmation table is still token-checked, a group discovered during
+revalidation is signaled before cleanup succeeds, a member that changes groups
+during confirmation is signaled in its new group, a PID first seen with a
+foreign token is read again on the confirmation table, a PID whose token was
+already read is read again even when its parent and group return to the
+pre-exit values, a pre-existing PID is read again when its parent or group
+changes, an unchanged pre-existing row that was never token-read stays unread,
+an unbounded confirmation
+chain stays unresolved, lineage reads a row again when its launcher-tree
+membership changes, a cached lineage token is read again on the next refresh, an initial descendant whose token already matched is read again on a later refresh, the launcher is read again on that refresh and a missing, unowned, or relation-changed launcher PID stays unavailable, a descendant absent from the latest refresh is omitted,
+a group that reappears after it was processed is revalidated once, a further
+reappearance stays unresolved, lineage publishes one row per PID, and lineage does not publish a
+verified empty descendant list while that row remains unexamined. A readable token with unavailable
identity stays unresolved. Native UID facts, rather than an earlier process-table
row, determine whether a token denial concerns another user. Darwin tests compile
the helper and exercise snapshot growth, absence, lifecycle, and UID observations;
diff --git a/src/process/ProcessOwnership.part3.test.ts b/src/process/ProcessOwnership.part3.test.ts
index bab43d7a97..875d87a0ed 100644
--- a/src/process/ProcessOwnership.part3.test.ts
+++ b/src/process/ProcessOwnership.part3.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest";
-import type { ProcessOwnershipHost } from "./ProcessOwnership.js";
+import type {
+ ProcessOwnershipHost,
+ ProcessTableEntry,
+} from "./ProcessOwnership.js";
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
const ownership = {
runId: "run-token",
@@ -106,7 +109,595 @@ describe("owned process-group cleanup discovery", () => {
},
});
});
+});
+describe("identityless lineage refresh keeps replacement rows", () => {
+ it("does not publish an empty lineage when exit refresh reveals a token-owned replacement", async () => {
+ const launcher = {
+ pid: 100,
+ parentPid: 1,
+ processGroupId: 100,
+ state: "S",
+ command: "root",
+ };
+ const child = {
+ pid: 101,
+ parentPid: 100,
+ processGroupId: 100,
+ state: "S",
+ command: "child",
+ };
+ const replacement = {
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ state: "S",
+ command: "reparented-grandchild",
+ };
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ });
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([launcher, child])
+ .mockResolvedValue([launcher, replacement]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason:
+ "process lineage refresh contained owned PID(s) outside the observed descendants: 102",
+ });
+ expect(environment).toHaveBeenCalledWith(102);
+ });
+
+ it("publishes a descendant that appears in the exit refresh and still belongs to the launcher", async () => {
+ const launcher = {
+ pid: 100,
+ parentPid: 1,
+ processGroupId: 100,
+ state: "S",
+ command: "root",
+ };
+ const child = {
+ pid: 101,
+ parentPid: 100,
+ processGroupId: 100,
+ state: "S",
+ command: "child",
+ };
+ const grandchild = {
+ pid: 102,
+ parentPid: 100,
+ processGroupId: 102,
+ state: "S",
+ command: "grandchild",
+ };
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([launcher, child])
+ .mockResolvedValue([launcher, grandchild]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment: (pid) =>
+ pid === 101
+ ? Promise.reject(
+ new Error("process exited during environment read"),
+ )
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "verified",
+ lineage: {
+ descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
+ },
+ });
+ });
+});
+
+describe("identityless lineage refresh ignores the prior process table", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command: `pid-${String(pid)}`,
+ });
+
+ it("does not token-read a pre-existing row when a descendant exits", async () => {
+ const environment = vi.fn((pid: number) =>
+ pid === 101
+ ? Promise.reject(new Error("process exited during environment read"))
+ : pid === 50
+ ? Promise.reject(new Error("unreadable pre-existing process"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), row(101, 100, 100), row(50, 1)])
+ .mockResolvedValue([row(100, 1, 100), row(50, 1)]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "verified",
+ lineage: { descendants: [] },
+ });
+ expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(50);
+ });
+
+ it("re-reads a refresh row after it moves under the launcher", async () => {
+ let replacementReads = 0;
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101 || pid === 103)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ if (pid !== 102)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ replacementReads += 1;
+ return Promise.resolve(
+ replacementReads === 1 ? {} : { REA_PROCESS_RUN_ID: "run-token" },
+ );
+ });
+ const outside = row(102, 1, 102);
+ const joined = row(102, 100, 102);
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(103, 100)])
+ .mockResolvedValueOnce([row(100, 1, 100), row(103, 100), outside])
+ .mockResolvedValue([row(100, 1, 100), joined]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "verified",
+ lineage: {
+ descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
+ },
+ });
+ expect(replacementReads).toBe(2);
+ });
+});
+
+describe("identityless lineage refresh follows tree membership", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command: `pid-${String(pid)}`,
+ });
+
+ it("token-checks an initial outsider after it joins the launcher", async () => {
+ const environment = vi.fn((pid: number) =>
+ pid === 101
+ ? Promise.reject(new Error("process exited during environment read"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(102, 1)])
+ .mockResolvedValue([row(100, 1, 100), row(102, 100)]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "verified",
+ lineage: {
+ descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
+ },
+ });
+ expect(environment).toHaveBeenCalledWith(102);
+ });
+
+ it("rejects a cached foreign row after an ancestor joins the launcher", async () => {
+ let outsiderReads = 0;
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101 || pid === 103)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ if (pid !== 102)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ outsiderReads += 1;
+ return Promise.resolve({});
+ });
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100),
+ row(101, 100),
+ row(103, 100),
+ row(50, 1),
+ ])
+ .mockResolvedValueOnce([
+ row(100, 1, 100),
+ row(103, 100),
+ row(50, 1),
+ row(102, 50),
+ ])
+ .mockResolvedValue([row(100, 1, 100), row(50, 100), row(102, 50)]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "process lineage contains an unowned or PID-reused process",
+ });
+ expect(outsiderReads).toBe(2);
+ expect(environment.mock.calls.filter(([pid]) => pid === 50)).toHaveLength(
+ 1,
+ );
+ });
+
+ it("re-reads a cached descendant when its relation is unchanged", async () => {
+ let replacementReads = 0;
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101 || pid === 103)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ if (pid !== 102)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ replacementReads += 1;
+ return Promise.resolve(
+ replacementReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
+ );
+ });
+ const descendant = row(102, 100, 102);
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(103, 100)])
+ .mockResolvedValueOnce([row(100, 1, 100), row(103, 100), descendant])
+ .mockResolvedValue([row(100, 1, 100), descendant]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "process lineage contains an unowned or PID-reused process",
+ });
+ expect(replacementReads).toBe(2);
+ });
+});
+
+describe("identityless lineage refresh drops exited descendants", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command: `pid-${String(pid)}`,
+ });
+
+ it("omits a validated descendant that is gone from the latest refresh", async () => {
+ const environment = vi.fn((pid: number) =>
+ pid === 101 || pid === 103
+ ? Promise.reject(new Error("process exited during environment read"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100),
+ row(102, 100),
+ row(101, 100),
+ row(103, 100),
+ ])
+ .mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(103, 100)])
+ .mockResolvedValue([row(100, 1, 100)]);
+ const result = await observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ });
+ expect(result.status).toBe("verified");
+ if (result.status !== "verified") return;
+ expect(result.lineage.descendants).toEqual([]);
+ });
+});
+
+describe("identityless lineage refresh rechecks the launcher relation", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ command = `pid-${String(pid)}`,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command,
+ });
+
+ it("rejects a same-token launcher whose process group changed", async () => {
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100, "root"),
+ row(102, 100),
+ row(101, 100),
+ ])
+ .mockResolvedValue([row(100, 1, 999, "root"), row(102, 100)]);
+ await expect(
+ observeOwnedProcessLineage(
+ { ...ownership, expectedCommand: "root" },
+ {
+ listProcesses,
+ environment: (pid) =>
+ pid === 101
+ ? Promise.reject(
+ new Error("process exited during environment read"),
+ )
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ signalGroup: vi.fn(),
+ },
+ ),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "owned launcher process-group identity did not match",
+ });
+ });
+
+ it("rejects a same-token launcher whose command changed", async () => {
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100, "root"),
+ row(102, 100),
+ row(101, 100),
+ ])
+ .mockResolvedValue([row(100, 1, 100, "replacement"), row(102, 100)]);
+ await expect(
+ observeOwnedProcessLineage(
+ { ...ownership, expectedCommand: "root" },
+ {
+ listProcesses,
+ environment: (pid) =>
+ pid === 101
+ ? Promise.reject(
+ new Error("process exited during environment read"),
+ )
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ signalGroup: vi.fn(),
+ },
+ ),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason:
+ "owned launcher command identity did not match (observed=replacement; expected=root)",
+ });
+ });
+
+ it("rejects a same-token launcher whose parent changed", async () => {
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100, "root"),
+ row(102, 100),
+ row(101, 100),
+ ])
+ .mockResolvedValue([row(100, 7, 100, "root"), row(102, 100)]);
+ await expect(
+ observeOwnedProcessLineage(
+ { ...ownership, expectedParentPid: 1, expectedCommand: "root" },
+ {
+ listProcesses,
+ environment: (pid) =>
+ pid === 101
+ ? Promise.reject(
+ new Error("process exited during environment read"),
+ )
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ signalGroup: vi.fn(),
+ },
+ ),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "owned launcher parent identity did not match",
+ });
+ });
+});
+
+describe("identityless lineage refresh rechecks the launcher", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command: `pid-${String(pid)}`,
+ });
+
+ it("does not publish lineage when the launcher PID is gone on a later refresh", async () => {
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(101, 100)])
+ .mockResolvedValue([row(102, 100)]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment: (pid) =>
+ pid === 101
+ ? Promise.reject(
+ new Error("process exited during environment read"),
+ )
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "owned launcher exited during lineage validation",
+ });
+ });
+
+ it("rejects a refresh whose launcher PID is no longer owned", async () => {
+ let launcherReads = 0;
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ if (pid !== 100)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ launcherReads += 1;
+ return Promise.resolve(
+ launcherReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
+ );
+ });
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(101, 100)])
+ .mockResolvedValue([row(100, 1, 100), row(102, 100)]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "process lineage contains an unowned or PID-reused process",
+ });
+ expect(launcherReads).toBe(2);
+ });
+});
+
+describe("identityless lineage refresh rechecks an initial descendant", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command: `pid-${String(pid)}`,
+ });
+
+ it("rejects a reused PID after an earlier descendant token matched", async () => {
+ let reads = 0;
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ if (pid !== 102)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ reads += 1;
+ return Promise.resolve(
+ reads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
+ );
+ });
+ const descendant = row(102, 100, 102);
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([row(100, 1, 100), descendant, row(101, 100)])
+ .mockResolvedValue([row(100, 1, 100), descendant]);
+ await expect(
+ observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ }),
+ ).resolves.toMatchObject({
+ status: "unavailable",
+ reason: "process lineage contains an unowned or PID-reused process",
+ });
+ expect(reads).toBe(2);
+ });
+});
+
+describe("identityless lineage refresh publishes one row per PID", () => {
+ const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ ): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command: `pid-${String(pid)}`,
+ });
+
+ it("keeps a single row when a descendant returns to its original group", async () => {
+ const environment = vi.fn((pid: number) =>
+ pid === 101 || pid === 104
+ ? Promise.reject(new Error("process exited during environment read"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100),
+ row(101, 100),
+ row(102, 100, 100),
+ row(104, 100),
+ ])
+ .mockResolvedValueOnce([
+ row(100, 1, 100),
+ row(102, 100, 102),
+ row(104, 100),
+ ])
+ .mockResolvedValue([row(100, 1, 100), row(102, 100, 100)]);
+ const result = await observeOwnedProcessLineage(ownership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ });
+ expect(result.status).toBe("verified");
+ if (result.status !== "verified") return;
+ expect(result.lineage.descendants).toEqual([
+ { pid: 102, parentPid: 100, processGroupId: 100 },
+ ]);
+ });
+});
+
+describe("owned process-group cleanup discovery", () => {
it("does not publish lineage when the launcher exits during validation", async () => {
const launcher = {
pid: 100,
diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts
index c8a74a83af..5ca6d021aa 100644
--- a/src/process/ProcessOwnership.ts
+++ b/src/process/ProcessOwnership.ts
@@ -348,7 +348,23 @@ export const cleanupOwnedProcessGroup = async (
);
if ("cleaned" in plan) return plan;
let signaled = false;
- for (const processGroupId of plan.signalOrder) {
+ const pendingGroups = [...plan.signalOrder];
+ const pending = new Set(pendingGroups);
+ const passes = new Map();
+ while (pendingGroups.length > 0) {
+ const processGroupId = pendingGroups.shift();
+ if (processGroupId === undefined) break;
+ pending.delete(processGroupId);
+ const seen = passes.get(processGroupId) ?? 0;
+ if (seen >= OWNED_GROUP_REVALIDATION_LIMIT)
+ return mergeUnverifiedObservations(
+ {
+ cleaned: false,
+ reason: `owned process group ${String(processGroupId)} reappeared after revalidation`,
+ },
+ plan.unverified,
+ );
+ passes.set(processGroupId, seen + 1);
const revalidation = await revalidateOwnedProcessGroup(
processGroupId,
ownership,
@@ -358,6 +374,19 @@ export const cleanupOwnedProcessGroup = async (
);
if ("cleaned" in revalidation)
return mergeUnverifiedObservations(revalidation, plan.unverified);
+ for (const groupId of revalidation.escapedProcessGroupIds) {
+ if (groupId === processGroupId || pending.has(groupId)) continue;
+ if ((passes.get(groupId) ?? 0) >= OWNED_GROUP_REVALIDATION_LIMIT)
+ return mergeUnverifiedObservations(
+ {
+ cleaned: false,
+ reason: `owned process group ${String(groupId)} reappeared after revalidation`,
+ },
+ plan.unverified,
+ );
+ pending.add(groupId);
+ pendingGroups.push(groupId);
+ }
if (revalidation.empty) continue;
try {
host.signalGroup(processGroupId, "SIGKILL");
@@ -497,6 +526,12 @@ interface OwnedCleanupPlan {
readonly unverified: readonly OwnershipSweepUnverifiedProcess[];
}
+/** Process table retained after launcher-exit settlement, plus groups proven by token. */
+interface SettledProcessTable {
+ readonly processes: readonly ProcessTableEntry[];
+ readonly escapedProcessGroupIds: readonly number[];
+}
+
interface ProcessRelationSnapshot {
readonly currentRelations: readonly ProcessTableEntry[];
readonly identities: ReadonlyMap;
@@ -561,6 +596,169 @@ const readProcessRelationSnapshot = async (
return { currentRelations, identities, failures };
};
+const settledProcessTable = (
+ processes: readonly ProcessTableEntry[],
+): SettledProcessTable => ({
+ processes,
+ escapedProcessGroupIds: [],
+});
+
+/** Confirmation tables taken after an unreadable exit-refresh row disappears. */
+const EXIT_REFRESH_CONFIRMATION_LIMIT = 8;
+
+/** Parent and group recorded for a PID in the pre-exit process table. */
+interface InitialProcessRelation {
+ readonly parentPid: number;
+ readonly processGroupId: number;
+}
+
+/**
+ * Newcomers are token-checked. A PID from the pre-exit table is checked only
+ * when its parent or group changed, which is the identityless sign that the
+ * PID may have been reused. An unchanged pre-existing row stays unread.
+ */
+const isExitRefreshCandidate = (
+ process: ProcessTableEntry,
+ ownership: OwnedProcessGroup,
+ initialRelations: ReadonlyMap,
+): boolean => {
+ if (process.processGroupId === ownership.processGroupId) return false;
+ const initial = initialRelations.get(process.pid);
+ return (
+ initial === undefined ||
+ initial.parentPid !== process.parentPid ||
+ initial.processGroupId !== process.processGroupId
+ );
+};
+
+/**
+ * Times one group may be revalidated in a single cleanup. The second pass
+ * covers a group that reappears after it was already processed. Another
+ * appearance stays unresolved instead of reporting success.
+ */
+const OWNED_GROUP_REVALIDATION_LIMIT = 2;
+
+/**
+ * Token-check rows that exist only in the post-exit table. A prior PID, group,
+ * or ancestry link is handled by the caller; these rows have none of those.
+ * Foreign tokens are not owned. A matching token authorizes only a group whose
+ * leader also carries that token. An unreadable live row stays unresolved.
+ */
+const classifyUnlinkedExitRows = async (
+ ownership: OwnedProcessGroup,
+ refreshed: readonly ProcessTableEntry[],
+ unlinked: readonly ProcessTableEntry[],
+ host: ProcessOwnershipHost,
+ initialRelations: ReadonlyMap,
+): Promise<
+ | { readonly kind: "clear"; readonly processes: readonly ProcessTableEntry[] }
+ | { readonly kind: "uncertain"; readonly reason: string }
+ | {
+ readonly kind: "escaped";
+ readonly processes: readonly ProcessTableEntry[];
+ readonly groupIds: readonly number[];
+ }
+> => {
+ let current = refreshed;
+ let pending = unlinked;
+ let confirmations = 0;
+ const matched: ProcessTableEntry[] = [];
+ const inspected = new Set();
+ // A readable token does not expire when the PID's parent and group return
+ // to the pre-exit values. Those PIDs are read again on the next table.
+ const tokenRead = new Set();
+ while (pending.length > 0) {
+ const due = pending.filter((process) => !inspected.has(process.pid));
+ if (due.length === 0) break;
+ for (const process of due) inspected.add(process.pid);
+ const unreadable: { readonly pid: number; readonly reason: string }[] = [];
+ for await (const { process, observation } of readProcessRunTokens(
+ host,
+ due,
+ )) {
+ if (observation.state === "readable") {
+ tokenRead.add(process.pid);
+ if (observation.runId === ownership.runId) matched.push(process);
+ continue;
+ }
+ unreadable.push({ pid: process.pid, reason: observation.reason });
+ }
+ if (unreadable.length === 0) break;
+ let liveRows: readonly ProcessTableEntry[] | undefined;
+ try {
+ liveRows = liveProcesses(await host.listProcesses());
+ } catch (cause: unknown) {
+ void cause;
+ liveRows = undefined;
+ }
+ const live =
+ liveRows === undefined
+ ? undefined
+ : new Set(liveRows.map(({ pid }) => pid));
+ const stillLive = unreadable.filter(
+ (candidate) => live === undefined || live.has(candidate.pid),
+ );
+ if (stillLive.length > 0) {
+ const only = stillLive.length === 1 ? stillLive[0] : undefined;
+ const pids = stillLive
+ .map(({ pid }) => pid)
+ .sort((left, right) => left - right);
+ return {
+ kind: "uncertain",
+ reason:
+ only === undefined
+ ? `process ownership could not be revalidated for PID(s): ${pids.join(", ")}`
+ : `process ownership could not be revalidated for PID ${String(only.pid)}: ${only.reason}`,
+ };
+ }
+ if (liveRows === undefined) break;
+ confirmations += 1;
+ if (confirmations > EXIT_REFRESH_CONFIRMATION_LIMIT)
+ return {
+ kind: "uncertain",
+ reason:
+ "process ownership could not be revalidated: exit refresh confirmation limit exceeded",
+ };
+ // The confirmation read is its own table. A PID classified on the previous
+ // table, including a readable foreign token, does not identify the process
+ // now using that PID. Read newcomers again, read a pre-exit PID again
+ // when its parent or group changed, and read a PID again when its token
+ // was already read even if that parent and group have returned.
+ current = liveRows;
+ inspected.clear();
+ matched.length = 0;
+ pending = current.filter(
+ (process) =>
+ tokenRead.has(process.pid) ||
+ isExitRefreshCandidate(process, ownership, initialRelations),
+ );
+ }
+ const livePids = new Set(current.map(({ pid }) => pid));
+ const stillMatched = matched.filter((process) => livePids.has(process.pid));
+ if (stillMatched.length === 0) return { kind: "clear", processes: current };
+ const matchedPids = new Set(stillMatched.map(({ pid }) => pid));
+ const groupIds = new Set();
+ for (const process of stillMatched) {
+ const leader = current.find(
+ (candidate) =>
+ candidate.pid === process.processGroupId &&
+ candidate.processGroupId === process.processGroupId,
+ );
+ if (leader === undefined || !matchedPids.has(leader.pid))
+ return {
+ kind: "uncertain",
+ reason:
+ "descendant process-group leader identity could not be verified",
+ };
+ groupIds.add(process.processGroupId);
+ }
+ return {
+ kind: "escaped",
+ processes: current,
+ groupIds: [...groupIds].sort((left, right) => left - right),
+ };
+};
+
// A process-table read can race exit and retain the old state with an altered
// command. Native terminal facts can settle that mismatch; exit-in-progress
// must be confirmed first. Surviving members still require normal token checks.
@@ -570,11 +768,11 @@ const settleLauncherExit = async (
host: ProcessOwnershipHost,
exitSettlement: ProcessExitSettlementBudget,
): Promise<
- | readonly ProcessTableEntry[]
+ | SettledProcessTable
| Extract
> => {
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
- if (launcher === undefined) return processes;
+ if (launcher === undefined) return settledProcessTable(processes);
let identityCheckedLauncher = launcher;
if (host.processIdentities !== undefined) {
const initialIdentities = await readNativeProcessIdentityMap(
@@ -605,7 +803,9 @@ const settleLauncherExit = async (
reason: "owned launcher exit could not be confirmed",
};
if (processIdentityHasExited(identity))
- return processes.filter(({ pid }) => pid !== launcher.pid);
+ return settledProcessTable(
+ processes.filter(({ pid }) => pid !== launcher.pid),
+ );
if (
identity?.state !== "readable" ||
identity.relation?.state !== "observed"
@@ -621,7 +821,7 @@ const settleLauncherExit = async (
};
}
const reason = launcherIdentityFailure(identityCheckedLauncher, ownership);
- if (reason === null) return processes;
+ if (reason === null) return settledProcessTable(processes);
// A native identity read already says that the launcher still exists and
// supplies its current parent/group relation. Refreshing the process table
// cannot repair a command or launch-relation mismatch for that generation.
@@ -654,7 +854,36 @@ const settleLauncherExit = async (
)
)
return { cleaned: false, reason };
- return refreshed.processes;
+ const initialRelations = new Map(
+ processes.map((process) => [
+ process.pid,
+ {
+ parentPid: process.parentPid,
+ processGroupId: process.processGroupId,
+ },
+ ]),
+ );
+ const unlinked = refreshed.processes.filter(
+ (process) =>
+ isExitRefreshCandidate(process, ownership, initialRelations) &&
+ !descendantPids.has(process.pid) &&
+ !descendantGroupIds.has(process.processGroupId) &&
+ !refreshedDescendantPids.has(process.pid),
+ );
+ const classified = await classifyUnlinkedExitRows(
+ ownership,
+ refreshed.processes,
+ unlinked,
+ host,
+ initialRelations,
+ );
+ if (classified.kind === "uncertain")
+ return { cleaned: false, reason: classified.reason };
+ return {
+ processes: classified.processes,
+ escapedProcessGroupIds:
+ classified.kind === "escaped" ? classified.groupIds : [],
+ };
}
return { cleaned: false, reason };
};
@@ -665,13 +894,15 @@ const createOwnedCleanupPlan = async (
host: ProcessOwnershipHost,
exitSettlement: ProcessExitSettlementBudget,
): Promise => {
- const processes = await settleLauncherExit(
+ const settled = await settleLauncherExit(
ownership,
initialProcesses,
host,
exitSettlement,
);
- if ("cleaned" in processes) return processes;
+ if ("cleaned" in settled) return settled;
+ const processes = settled.processes;
+ const escapedProcessGroupIds = new Set(settled.escapedProcessGroupIds);
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
processes,
@@ -714,6 +945,7 @@ const createOwnedCleanupPlan = async (
launcher === undefined &&
rootMembers.length === 0 &&
tokenOwned.owned.length === 0 &&
+ escapedProcessGroupIds.size === 0 &&
!relationSnapshot.currentRelations.some(({ processGroupId }) =>
sampledProcessGroupIds.has(processGroupId),
)
@@ -748,6 +980,8 @@ const createOwnedCleanupPlan = async (
processGroupIds.add(process.processGroupId);
for (const processGroupId of sampledProcessGroupIds)
processGroupIds.add(processGroupId);
+ for (const processGroupId of escapedProcessGroupIds)
+ processGroupIds.add(processGroupId);
const signalableGroups = new Set();
const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures];
const unverifiableSampledGroups = new Set();
@@ -757,9 +991,12 @@ const createOwnedCleanupPlan = async (
({ pid, processGroupId: observedGroupId }) =>
pid === processGroupId && observedGroupId === processGroupId,
);
+ const provenGroup =
+ tokenOwnedGroupIds.has(processGroupId) ||
+ escapedProcessGroupIds.has(processGroupId);
if (
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
- !tokenOwnedGroupIds.has(processGroupId) &&
+ !provenGroup &&
!sampledProcessGroupIds.has(processGroupId)
)
return {
@@ -769,7 +1006,7 @@ const createOwnedCleanupPlan = async (
};
if (
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
- !tokenOwnedGroupIds.has(processGroupId)
+ !provenGroup
)
unverifiableSampledGroups.add(processGroupId);
}
@@ -864,16 +1101,25 @@ const revalidateOwnedProcessGroup = async (
host: ProcessOwnershipHost,
expectedIdentities: ReadonlyMap,
exitSettlement: ProcessExitSettlementBudget,
-): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => {
+): Promise<
+ | {
+ readonly empty: boolean;
+ readonly escapedProcessGroupIds: readonly number[];
+ }
+ | ProcessCleanupResult
+> => {
let members: readonly ProcessTableEntry[];
+ let escapedProcessGroupIds: readonly number[] = [];
try {
- const processes = await settleLauncherExit(
+ const settled = await settleLauncherExit(
ownership,
liveProcesses(await host.listProcesses()),
host,
exitSettlement,
);
- if ("cleaned" in processes) return processes;
+ if ("cleaned" in settled) return settled;
+ escapedProcessGroupIds = settled.escapedProcessGroupIds;
+ const processes = settled.processes;
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
processes,
@@ -910,7 +1156,7 @@ const revalidateOwnedProcessGroup = async (
reason:
"descendant process-group leader identity could not be revalidated",
};
- return { empty: members.length === 0 };
+ return { empty: members.length === 0, escapedProcessGroupIds };
};
const cleanupValidationFailure = (
diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts
index a2ee26b029..48b05522fe 100644
--- a/src/process/ProcessOwnership.validation.test.ts
+++ b/src/process/ProcessOwnership.validation.test.ts
@@ -310,6 +310,577 @@ describe("owned process-group cleanup validation: refreshed descendant lineage",
);
});
+describe("identityless exit refresh signals a token-owned replacement", () => {
+ it("token-checks a reparented grandchild that appears only after the launcher exits", async () => {
+ const child = { ...launcher, pid: 101, parentPid: 100 };
+ const escaped = {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "reparented-grandchild",
+ };
+ const signalGroup = vi.fn();
+ const environment = vi.fn((pid: number) =>
+ Promise.resolve({
+ REA_PROCESS_RUN_ID: pid === 102 ? ownership.runId : "run-token",
+ }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([{ ...launcher, command: "[MainThread]" }, child])
+ .mockResolvedValue([escaped]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(environment).toHaveBeenCalledWith(102);
+ expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
+ });
+
+ it("does not token-read rows that were already outside the exited snapshot", async () => {
+ const unrelated = {
+ ...launcher,
+ pid: 50,
+ parentPid: 1,
+ processGroupId: 50,
+ command: "unrelated",
+ };
+ const escaped = {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "reparented-grandchild",
+ };
+ const signalGroup = vi.fn();
+ const environment = vi.fn((pid: number) =>
+ Promise.resolve({
+ REA_PROCESS_RUN_ID: pid === 102 ? ownership.runId : "other-run",
+ }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ unrelated,
+ ])
+ .mockResolvedValue([unrelated, escaped]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(50);
+ expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
+ });
+});
+
+describe("identityless exit refresh leaves unrelated replacements unresolved", () => {
+ it.each([
+ ["foreign", "other-run"],
+ ["absent", undefined],
+ ] as const)(
+ "does not signal a %s replacement row after the launcher exits",
+ async (_label, runId) => {
+ const signalGroup = vi.fn();
+ const environment = vi.fn(() =>
+ Promise.resolve(
+ runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId },
+ ),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockResolvedValue([
+ {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "foreign-replacement",
+ },
+ ]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: false });
+ expect(environment).toHaveBeenCalledWith(102);
+ expect(signalGroup).not.toHaveBeenCalled();
+ },
+ );
+
+ it("keeps cleanup unresolved when a replacement row's token cannot be read", async () => {
+ const signalGroup = vi.fn();
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockResolvedValue([
+ {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "unreadable-replacement",
+ },
+ ]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: () =>
+ Promise.reject(new Error("environment unavailable")),
+ signalGroup,
+ },
+ ),
+ ).resolves.toMatchObject({
+ cleaned: false,
+ reason:
+ "process ownership could not be revalidated for PID 102: environment unavailable",
+ });
+ expect(signalGroup).not.toHaveBeenCalled();
+ });
+
+ it("does not signal a token-owned replacement whose group leader is absent", async () => {
+ const signalGroup = vi.fn();
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockResolvedValue([
+ {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 999,
+ command: "member-without-leader",
+ },
+ ]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: () =>
+ Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ signalGroup,
+ },
+ ),
+ ).resolves.toMatchObject({
+ cleaned: false,
+ reason: "descendant process-group leader identity could not be verified",
+ });
+ expect(signalGroup).not.toHaveBeenCalled();
+ });
+});
+
+describe("identityless exit refresh keeps later confirmation rows", () => {
+ it("token-checks a replacement born in the liveness table after an unreadable row exits", async () => {
+ const unreadable = {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "unreadable-replacement",
+ };
+ const escaped = {
+ ...launcher,
+ pid: 103,
+ parentPid: 1,
+ processGroupId: 103,
+ command: "owned-replacement",
+ };
+ const signalGroup = vi.fn();
+ const environment = vi.fn((pid: number) =>
+ pid === 102
+ ? Promise.reject(new Error("environment unavailable"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockResolvedValueOnce([unreadable])
+ .mockResolvedValue([escaped]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(environment).toHaveBeenCalledWith(103);
+ expect(signalGroup.mock.calls).toEqual([[103, "SIGKILL"]]);
+ });
+
+ it("signals an escaped group discovered while revalidating the planned group", async () => {
+ const escaped = {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "reparented-grandchild",
+ };
+ const signalGroup = vi.fn();
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([launcher])
+ .mockResolvedValueOnce([{ ...launcher, command: "[MainThread]" }])
+ .mockResolvedValue([escaped]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: () =>
+ Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ signalGroup,
+ },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
+ });
+
+ it("stays unresolved when exit-refresh confirmations do not settle", async () => {
+ const signalGroup = vi.fn();
+ let nextPid = 200;
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockImplementation(() => {
+ const pid = nextPid;
+ nextPid += 1;
+ return Promise.resolve([
+ {
+ ...launcher,
+ pid,
+ parentPid: 1,
+ processGroupId: pid,
+ command: "churn",
+ },
+ ]);
+ });
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: () =>
+ Promise.reject(new Error("environment unavailable")),
+ signalGroup,
+ },
+ ),
+ ).resolves.toMatchObject({
+ cleaned: false,
+ reason:
+ "process ownership could not be revalidated: exit refresh confirmation limit exceeded",
+ });
+ expect(signalGroup).not.toHaveBeenCalled();
+ });
+});
+
+describe("identityless exit refresh follows a helper that changes groups", () => {
+ it("signals the group a token-owned member joins during confirmation", async () => {
+ const leader = {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "owned-leader",
+ };
+ const member = {
+ ...launcher,
+ pid: 103,
+ parentPid: 102,
+ processGroupId: 102,
+ command: "owned-member",
+ };
+ const moved = { ...member, parentPid: 1, processGroupId: 103 };
+ const unreadable = {
+ ...launcher,
+ pid: 104,
+ parentPid: 1,
+ processGroupId: 104,
+ command: "unreadable",
+ };
+ const signalGroup = vi.fn();
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockResolvedValueOnce([leader, member, unreadable])
+ .mockResolvedValue([leader, moved]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: (pid) =>
+ pid === 104
+ ? Promise.reject(new Error("environment unavailable"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ signalGroup,
+ },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(signalGroup.mock.calls).toEqual([
+ [102, "SIGKILL"],
+ [103, "SIGKILL"],
+ ]);
+ });
+});
+
+describe("identityless exit refresh rechecks a prior PID whose relation changes", () => {
+ it("token-checks a pre-existing PID after its parent changes", async () => {
+ const unrelated = {
+ ...launcher,
+ pid: 50,
+ parentPid: 999,
+ processGroupId: 50,
+ command: "unrelated",
+ };
+ const stable = {
+ ...launcher,
+ pid: 60,
+ parentPid: 1,
+ processGroupId: 60,
+ command: "stable-outsider",
+ };
+ const signalGroup = vi.fn();
+ const environment = vi.fn((pid: number) =>
+ Promise.resolve({
+ REA_PROCESS_RUN_ID: pid === 50 ? ownership.runId : "other-run",
+ }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ unrelated,
+ stable,
+ ])
+ .mockResolvedValue([{ ...unrelated, parentPid: 1 }, stable]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(environment).toHaveBeenCalledWith(50);
+ expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(60);
+ expect(signalGroup.mock.calls).toEqual([[50, "SIGKILL"]]);
+ });
+});
+
+describe("identityless exit refresh keeps a token-read helper", () => {
+ it("rechecks a matched helper after it returns to its original group", async () => {
+ const helper = {
+ ...launcher,
+ pid: 50,
+ parentPid: 1,
+ processGroupId: 50,
+ command: "helper",
+ };
+ const leader = {
+ ...launcher,
+ pid: 60,
+ parentPid: 1,
+ processGroupId: 60,
+ command: "owned-leader",
+ };
+ const stable = {
+ ...launcher,
+ pid: 70,
+ parentPid: 1,
+ processGroupId: 70,
+ command: "stable-outsider",
+ };
+ const unreadable = {
+ ...launcher,
+ pid: 104,
+ parentPid: 1,
+ processGroupId: 104,
+ command: "unreadable",
+ };
+ const signalGroup = vi.fn();
+ const environment = vi.fn((pid: number) =>
+ pid === 104
+ ? Promise.reject(new Error("environment unavailable"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ );
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ helper,
+ stable,
+ ])
+ .mockResolvedValueOnce([
+ leader,
+ { ...helper, parentPid: 60, processGroupId: 60 },
+ unreadable,
+ stable,
+ ])
+ .mockResolvedValue([leader, helper, stable]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(70);
+ expect(signalGroup.mock.calls).toEqual([
+ [50, "SIGKILL"],
+ [60, "SIGKILL"],
+ ]);
+ });
+});
+
+describe("identityless exit refresh re-reads a reused foreign PID", () => {
+ it("token-checks a PID again after a foreign row exits into an owned replacement", async () => {
+ const reused = {
+ ...launcher,
+ pid: 102,
+ parentPid: 1,
+ processGroupId: 102,
+ command: "reused-pid",
+ };
+ const unreadable = {
+ ...launcher,
+ pid: 104,
+ parentPid: 1,
+ processGroupId: 104,
+ command: "unreadable",
+ };
+ let reads = 0;
+ const signalGroup = vi.fn();
+ const environment = vi.fn((pid: number) => {
+ if (pid === 104)
+ return Promise.reject(new Error("environment unavailable"));
+ if (pid !== 102)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId });
+ reads += 1;
+ return Promise.resolve({
+ REA_PROCESS_RUN_ID: reads === 1 ? "other-run" : ownership.runId,
+ });
+ });
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ { ...launcher, command: "[MainThread]" },
+ { ...launcher, pid: 101, parentPid: 100 },
+ ])
+ .mockResolvedValueOnce([reused, unreadable])
+ .mockResolvedValue([reused]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ { listProcesses, environment, signalGroup },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(reads).toBeGreaterThan(1);
+ expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]);
+ });
+});
+
+describe("identityless cleanup revalidates a group that reappears", () => {
+ const leader = (pid: number, command = "fixture"): ProcessTableEntry => ({
+ ...launcher,
+ pid,
+ parentPid: pid === 100 ? 1 : 100,
+ processGroupId: pid,
+ command,
+ });
+
+ it("signals a token-owned group that reappears after it was already processed", async () => {
+ const signalGroup = vi.fn();
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([leader(100), leader(101)])
+ .mockResolvedValueOnce([leader(100)])
+ .mockResolvedValueOnce([leader(100, "[MainThread]")])
+ .mockResolvedValueOnce([leader(102)])
+ .mockResolvedValueOnce([leader(100, "[MainThread]")])
+ .mockResolvedValueOnce([leader(101)])
+ .mockResolvedValue([leader(101)]);
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: () =>
+ Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ signalGroup,
+ },
+ ),
+ ).resolves.toEqual({ cleaned: true, signaled: true });
+ expect(signalGroup.mock.calls).toEqual([
+ [100, "SIGKILL"],
+ [101, "SIGKILL"],
+ ]);
+ });
+
+ it("stays unresolved when a processed group keeps reappearing", async () => {
+ const signalGroup = vi.fn();
+ const tables = [
+ [leader(100), leader(101)],
+ [leader(100)],
+ [leader(100, "[MainThread]")],
+ [leader(102)],
+ [leader(100, "[MainThread]")],
+ [leader(101)],
+ [leader(100, "[MainThread]")],
+ [leader(102)],
+ [leader(100, "[MainThread]")],
+ [leader(101)],
+ ];
+ let reads = 0;
+ const listProcesses = vi.fn(() => {
+ const table = tables[reads] ?? [leader(101)];
+ reads += 1;
+ return Promise.resolve(table);
+ });
+ await expect(
+ cleanupOwnedProcessGroup(
+ { ...ownership, expectedCommand: "fixture" },
+ {
+ listProcesses,
+ environment: () =>
+ Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
+ signalGroup,
+ },
+ ),
+ ).resolves.toMatchObject({
+ cleaned: false,
+ reason: "owned process group 101 reappeared after revalidation",
+ });
+ expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
+ });
+});
+
describe("owned process-group cleanup validation: launcher exit races", () => {
it.each([101, 999])(
"retains cleanup uncertainty for a detached descendant in group %s after launcher exit",
diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts
index fd287a031f..24608c2e3f 100644
--- a/src/process/ProcessOwnershipObservation.ts
+++ b/src/process/ProcessOwnershipObservation.ts
@@ -239,6 +239,27 @@ export const observeOwnedProcessLineage = async (
return unavailableLineage(ownership, identityFailure);
const descendants = descendantsOf([launcher.pid], processes);
const verifiedDescendants: ProcessTableEntry[] = [];
+ // Seed every initial row. A later refresh reads a row again when its parent,
+ // group, or launcher-tree membership changes. A row whose token was read is
+ // read again on every later refresh: parent and group are not a generation.
+ const initialTree = new Set(descendants.map(({ pid }) => pid));
+ const initialRelations = new Map();
+ const examinedRefresh = new Map();
+ for (const process of processes) {
+ if (process.pid === launcher.pid) continue;
+ const relation: ExaminedLineageRefresh = {
+ parentPid: process.parentPid,
+ processGroupId: process.processGroupId,
+ inTree: initialTree.has(process.pid),
+ tokenChecked: false,
+ descendant: undefined,
+ };
+ initialRelations.set(process.pid, relation);
+ examinedRefresh.set(process.pid, relation);
+ }
+ let refreshDescendants: readonly ProcessTableEntry[] = [];
+ let suppressedRefreshPids = new Set();
+ let latestRefreshPids: ReadonlySet | undefined;
for await (const { process: member, observation } of readProcessRunTokens(
host,
[launcher, ...descendants],
@@ -250,19 +271,43 @@ export const observeOwnedProcessLineage = async (
"process lineage contains an unowned or PID-reused process",
);
if (member.pid !== launcher.pid) verifiedDescendants.push(member);
+ const seeded = examinedRefresh.get(member.pid);
+ if (seeded !== undefined)
+ examinedRefresh.set(member.pid, { ...seeded, tokenChecked: true });
} else {
- if (await processIsGone(host, member.pid)) {
- if (member.pid === launcher.pid)
- return unavailableLineage(
- ownership,
- "owned launcher exited during lineage validation",
- );
- continue;
+ let refreshed: readonly ProcessTableEntry[];
+ try {
+ refreshed = liveProcesses(await host.listProcesses());
+ } catch (cause: unknown) {
+ void cause;
+ return unavailableLineage(
+ ownership,
+ `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
+ );
}
- return unavailableLineage(
+ if (refreshed.some((process) => process.pid === member.pid))
+ return unavailableLineage(
+ ownership,
+ `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
+ );
+ if (member.pid === launcher.pid)
+ return unavailableLineage(
+ ownership,
+ "owned launcher exited during lineage validation",
+ );
+ const classified = await classifyIdentitylessLineageRefresh(
ownership,
- `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
+ launcher.pid,
+ refreshed,
+ host,
+ examinedRefresh,
+ initialRelations,
);
+ if (classified.status === "unavailable")
+ return unavailableLineage(ownership, classified.reason);
+ refreshDescendants = classified.descendants;
+ suppressedRefreshPids = new Set(classified.suppressedPids);
+ latestRefreshPids = new Set(refreshed.map(({ pid }) => pid));
}
}
return {
@@ -273,7 +318,15 @@ export const observeOwnedProcessLineage = async (
launcherPid: launcher.pid,
launcherParentPid: launcher.parentPid,
processGroupId: launcher.processGroupId,
- descendants: verifiedDescendants
+ descendants: [
+ ...verifiedDescendants.filter(
+ ({ pid }) =>
+ (latestRefreshPids === undefined || latestRefreshPids.has(pid)) &&
+ !suppressedRefreshPids.has(pid) &&
+ !refreshDescendants.some((descendant) => descendant.pid === pid),
+ ),
+ ...refreshDescendants,
+ ]
.sort((left, right) => left.pid - right.pid)
.map(({ pid, parentPid, processGroupId }) => ({
pid,
@@ -431,6 +484,145 @@ const observeNativeOwnedProcessLineage = async (
};
};
+/** Relation already classified for one PID seen by an identityless refresh. */
+interface ExaminedLineageRefresh {
+ readonly parentPid: number;
+ readonly processGroupId: number;
+ /** True when this row was a launcher descendant at this classification. */
+ readonly inTree: boolean;
+ /** True after this refresh has read the row's run token. */
+ readonly tokenChecked: boolean;
+ readonly descendant: ProcessTableEntry | undefined;
+}
+
+/**
+ * A liveness refresh is a process table, not a boolean. New rows still under
+ * the launcher join the published lineage after a matching token. A matching
+ * token outside that tree, or an unreadable live row, blocks a verified result.
+ * A readable foreign token outside the tree is not part of this run. A row is
+ * read again when its parent, process group, or launcher-tree membership
+ * changes. A row whose token was already read is read again on every later
+ * refresh, because those fields do not identify a process generation.
+ * Unchanged rows from the initial snapshot are not read again. The launcher
+ * is read on every refresh; a missing, unowned, or relation-mismatched
+ * launcher PID is unavailable.
+ */
+const classifyIdentitylessLineageRefresh = async (
+ ownership: OwnedProcessGroup,
+ launcherPid: number,
+ refreshed: readonly ProcessTableEntry[],
+ host: ProcessOwnershipHost,
+ examined: Map,
+ initialRelations: ReadonlyMap,
+): Promise<
+ | {
+ readonly status: "classified";
+ readonly descendants: readonly ProcessTableEntry[];
+ readonly suppressedPids: readonly number[];
+ }
+ | { readonly status: "unavailable"; readonly reason: string }
+> => {
+ const refreshedLauncher = refreshed.find(
+ (process) => process.pid === launcherPid,
+ );
+ if (refreshedLauncher === undefined)
+ return {
+ status: "unavailable",
+ reason: "owned launcher exited during lineage validation",
+ };
+ for await (const { observation } of readProcessRunTokens(host, [
+ refreshedLauncher,
+ ])) {
+ if (observation.state !== "readable")
+ return {
+ status: "unavailable",
+ reason: `process ownership could not be revalidated for PID ${String(launcherPid)}: ${observation.reason}`,
+ };
+ if (observation.runId !== ownership.runId)
+ return {
+ status: "unavailable",
+ reason: "process lineage contains an unowned or PID-reused process",
+ };
+ }
+ const identityFailure = launcherIdentityFailure(refreshedLauncher, ownership);
+ if (identityFailure !== null)
+ return { status: "unavailable", reason: identityFailure };
+ const inTree = new Set(
+ descendantsOf([launcherPid], refreshed).map(({ pid }) => pid),
+ );
+ const due = refreshed.filter((process) => {
+ if (process.pid === launcherPid) return false;
+ const previous = examined.get(process.pid);
+ return (
+ previous === undefined ||
+ previous.tokenChecked ||
+ previous.parentPid !== process.parentPid ||
+ previous.processGroupId !== process.processGroupId ||
+ previous.inTree !== inTree.has(process.pid)
+ );
+ });
+ if (due.length > 0) {
+ const included = new Map();
+ const ownedOutside: number[] = [];
+ for await (const { process, observation } of readProcessRunTokens(
+ host,
+ due,
+ )) {
+ if (observation.state !== "readable")
+ return {
+ status: "unavailable",
+ reason: `process ownership could not be revalidated for PID ${String(process.pid)}: ${observation.reason}`,
+ };
+ if (observation.runId !== ownership.runId) {
+ if (inTree.has(process.pid))
+ return {
+ status: "unavailable",
+ reason: "process lineage contains an unowned or PID-reused process",
+ };
+ continue;
+ }
+ if (inTree.has(process.pid)) included.set(process.pid, process);
+ else ownedOutside.push(process.pid);
+ }
+ if (ownedOutside.length > 0)
+ return {
+ status: "unavailable",
+ reason: `process lineage refresh contained owned PID(s) outside the observed descendants: ${ownedOutside
+ .sort((left, right) => left - right)
+ .join(", ")}`,
+ };
+ for (const process of due)
+ examined.set(process.pid, {
+ parentPid: process.parentPid,
+ processGroupId: process.processGroupId,
+ inTree: inTree.has(process.pid),
+ tokenChecked: true,
+ descendant: included.get(process.pid),
+ });
+ }
+ const livePids = new Set(refreshed.map(({ pid }) => pid));
+ for (const pid of examined.keys())
+ if (!livePids.has(pid)) examined.delete(pid);
+ const suppressedPids = [...examined.keys()].filter((pid) => {
+ const seed = initialRelations.get(pid);
+ const entry = examined.get(pid);
+ return (
+ seed !== undefined &&
+ entry !== undefined &&
+ (seed.parentPid !== entry.parentPid ||
+ seed.processGroupId !== entry.processGroupId ||
+ seed.inTree !== entry.inTree)
+ );
+ });
+ return {
+ status: "classified",
+ descendants: [...examined.values()].flatMap((entry) =>
+ entry.descendant === undefined ? [] : [entry.descendant],
+ ),
+ suppressedPids,
+ };
+};
+
const processWithNativeRelation = (
process: ProcessTableEntry,
identity: ProcessIdentityObservation,
From 08f4ba99ce4b5e53f72f00eba50ff74e9f1e1b24 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:56:36 +0800
Subject: [PATCH 03/45] test(process): share lineage fixtures to restore the
duplication gate
Extract repeated process rows, environment transitions and exact lineage expectations while retaining all identity, replacement-read and cancellation cases. Clean main exceeded the configured 3.5% duplication limit at 3.521867%; this refactor passes at 3.499188% without changing the limit.
Validation: 72 focused process-ownership tests and the full check:ci gate passed. Independent source review found no assertion or coverage loss. No runtime behavior changes.
---
src/process/ProcessOwnership.fixture.ts | 20 +-
src/process/ProcessOwnership.part2.test.ts | 26 +-
src/process/ProcessOwnership.part3.test.ts | 426 +++++-------------
.../ProcessOwnership.validation.test.ts | 68 ++-
4 files changed, 172 insertions(+), 368 deletions(-)
diff --git a/src/process/ProcessOwnership.fixture.ts b/src/process/ProcessOwnership.fixture.ts
index fead795dad..af86243a71 100644
--- a/src/process/ProcessOwnership.fixture.ts
+++ b/src/process/ProcessOwnership.fixture.ts
@@ -1,7 +1,25 @@
-import { vi } from "vitest";
+import { expect, vi } from "vitest";
import type { ProcessOwnershipHost } from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
+export const verifiedLineage = (
+ descendants: readonly {
+ readonly pid: number;
+ readonly parentPid: number;
+ readonly processGroupId: number;
+ }[],
+) => ({
+ status: "verified",
+ observedAt: expect.any(String),
+ lineage: {
+ runId: "run-token",
+ launcherPid: 100,
+ launcherParentPid: 1,
+ processGroupId: 100,
+ descendants,
+ },
+});
+
export const createOwnership = () => ({
runId: "run-token",
leaderPid: 100,
diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts
index 9984ccfbe8..e88fb64799 100644
--- a/src/process/ProcessOwnership.part2.test.ts
+++ b/src/process/ProcessOwnership.part2.test.ts
@@ -5,7 +5,11 @@ import {
type ProcessOwnershipHost,
} from "./ProcessOwnership.js";
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
-import { createOwnership, host } from "./ProcessOwnership.fixture.js";
+import {
+ createOwnership,
+ host,
+ verifiedLineage,
+} from "./ProcessOwnership.fixture.js";
let ownership = createOwnership();
beforeEach(() => {
@@ -173,20 +177,12 @@ describe("owned process-group cleanup discovery", () => {
});
await expect(
observeOwnedProcessLineage(ownership, adapter),
- ).resolves.toEqual({
- status: "verified",
- observedAt: expect.any(String),
- lineage: {
- runId: "run-token",
- launcherPid: 100,
- launcherParentPid: 1,
- processGroupId: 100,
- descendants: [
- { pid: 101, parentPid: 100, processGroupId: 100 },
- { pid: 102, parentPid: 100, processGroupId: 100 },
- ],
- },
- });
+ ).resolves.toEqual(
+ verifiedLineage([
+ { pid: 101, parentPid: 100, processGroupId: 100 },
+ { pid: 102, parentPid: 100, processGroupId: 100 },
+ ]),
+ );
});
});
diff --git a/src/process/ProcessOwnership.part3.test.ts b/src/process/ProcessOwnership.part3.test.ts
index 875d87a0ed..6707deb12c 100644
--- a/src/process/ProcessOwnership.part3.test.ts
+++ b/src/process/ProcessOwnership.part3.test.ts
@@ -4,11 +4,57 @@ import type {
ProcessTableEntry,
} from "./ProcessOwnership.js";
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
+import { verifiedLineage } from "./ProcessOwnership.fixture.js";
const ownership = {
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
};
+const row = (
+ pid: number,
+ parentPid: number,
+ processGroupId = pid,
+ command = `pid-${String(pid)}`,
+): ProcessTableEntry => ({
+ pid,
+ parentPid,
+ processGroupId,
+ state: "S",
+ command,
+});
+const environmentForExited = (...pids: number[]) =>
+ vi.fn((pid: number) =>
+ pids.includes(pid)
+ ? Promise.reject(new Error("process exited during environment read"))
+ : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ );
+const environmentForReplacementReads = (
+ runIdAtRead: (read: number) => string | undefined,
+) => {
+ let reads = 0;
+ const environment = vi.fn((pid: number) => {
+ if (pid === 101 || pid === 103)
+ return Promise.reject(
+ new Error("process exited during environment read"),
+ );
+ if (pid !== 102)
+ return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
+ reads += 1;
+ const runId = runIdAtRead(reads);
+ return Promise.resolve(runId ? { REA_PROCESS_RUN_ID: runId } : {});
+ });
+ return { environment, reads: () => reads };
+};
+const observeLineage = (
+ listProcesses: ProcessOwnershipHost["listProcesses"],
+ environment: ProcessOwnershipHost["environment"],
+ processOwnership = ownership,
+) =>
+ observeOwnedProcessLineage(processOwnership, {
+ listProcesses,
+ environment,
+ signalGroup: vi.fn(),
+ });
describe("owned process-group cleanup discovery", () => {
it("walks the PPID tree across process-group boundaries", async () => {
const environment = vi.fn((pid: number) =>
@@ -53,20 +99,12 @@ describe("owned process-group cleanup discovery", () => {
};
await expect(
observeOwnedProcessLineage(ownership, adapter),
- ).resolves.toEqual({
- status: "verified",
- observedAt: expect.any(String),
- lineage: {
- runId: "run-token",
- launcherPid: 100,
- launcherParentPid: 1,
- processGroupId: 100,
- descendants: [
- { pid: 101, parentPid: 100, processGroupId: 101 },
- { pid: 102, parentPid: 101, processGroupId: 102 },
- ],
- },
- });
+ ).resolves.toEqual(
+ verifiedLineage([
+ { pid: 101, parentPid: 100, processGroupId: 101 },
+ { pid: 102, parentPid: 101, processGroupId: 102 },
+ ]),
+ );
});
it("omits a descendant that exits while lineage is validated", async () => {
const launcher = {
@@ -90,10 +128,7 @@ describe("owned process-group cleanup discovery", () => {
.mockResolvedValueOnce([launcher]);
const host: ProcessOwnershipHost = {
listProcesses,
- environment: (pid) =>
- pid === 101
- ? Promise.reject(new Error("process exited during environment read"))
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ environment: environmentForExited(101),
signalGroup: vi.fn(),
};
@@ -113,44 +148,16 @@ describe("owned process-group cleanup discovery", () => {
describe("identityless lineage refresh keeps replacement rows", () => {
it("does not publish an empty lineage when exit refresh reveals a token-owned replacement", async () => {
- const launcher = {
- pid: 100,
- parentPid: 1,
- processGroupId: 100,
- state: "S",
- command: "root",
- };
- const child = {
- pid: 101,
- parentPid: 100,
- processGroupId: 100,
- state: "S",
- command: "child",
- };
- const replacement = {
- pid: 102,
- parentPid: 1,
- processGroupId: 102,
- state: "S",
- command: "reparented-grandchild",
- };
- const environment = vi.fn((pid: number) => {
- if (pid === 101)
- return Promise.reject(
- new Error("process exited during environment read"),
- );
- return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
- });
+ const launcher = row(100, 1, 100, "root");
+ const child = row(101, 100, 100, "child");
+ const replacement = row(102, 1, 102, "reparented-grandchild");
+ const environment = environmentForExited(101);
const listProcesses = vi
.fn()
.mockResolvedValueOnce([launcher, child])
.mockResolvedValue([launcher, replacement]);
await expect(
- observeOwnedProcessLineage(ownership, {
- listProcesses,
- environment,
- signalGroup: vi.fn(),
- }),
+ observeLineage(listProcesses, environment),
).resolves.toMatchObject({
status: "unavailable",
reason:
@@ -160,42 +167,15 @@ describe("identityless lineage refresh keeps replacement rows", () => {
});
it("publishes a descendant that appears in the exit refresh and still belongs to the launcher", async () => {
- const launcher = {
- pid: 100,
- parentPid: 1,
- processGroupId: 100,
- state: "S",
- command: "root",
- };
- const child = {
- pid: 101,
- parentPid: 100,
- processGroupId: 100,
- state: "S",
- command: "child",
- };
- const grandchild = {
- pid: 102,
- parentPid: 100,
- processGroupId: 102,
- state: "S",
- command: "grandchild",
- };
+ const launcher = row(100, 1, 100, "root");
+ const child = row(101, 100, 100, "child");
+ const grandchild = row(102, 100, 102, "grandchild");
const listProcesses = vi
.fn()
.mockResolvedValueOnce([launcher, child])
.mockResolvedValue([launcher, grandchild]);
await expect(
- observeOwnedProcessLineage(ownership, {
- listProcesses,
- environment: (pid) =>
- pid === 101
- ? Promise.reject(
- new Error("process exited during environment read"),
- )
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
- signalGroup: vi.fn(),
- }),
+ observeLineage(listProcesses, environmentForExited(101)),
).resolves.toMatchObject({
status: "verified",
lineage: {
@@ -206,18 +186,6 @@ describe("identityless lineage refresh keeps replacement rows", () => {
});
describe("identityless lineage refresh ignores the prior process table", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command: `pid-${String(pid)}`,
- });
-
it("does not token-read a pre-existing row when a descendant exits", async () => {
const environment = vi.fn((pid: number) =>
pid === 101
@@ -231,11 +199,7 @@ describe("identityless lineage refresh ignores the prior process table", () => {
.mockResolvedValueOnce([row(100, 1, 100), row(101, 100, 100), row(50, 1)])
.mockResolvedValue([row(100, 1, 100), row(50, 1)]);
await expect(
- observeOwnedProcessLineage(ownership, {
- listProcesses,
- environment,
- signalGroup: vi.fn(),
- }),
+ observeLineage(listProcesses, environment),
).resolves.toMatchObject({
status: "verified",
lineage: { descendants: [] },
@@ -244,19 +208,9 @@ describe("identityless lineage refresh ignores the prior process table", () => {
});
it("re-reads a refresh row after it moves under the launcher", async () => {
- let replacementReads = 0;
- const environment = vi.fn((pid: number) => {
- if (pid === 101 || pid === 103)
- return Promise.reject(
- new Error("process exited during environment read"),
- );
- if (pid !== 102)
- return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
- replacementReads += 1;
- return Promise.resolve(
- replacementReads === 1 ? {} : { REA_PROCESS_RUN_ID: "run-token" },
- );
- });
+ const { environment, reads } = environmentForReplacementReads((read) =>
+ read === 1 ? undefined : "run-token",
+ );
const outside = row(102, 1, 102);
const joined = row(102, 100, 102);
const listProcesses = vi
@@ -265,50 +219,26 @@ describe("identityless lineage refresh ignores the prior process table", () => {
.mockResolvedValueOnce([row(100, 1, 100), row(103, 100), outside])
.mockResolvedValue([row(100, 1, 100), joined]);
await expect(
- observeOwnedProcessLineage(ownership, {
- listProcesses,
- environment,
- signalGroup: vi.fn(),
- }),
+ observeLineage(listProcesses, environment),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }],
},
});
- expect(replacementReads).toBe(2);
+ expect(reads()).toBe(2);
});
});
describe("identityless lineage refresh follows tree membership", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command: `pid-${String(pid)}`,
- });
-
it("token-checks an initial outsider after it joins the launcher", async () => {
- const environment = vi.fn((pid: number) =>
- pid === 101
- ? Promise.reject(new Error("process exited during environment read"))
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
- );
+ const environment = environmentForExited(101);
const listProcesses = vi
.fn()
.mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(102, 1)])
.mockResolvedValue([row(100, 1, 100), row(102, 100)]);
await expect(
- observeOwnedProcessLineage(ownership, {
- listProcesses,
- environment,
- signalGroup: vi.fn(),
- }),
+ observeLineage(listProcesses, environment),
).resolves.toMatchObject({
status: "verified",
lineage: {
@@ -362,19 +292,9 @@ describe("identityless lineage refresh follows tree membership", () => {
});
it("re-reads a cached descendant when its relation is unchanged", async () => {
- let replacementReads = 0;
- const environment = vi.fn((pid: number) => {
- if (pid === 101 || pid === 103)
- return Promise.reject(
- new Error("process exited during environment read"),
- );
- if (pid !== 102)
- return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
- replacementReads += 1;
- return Promise.resolve(
- replacementReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {},
- );
- });
+ const { environment, reads } = environmentForReplacementReads((read) =>
+ read === 1 ? "run-token" : undefined,
+ );
const descendant = row(102, 100, 102);
const listProcesses = vi
.fn()
@@ -391,23 +311,11 @@ describe("identityless lineage refresh follows tree membership", () => {
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
});
- expect(replacementReads).toBe(2);
+ expect(reads()).toBe(2);
});
});
describe("identityless lineage refresh drops exited descendants", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command: `pid-${String(pid)}`,
- });
-
it("omits a validated descendant that is gone from the latest refresh", async () => {
const environment = vi.fn((pid: number) =>
pid === 101 || pid === 103
@@ -424,11 +332,7 @@ describe("identityless lineage refresh drops exited descendants", () => {
])
.mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(103, 100)])
.mockResolvedValue([row(100, 1, 100)]);
- const result = await observeOwnedProcessLineage(ownership, {
- listProcesses,
- environment,
- signalGroup: vi.fn(),
- });
+ const result = await observeLineage(listProcesses, environment);
expect(result.status).toBe("verified");
if (result.status !== "verified") return;
expect(result.lineage.descendants).toEqual([]);
@@ -436,121 +340,48 @@ describe("identityless lineage refresh drops exited descendants", () => {
});
describe("identityless lineage refresh rechecks the launcher relation", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- command = `pid-${String(pid)}`,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command,
- });
-
- it("rejects a same-token launcher whose process group changed", async () => {
- const listProcesses = vi
- .fn()
- .mockResolvedValueOnce([
- row(100, 1, 100, "root"),
- row(102, 100),
- row(101, 100),
- ])
- .mockResolvedValue([row(100, 1, 999, "root"), row(102, 100)]);
- await expect(
- observeOwnedProcessLineage(
- { ...ownership, expectedCommand: "root" },
- {
+ it.each([
+ [
+ "process group",
+ row(100, 1, 999, "root"),
+ { ...ownership, expectedCommand: "root" },
+ "owned launcher process-group identity did not match",
+ ],
+ [
+ "command",
+ row(100, 1, 100, "replacement"),
+ { ...ownership, expectedCommand: "root" },
+ "owned launcher command identity did not match (observed=replacement; expected=root)",
+ ],
+ [
+ "parent",
+ row(100, 7, 100, "root"),
+ { ...ownership, expectedParentPid: 1, expectedCommand: "root" },
+ "owned launcher parent identity did not match",
+ ],
+ ] as const)(
+ "rejects a same-token launcher whose %s changed",
+ async (_identity, currentLauncher, processOwnership, reason) => {
+ const listProcesses = vi
+ .fn()
+ .mockResolvedValueOnce([
+ row(100, 1, 100, "root"),
+ row(102, 100),
+ row(101, 100),
+ ])
+ .mockResolvedValue([currentLauncher, row(102, 100)]);
+ await expect(
+ observeLineage(
listProcesses,
- environment: (pid) =>
- pid === 101
- ? Promise.reject(
- new Error("process exited during environment read"),
- )
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
- signalGroup: vi.fn(),
- },
- ),
- ).resolves.toMatchObject({
- status: "unavailable",
- reason: "owned launcher process-group identity did not match",
- });
- });
-
- it("rejects a same-token launcher whose command changed", async () => {
- const listProcesses = vi
- .fn()
- .mockResolvedValueOnce([
- row(100, 1, 100, "root"),
- row(102, 100),
- row(101, 100),
- ])
- .mockResolvedValue([row(100, 1, 100, "replacement"), row(102, 100)]);
- await expect(
- observeOwnedProcessLineage(
- { ...ownership, expectedCommand: "root" },
- {
- listProcesses,
- environment: (pid) =>
- pid === 101
- ? Promise.reject(
- new Error("process exited during environment read"),
- )
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
- signalGroup: vi.fn(),
- },
- ),
- ).resolves.toMatchObject({
- status: "unavailable",
- reason:
- "owned launcher command identity did not match (observed=replacement; expected=root)",
- });
- });
-
- it("rejects a same-token launcher whose parent changed", async () => {
- const listProcesses = vi
- .fn()
- .mockResolvedValueOnce([
- row(100, 1, 100, "root"),
- row(102, 100),
- row(101, 100),
- ])
- .mockResolvedValue([row(100, 7, 100, "root"), row(102, 100)]);
- await expect(
- observeOwnedProcessLineage(
- { ...ownership, expectedParentPid: 1, expectedCommand: "root" },
- {
- listProcesses,
- environment: (pid) =>
- pid === 101
- ? Promise.reject(
- new Error("process exited during environment read"),
- )
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
- signalGroup: vi.fn(),
- },
- ),
- ).resolves.toMatchObject({
- status: "unavailable",
- reason: "owned launcher parent identity did not match",
- });
- });
+ environmentForExited(101),
+ processOwnership,
+ ),
+ ).resolves.toMatchObject({ status: "unavailable", reason });
+ },
+ );
});
describe("identityless lineage refresh rechecks the launcher", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command: `pid-${String(pid)}`,
- });
-
it("does not publish lineage when the launcher PID is gone on a later refresh", async () => {
const listProcesses = vi
.fn()
@@ -559,12 +390,7 @@ describe("identityless lineage refresh rechecks the launcher", () => {
await expect(
observeOwnedProcessLineage(ownership, {
listProcesses,
- environment: (pid) =>
- pid === 101
- ? Promise.reject(
- new Error("process exited during environment read"),
- )
- : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
+ environment: environmentForExited(101),
signalGroup: vi.fn(),
}),
).resolves.toMatchObject({
@@ -606,18 +432,6 @@ describe("identityless lineage refresh rechecks the launcher", () => {
});
describe("identityless lineage refresh rechecks an initial descendant", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command: `pid-${String(pid)}`,
- });
-
it("rejects a reused PID after an earlier descendant token matched", async () => {
let reads = 0;
const environment = vi.fn((pid: number) => {
@@ -652,18 +466,6 @@ describe("identityless lineage refresh rechecks an initial descendant", () => {
});
describe("identityless lineage refresh publishes one row per PID", () => {
- const row = (
- pid: number,
- parentPid: number,
- processGroupId = pid,
- ): ProcessTableEntry => ({
- pid,
- parentPid,
- processGroupId,
- state: "S",
- command: `pid-${String(pid)}`,
- });
-
it("keeps a single row when a descendant returns to its original group", async () => {
const environment = vi.fn((pid: number) =>
pid === 101 || pid === 104
diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts
index 48b05522fe..e7fa1ce7e9 100644
--- a/src/process/ProcessOwnership.validation.test.ts
+++ b/src/process/ProcessOwnership.validation.test.ts
@@ -24,6 +24,24 @@ const launcher: ProcessTableEntry = {
state: "S",
command: "fixture",
};
+const nonSignalingObserver = (
+ listProcesses: ProcessOwnershipHost["listProcesses"],
+): ProcessOwnershipHost => ({
+ listProcesses,
+ environment: () => Promise.resolve({}),
+ signalGroup: () => {
+ throw new Error("Observation must never signal processes");
+ },
+});
+const expectUnownedLineage = (adapter: ProcessOwnershipHost) =>
+ expect(observeOwnedProcessLineage(ownership, adapter)).resolves.toEqual({
+ status: "unavailable",
+ observedAt: expect.any(String),
+ runId: "run-token",
+ launcherPid: 100,
+ processGroupId: 100,
+ reason: "process lineage contains an unowned or PID-reused process",
+ });
describe("owned process-group cleanup validation: ownership and lineage", () => {
it("settles unreadable observations for Darwin processes exiting in the kernel", async () => {
@@ -79,32 +97,14 @@ describe("owned process-group cleanup validation: ownership and lineage", () =>
}),
signalGroup: vi.fn(),
};
- await expect(
- observeOwnedProcessLineage(ownership, adapter),
- ).resolves.toEqual({
- status: "unavailable",
- observedAt: expect.any(String),
- runId: "run-token",
- launcherPid: 100,
- processGroupId: 100,
- reason: "process lineage contains an unowned or PID-reused process",
- });
+ await expectUnownedLineage(adapter);
});
it("does not publish lineage when any member fails ownership checks", async () => {
const { adapter } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },
101: { REA_PROCESS_RUN_ID: "other-run" },
});
- await expect(
- observeOwnedProcessLineage(ownership, adapter),
- ).resolves.toEqual({
- status: "unavailable",
- observedAt: expect.any(String),
- runId: "run-token",
- launcherPid: 100,
- processGroupId: 100,
- reason: "process lineage contains an unowned or PID-reused process",
- });
+ await expectUnownedLineage(adapter);
});
it("fails closed for stale metadata or an unrelated concurrent process", async () => {
const { adapter, signalGroup } = host({
@@ -1024,17 +1024,11 @@ describe("owned process-group cleanup validation: exited members", () => {
it("preserves cancellation during process-table observation instead of reporting unverifiable ownership", async () => {
const controller = new AbortController();
const reason = new Error("cancelled process observation");
- const adapter: ProcessOwnershipHost = {
- listProcesses: async (signal) => {
- controller.abort(reason);
- signal?.throwIfAborted();
- return [];
- },
- environment: () => Promise.resolve({}),
- signalGroup: () => {
- throw new Error("Observation must never signal processes");
- },
- };
+ const adapter = nonSignalingObserver(async (signal) => {
+ controller.abort(reason);
+ signal?.throwIfAborted();
+ return [];
+ });
await expect(
observeOwnedProcessGroups(
ownership.runId,
@@ -1048,15 +1042,9 @@ describe("owned process-group cleanup validation: exited members", () => {
const controller = new AbortController();
const reason = new Error("already cancelled observation");
controller.abort(reason);
- const adapter: ProcessOwnershipHost = {
- listProcesses: () => {
- throw new Error("Cancelled observation must not inspect processes");
- },
- environment: () => Promise.resolve({}),
- signalGroup: () => {
- throw new Error("Observation must never signal processes");
- },
- };
+ const adapter = nonSignalingObserver(() => {
+ throw new Error("Cancelled observation must not inspect processes");
+ });
await expect(
observeOwnedProcessGroups(
ownership.runId,
From c19c2d6723fb92548b0c8a1ed0696d721a80aae6 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:58:15 +0800
Subject: [PATCH 04/45] fix(native): separate container facts from Go metadata
admission
Move ELF, PE and Mach-O container parsing into a shared native owner. Go alone selects and validates Go build-info sections, so unrelated malformed Go metadata no longer blocks Rust dependency or IL2CPP inspection. Remove the superseded Go-specific container representation and migrate all consumers. Adapted from draft PR #5510 without asserting human PR readiness.
Validation: 51 compiled CLI/MCP acceptance tests across Rust, Go and IL2CPP; 186 shared-reader tests; full check:ci passed. Three public synthetic-file probes reproduce the baseline Rust refusal and validate the fixed SDK-advertised MCP output, artifact digests and unchanged bytes. The exact zero-address nonallocated ELF case now succeeds for Rust while Go still reports invalid_format. No target execution or real compiler-artifact qualification was performed; no performance improvement is claimed.
Closes #3398
---
src/go/GoBinaryImage.ts | 135 ++++++----------
src/go/GoBinaryProvider.ts | 12 +-
src/go/GoBuildInfoSearch.ts | 137 ++++++++++++++++
src/go/GoMachoImage.alignment.test.ts | 2 +-
src/il2cpp/ElfRelocations.ts | 20 +--
src/il2cpp/Il2cppBinaryImage.ts | 34 ++--
.../metadata/BinaryContainer.ts} | 123 ++++++++------
.../metadata/ElfImage.ts} | 151 +++++++-----------
src/native/metadata/Il2cppContainer.test.ts | 50 ++++++
.../metadata/MachoImage.ts} | 137 +++++++---------
.../metadata/PeImage.test.ts} | 45 ++++--
.../metadata/PeImage.ts} | 112 +++++++------
src/native/metadata/readBinaryContainer.ts | 48 ++++++
src/rust/RustBinaryImage.ts | 26 +--
tests/acceptance/rustBinaryCli.test.ts | 94 +++++++++++
tests/boundary/go/goBinaryImage.test.ts | 48 +++++-
tests/boundary/go/goBinaryMappings.test.ts | 2 +-
tests/boundary/rust/rustBinaryImage.test.ts | 48 ++++++
tests/fixtures/rust/image.ts | 72 +++++++++
19 files changed, 876 insertions(+), 420 deletions(-)
create mode 100644 src/go/GoBuildInfoSearch.ts
rename src/{go/GoBinaryContainer.ts => native/metadata/BinaryContainer.ts} (69%)
rename src/{go/GoElfImage.ts => native/metadata/ElfImage.ts} (67%)
create mode 100644 src/native/metadata/Il2cppContainer.test.ts
rename src/{go/GoMachoImage.ts => native/metadata/MachoImage.ts} (68%)
rename src/{go/GoPeImage.test.ts => native/metadata/PeImage.test.ts} (55%)
rename src/{go/GoPeImage.ts => native/metadata/PeImage.ts} (70%)
create mode 100644 src/native/metadata/readBinaryContainer.ts
diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts
index c9e2366c8c..8519b97451 100644
--- a/src/go/GoBinaryImage.ts
+++ b/src/go/GoBinaryImage.ts
@@ -1,25 +1,14 @@
import {
- GoBinaryFormatFailure,
- GoBinaryResourceFailure,
- GoBinaryReader,
- goUtf8,
+ BinaryFormatFailure,
+ BinaryResourceFailure,
+ BinaryReader,
mappedFileOffset,
isZeroFilledSpan,
- type GoBinaryContainer,
- type GoFileMapping,
-} from "./GoBinaryContainer.js";
-import {
- classifyFatContainerIdentity,
- readFatContainerHeader,
-} from "../domain/apple/machoContainer.js";
-import { readGoElfImage } from "./GoElfImage.js";
-import { readGoMachoImage } from "./GoMachoImage.js";
-import { readGoPeImage } from "./GoPeImage.js";
-
-export {
- GoBinaryFormatFailure,
- GoBinaryResourceFailure,
-} from "./GoBinaryContainer.js";
+ type BinaryContainer,
+ type FileMapping,
+} from "../native/metadata/BinaryContainer.js";
+import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
+import { goBuildInfoSearch } from "./GoBuildInfoSearch.js";
const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex");
const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex");
@@ -56,19 +45,19 @@ export interface GoBinaryImage {
/** Inspect bounded executable bytes using native mappings, without loading or executing code. */
export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
- const container = readGoBinaryContainer(bytes);
+ const container = readBinaryContainer(bytes);
const { format, architecture, bits, byte_order } = container;
- let selected: { search: GoFileMapping; header: number } | null = null;
+ let selected: { search: FileMapping; header: number } | null = null;
const searched = new Set();
let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES;
- for (const search of container.search) {
+ for (const search of goBuildInfoSearch(bytes, container)) {
// Identical file spans and alignment inspect the same candidate bytes.
// Partially overlapping spans still consume a cumulative work budget.
const identity = `${search.offset}:${search.size}:${search.address % 16n}`;
if (searched.has(identity)) continue;
searched.add(identity);
if (search.size > remainingSearchBytes)
- throw new GoBinaryResourceFailure(
+ throw new BinaryResourceFailure(
"search",
MAX_HEADER_SEARCH_BYTES,
"Go build-info discovery exceeds the cumulative 256 MiB header-search budget",
@@ -93,22 +82,22 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
container.zero_fills,
) !== header
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info bytes disagree with their virtual mapping",
);
};
verifyMapping(32);
- const reader = new GoBinaryReader(bytes, byte_order === "little");
+ const reader = new BinaryReader(bytes, byte_order === "little");
const ptrSize = bytes[header + 14];
const flags = bytes[header + 15] ?? 0;
if ((flags & ~3) !== 0)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"unsupported",
"Go build-info flags contain an unsupported format bit",
);
if ((flags & 2) === 0 && (flags & 1) !== (byte_order === "big" ? 1 : 0))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info byte order disagrees with the image",
);
@@ -125,12 +114,12 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
verifyMapping(module.location.offset + module.location.bytes - header);
} else {
if (ptrSize !== 4 && ptrSize !== 8)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info pointer width must be 4 or 8",
);
if (ptrSize * 8 !== bits)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info pointer width disagrees with the image",
);
@@ -149,13 +138,13 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
);
}
if (version.location.bytes + module.location.bytes > MAX_BUILD_INFO_BYTES)
- throw new GoBinaryResourceFailure(
+ throw new BinaryResourceFailure(
"build-info",
MAX_BUILD_INFO_BYTES,
"Go build-info strings exceed the aggregate 1 MiB limit",
);
if (version.bytes.length === 0)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info compiler version is empty",
);
@@ -168,7 +157,7 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
!module.bytes.subarray(-16).equals(MODULE_END) ||
module.bytes[module.bytes.length - 17] !== 10
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go module string framing is invalid",
);
@@ -194,9 +183,9 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
const observedText = (bytes: Buffer): string | null => {
try {
- return goUtf8(bytes, "Go build string");
+ return observedUtf8(bytes, "Go build string");
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return null;
+ if (cause instanceof BinaryFormatFailure) return null;
throw cause;
}
};
@@ -206,45 +195,7 @@ interface LocatedString {
readonly location: GoStringLocation;
}
-/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */
-export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => {
- if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
- return readGoElfImage(bytes);
- if (bytes.subarray(0, 2).equals(Buffer.from("MZ")))
- return readGoPeImage(bytes);
- if (bytes.length >= 4) {
- const magic = bytes.readUInt32BE();
- if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic)) {
- const fatHeader = readFatContainerHeader(bytes, bytes.length);
- if (fatHeader.state === "malformed")
- throw new GoBinaryFormatFailure("malformed", fatHeader.reason);
- const identity =
- fatHeader.state === "parsed"
- ? classifyFatContainerIdentity(fatHeader, (offset) => {
- if (offset >= bytes.length) return undefined;
- if (offset + 4 > bytes.length) return null;
- return bytes.readUInt32BE(offset);
- })
- : undefined;
- // An invalid FAT-shaped table is not proof of a Mach-O container: JVM
- // constant-pool bytes can share both the magic and a known CPU value.
- const confirmedFatMachO = identity?.state === "mach-o";
- if (confirmedFatMachO)
- throw new GoBinaryFormatFailure(
- "unsupported",
- "Universal/fat Mach-O containers require selecting a thin slice",
- );
- }
- if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
- return readGoMachoImage(bytes);
- }
- throw new GoBinaryFormatFailure(
- "unsupported",
- "Only ELF, PE, and thin Mach-O native images are supported",
- );
-};
-
-const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => {
+const findHeader = (bytes: Buffer, search: FileMapping): number | null => {
if (search.size === 0) return null;
const end = search.offset + search.size;
const selected = bytes.subarray(search.offset, end);
@@ -256,7 +207,7 @@ const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => {
const address = search.address + BigInt(candidate - search.offset);
if (address % 16n === 0n) {
if (end - candidate < 32)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info header is truncated",
);
@@ -268,34 +219,34 @@ const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => {
};
const readInlineString = (
- reader: GoBinaryReader,
+ reader: BinaryReader,
offset: number,
remaining: number,
): LocatedString => {
let length = 0n;
for (let index = 0; index < 10; index++) {
if (index >= remaining)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info varint is truncated",
);
const byte = reader.bytes[offset + index] ?? 0;
if (index === 9 && byte > 1)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info varint overflows 64 bits",
);
length |= BigInt(byte & 127) << BigInt(index * 7);
if ((byte & 128) === 0) {
if (length > BigInt(MAX_BUILD_INFO_BYTES))
- throw new GoBinaryResourceFailure(
+ throw new BinaryResourceFailure(
"build-info",
MAX_BUILD_INFO_BYTES,
"Go build-info string length exceeds the 1 MiB limit",
);
const size = Number(length);
if (size > remaining - index - 1)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info string range is truncated",
);
@@ -306,15 +257,15 @@ const readInlineString = (
};
}
}
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Go build-info varint is unterminated",
);
};
const readPointerString = (
- reader: GoBinaryReader,
- container: GoBinaryContainer,
+ reader: BinaryReader,
+ container: BinaryContainer,
address: bigint,
): LocatedString => {
const { mappings, zero_fills: zeroFills, bits } = container;
@@ -323,7 +274,7 @@ const readPointerString = (
const valueAddress = reader.word(header, bits);
const length = reader.word(header + width, bits);
if (length > BigInt(MAX_BUILD_INFO_BYTES))
- throw new GoBinaryResourceFailure(
+ throw new BinaryResourceFailure(
"build-info",
MAX_BUILD_INFO_BYTES,
"Go build-info pointer string length exceeds the 1 MiB limit",
@@ -341,8 +292,8 @@ const readPointerString = (
};
const readOptionalModuleString = (
- reader: GoBinaryReader,
- container: GoBinaryContainer,
+ reader: BinaryReader,
+ container: BinaryContainer,
address: bigint,
pointerField: number,
): LocatedString =>
@@ -354,3 +305,17 @@ const readOptionalModuleString = (
)
? { bytes: Buffer.alloc(0), location: { offset: pointerField, bytes: 0 } }
: readPointerString(reader, container, address);
+
+/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */
+export const observedUtf8 = (bytes: Buffer, label: string): string => {
+ try {
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
+ bytes,
+ );
+ } catch (cause) {
+ throw new BinaryFormatFailure(
+ "malformed",
+ `${label} contains malformed UTF-8: ${String(cause)}`,
+ );
+ }
+};
diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts
index 7bbeb5c1d5..9488f07d14 100644
--- a/src/go/GoBinaryProvider.ts
+++ b/src/go/GoBinaryProvider.ts
@@ -30,11 +30,11 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
+import { readGoBinaryImage } from "./GoBinaryImage.js";
import {
- readGoBinaryImage,
- GoBinaryFormatFailure,
- GoBinaryResourceFailure,
-} from "./GoBinaryImage.js";
+ BinaryFormatFailure,
+ BinaryResourceFailure,
+} from "../native/metadata/BinaryContainer.js";
import { parseGoModuleBytes, parseGoModuleText } from "./GoModuleText.js";
/** Bundled format reader identity; embedded compiler versions are separate observed facts. */
@@ -57,7 +57,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
- if (cause instanceof GoBinaryResourceFailure)
+ if (cause instanceof BinaryResourceFailure)
return new AnalysisResourceConstraintError(
OPERATION,
cause.boundary === "search" ? "cpu" : "memory",
@@ -75,7 +75,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
),
},
);
- if (cause instanceof GoBinaryFormatFailure)
+ if (cause instanceof BinaryFormatFailure)
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
cause,
diff --git a/src/go/GoBuildInfoSearch.ts b/src/go/GoBuildInfoSearch.ts
new file mode 100644
index 0000000000..7227acb6f8
--- /dev/null
+++ b/src/go/GoBuildInfoSearch.ts
@@ -0,0 +1,137 @@
+import {
+ BinaryFormatFailure,
+ BinaryReader,
+ mappedFileOffset,
+ type BinaryContainer,
+ type FileMapping,
+} from "../native/metadata/BinaryContainer.js";
+
+const fallbackSearchRanges = (
+ container: BinaryContainer,
+): readonly FileMapping[] => {
+ if (container.format === "elf")
+ return (container.load_regions ?? [])
+ .filter((region) => region.write && !region.execute)
+ .map((region) => region.mapping);
+ if (container.format === "macho")
+ return (container.load_regions ?? [])
+ .filter(
+ (region) =>
+ region.mapping.address !== 0n &&
+ region.protection_masks?.current === 3 &&
+ region.protection_masks.maximum === 3,
+ )
+ .slice(0, 1)
+ .map((region) => region.mapping);
+ if (container.image_base === undefined) return [];
+ const section = container.sections.find(
+ (candidate) =>
+ candidate.format === "pe" &&
+ candidate.virtual_address !== 0 &&
+ candidate.raw_size !== 0 &&
+ (candidate.characteristics & ~0x00600000) >>> 0 === 0xc0000040,
+ );
+ return section?.format === "pe"
+ ? [
+ {
+ address: container.image_base + BigInt(section.virtual_address),
+ offset: section.offset,
+ size: section.size,
+ },
+ ]
+ : [];
+};
+
+/** Go owns build-info section selection and validity; native readers only own container facts. */
+export const goBuildInfoSearch = (
+ bytes: Buffer,
+ container: BinaryContainer,
+): readonly FileMapping[] => {
+ const reader = new BinaryReader(bytes, container.byte_order === "little");
+ const named: FileMapping[] = [];
+ for (const section of container.sections) {
+ if (section.format === "elf" && section.name === ".go.buildinfo") {
+ if (named.length !== 0)
+ throw new BinaryFormatFailure(
+ "malformed",
+ "ELF contains ambiguous build-info sections",
+ );
+ if (section.type !== 1)
+ throw new BinaryFormatFailure(
+ "malformed",
+ "ELF build-info section is not file-backed data",
+ );
+ if (section.compressed)
+ throw new BinaryFormatFailure(
+ "unsupported",
+ "Compressed ELF build-info sections are unsupported",
+ );
+ const search = {
+ ...reader.fileRange(
+ section.declared_range.offset,
+ section.declared_range.size,
+ "ELF build-info section",
+ ),
+ address: section.address,
+ };
+ if (
+ mappedFileOffset(
+ container.mappings,
+ search.address,
+ search.size,
+ container.zero_fills,
+ ) !== search.offset
+ )
+ throw new BinaryFormatFailure(
+ "malformed",
+ "ELF build-info section disagrees with its load mapping",
+ );
+ named.push(search);
+ } else if (
+ section.format === "macho" &&
+ section.section_name === "__go_buildinfo"
+ ) {
+ if (section.segment_unmapped)
+ throw new BinaryFormatFailure(
+ "malformed",
+ "Mach-O build-info section is in an unmapped segment",
+ );
+ if (section.segment_mapping === null)
+ throw new BinaryFormatFailure(
+ "malformed",
+ "Mach-O build-info section is not file mapped",
+ );
+ if ([1, 0xc, 0x12].includes(section.type))
+ throw new BinaryFormatFailure(
+ "malformed",
+ "Mach-O build-info section is zero-fill data",
+ );
+ const search = {
+ ...reader.fileRange(
+ section.declared_range.offset,
+ section.declared_range.size,
+ "Mach-O build-info section",
+ ),
+ address: section.address,
+ };
+ if (
+ mappedFileOffset(
+ [section.segment_mapping],
+ search.address,
+ search.size,
+ ) !== search.offset
+ )
+ throw new BinaryFormatFailure(
+ "malformed",
+ "Mach-O build-info section disagrees with its segment",
+ );
+ named.push(search);
+ }
+ }
+ if (named.length > 1)
+ throw new BinaryFormatFailure(
+ "malformed",
+ "Mach-O has ambiguous build-info sections",
+ );
+ return named.length > 0 ? named : fallbackSearchRanges(container);
+};
diff --git a/src/go/GoMachoImage.alignment.test.ts b/src/go/GoMachoImage.alignment.test.ts
index 07a378a86b..a2ad09b488 100644
--- a/src/go/GoMachoImage.alignment.test.ts
+++ b/src/go/GoMachoImage.alignment.test.ts
@@ -7,7 +7,7 @@ it("rejects a 12-byte load command in a 64-bit Go Mach-O image", () => {
readGoBinaryImage(Buffer.from(machoImageWithUnknownCommand(true, 12))),
).toThrow(
expect.objectContaining({
- name: "GoBinaryFormatFailure",
+ name: "BinaryFormatFailure",
kind: "malformed",
}),
);
diff --git a/src/il2cpp/ElfRelocations.ts b/src/il2cpp/ElfRelocations.ts
index f06a846d47..ed8b540093 100644
--- a/src/il2cpp/ElfRelocations.ts
+++ b/src/il2cpp/ElfRelocations.ts
@@ -1,10 +1,10 @@
import {
- GoBinaryFormatFailure,
+ BinaryFormatFailure,
mappedFileOffset,
isZeroFilledSpan,
- type GoFileMapping,
- type GoZeroFillMapping,
-} from "../go/GoBinaryContainer.js";
+ type FileMapping,
+ type ZeroFillMapping,
+} from "../native/metadata/BinaryContainer.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
const DT_RELA = 7n,
@@ -26,8 +26,8 @@ interface RelocatedImage {
}
interface RelocationSource {
readonly image: Buffer;
- readonly mappings: readonly GoFileMapping[];
- readonly zeroFills: readonly GoZeroFillMapping[];
+ readonly mappings: readonly FileMapping[];
+ readonly zeroFills: readonly ZeroFillMapping[];
readonly relative: number;
readonly unknownPointerKinds: ReadonlySet;
}
@@ -101,14 +101,14 @@ function* dynamicTags(image: Buffer): Generator> {
}
const fileOffset = (
- mappings: readonly GoFileMapping[],
+ mappings: readonly FileMapping[],
address: bigint,
size: number,
): number | null => {
try {
return mappedFileOffset(mappings, address, size);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return null;
+ if (cause instanceof BinaryFormatFailure) return null;
throw cause;
}
};
@@ -178,8 +178,8 @@ function* applyRelaTable(
/** Materialize relative pointers in a private copy, preserving unknown and .bss facts. */
export function* applyElfRelativeRelocationsSteps(
image: Buffer,
- mappings: readonly GoFileMapping[],
- zeroFills: readonly GoZeroFillMapping[],
+ mappings: readonly FileMapping[],
+ zeroFills: readonly ZeroFillMapping[],
): Generator {
if (image.length < 64) throw malformed("ELF header is truncated");
const kinds = RELOCATION_KINDS.get(image.readUInt16LE(18));
diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts
index 9c14aa08b2..9540da86b1 100644
--- a/src/il2cpp/Il2cppBinaryImage.ts
+++ b/src/il2cpp/Il2cppBinaryImage.ts
@@ -3,13 +3,13 @@ import {
validateFatSlice,
} from "../domain/apple/machoContainer.js";
import {
- GoBinaryFormatFailure,
- GoBinaryResourceFailure,
+ BinaryFormatFailure,
+ BinaryResourceFailure,
mappedFileOffset,
isZeroFilledSpan,
- type GoBinaryContainer,
-} from "../go/GoBinaryContainer.js";
-import { readGoBinaryContainer } from "../go/GoBinaryImage.js";
+ type BinaryContainer,
+} from "../native/metadata/BinaryContainer.js";
+import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
import { applyElfRelativeRelocationsSteps } from "./ElfRelocations.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
const MACHO_CPU = new Map([
@@ -64,7 +64,7 @@ const selectImage = (
export interface NativeImage {
readonly bytes: Buffer;
- readonly container: GoBinaryContainer;
+ readonly container: BinaryContainer;
readonly imageBase: bigint;
readonly unresolvedPointerSlots: ReadonlySet;
readonly zeroFillPointers: ReadonlyMap;
@@ -75,16 +75,16 @@ export function* openImageSteps(
architecture: string | undefined,
): Generator {
let image = selectImage(bytes, architecture);
- let container: GoBinaryContainer;
+ let container: BinaryContainer;
try {
- container = readGoBinaryContainer(image);
+ container = readBinaryContainer(image);
} catch (cause) {
- if (cause instanceof GoBinaryResourceFailure)
+ if (cause instanceof BinaryResourceFailure)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
cause.message,
);
- if (cause instanceof GoBinaryFormatFailure)
+ if (cause instanceof BinaryFormatFailure)
throw new Il2cppBinaryFormatFailure(
cause.kind === "unsupported"
? "unsupported-container"
@@ -121,7 +121,7 @@ export function* openImageSteps(
container.zero_fills,
);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure)
+ if (cause instanceof BinaryFormatFailure)
throw new Il2cppBinaryFormatFailure("malformed-container", cause.message);
throw cause;
}
@@ -172,7 +172,7 @@ export const readPointer = (
);
return image.bytes.readBigUInt64LE(offset);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return null;
+ if (cause instanceof BinaryFormatFailure) return null;
throw cause;
}
};
@@ -195,7 +195,7 @@ export const readUInt32 = (
),
);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return null;
+ if (cause instanceof BinaryFormatFailure) return null;
throw cause;
}
};
@@ -205,7 +205,7 @@ export const isMapped = (image: NativeImage, address: bigint, size: number) => {
mappedFileOffset(image.container.mappings, address, size);
return true;
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return false;
+ if (cause instanceof BinaryFormatFailure) return false;
throw cause;
}
};
@@ -262,7 +262,7 @@ export const hasPointerArrayCoverage = (
);
}
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return false;
+ if (cause instanceof BinaryFormatFailure) return false;
throw cause;
}
cursor += BigInt(size);
@@ -284,7 +284,7 @@ export const isExecutable = (image: NativeImage, address: bigint): boolean => {
mappedFileOffset(image.container.executable_mappings ?? [], address, 1)
);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure) return false;
+ if (cause instanceof BinaryFormatFailure) return false;
throw cause;
}
};
@@ -312,7 +312,7 @@ export const readSlot = (
try {
return isZeroFilled(image, address, 8) ? 0n : readPointer(image, address);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure)
+ if (cause instanceof BinaryFormatFailure)
throw new Il2cppBinaryFormatFailure("malformed-container", cause.message);
throw cause;
}
diff --git a/src/go/GoBinaryContainer.ts b/src/native/metadata/BinaryContainer.ts
similarity index 69%
rename from src/go/GoBinaryContainer.ts
rename to src/native/metadata/BinaryContainer.ts
index 354a04c472..6d0766be20 100644
--- a/src/go/GoBinaryContainer.ts
+++ b/src/native/metadata/BinaryContainer.ts
@@ -1,69 +1,112 @@
/** Distinguish corrupt recognized binaries from containers outside parser coverage. */
-export class GoBinaryFormatFailure extends Error {
+export class BinaryFormatFailure extends Error {
constructor(
readonly kind: "malformed" | "unsupported",
message: string,
) {
super(message);
- this.name = "GoBinaryFormatFailure";
+ this.name = "BinaryFormatFailure";
}
}
/** Resource boundary exceeded while decoding metadata or native container structure. */
-export class GoBinaryResourceFailure extends Error {
+export class BinaryResourceFailure extends Error {
constructor(
readonly boundary: "build-info" | "structure" | "search",
readonly maximum_bytes: number,
message: string,
) {
super(message);
- this.name = "GoBinaryResourceFailure";
+ this.name = "BinaryResourceFailure";
}
}
/** One file-backed virtual range; addresses stay exact until mapped to file offsets. */
-export interface GoFileMapping {
+export interface FileMapping {
readonly address: bigint;
readonly offset: number;
readonly size: number;
}
/** Declared zero-initialized virtual memory with no corresponding file bytes. */
-export interface GoZeroFillMapping {
+export interface ZeroFillMapping {
readonly address: bigint;
readonly size: bigint;
}
-/** One named file section. Invalid declared ranges stay visible with `file_backed: false`. */
-export interface GoImageSection {
+/** Native loader region facts; language-specific fallback selection belongs to consumers. */
+export interface LoadRegion {
+ readonly mapping: FileMapping;
+ readonly memory_size: bigint;
+ readonly read: boolean;
+ readonly write: boolean;
+ readonly execute: boolean;
+ /** Raw Mach-O VM_PROT masks preserve bits not represented by booleans. */
+ readonly protection_masks?: {
+ readonly current: number;
+ readonly maximum: number;
+ };
+}
+
+/** Parsed section facts; consumer metadata validity is separate from native structure. */
+export type ImageSection = {
readonly name: string;
readonly offset: number;
readonly size: number;
readonly file_backed: boolean;
readonly read_only: boolean;
readonly compressed: boolean;
-}
+} & (
+ | {
+ readonly format: "elf";
+ readonly type: number;
+ readonly address: bigint;
+ readonly declared_range: {
+ readonly offset: bigint;
+ readonly size: bigint;
+ };
+ }
+ | {
+ readonly format: "pe";
+ readonly virtual_address: number;
+ readonly virtual_size: number;
+ readonly raw_size: number;
+ readonly characteristics: number;
+ }
+ | {
+ readonly format: "macho";
+ readonly section_name: string;
+ readonly type: number;
+ readonly address: bigint;
+ readonly declared_range: {
+ readonly offset: bigint;
+ readonly size: bigint;
+ };
+ readonly segment_mapping: FileMapping | null;
+ readonly segment_unmapped: boolean;
+ }
+);
-/** Native container facts and its ordered producer-defined build-info search ranges. */
-export interface GoBinaryContainer {
+/** Native container facts, without language metadata search policy. */
+export interface BinaryContainer {
readonly format: "elf" | "pe" | "macho";
readonly architecture: string;
readonly bits: 32 | 64;
readonly byte_order: "little" | "big";
- /** Declared preferred base when the container supplies one (PE). */
+ /** Declared PE preferred base, when available. */
readonly image_base?: bigint;
- /** Mach-O load-command observation; omitted for other formats. */
+ /** Mach-O load-command observation, when available. */
readonly macho_chained_fixups?: boolean;
- readonly mappings: readonly GoFileMapping[];
- /** File-backed ranges the native loader marks executable; omitted if unobserved. */
- readonly executable_mappings?: readonly GoFileMapping[];
- readonly zero_fills: readonly GoZeroFillMapping[];
- readonly sections: readonly GoImageSection[];
- readonly search: readonly GoFileMapping[];
+ readonly mappings: readonly FileMapping[];
+ readonly load_regions?: readonly LoadRegion[];
+ /** File-backed ranges the native loader marks executable. */
+ readonly executable_mappings?: readonly FileMapping[];
+ readonly zero_fills: readonly ZeroFillMapping[];
+ readonly sections: readonly ImageSection[];
}
/** Reader that checks every structural range before decoding numeric fields. */
-export class GoBinaryReader {
+export class BinaryReader {
constructor(
readonly bytes: Buffer,
readonly little: boolean,
@@ -78,7 +121,7 @@ export class GoBinaryReader {
offset > this.bytes.length ||
size > this.bytes.length - offset
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
`${label} range is truncated or outside the file`,
);
@@ -101,12 +144,12 @@ export class GoBinaryReader {
return this.little ? value.readBigUInt64LE() : value.readBigUInt64BE();
}
- fileRange(offset: bigint, size: bigint, label: string): GoFileMapping {
+ fileRange(offset: bigint, size: bigint, label: string): FileMapping {
if (
offset > BigInt(this.bytes.length) ||
size > BigInt(this.bytes.length) - offset
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
`${label} range is outside the file`,
);
@@ -118,7 +161,7 @@ export class GoBinaryReader {
table(offset: bigint, count: number, width: number, label: string): number {
const size = BigInt(count) * BigInt(width);
if (size > 16n * 1024n * 1024n)
- throw new GoBinaryResourceFailure(
+ throw new BinaryResourceFailure(
"structure",
16 * 1024 * 1024,
`${label} exceeds the 16 MiB structural decoding budget`,
@@ -155,10 +198,10 @@ export const declaredFileRange = (
/** Map a virtual span without losing precision or selecting an ambiguous alias. */
export const mappedFileOffset = (
- mappings: readonly GoFileMapping[],
+ mappings: readonly FileMapping[],
address: bigint,
size: number,
- zeroFills: readonly GoZeroFillMapping[] = [],
+ zeroFills: readonly ZeroFillMapping[] = [],
): number => {
let found: number | undefined;
for (const mapping of mappings) {
@@ -171,20 +214,20 @@ export const mappedFileOffset = (
continue;
const offset = mapping.offset + Number(delta);
if (found !== undefined && found !== offset)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Virtual address has ambiguous file mappings",
);
found = offset;
}
if (found === undefined)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Virtual address or string range is not file mapped",
);
for (const zeroFill of zeroFills) {
if (spansOverlap(address, BigInt(size), zeroFill.address, zeroFill.size))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Virtual address has ambiguous file and zero-fill mappings",
);
@@ -199,7 +242,7 @@ export const mappedFileOffset = (
BigInt(found) + start - address !==
BigInt(mapping.offset) + start - mapping.address
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Virtual address has ambiguous file mappings",
);
@@ -209,8 +252,8 @@ export const mappedFileOffset = (
/** Recognize a wholly zero-filled span while refusing even partial file aliases. */
export const isZeroFilledSpan = (
- mappings: readonly GoFileMapping[],
- zeroFills: readonly GoZeroFillMapping[],
+ mappings: readonly FileMapping[],
+ zeroFills: readonly ZeroFillMapping[],
address: bigint,
size: number,
): boolean => {
@@ -225,7 +268,7 @@ export const isZeroFilledSpan = (
return false;
for (const mapping of mappings) {
if (spansOverlap(address, bytes, mapping.address, BigInt(mapping.size)))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Virtual address has ambiguous file and zero-fill mappings",
);
@@ -243,17 +286,3 @@ const spansOverlap = (
otherSize > 0n &&
address < otherAddress + otherSize &&
otherAddress < address + size;
-
-/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */
-export const goUtf8 = (bytes: Buffer, label: string): string => {
- try {
- return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
- bytes,
- );
- } catch (cause) {
- throw new GoBinaryFormatFailure(
- "malformed",
- `${label} contains malformed UTF-8: ${String(cause)}`,
- );
- }
-};
diff --git a/src/go/GoElfImage.ts b/src/native/metadata/ElfImage.ts
similarity index 67%
rename from src/go/GoElfImage.ts
rename to src/native/metadata/ElfImage.ts
index a53fbae62a..71e9f99f3c 100644
--- a/src/go/GoElfImage.ts
+++ b/src/native/metadata/ElfImage.ts
@@ -1,22 +1,22 @@
import {
declaredFileRange,
- GoBinaryFormatFailure,
- GoBinaryReader,
- mappedFileOffset,
- type GoBinaryContainer,
- type GoFileMapping,
- type GoImageSection,
- type GoZeroFillMapping,
-} from "./GoBinaryContainer.js";
-import { readElfFixedHeader } from "../domain/elfFixedHeader.js";
+ BinaryFormatFailure,
+ BinaryReader,
+ type BinaryContainer,
+ type FileMapping,
+ type ImageSection,
+ type LoadRegion,
+ type ZeroFillMapping,
+} from "./BinaryContainer.js";
+import { readElfFixedHeader } from "../../domain/elfFixedHeader.js";
-/** Read ELF build-info sections and file-backed program mappings. */
-export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
+/** Read ELF sections and file-backed program mappings without interpreting language metadata. */
+export const readElfImage = (bytes: Buffer): BinaryContainer => {
const fixedHeader = readElfFixedHeader(bytes);
if (!fixedHeader.ok)
- throw new GoBinaryFormatFailure("malformed", fixedHeader.error);
+ throw new BinaryFormatFailure("malformed", fixedHeader.error);
const { elfClass: bits, headerSize } = fixedHeader.value;
- const reader = new GoBinaryReader(
+ const reader = new BinaryReader(
bytes,
fixedHeader.value.byteOrder === "little",
);
@@ -32,7 +32,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
(phCount !== 0 && phOffset < BigInt(headerSize)) ||
(shOffset !== 0n && shOffset < BigInt(headerSize))
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF header table overlaps the native header",
);
@@ -41,7 +41,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
(shCount === 0 || phCount === 0xffff || namesIndex === 0xffff)
) {
if (shWidth < (wide ? 64 : 40))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF extended section header width is invalid",
);
@@ -51,14 +51,14 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
"ELF section zero",
).offset;
if (reader.u32(first + 4) !== 0)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF initial section for extended numbering must have SHT_NULL type",
);
if (shCount === 0) {
const count = reader.word(first + (wide ? 32 : 20), bits);
if (count < 0xff00n || count > BigInt(Number.MAX_SAFE_INTEGER))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF extended section count is invalid",
);
@@ -67,7 +67,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
if (phCount === 0xffff) {
phCount = reader.u32(first + (wide ? 44 : 28));
if (phCount < 0xffff)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF extended program count is below its reserved range",
);
@@ -75,7 +75,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
if (namesIndex === 0xffff) {
namesIndex = reader.u32(first + (wide ? 40 : 24));
if (namesIndex < 0xff00)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF extended section name index is below its reserved range",
);
@@ -84,13 +84,10 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
shOffset === 0n &&
(shCount !== 0 || namesIndex !== 0 || phCount === 0xffff)
) {
- throw new GoBinaryFormatFailure(
- "malformed",
- "ELF section table is missing",
- );
+ throw new BinaryFormatFailure("malformed", "ELF section table is missing");
}
if (phCount !== 0 && phWidth < (wide ? 56 : 32))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF program header width is invalid",
);
@@ -100,11 +97,11 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
phWidth,
"ELF program headers",
);
- const mappings: GoFileMapping[] = [];
- const executableMappings: GoFileMapping[] = [];
- const zeroFills: GoZeroFillMapping[] = [];
- const sections: GoImageSection[] = [];
- const fallback: GoFileMapping[] = [];
+ const mappings: FileMapping[] = [];
+ const loadRegions: LoadRegion[] = [];
+ const executableMappings: FileMapping[] = [];
+ const zeroFills: ZeroFillMapping[] = [];
+ const sections: ImageSection[] = [];
for (let index = 0; index < phCount; index++) {
const offset = phStart + index * phWidth;
if (reader.u32(offset) !== 1) continue;
@@ -114,7 +111,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
const size = reader.word(offset + (wide ? 32 : 16), bits);
const memorySize = reader.word(offset + (wide ? 40 : 20), bits);
if (size > memorySize || address + memorySize > 1n << BigInt(bits))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF load segment sizes or addresses are invalid",
);
@@ -126,16 +123,21 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
};
if (mapping.size !== 0) {
mappings.push(mapping);
+ loadRegions.push({
+ mapping,
+ memory_size: memorySize,
+ read: (flags & 4) !== 0,
+ write: (flags & 2) !== 0,
+ execute: (flags & 1) !== 0,
+ });
if ((flags & 1) !== 0) executableMappings.push(mapping);
}
if (memorySize > size)
zeroFills.push({ address: address + size, size: memorySize - size });
- if (mapping.size !== 0 && (flags & 3) === 2) fallback.push(mapping);
}
- let search: GoFileMapping | null = null;
if (shCount !== 0) {
if (shWidth < (wide ? 64 : 40) || namesIndex >= shCount)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF section width or name-table index is invalid",
);
@@ -148,7 +150,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
if (namesIndex !== 0) {
const namesHeader = shStart + namesIndex * shWidth;
if (reader.u32(namesHeader + 4) !== 3)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF section name table is not a string table",
);
@@ -161,7 +163,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
const offset = shStart + index * shWidth;
const nameOffset = reader.u32(offset);
if (nameOffset >= names.size)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"ELF section name offset is outside its string table",
);
@@ -174,73 +176,29 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
wide,
header: offset,
});
- // Comparing the complete fixed name avoids quadratic scans through malicious string tables.
- const expectedName = Buffer.from(".go.buildinfo\0");
- if (
- expectedName.length > names.size - nameOffset ||
- !bytes
- .subarray(
- names.offset + nameOffset,
- names.offset + nameOffset + expectedName.length,
- )
- .equals(expectedName)
- )
- continue;
- if (search !== null)
- throw new GoBinaryFormatFailure(
- "malformed",
- "ELF contains ambiguous build-info sections",
- );
- if (reader.u32(offset + 4) !== 1)
- throw new GoBinaryFormatFailure(
- "malformed",
- "ELF build-info section is not file-backed data",
- );
- if ((reader.word(offset + 8, bits) & 0x800n) !== 0n)
- throw new GoBinaryFormatFailure(
- "unsupported",
- "Compressed ELF build-info sections are unsupported",
- );
- const address = reader.word(offset + (wide ? 16 : 12), bits);
- search = {
- ...reader.fileRange(
- reader.word(offset + (wide ? 24 : 16), bits),
- reader.word(offset + (wide ? 32 : 20), bits),
- "ELF build-info section",
- ),
- address,
- };
- if (
- mappedFileOffset(mappings, address, search.size, zeroFills) !==
- search.offset
- )
- throw new GoBinaryFormatFailure(
- "malformed",
- "ELF build-info section disagrees with its load mapping",
- );
}
}
}
return {
format: "elf",
- architecture: goArchitectureForElfMachine(fixedHeader.value.machine),
+ architecture: architectureForElfMachine(fixedHeader.value.machine),
bits,
byte_order: reader.little ? "little" : "big",
mappings,
+ load_regions: loadRegions,
executable_mappings: executableMappings,
zero_fills: zeroFills,
sections,
- search: search === null ? fallback : [search],
};
};
const recordElfSection = (
- sections: GoImageSection[],
+ sections: ImageSection[],
input: {
readonly bytes: Buffer;
readonly names: { readonly offset: number; readonly size: number };
readonly nameOffset: number;
- readonly reader: GoBinaryReader;
+ readonly reader: BinaryReader;
readonly bits: 32 | 64;
readonly wide: boolean;
readonly header: number;
@@ -299,18 +257,22 @@ const elfImageSection = ({
header,
name,
}: {
- readonly reader: GoBinaryReader;
+ readonly reader: BinaryReader;
readonly bits: 32 | 64;
readonly wide: boolean;
readonly header: number;
readonly name: string;
-}): GoImageSection => {
+}): ImageSection => {
const type = reader.u32(header + 4);
const flags = reader.word(header + 8, bits);
+ const declared_range = {
+ offset: reader.word(header + (wide ? 24 : 16), bits),
+ size: reader.word(header + (wide ? 32 : 20), bits),
+ };
const declared = declaredFileRange(
reader.bytes.length,
- reader.word(header + (wide ? 24 : 16), bits),
- reader.word(header + (wide ? 32 : 20), bits),
+ declared_range.offset,
+ declared_range.size,
);
// SHT_NOBITS offsets and sizes do not describe stored bytes.
const range =
@@ -323,13 +285,22 @@ const elfImageSection = ({
(flags & 1n) === 0n &&
(flags & 4n) === 0n &&
(flags & 2n) !== 0n;
- return { name, ...range, read_only: readOnly, compressed };
+ return {
+ format: "elf",
+ name,
+ type,
+ address: reader.word(header + (wide ? 16 : 12), bits),
+ declared_range,
+ ...range,
+ read_only: readOnly,
+ compressed,
+ };
};
const SHT_NOBITS = 8;
-/** Go build-info vocabulary, including families outside REA target admission. */
-const goArchitectureForElfMachine = (machine: number): string => {
+/** Map ELF machine identifiers to stable architecture names. */
+const architectureForElfMachine = (machine: number): string => {
const names = new Map([
[3, "386"],
[8, "mips"],
diff --git a/src/native/metadata/Il2cppContainer.test.ts b/src/native/metadata/Il2cppContainer.test.ts
new file mode 100644
index 0000000000..a9bea21e3c
--- /dev/null
+++ b/src/native/metadata/Il2cppContainer.test.ts
@@ -0,0 +1,50 @@
+import { expect, it } from "vitest";
+import { readGoBinaryImage } from "../../go/GoBinaryImage.js";
+import { createRustImageWithGoSection } from "../../../tests/fixtures/rust/image.js";
+import { openImageSteps } from "../../il2cpp/Il2cppBinaryImage.js";
+import { readBinaryContainer } from "./readBinaryContainer.js";
+
+it.each(["unmapped", "zero-fill", "duplicate", "out-of-file"] as const)(
+ "keeps IL2CPP admission independent of a Mach-O Go build-info %s failure",
+ (fault) => {
+ const goNamed = createRustImageWithGoSection("macho", fault);
+ const control = Buffer.from(goNamed);
+ const sectionName = Buffer.from("__other\0".padEnd(16, "\0"), "ascii");
+ for (let index = 0; index < (fault === "duplicate" ? 2 : 1); index++)
+ sectionName.copy(control, 408 + index * 152);
+
+ expect(() => readGoBinaryImage(goNamed)).toThrow(/build-info|mapped/i);
+ expect(readGoBinaryImage(control).build_info).toBeNull();
+ expect(readBinaryContainer(goNamed).load_regions).toEqual(
+ readBinaryContainer(control).load_regions,
+ );
+
+ const image = openImageSteps(goNamed, undefined).next();
+ expect(image.done).toBe(true);
+ if (!image.done)
+ throw new Error("container admission unexpectedly yielded");
+ expect(image.value.bytes).toBe(goNamed);
+ expect(image.value.container).toMatchObject({
+ format: "macho",
+ macho_chained_fixups: false,
+ });
+ expect(image.value.container.executable_mappings?.length).toBeGreaterThan(
+ 0,
+ );
+ expect(
+ image.value.container.sections.filter(
+ (section) =>
+ section.format === "macho" &&
+ section.section_name === "__go_buildinfo",
+ ),
+ ).toHaveLength(fault === "duplicate" ? 2 : 1);
+
+ const controlImage = openImageSteps(control, undefined).next();
+ expect(controlImage.done).toBe(true);
+ if (!controlImage.done)
+ throw new Error("control container admission unexpectedly yielded");
+ expect(controlImage.value.container.mappings).toEqual(
+ image.value.container.mappings,
+ );
+ },
+);
diff --git a/src/go/GoMachoImage.ts b/src/native/metadata/MachoImage.ts
similarity index 68%
rename from src/go/GoMachoImage.ts
rename to src/native/metadata/MachoImage.ts
index ee5e000848..3e3c41d071 100644
--- a/src/go/GoMachoImage.ts
+++ b/src/native/metadata/MachoImage.ts
@@ -1,21 +1,21 @@
import {
declaredFileRange,
- GoBinaryFormatFailure,
- GoBinaryReader,
- mappedFileOffset,
- type GoBinaryContainer,
- type GoFileMapping,
- type GoImageSection,
- type GoZeroFillMapping,
-} from "./GoBinaryContainer.js";
-import { machoLoadCommandAlignment } from "../domain/apple/machoContainer.js";
+ BinaryFormatFailure,
+ BinaryReader,
+ type BinaryContainer,
+ type FileMapping,
+ type ImageSection,
+ type LoadRegion,
+ type ZeroFillMapping,
+} from "./BinaryContainer.js";
+import { machoLoadCommandAlignment } from "../../domain/apple/machoContainer.js";
/** Read thin Mach-O images, retaining the slice's exact file-backed mappings. */
-export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
+export const readMachoImage = (bytes: Buffer): BinaryContainer => {
const magic = bytes.readUInt32BE(0);
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const bits = magic === 0xfeedfacf || magic === 0xcffaedfe ? 64 : 32;
- const reader = new GoBinaryReader(bytes, little);
+ const reader = new BinaryReader(bytes, little);
const headerSize = bits === 64 ? 32 : 28;
reader.range(0, headerSize, "Mach-O header");
const count = reader.u32(16);
@@ -27,18 +27,17 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
"Mach-O load commands",
);
if (count > Math.floor(commandBytes / 8))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O command count exceeds its load-command range",
);
- const mappings: GoFileMapping[] = [];
- const executableMappings: GoFileMapping[] = [];
- const zeroFills: GoZeroFillMapping[] = [];
- const sections: GoImageSection[] = [];
- const named: GoFileMapping[] = [];
- let fallback: GoFileMapping | null = null;
- let offset = commandStart;
+ const mappings: FileMapping[] = [];
+ const loadRegions: LoadRegion[] = [];
+ const executableMappings: FileMapping[] = [];
+ const zeroFills: ZeroFillMapping[] = [];
+ const sections: ImageSection[] = [];
let chainedFixups = false;
+ let offset = commandStart;
const end = commandStart + commandBytes;
for (let index = 0; index < count; index++) {
const command = reader.u32(offset);
@@ -48,7 +47,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
size % machoLoadCommandAlignment(bits === 64) !== 0 ||
size > end - offset
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O load command size is invalid or truncated",
);
@@ -56,14 +55,14 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
if (command === 1 || command === 0x19) {
const wide = command === 0x19;
if (wide !== (bits === 64))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O segment command does not match the header width",
);
const segmentHeader = wide ? 72 : 56;
const sectionWidth = wide ? 80 : 68;
if (size < segmentHeader)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O segment header is truncated",
);
@@ -79,12 +78,12 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
(!unmapped && fileSize > memorySize) ||
address + memorySize > 1n << BigInt(bits)
)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O segment addresses or sizes are invalid",
);
if (sectionCount > Math.floor((size - segmentHeader) / sectionWidth))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O section table exceeds its segment command",
);
@@ -101,6 +100,17 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
);
if (mapping !== undefined && name !== "__PAGEZERO") {
mappings.push(mapping);
+ loadRegions.push({
+ mapping,
+ memory_size: memorySize,
+ read: (protection & 1) !== 0,
+ write: (protection & 2) !== 0,
+ execute: (protection & 4) !== 0,
+ protection_masks: {
+ current: protection,
+ maximum: maxProtection,
+ },
+ });
if ((protection & 4) !== 0) executableMappings.push(mapping);
}
if (memorySize > fileSize && name !== "__PAGEZERO")
@@ -108,14 +118,6 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
address: address + fileSize,
size: memorySize - fileSize,
});
- if (
- fallback === null &&
- mapping !== undefined &&
- address !== 0n &&
- protection === 3 &&
- maxProtection === 3
- )
- fallback = mapping;
for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) {
const section = offset + segmentHeader + sectionIndex * sectionWidth;
const sectionName = fixedName(
@@ -129,6 +131,9 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
header: section,
name: `${fixedName(reader.range(section + 16, 16, "Mach-O section segment"))},${sectionName}`,
protection,
+ sectionName,
+ segmentMapping: mapping ?? null,
+ segmentUnmapped: unmapped,
unmapped: unmapped || mapping === undefined,
segmentAddress: address,
segmentMemorySize: memorySize,
@@ -136,56 +141,15 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
segmentFileSize: fileSize,
}),
);
- if (sectionName !== "__go_buildinfo") continue;
- if (unmapped)
- throw new GoBinaryFormatFailure(
- "malformed",
- "Mach-O build-info section is in an unmapped segment",
- );
- if (mapping === undefined)
- throw new GoBinaryFormatFailure(
- "malformed",
- "Mach-O build-info section is not file mapped",
- );
- const sectionFlags = reader.u32(section + (wide ? 64 : 56));
- if ([1, 0xc, 0x12].includes(sectionFlags & 0xff))
- throw new GoBinaryFormatFailure(
- "malformed",
- "Mach-O build-info section is zero-fill data",
- );
- const sectionAddress = reader.word(section + 32, bits);
- const sectionSize = reader.word(section + (wide ? 40 : 36), bits);
- const sectionOffset = BigInt(reader.u32(section + (wide ? 48 : 40)));
- const info = {
- ...reader.fileRange(
- sectionOffset,
- sectionSize,
- "Mach-O build-info section",
- ),
- address: sectionAddress,
- };
- if (
- mappedFileOffset([mapping], sectionAddress, info.size) !== info.offset
- )
- throw new GoBinaryFormatFailure(
- "malformed",
- "Mach-O build-info section disagrees with its segment",
- );
- named.push(info);
}
}
offset += size;
}
if (offset !== end)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"Mach-O load-command byte count does not match its commands",
);
- if (named.length > 1)
- throw new GoBinaryFormatFailure(
- "malformed",
- "Mach-O has ambiguous build-info sections",
- );
const cpu = reader.u32(4);
const names = new Map([
[7, "386"],
@@ -202,10 +166,10 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
bits,
byte_order: little ? "little" : "big",
mappings,
+ load_regions: loadRegions,
executable_mappings: executableMappings,
zero_fills: zeroFills,
sections,
- search: named.length > 0 ? named : fallback === null ? [] : [fallback],
};
};
@@ -216,24 +180,30 @@ const machoImageSection = ({
header,
name,
protection,
+ sectionName,
+ segmentMapping,
+ segmentUnmapped,
unmapped,
segmentAddress,
segmentMemorySize,
segmentFileOffset,
segmentFileSize,
}: {
- readonly reader: GoBinaryReader;
+ readonly reader: BinaryReader;
readonly bits: 32 | 64;
readonly wide: boolean;
readonly header: number;
readonly name: string;
readonly protection: number;
+ readonly sectionName: string;
+ readonly segmentMapping: FileMapping | null;
+ readonly segmentUnmapped: boolean;
readonly unmapped: boolean;
readonly segmentAddress: bigint;
readonly segmentMemorySize: bigint;
readonly segmentFileOffset: bigint;
readonly segmentFileSize: bigint;
-}): GoImageSection => {
+}): ImageSection => {
const flags = reader.u32(header + (wide ? 64 : 56));
const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff);
const sectionAddress = reader.word(header + 32, bits);
@@ -251,11 +221,7 @@ const machoImageSection = ({
sectionSize <= segmentFileSize - fileDelta;
const range = unmapped
? { offset: 0, size: 0, file_backed: false }
- : declaredFileRange(
- reader.bytes.length,
- BigInt(reader.u32(header + (wide ? 48 : 40))),
- reader.word(header + (wide ? 40 : 36), bits),
- );
+ : declaredFileRange(reader.bytes.length, sectionOffset, sectionSize);
const fileBacked =
range.file_backed && !zeroFill && inSegmentMemory && inSegmentFile;
const readOnly =
@@ -264,7 +230,14 @@ const machoImageSection = ({
(protection & 2) === 0 &&
(flags & 0x80000400) === 0;
return {
+ format: "macho",
name,
+ section_name: sectionName,
+ type: flags & 0xff,
+ address: sectionAddress,
+ declared_range: { offset: sectionOffset, size: sectionSize },
+ segment_mapping: segmentMapping,
+ segment_unmapped: segmentUnmapped,
...range,
file_backed: fileBacked,
read_only: readOnly,
diff --git a/src/go/GoPeImage.test.ts b/src/native/metadata/PeImage.test.ts
similarity index 55%
rename from src/go/GoPeImage.test.ts
rename to src/native/metadata/PeImage.test.ts
index 6119176c7c..70f33dfb34 100644
--- a/src/go/GoPeImage.test.ts
+++ b/src/native/metadata/PeImage.test.ts
@@ -1,10 +1,10 @@
import { expect, it } from "vitest";
-import { readGoBinaryImage } from "./GoBinaryImage.js";
-import { readGoPeImage } from "./GoPeImage.js";
+import { readGoBinaryImage } from "../../go/GoBinaryImage.js";
+import { readPeImage } from "./PeImage.js";
import {
createGoBinaryFixture,
setGoPeVirtualSize,
-} from "../../tests/fixtures/go/image.js";
+} from "../../../tests/fixtures/go/image.js";
it.each([32, 64] as const)(
"uses raw-size fallback for PE%d Go build-info and keeps RVA mappings intact",
@@ -15,7 +15,7 @@ it.each([32, 64] as const)(
expect(expected).not.toBeNull();
setGoPeVirtualSize(fixture.bytes, 0);
- const container = readGoPeImage(fixture.bytes);
+ const container = readPeImage(fixture.bytes);
expect(container.sections[0]).toMatchObject({
name: ".data",
offset: 1024,
@@ -27,16 +27,35 @@ it.each([32, 64] as const)(
offset: 1024,
size: 2048,
});
- expect(container.search[0]).toMatchObject({
- address: 0x10400n,
- offset: 1024,
- size: 2048,
+ expect(container.sections[0]).toMatchObject({
+ virtual_address: 0x400,
+ virtual_size: 0,
+ raw_size: 2048,
+ characteristics: 0xc0000040,
});
+ expect(container).not.toHaveProperty("data_ranges");
+ expect(container.image_base).toBe(0x10000n);
+ expect(container.executable_mappings).toEqual([]);
expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected);
}
},
);
+it("keeps Go fallback candidates when a PE section has no printable name", () => {
+ const fixture = createGoBinaryFixture({ format: "pe", bits: 64 });
+ const expected = readGoBinaryImage(fixture.bytes).build_info;
+ const pe = fixture.bytes.readUInt32LE(0x3c);
+ const section = pe + 24 + fixture.bytes.readUInt16LE(pe + 20);
+ fixture.bytes.fill(0, section, section + 8);
+
+ const container = readPeImage(fixture.bytes);
+ expect(container.sections[0]).toMatchObject({
+ name: "",
+ characteristics: 0xc0000040,
+ });
+ expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected);
+});
+
it.each(["inline", "pointer"] as const)(
"does not search PE raw alignment padding beyond a nonzero VirtualSize (%s)",
(encoding) => {
@@ -51,13 +70,19 @@ it.each(["inline", "pointer"] as const)(
fixture.bytes.fill(0, fixture.headerOffset, fixture.headerOffset + 512);
setGoPeVirtualSize(fixture.bytes, 512);
- const container = readGoPeImage(fixture.bytes);
+ const container = readPeImage(fixture.bytes);
expect(container.sections[0]).toMatchObject({
size: 512,
file_backed: true,
});
expect(container.mappings[0]).toMatchObject({ size: 2048, offset: 1024 });
- expect(container.search[0]).toMatchObject({ size: 512, offset: 1024 });
+ expect(container.sections[0]).toMatchObject({
+ size: 512,
+ offset: 1024,
+ virtual_size: 512,
+ raw_size: 2048,
+ });
+ expect(container).not.toHaveProperty("data_ranges");
expect(readGoBinaryImage(fixture.bytes).build_info).toBeNull();
},
);
diff --git a/src/go/GoPeImage.ts b/src/native/metadata/PeImage.ts
similarity index 70%
rename from src/go/GoPeImage.ts
rename to src/native/metadata/PeImage.ts
index dfb9f9c579..f694550c71 100644
--- a/src/go/GoPeImage.ts
+++ b/src/native/metadata/PeImage.ts
@@ -1,38 +1,41 @@
-import { observeMzWindowsHeader, parseDosMzHeader } from "../domain/dosMz.js";
+import {
+ observeMzWindowsHeader,
+ parseDosMzHeader,
+} from "../../domain/dosMz.js";
import {
declaredFileRange,
- GoBinaryFormatFailure,
- GoBinaryReader,
- type GoBinaryContainer,
- type GoFileMapping,
- type GoImageSection,
- type GoZeroFillMapping,
-} from "./GoBinaryContainer.js";
+ BinaryFormatFailure,
+ BinaryReader,
+ type BinaryContainer,
+ type FileMapping,
+ type ImageSection,
+ type ZeroFillMapping,
+} from "./BinaryContainer.js";
-const peHeaderOffset = (reader: GoBinaryReader): number => {
+const peHeaderOffset = (reader: BinaryReader): number => {
const windowsHeader = observeMzWindowsHeader(reader.bytes);
if (windowsHeader.state !== "declared") {
const dos = parseDosMzHeader(reader.bytes);
- if (!dos.ok) throw new GoBinaryFormatFailure("malformed", dos.error);
- throw new GoBinaryFormatFailure(
+ if (!dos.ok) throw new BinaryFormatFailure("malformed", dos.error);
+ throw new BinaryFormatFailure(
"unsupported",
"DOS MZ images are outside the supported ELF, PE and thin Mach-O formats",
);
}
const { offset: pe } = windowsHeader;
if (pe < 64)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"PE signature overlaps the DOS header",
);
reader.range(pe, 24, "PE signature and COFF header");
if (["NE", "LE", "LX"].includes(windowsHeader.signature))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"unsupported",
`${windowsHeader.signature} images are outside the supported ELF, PE and thin Mach-O formats`,
);
if (windowsHeader.signature !== "PE")
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"PE signature is invalid or overlaps the DOS header",
);
@@ -40,41 +43,41 @@ const peHeaderOffset = (reader: GoBinaryReader): number => {
};
const optionalHeaderMagic = (
- reader: GoBinaryReader,
+ reader: BinaryReader,
offset: number,
size: number,
): number => {
reader.range(offset, size, "PE optional header");
if (size < 2)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"PE optional header is missing its complete magic field",
);
return reader.u16(offset);
};
-/** Read PE32/PE32+ native image mappings and the linker data section. */
-export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
- const reader = new GoBinaryReader(bytes, true);
+/** Read PE32/PE32+ native image mappings and section facts. */
+export const readPeImage = (bytes: Buffer): BinaryContainer => {
+ const reader = new BinaryReader(bytes, true);
const pe = peHeaderOffset(reader);
const optional = pe + 24;
const optionalSize = reader.u16(pe + 20);
const magic = optionalHeaderMagic(reader, optional, optionalSize);
const bits = magic === 0x10b ? 32 : magic === 0x20b ? 64 : null;
if (bits === null)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"unsupported",
"Only PE32 and PE32+ image optional headers are supported",
);
if (optionalSize < (bits === 64 ? 112 : 96))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"PE optional header is truncated",
);
const directoriesOffset = bits === 64 ? 112 : 96;
const directories = reader.u32(optional + directoriesOffset - 4);
if (directories * 8 > optionalSize - directoriesOffset)
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"PE data directories extend beyond the optional header",
);
@@ -86,11 +89,10 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
40,
"PE sections",
);
- const mappings: GoFileMapping[] = [];
- const executableMappings: GoFileMapping[] = [];
- const zeroFills: GoZeroFillMapping[] = [];
- const sections: GoImageSection[] = [];
- let search: GoFileMapping | null = null;
+ const mappings: FileMapping[] = [];
+ const executableMappings: FileMapping[] = [];
+ const zeroFills: ZeroFillMapping[] = [];
+ const sections: ImageSection[] = [];
for (let index = 0; index < count; index++) {
const entry = start + index * 40;
const sectionName = peSectionName(
@@ -103,19 +105,21 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
const characteristics = reader.u32(entry + 36);
// SizeOfRawData includes file-alignment padding. VirtualSize is the content.
const contentSize = virtualSize === 0 ? size : Math.min(virtualSize, size);
- if (sectionName.length > 0)
- sections.push(
- peImageSection({
- reader,
- name: sectionName,
- size: contentSize,
- offset,
- characteristics,
- }),
- );
+ sections.push(
+ peImageSection({
+ reader,
+ name: sectionName,
+ size: contentSize,
+ offset,
+ characteristics,
+ virtualAddress,
+ virtualSize,
+ rawSize: size,
+ }),
+ );
const address = imageBase + BigInt(virtualAddress);
if (address + BigInt(Math.max(size, virtualSize)) > 1n << BigInt(bits))
- throw new GoBinaryFormatFailure(
+ throw new BinaryFormatFailure(
"malformed",
"PE section virtual address overflows its address width",
);
@@ -135,15 +139,6 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
address: address + BigInt(size),
size: BigInt(virtualSize - size),
});
- // Match debug/buildinfo's first initialized, readable, writable data section.
- if (
- search === null &&
- virtualAddress !== 0 &&
- size !== 0 &&
- (characteristics & ~0x00600000) >>> 0 === 0xc0000040
- ) {
- search = { ...mapping, size: contentSize };
- }
}
const machine = reader.u16(pe + 4);
const names = new Map([
@@ -164,7 +159,6 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
executable_mappings: executableMappings,
zero_fills: zeroFills,
sections,
- search: search === null ? [] : [search],
};
};
@@ -179,13 +173,19 @@ const peImageSection = ({
size,
offset,
characteristics,
+ virtualAddress,
+ virtualSize,
+ rawSize,
}: {
- readonly reader: GoBinaryReader;
+ readonly reader: BinaryReader;
readonly name: string;
readonly size: number;
readonly offset: number;
readonly characteristics: number;
-}): GoImageSection => {
+ readonly virtualAddress: number;
+ readonly virtualSize: number;
+ readonly rawSize: number;
+}): ImageSection => {
const range = declaredFileRange(
reader.bytes.length,
BigInt(offset),
@@ -197,5 +197,15 @@ const peImageSection = ({
(characteristics & 0x80000000) === 0 &&
(characteristics & 0x20000000) === 0 &&
(characteristics & 0x40) !== 0;
- return { name, ...range, read_only: readOnly, compressed: false };
+ return {
+ format: "pe",
+ name,
+ virtual_address: virtualAddress,
+ virtual_size: virtualSize,
+ raw_size: rawSize,
+ characteristics,
+ ...range,
+ read_only: readOnly,
+ compressed: false,
+ };
};
diff --git a/src/native/metadata/readBinaryContainer.ts b/src/native/metadata/readBinaryContainer.ts
new file mode 100644
index 0000000000..d8fa65f8a0
--- /dev/null
+++ b/src/native/metadata/readBinaryContainer.ts
@@ -0,0 +1,48 @@
+import {
+ BinaryFormatFailure,
+ type BinaryContainer,
+} from "./BinaryContainer.js";
+import {
+ classifyFatContainerIdentity,
+ readFatContainerHeader,
+} from "../../domain/apple/machoContainer.js";
+import { readElfImage } from "./ElfImage.js";
+import { readPeImage } from "./PeImage.js";
+import { readMachoImage } from "./MachoImage.js";
+
+/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */
+export const readBinaryContainer = (bytes: Buffer): BinaryContainer => {
+ if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
+ return readElfImage(bytes);
+ if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) return readPeImage(bytes);
+ if (bytes.length >= 4) {
+ const magic = bytes.readUInt32BE();
+ if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic)) {
+ const fatHeader = readFatContainerHeader(bytes, bytes.length);
+ if (fatHeader.state === "malformed")
+ throw new BinaryFormatFailure("malformed", fatHeader.reason);
+ const identity =
+ fatHeader.state === "parsed"
+ ? classifyFatContainerIdentity(fatHeader, (offset) => {
+ if (offset >= bytes.length) return undefined;
+ if (offset + 4 > bytes.length) return null;
+ return bytes.readUInt32BE(offset);
+ })
+ : undefined;
+ // An invalid FAT-shaped table is not proof of a Mach-O container: JVM
+ // constant-pool bytes can share both the magic and a known CPU value.
+ const confirmedFatMachO = identity?.state === "mach-o";
+ if (confirmedFatMachO)
+ throw new BinaryFormatFailure(
+ "unsupported",
+ "Universal/fat Mach-O containers require selecting a thin slice",
+ );
+ }
+ if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
+ return readMachoImage(bytes);
+ }
+ throw new BinaryFormatFailure(
+ "unsupported",
+ "Only ELF, PE, and thin Mach-O native images are supported",
+ );
+};
diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts
index ae077d21ca..01d0d9a66d 100644
--- a/src/rust/RustBinaryImage.ts
+++ b/src/rust/RustBinaryImage.ts
@@ -1,9 +1,9 @@
import {
- GoBinaryFormatFailure,
- GoBinaryResourceFailure,
- type GoImageSection,
-} from "../go/GoBinaryContainer.js";
-import { readGoBinaryContainer } from "../go/GoBinaryImage.js";
+ BinaryFormatFailure,
+ BinaryResourceFailure,
+ type ImageSection,
+} from "../native/metadata/BinaryContainer.js";
+import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
import type {
RustAuditableMetadata,
RustBinary,
@@ -48,13 +48,13 @@ export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => {
const readContainer = (bytes: Buffer) => {
try {
- return readGoBinaryContainer(bytes);
+ return readBinaryContainer(bytes);
} catch (cause) {
- if (cause instanceof GoBinaryFormatFailure)
+ if (cause instanceof BinaryFormatFailure)
throw new RustAuditableFormatFailure(cause.kind, cause.message, {
cause,
});
- if (cause instanceof GoBinaryResourceFailure)
+ if (cause instanceof BinaryResourceFailure)
throw new RustAuditableResourceFailure(
{
resource: "memory",
@@ -71,8 +71,8 @@ const readContainer = (bytes: Buffer) => {
const dependencySection = (
format: RustBinaryImage["format"],
- sections: readonly GoImageSection[],
-): GoImageSection | null => {
+ sections: readonly ImageSection[],
+): ImageSection | null => {
const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0";
const matches = sections.filter((section) => section.name === expected);
if (matches.length > 1)
@@ -98,7 +98,7 @@ const dependencySection = (
const readDependency = (
bytes: Buffer,
format: RustBinaryImage["format"],
- section: GoImageSection,
+ section: ImageSection,
): RustAuditableMetadata =>
readAuditableSection(
format === "macho" ? "__DATA,.dep-v0" : ".dep-v0",
@@ -108,7 +108,7 @@ const readDependency = (
const rustcCommit = (
bytes: Buffer,
- sections: readonly GoImageSection[],
+ sections: readonly ImageSection[],
): RustBinaryImage["rustc_commit"] => {
const hashes: { hash: string; file_offset: number }[] = [];
const seen = new Set();
@@ -120,7 +120,7 @@ const rustcCommit = (
};
const mergedReadOnlyRanges = (
- sections: readonly GoImageSection[],
+ sections: readonly ImageSection[],
): { readonly offset: number; readonly end: number }[] => {
const ranges = sections
.filter((section) => section.read_only && section.size > 0)
diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts
index fbd8f69f56..d8d6dc94f5 100644
--- a/tests/acceptance/rustBinaryCli.test.ts
+++ b/tests/acceptance/rustBinaryCli.test.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import { deflateSync } from "node:zlib";
import { readFile, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
@@ -7,6 +8,8 @@ import { toolContract } from "../../src/contracts/toolContracts.js";
import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js";
import {
createRustImage,
+ createRustImageWithGoSection,
+ RUSTC_HASH,
rustDependencyPayload,
} from "../fixtures/rust/image.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
@@ -229,3 +232,94 @@ cliTest(
expect(await readFile(path)).toEqual(bytes);
},
);
+
+cliTest(
+ "keeps Rust evidence independent of Go section errors through public CLI and stdio MCP",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-rust-cli-go-section-");
+ const { client, call } = await connectLocalToolsMcp();
+ for (const [format, fault, code] of [
+ ["elf", "compressed", "unsupported_target"],
+ ["elf", "zero-address", "invalid_request"],
+ ["macho", "zero-fill", "invalid_request"],
+ ] as const) {
+ const bytes = createRustImageWithGoSection(format, fault);
+ if (fault === "zero-address") {
+ const sectionHeader = 512;
+ expect(bytes.readUInt32LE(sectionHeader + 4)).toBe(1);
+ expect(bytes.readBigUInt64LE(sectionHeader + 8)).toBe(0n);
+ expect(bytes.readBigUInt64LE(sectionHeader + 16)).toBe(0n);
+ expect(bytes.readBigUInt64LE(sectionHeader + 24)).toBe(3072n);
+ expect(bytes.readBigUInt64LE(sectionHeader + 32)).toBe(16n);
+ const nameOffset = bytes.readUInt32LE(sectionHeader);
+ expect(
+ bytes.toString(
+ "ascii",
+ 768 + nameOffset,
+ 768 + nameOffset + ".go.buildinfo".length,
+ ),
+ ).toBe(".go.buildinfo");
+ }
+ const path = join(root, `mixed.${format}`);
+ await writeFile(path, bytes);
+ const result = await cli.run({
+ arguments: ["inspect-rust-binary", path, "--json"],
+ });
+ expect(result.exitCode, result.stderr).toBe(0);
+ const evidence = toolContract("inspect_rust_binary").outputSchema.parse(
+ result.json,
+ );
+ expect(evidence.normalized_result).toMatchObject({
+ artifact: {
+ path,
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ },
+ format,
+ auditable_metadata: {
+ file_offset: 1024,
+ root_package_index: 1,
+ packages: [
+ { name: "itoa", version: "1.0.18" },
+ { name: "sample", version: "0.1.0", root: true },
+ ],
+ dependency_edges: [{ from_index: 1, to_index: 0 }],
+ },
+ rustc_commit: {
+ evidence_kind: "inferred",
+ hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }],
+ },
+ });
+ const response = await call("inspect_rust_binary", { path });
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ expect(response.structuredContent).toEqual(evidence);
+ const go = await cli.run({
+ arguments: ["inspect-go-binary", path, "--json"],
+ });
+ expect(go.exitCode).toBe(1);
+ expect(go.json).toMatchObject({
+ code,
+ details: { operation: "inspect_go_binary" },
+ });
+ if (fault === "zero-address")
+ expect(go.json).toMatchObject({
+ details: {
+ issues: [{ path: ["path"], reason: "invalid_format" }],
+ },
+ });
+ const goResponse = await call("inspect_go_binary", { path });
+ expect(parseMcpToolError(goResponse).error).toMatchObject({
+ code,
+ details: { operation: "inspect_go_binary" },
+ });
+ if (fault === "zero-address")
+ expect(parseMcpToolError(goResponse).error).toMatchObject({
+ details: {
+ issues: [{ path: ["path"], reason: "invalid_format" }],
+ },
+ });
+ }
+ await client.ping();
+ },
+ 30000,
+);
diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts
index 9bc91c1853..c45d26f361 100644
--- a/tests/boundary/go/goBinaryImage.test.ts
+++ b/tests/boundary/go/goBinaryImage.test.ts
@@ -5,11 +5,11 @@ import {
fatImage,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
-import {
- readGoBinaryImage,
- GoBinaryResourceFailure,
-} from "../../../src/go/GoBinaryImage.js";
-import { readGoMachoImage } from "../../../src/go/GoMachoImage.js";
+import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js";
+import { goBuildInfoSearch } from "../../../src/go/GoBuildInfoSearch.js";
+import { BinaryResourceFailure } from "../../../src/native/metadata/BinaryContainer.js";
+import { readMachoImage } from "../../../src/native/metadata/MachoImage.js";
+import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js";
import {
createGoBinaryFixture,
createGoSplitElfFixture,
@@ -17,9 +17,43 @@ import {
GO_MODULE_TEXT,
} from "../../fixtures/go/image.js";
+it("preserves exact Mach-O protection masks for Go fallback selection", () => {
+ const { bytes } = createGoBinaryFixture({
+ format: "macho",
+ sectionless: true,
+ });
+ const firstSegment = 32;
+ const secondSegment = 104;
+ bytes.writeUInt32LE(2, 16);
+ bytes.writeUInt32LE(144, 20);
+ bytes.writeUInt32LE(11, firstSegment + 56);
+ bytes.writeUInt32LE(11, firstSegment + 60);
+ bytes.writeUInt32LE(0x19, secondSegment);
+ bytes.writeUInt32LE(72, secondSegment + 4);
+ bytes.write("__DATA2", secondSegment + 8, "ascii");
+ bytes.writeBigUInt64LE(0x20000n, secondSegment + 24);
+ bytes.writeBigUInt64LE(BigInt(bytes.length), secondSegment + 32);
+ bytes.writeBigUInt64LE(0n, secondSegment + 40);
+ bytes.writeBigUInt64LE(BigInt(bytes.length), secondSegment + 48);
+ bytes.writeUInt32LE(3, secondSegment + 56);
+ bytes.writeUInt32LE(3, secondSegment + 60);
+
+ const container = readBinaryContainer(bytes);
+ expect(
+ container.load_regions?.map((region) => region.protection_masks),
+ ).toEqual([
+ { current: 11, maximum: 11 },
+ { current: 3, maximum: 3 },
+ ]);
+ expect(goBuildInfoSearch(bytes, container)).toMatchObject([
+ { address: 0x20000n, offset: 0, size: bytes.length },
+ ]);
+ expect(readGoBinaryImage(bytes).build_info?.go_version).toBe("go1.26.0");
+});
+
it("bounds cumulative header searches across overlapping ELF file spans", () => {
const bytes = createGoOverlappingElfFixture();
- expect(() => readGoBinaryImage(bytes)).toThrow(GoBinaryResourceFailure);
+ expect(() => readGoBinaryImage(bytes)).toThrow(BinaryResourceFailure);
expect(() => readGoBinaryImage(bytes)).toThrow(/256 MiB.*search/);
});
@@ -633,7 +667,7 @@ it("does not mark Mach-O section bytes outside their owning segment as read-only
fixture.bytes.writeBigUInt64LE(0x10c00n, section + 32);
fixture.bytes.writeBigUInt64LE(128n, section + 40);
fixture.bytes.writeUInt32LE(3072, section + 48);
- const image = readGoMachoImage(fixture.bytes);
+ const image = readMachoImage(fixture.bytes);
expect(image.sections[0]).toMatchObject({
file_backed: false,
read_only: false,
diff --git a/tests/boundary/go/goBinaryMappings.test.ts b/tests/boundary/go/goBinaryMappings.test.ts
index 96a850e48b..c0b4223225 100644
--- a/tests/boundary/go/goBinaryMappings.test.ts
+++ b/tests/boundary/go/goBinaryMappings.test.ts
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
-import { mappedFileOffset } from "../../../src/go/GoBinaryContainer.js";
+import { mappedFileOffset } from "../../../src/native/metadata/BinaryContainer.js";
const selected = { address: 100n, offset: 16, size: 64 };
diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts
index d4aa12283f..4f94a4cbdc 100644
--- a/tests/boundary/rust/rustBinaryImage.test.ts
+++ b/tests/boundary/rust/rustBinaryImage.test.ts
@@ -2,11 +2,14 @@ import { expect, it } from "vitest";
import { fat } from "../../../src/domain/binaryTarget.fixture.js";
import {
createRustImage,
+ createRustImageWithGoSection,
createRustHashOverflowImage,
RUSTC_HASH,
rustDependencyPayload,
} from "../../fixtures/rust/image.js";
import { readRustBinaryImage } from "../../../src/rust/RustBinaryImage.js";
+import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js";
+import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js";
import { RustAuditableFormatFailure } from "../../../src/rust/RustAuditableReader.js";
const packages = [
@@ -139,3 +142,48 @@ it("refuses read-only data with more distinct rustc paths than it retains", () =
}),
);
});
+
+it.each([
+ ["elf", "compressed"],
+ ["elf", "unmapped"],
+ ["elf", "zero-fill"],
+ ["elf", "duplicate"],
+ ["elf", "out-of-file"],
+ ["elf", "zero-address"],
+ ["macho", "unmapped"],
+ ["macho", "zero-fill"],
+ ["macho", "duplicate"],
+ ["macho", "out-of-file"],
+] as const)(
+ "retains Rust evidence beside an unrelated %s %s Go section",
+ (format, fault) => {
+ const bytes = createRustImageWithGoSection(format, fault);
+ const control = Buffer.from(bytes);
+ const sectionOffset = format === "elf" ? 768 + 11 : 408;
+ const sectionName = Buffer.from(
+ (format === "elf" ? ".other\0" : "__other\0").padEnd(
+ format === "elf" ? 13 : 16,
+ "\0",
+ ),
+ "ascii",
+ );
+ if (format === "elf") {
+ const nameOffset = Number(bytes.readBigUInt64LE(320 + 32));
+ sectionName.copy(control, 768 + nameOffset);
+ } else {
+ sectionName.copy(control, sectionOffset);
+ }
+ expect(readBinaryContainer(bytes).load_regions).toEqual(
+ readBinaryContainer(control).load_regions,
+ );
+ expect(() => readGoBinaryImage(bytes)).toThrow(/build-info|mapped/i);
+ expect(readRustBinaryImage(bytes)).toMatchObject({
+ format,
+ auditable_metadata: { file_offset: 1024, packages },
+ rustc_commit: {
+ evidence_kind: "inferred",
+ hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }],
+ },
+ });
+ },
+);
diff --git a/tests/fixtures/rust/image.ts b/tests/fixtures/rust/image.ts
index 6bbac25abe..c80a40ad24 100644
--- a/tests/fixtures/rust/image.ts
+++ b/tests/fixtures/rust/image.ts
@@ -261,3 +261,75 @@ export const createRustHashOverflowImage = (): Buffer => {
}
return bytes;
};
+
+/** Valid Rust evidence beside an independently invalid Go metadata section. */
+export const createRustImageWithGoSection = (
+ format: "elf" | "macho",
+ fault:
+ | "compressed"
+ | "unmapped"
+ | "zero-fill"
+ | "duplicate"
+ | "out-of-file"
+ | "zero-address",
+): Buffer => {
+ const bytes = createRustImage({ format });
+ const count = fault === "duplicate" ? 2 : 1;
+ if (format === "elf") {
+ const nameOffset = Number(bytes.readBigUInt64LE(320 + 32));
+ const name = Buffer.from(".go.buildinfo\0");
+ name.copy(bytes, 768 + nameOffset);
+ bytes.writeBigUInt64LE(BigInt(nameOffset + name.length), 320 + 32);
+ bytes.writeUInt16LE(4 + count, 60);
+ for (let index = 0; index < count; index++) {
+ const header = 512 + index * 64;
+ bytes.writeUInt32LE(nameOffset, header);
+ bytes.writeUInt32LE(fault === "zero-fill" ? 8 : 1, header + 4);
+ bytes.writeBigUInt64LE(fault === "compressed" ? 0x800n : 0n, header + 8);
+ bytes.writeBigUInt64LE(
+ fault === "zero-address"
+ ? 0n
+ : fault === "unmapped"
+ ? 0x600000n
+ : 0x400c00n,
+ header + 16,
+ );
+ bytes.writeBigUInt64LE(
+ fault === "out-of-file" ? 4088n : 3072n,
+ header + 24,
+ );
+ bytes.writeBigUInt64LE(fault === "zero-address" ? 16n : 32n, header + 32);
+ if (fault === "zero-address")
+ Buffer.from("additional data\0", "ascii").copy(bytes, 3072);
+ }
+ } else {
+ if (fault === "compressed") throw new Error("Not a Mach-O section flag");
+ if (fault === "zero-address") throw new Error("Not an ELF section address");
+ bytes.writeUInt32LE(2 + count, 16);
+ bytes.writeUInt32LE((2 + count) * 152, 20);
+ for (let index = 0; index < count; index++) {
+ const command = 336 + index * 152;
+ const little: RustImageWrite["little"] = (offset, value, width) => {
+ if (width === 16) bytes.writeUInt16LE(value, offset);
+ else if (width === 32) bytes.writeUInt32LE(value, offset);
+ else bytes.writeBigUInt64LE(BigInt(value), offset);
+ };
+ writeMachoSegment({
+ bytes,
+ little,
+ command,
+ segment: "__EXTRA",
+ section: "__go_buildinfo",
+ body: Buffer.alloc(32),
+ fileOffset: 3072,
+ protection: fault === "unmapped" ? 0 : 3,
+ flags: fault === "zero-fill" ? 1 : 0,
+ });
+ if (fault === "out-of-file") bytes.writeUInt32LE(4088, command + 72 + 48);
+ bytes.writeBigUInt64LE(0x200000000n, command + 24);
+ bytes.writeBigUInt64LE(0x200000000n, command + 72 + 32);
+ if (fault === "unmapped") bytes.writeBigUInt64LE(0n, command + 32);
+ }
+ }
+ return bytes;
+};
From bbbd208743d30edc0ec260667c51a184ddad6007 Mon Sep 17 00:00:00 2001
From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:00:16 +0800
Subject: [PATCH 05/45] fix(pcap): preserve frames with empty protocol stacks
(#5762)
Preserve valid empty TShark protocol stacks at the shared parser, retain frame and other fact inventories, and cover malformed producer output and mixed-frame ordering.
Validated with focused source/boundary tests, full static checks, docs checks, real Linux CLI/MCP and a mixed-interface public HTTP pcapng. Latest-head Codex found no issues; Greptile is 5/5 and its test-coverage thread is addressed. Remaining queued CI was bypassed under the human owner's explicit authorization; this does not claim that the full CI suite passed.
Fixes #5734.
---
docs/pcap-analysis.md | 6 ++-
src/pcap/PcapOutput.test.ts | 87 +++++++++++++++++++++++++++++++++++++
src/pcap/PcapOutput.ts | 16 +++----
3 files changed, 100 insertions(+), 9 deletions(-)
diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md
index 3e13ec2251..58c5dcd037 100644
--- a/docs/pcap-analysis.md
+++ b/docs/pcap-analysis.md
@@ -67,7 +67,11 @@ Addresses count once per packet, including loopback and nested IP occurrences.
DNS addresses are associated only with one question in the same response packet;
additional records may be included, so this does not prove final resolution.
Ambiguous addresses remain in a separate inventory. Protocol distribution counts
-exact `frame.protocols` stack strings. TLS SNI is recorded handshake metadata,
+exact nonempty `frame.protocols` stack strings. Frames for which TShark reports
+an empty stack, including zero-length packets, still contribute to the packet
+count without adding a protocol row. An all-empty stack inventory can have
+complete examined coverage; it does not establish the absence of network traffic.
+TLS SNI is recorded handshake metadata,
not process identity or proof of the destination's authenticity.
All examined inventories are retained within the resource budget; no fixed
diff --git a/src/pcap/PcapOutput.test.ts b/src/pcap/PcapOutput.test.ts
index 12569151f0..251abb39e2 100644
--- a/src/pcap/PcapOutput.test.ts
+++ b/src/pcap/PcapOutput.test.ts
@@ -16,6 +16,93 @@ const capture = (layers: ReadonlyArray>) =>
})),
);
+it.each([
+ [[], []],
+ [["", ""], []],
+ [["user_dlt:data", ""], [{ protocol: "user_dlt:data", packet_count: 1 }]],
+ [["", "user_dlt:data"], [{ protocol: "user_dlt:data", packet_count: 1 }]],
+ [
+ ["user_dlt:data", "", "user_dlt:data"],
+ [{ protocol: "user_dlt:data", packet_count: 2 }],
+ ],
+] as const)(
+ "counts every frame while aggregating only reported protocol stacks: %j",
+ async (stacks, expected) => {
+ const result = await parseTsharkJsonOutput(
+ capture(stacks.map((stack) => ({ "frame.protocols": [stack] }))),
+ );
+ expect(result.packetCount).toBe(stacks.length);
+ expect(result.protocolDistribution).toEqual(expected);
+ expect(result.protocolCount).toBe(expected.length);
+ expect(result.partial).toBe(false);
+ },
+);
+
+it.each([
+ { "frame.number": ["1"] },
+ { "frame.number": ["1"], "frame.protocols": [] },
+ { "frame.number": ["1"], "frame.protocols": ["", "ip"] },
+ { "frame.number": ["1"], "frame.protocols": ["ip", "ip"] },
+ { "frame.number": ["1"], "frame.protocols": [null] },
+ { "frame.number": ["1"], "frame.protocols": "" },
+ { "frame.protocols": [""] },
+ { "frame.number": ["2"], "frame.protocols": [""] },
+])("rejects malformed or ambiguous frame inventories: %j", async (layers) => {
+ await expect(
+ parseTsharkJsonOutput(JSON.stringify([{ _source: { layers } }])),
+ ).rejects.toMatchObject({ _tag: "AnalysisProtocolError" });
+});
+
+it.each(["before", "after"] as const)(
+ "retains endpoint and DNS facts with an empty stack %s the response",
+ async (position) => {
+ const empty = { "frame.protocols": [""] };
+ const response = {
+ "frame.protocols": ["eth:ethertype:ip:udp:dns"],
+ "ip.src": ["192.0.2.53"],
+ "ip.dst": ["192.0.2.1"],
+ "dns.qry.name": ["api.example"],
+ "dns.flags.response": ["True"],
+ "dns.a": ["203.0.113.5"],
+ };
+ const result = await parseTsharkJsonOutput(
+ capture(position === "before" ? [empty, response] : [response, empty]),
+ );
+ expect(result).toMatchObject({
+ packetCount: 2,
+ protocolCount: 1,
+ protocolDistribution: [
+ { protocol: "eth:ethertype:ip:udp:dns", packet_count: 1 },
+ ],
+ endpointCount: 2,
+ endpoints: [
+ { address: "192.0.2.1", port: null, packet_count: 1 },
+ { address: "192.0.2.53", port: null, packet_count: 1 },
+ ],
+ dnsQueryCount: 1,
+ dnsQueries: [
+ {
+ query: "api.example",
+ response_addresses: ["203.0.113.5"],
+ response_address_count: 1,
+ },
+ ],
+ partial: false,
+ });
+ },
+);
+
+it("rejects duplicate frame identities even when protocol stacks are empty", async () => {
+ await expect(
+ parseTsharkJsonOutput(
+ capture([
+ { "frame.protocols": [""] },
+ { "frame.number": ["1"], "frame.protocols": [""] },
+ ]),
+ ),
+ ).rejects.toMatchObject({ _tag: "AnalysisProtocolError" });
+});
+
it("binds interleaved DNS answers to the question in their response packet", async () => {
const result = await parseTsharkJsonOutput(
capture([
diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts
index 7226e46370..1f6da7ee6e 100644
--- a/src/pcap/PcapOutput.ts
+++ b/src/pcap/PcapOutput.ts
@@ -269,13 +269,14 @@ export const parseTsharkJsonOutput = async (
return protocolFailure(
"frame inventory is missing, duplicated or out of order",
);
- if (
- field("frame.protocols").length !== 1 ||
- field("frame.protocols")[0] === ""
- )
+ const protocolStack = field("frame.protocols");
+ if (protocolStack.length !== 1)
return protocolFailure(
"packet protocol inventory is missing or ambiguous",
);
+ // TShark reports [""] for a frame with no dissected protocol, including
+ // zero-length packets. Keep the frame without inventing a protocol row.
+ const protocol = protocolStack[0]!;
// Count one occurrence per packet even for loopback or nested IP layers.
for (const address of new Set([
...field("ip.src"),
@@ -314,10 +315,9 @@ export const parseTsharkJsonOutput = async (
}
for (const value of field("tls.handshake.extensions_server_name"))
if (value !== "") add(sni, value);
- for (const value of new Set(field("frame.protocols"))) {
- if (value === "") continue;
- if (!protocols.has(value)) charge(value);
- protocols.set(value, (protocols.get(value) ?? 0) + 1);
+ if (protocol !== "") {
+ if (!protocols.has(protocol)) charge(protocol);
+ protocols.set(protocol, (protocols.get(protocol) ?? 0) + 1);
}
for (const value of field("http.request.full_uri")) {
if (value === "" || observedUris.has(value)) continue;
From ccfaf520b752a789ac19496557a3b0c4d4910bde Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:02:15 +0800
Subject: [PATCH 06/45] perf(test): reuse one compiled CLI process per
JavaScript application file (#5676)
* perf(test): reuse one compiled CLI process per JavaScript application file
analyzeJavaScriptDirectory CLI cases were still starting a fresh process
after stdio MCP reuse. Keep one compiled process for the same child
environment so later cases skip Node and CLI startup.
Co-authored-by: morluto
* fix(test): bound shared CLI worker lifetime
Retire a worker that reports a fatal failure before resolving that command,
and wait for it to exit before the next command starts. Join a session frame
once its bytes are complete instead of copying the unfinished buffer on every
chunk. Restart the shared process after 32 commands or a reported 1 GiB
resident set so one file does not keep a single heap high-water mark.
Co-authored-by: morluto
* fix(test): retire a shared CLI worker after a transport failure
A malformed session frame rejected the command and sent SIGTERM without
clearing the worker. signalCode stays unset until the process exits, so the
next queued command could write to that same process. Transport failures now
use the fatal-reply retirement path and the queue waits for that exit.
Co-authored-by: morluto
---------
Co-authored-by: Cursor Agent
Co-authored-by: morluto
---
docs/testing.md | 10 +-
.../cli/sharedJavaScriptCliSession.test.ts | 577 ++++++++++++++++++
tests/support/cli/cliCommandSession.ts | 448 ++++++++++++++
.../support/cli/cliCommandSessionFrames.d.mts | 13 +
tests/support/cli/cliCommandSessionFrames.mjs | 77 +++
tests/support/cli/cliCommandSessionWorker.mjs | 263 ++++++++
tests/support/cli/cliFixture.ts | 85 ++-
tests/support/cli/javascriptApplication.ts | 9 +-
8 files changed, 1464 insertions(+), 18 deletions(-)
create mode 100644 tests/boundary/cli/sharedJavaScriptCliSession.test.ts
create mode 100644 tests/support/cli/cliCommandSession.ts
create mode 100644 tests/support/cli/cliCommandSessionFrames.d.mts
create mode 100644 tests/support/cli/cliCommandSessionFrames.mjs
create mode 100644 tests/support/cli/cliCommandSessionWorker.mjs
diff --git a/docs/testing.md b/docs/testing.md
index e0b3f39a77..ab8e501a5f 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -156,7 +156,15 @@ rejection without a reason. That cleanup stays owned until a later close or
call releases it. Another server does not open while the cleanup remains, and
a cleanup that already succeeded is not run again. Pass
`mcpCall` when a case needs its own server, environment, or lifecycle. CLI
-cases still start a fresh process.
+cases for that helper share one compiled CLI process per child environment.
+The first case pays process startup; later cases reuse the loaded runtime.
+The process restarts when the environment changes, after 32 commands, or when
+the worker reports a resident set of at least 1 GiB. A fatal worker failure
+or a broken session frame retires that process and waits for it to exit before
+the next command starts.
+`cli.run` still starts a fresh process, including stdin and explicit deadlines.
+File teardown stops the shared CLI process, including startup still in flight,
+and does not open another after close.
The Apple artifact verifier step shares a job-temporary bytecode cache among
its fresh CLI/MCP children via `REA_ARTIFACT_NODE_COMPILE_CACHE`. The artifact
diff --git a/tests/boundary/cli/sharedJavaScriptCliSession.test.ts b/tests/boundary/cli/sharedJavaScriptCliSession.test.ts
new file mode 100644
index 0000000000..3794f70486
--- /dev/null
+++ b/tests/boundary/cli/sharedJavaScriptCliSession.test.ts
@@ -0,0 +1,577 @@
+import { EventEmitter, PassThrough } from "node:stream";
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+
+import { expect, test } from "vitest";
+import { z } from "zod";
+
+import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import {
+ cliTest,
+ type CliInvocation,
+ type CliResult,
+} from "../../support/cli/cliFixture.js";
+import { createCliCommandSession } from "../../support/cli/cliCommandSession.js";
+import {
+ createCliSessionFrameReader,
+ encodeCliSessionFrame,
+ MAX_CLI_SESSION_FRAME_BYTES,
+} from "../../support/cli/cliCommandSessionFrames.mjs";
+import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
+import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js";
+
+const requestSchema = z.object({
+ id: z.number(),
+ arguments: z.array(z.string()),
+});
+
+const command = (
+ arguments_: readonly string[],
+ environment: Readonly>,
+): CliInvocation & {
+ cwd: string;
+ environment: Readonly>;
+} => ({
+ arguments: arguments_,
+ cwd: process.cwd(),
+ environment,
+});
+
+interface FakeCliProcess extends EventEmitter {
+ stdin: PassThrough;
+ stdout: PassThrough;
+ stderr: PassThrough;
+ pid: number;
+ exitCode: number | null;
+ signalCode: NodeJS.Signals | null;
+ kill(signal?: NodeJS.Signals): boolean;
+}
+
+const fakeProcess = (pid: number): FakeCliProcess => {
+ const child = new EventEmitter() as FakeCliProcess;
+ child.stdin = new PassThrough();
+ child.stdout = new PassThrough();
+ child.stderr = new PassThrough();
+ child.pid = pid;
+ child.exitCode = null;
+ child.signalCode = null;
+ child.kill = (signal: NodeJS.Signals = "SIGTERM") => {
+ if (child.exitCode !== null || child.signalCode !== null) return true;
+ child.signalCode = signal;
+ child.emit("exit", null, signal);
+ child.emit("close", null, signal);
+ return true;
+ };
+ return child;
+};
+
+const asCliProcess = (child: FakeCliProcess): ChildProcessWithoutNullStreams =>
+ child as unknown as ChildProcessWithoutNullStreams;
+
+const waitFor = async (ready: () => boolean): Promise => {
+ for (let attempt = 0; attempt < 30; attempt += 1) {
+ if (ready()) return;
+ await new Promise((resolve) => {
+ setImmediate(resolve);
+ });
+ }
+ throw new Error("shared CLI process did not reach the expected state");
+};
+
+test("reads a CLI session frame split across chunks", () => {
+ const frames: unknown[] = [];
+ const reader = createCliSessionFrameReader((frame) => {
+ frames.push(frame);
+ });
+ const encoded = encodeCliSessionFrame({ ready: true, seq: 1 });
+ const second = encodeCliSessionFrame({ id: 2 });
+ const combined = Buffer.concat([encoded, second]);
+ reader(combined.subarray(0, 1));
+ expect(frames).toEqual([]);
+ reader(combined.subarray(1, 3));
+ expect(frames).toEqual([]);
+ reader(combined.subarray(3));
+ expect(frames).toEqual([{ ready: true, seq: 1 }, { id: 2 }]);
+});
+
+test("rejects an oversized CLI session frame", () => {
+ const reader = createCliSessionFrameReader(() => undefined);
+ const header = Buffer.alloc(4);
+ header.writeUInt32BE(MAX_CLI_SESSION_FRAME_BYTES + 1);
+ expect(() => reader(header)).toThrow(/limit/);
+});
+
+test("reads a CLI session frame delivered one byte at a time", () => {
+ const frames: unknown[] = [];
+ const reader = createCliSessionFrameReader((frame) => {
+ frames.push(frame);
+ });
+ const encoded = encodeCliSessionFrame({
+ id: 7,
+ note: "x".repeat(64),
+ });
+ for (const byte of encoded) reader(Buffer.from([byte]));
+ expect(frames).toEqual([{ id: 7, note: "x".repeat(64) }]);
+});
+
+test("stops an unready shared CLI process when the session closes", async () => {
+ let spawned: FakeCliProcess | undefined;
+ const session = createCliCommandSession({
+ spawnWorker: () => {
+ spawned = fakeProcess(41);
+ return asCliProcess(spawned);
+ },
+ });
+ const pending = session.run(
+ command(["--version"], { PATH: "/usr/bin", REA_LOG_LEVEL: "silent" }),
+ );
+ await waitFor(() => spawned !== undefined);
+ await session.close();
+ await expect(pending).rejects.toThrow("Shared CLI session is closed");
+ expect(spawned?.signalCode).toBe("SIGTERM");
+ await expect(
+ session.run(
+ command(["--version"], { PATH: "/usr/bin", REA_LOG_LEVEL: "silent" }),
+ ),
+ ).rejects.toThrow("Shared CLI session is closed");
+ await session.close();
+});
+
+test("stops an in-flight shared CLI command when the session closes", async () => {
+ let received = false;
+ let spawned: FakeCliProcess | undefined;
+ const session = createCliCommandSession({
+ spawnWorker: () => {
+ const child = fakeProcess(42);
+ spawned = child;
+ child.stdout.write(encodeCliSessionFrame({ ready: true }));
+ child.stdin.on("data", () => {
+ received = true;
+ });
+ return asCliProcess(child);
+ },
+ });
+ const pending = session.run(
+ command(["--version"], { PATH: "/usr/bin", REA_LOG_LEVEL: "silent" }),
+ );
+ await waitFor(() => received);
+ await session.close();
+ await expect(pending).rejects.toThrow("Shared CLI session is closed");
+ expect(spawned?.signalCode).toBe("SIGTERM");
+});
+
+test("reuses one process and restarts it when the environment changes", async () => {
+ let spawns = 0;
+ const session = createCliCommandSession({
+ spawnWorker: (environment) => {
+ spawns += 1;
+ const child = fakeProcess(50 + spawns);
+ const reader = createCliSessionFrameReader((value) => {
+ const request = requestSchema.parse(value);
+ child.stdout.write(
+ encodeCliSessionFrame({
+ id: request.id,
+ exitCode: 0,
+ stdout: request.arguments.join(" "),
+ stderr: environment.MARKER ?? "",
+ }),
+ );
+ });
+ child.stdout.write(encodeCliSessionFrame({ ready: true }));
+ child.stdin.on("data", (chunk) => {
+ reader(chunk);
+ });
+ return asCliProcess(child);
+ },
+ });
+ const first = await session.run(
+ command(["one"], { PATH: "a", MARKER: "first" }),
+ );
+ const second = await session.run(
+ command(["two"], { PATH: "a", MARKER: "first" }),
+ );
+ expect(spawns).toBe(1);
+ expect(session.processId()).toBe(51);
+ expect(first.stdout).toBe("one");
+ expect(second.stdout).toBe("two");
+ expect(first.stderr).toBe("first");
+ expect(second.stderr).toBe("first");
+
+ const changed = await session.run(
+ command(["three"], { PATH: "b", MARKER: "second" }),
+ );
+ expect(spawns).toBe(2);
+ expect(session.processId()).toBe(52);
+ expect(changed.stdout).toBe("three");
+ expect(changed.stderr).toBe("second");
+ await session.close();
+ expect(session.processId()).toBeUndefined();
+});
+
+const reply = (
+ child: FakeCliProcess,
+ id: number,
+ fields: {
+ exitCode?: number;
+ stdout?: string;
+ stderr?: string;
+ rss?: number;
+ fatal?: boolean;
+ },
+): void => {
+ child.stdout.write(
+ encodeCliSessionFrame({
+ id,
+ exitCode: fields.exitCode ?? 0,
+ stdout: fields.stdout ?? "",
+ stderr: fields.stderr ?? "",
+ ...(fields.rss === undefined ? {} : { rss: fields.rss }),
+ ...(fields.fatal === undefined ? {} : { fatal: fields.fatal }),
+ }),
+ );
+};
+
+const respondingProcess = (
+ pid: number,
+ onRequest: (
+ request: { id: number; arguments: string[] },
+ child: FakeCliProcess,
+ ) => void,
+): FakeCliProcess => {
+ const child = fakeProcess(pid);
+ child.stdout.write(encodeCliSessionFrame({ ready: true }));
+ const reader = createCliSessionFrameReader((value) => {
+ onRequest(requestSchema.parse(value), child);
+ });
+ child.stdin.on("data", (chunk: Buffer) => {
+ reader(chunk);
+ });
+ return child;
+};
+
+test("starts another process after the shared CLI process exits", async () => {
+ const children: FakeCliProcess[] = [];
+ const session = createCliCommandSession({
+ spawnWorker: () => {
+ const child = fakeProcess(70 + children.length);
+ children.push(child);
+ child.stdout.write(encodeCliSessionFrame({ ready: true }));
+ if (children.length === 1) {
+ child.stdin.on("data", () => {
+ child.kill("SIGTERM");
+ });
+ } else {
+ const reader = createCliSessionFrameReader((value) => {
+ const request = requestSchema.parse(value);
+ child.stdout.write(
+ encodeCliSessionFrame({
+ id: request.id,
+ exitCode: 0,
+ stdout: "recovered",
+ stderr: "",
+ }),
+ );
+ });
+ child.stdin.on("data", (chunk) => {
+ reader(chunk);
+ });
+ }
+ return asCliProcess(child);
+ },
+ });
+ await expect(
+ session.run(command(["fail"], { PATH: "/usr/bin" })),
+ ).rejects.toThrow(/Shared CLI process exited/);
+ await expect(
+ session.run(command(["again"], { PATH: "/usr/bin" })),
+ ).resolves.toMatchObject({
+ exitCode: 0,
+ stdout: "recovered",
+ });
+ expect(children).toHaveLength(2);
+ await session.close();
+});
+
+test("retires a fatal worker before starting the next command", async () => {
+ const events: string[] = [];
+ const session = createCliCommandSession({
+ spawnWorker: () => {
+ const generation = events.filter((event) =>
+ event.startsWith("spawn"),
+ ).length;
+ events.push(`spawn:${generation}`);
+ const child = respondingProcess(80 + generation, (request, current) => {
+ events.push(`command:${generation}`);
+ if (generation === 0) {
+ reply(current, request.id, {
+ exitCode: 1,
+ stderr: "boom",
+ fatal: true,
+ });
+ return;
+ }
+ reply(current, request.id, { stdout: "next" });
+ });
+ const stop = child.kill.bind(child);
+ child.kill = (signal?: NodeJS.Signals) => {
+ events.push(`kill:${generation}`);
+ return stop(signal);
+ };
+ return asCliProcess(child);
+ },
+ });
+ const fatal = session.run(command(["fail"], { PATH: "/usr/bin" }));
+ const next = session.run(command(["next"], { PATH: "/usr/bin" }));
+ await expect(fatal).resolves.toMatchObject({ exitCode: 1, stderr: "boom" });
+ await expect(next).resolves.toMatchObject({ exitCode: 0, stdout: "next" });
+ expect(events).toEqual([
+ "spawn:0",
+ "command:0",
+ "kill:0",
+ "spawn:1",
+ "command:1",
+ ]);
+ await session.close();
+});
+
+test("retires a transport failure before the next command reuses the worker", async () => {
+ const events: string[] = [];
+ const session = createCliCommandSession({
+ spawnWorker: () => {
+ const generation = events.filter((event) =>
+ event.startsWith("spawn"),
+ ).length;
+ events.push(`spawn:${generation}`);
+ const child = respondingProcess(120 + generation, (request, current) => {
+ events.push(`command:${generation}`);
+ if (generation === 0) {
+ current.stdout.write(encodeCliSessionFrame({ id: request.id }));
+ return;
+ }
+ reply(current, request.id, { stdout: "recovered" });
+ });
+ const stop = child.kill.bind(child);
+ child.kill = (signal: NodeJS.Signals = "SIGTERM") => {
+ events.push(`kill:${generation}`);
+ // Leave signalCode unset until a later turn. A synchronous SIGTERM
+ // does not update it, so reuse cannot depend on that field.
+ queueMicrotask(() => {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ stop(signal);
+ });
+ return true;
+ };
+ return asCliProcess(child);
+ },
+ });
+ const failed = session.run(command(["bad"], { PATH: "/usr/bin" }));
+ const next = session.run(command(["next"], { PATH: "/usr/bin" }));
+ await expect(failed).rejects.toThrow(/exit code/);
+ await expect(next).resolves.toMatchObject({ stdout: "recovered" });
+ expect(events).toEqual([
+ "spawn:0",
+ "command:0",
+ "kill:0",
+ "spawn:1",
+ "command:1",
+ ]);
+ await session.close();
+});
+
+test("restarts the shared CLI process after the command limit", async () => {
+ const children: FakeCliProcess[] = [];
+ const session = createCliCommandSession({
+ commandLimit: 2,
+ spawnWorker: () => {
+ const child = respondingProcess(
+ 90 + children.length,
+ (request, current) => {
+ reply(current, request.id, {
+ stdout: String(children.indexOf(current)),
+ });
+ },
+ );
+ children.push(child);
+ return asCliProcess(child);
+ },
+ });
+ const first = await session.run(command(["one"], { PATH: "/usr/bin" }));
+ const second = await session.run(command(["two"], { PATH: "/usr/bin" }));
+ expect(children).toHaveLength(1);
+ expect(first.stdout).toBe("0");
+ expect(second.stdout).toBe("0");
+ const third = await session.run(command(["three"], { PATH: "/usr/bin" }));
+ expect(children).toHaveLength(2);
+ expect(children[0]?.signalCode).toBe("SIGTERM");
+ expect(third.stdout).toBe("1");
+ expect(session.processId()).toBe(91);
+ await session.close();
+});
+
+test("restarts the shared CLI process after the resident-set limit", async () => {
+ const children: FakeCliProcess[] = [];
+ const session = createCliCommandSession({
+ commandLimit: 100,
+ residentLimitBytes: 100,
+ spawnWorker: () => {
+ const generation = children.length;
+ const child = respondingProcess(100 + generation, (request, current) => {
+ reply(current, request.id, {
+ stdout: String(generation),
+ rss: generation === 0 ? 150 : 10,
+ });
+ });
+ children.push(child);
+ return asCliProcess(child);
+ },
+ });
+ await expect(
+ session.run(command(["one"], { PATH: "/usr/bin" })),
+ ).resolves.toMatchObject({ stdout: "0" });
+ expect(session.processId()).toBe(100);
+ await expect(
+ session.run(command(["two"], { PATH: "/usr/bin" })),
+ ).resolves.toMatchObject({ stdout: "1" });
+ expect(children[0]?.signalCode).toBe("SIGTERM");
+ expect(session.processId()).toBe(101);
+ await session.close();
+});
+
+test("starts a new process after the real worker reports a fatal failure", async () => {
+ const session = createCliCommandSession();
+ const failed = await session.run({
+ arguments: ["--version"],
+ cwd: "/workspace/this-directory-does-not-exist",
+ environment: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
+ });
+ expect(failed.exitCode).toBe(1);
+ expect(failed.stderr).toMatch(/ENOENT/);
+ const recovered = await session.run({
+ arguments: ["--version"],
+ cwd: process.cwd(),
+ environment: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
+ });
+ expect(recovered.exitCode).toBe(0);
+ expect(recovered.stdout.length).toBeGreaterThan(0);
+ expect(session.processId()).toEqual(expect.any(Number));
+ await session.close();
+});
+
+const requireSharedCli = (cli: {
+ sharedCliRun?: (invocation: CliInvocation) => Promise;
+}): ((invocation: CliInvocation) => Promise) => {
+ const run = cli.sharedCliRun;
+ if (run === undefined)
+ throw new Error("cliTest did not provide a shared CLI session");
+ return run;
+};
+
+const normalized = (result: CliResult) =>
+ z
+ .object({ normalized_result: javascriptApplicationAnalysisResultSchema })
+ .parse(result.json).normalized_result;
+
+const analyzeArguments = (root: string): readonly string[] => [
+ "analyze-javascript-application",
+ root,
+ "--format",
+ "json",
+];
+
+const writeApplication = async (prefix: string): Promise => {
+ const root = await createTestTempDirectory(prefix);
+ await writeFixtureFiles(root, {
+ "package.json": JSON.stringify({ main: "main.cjs" }),
+ "main.cjs":
+ 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");',
+ "index.html": "page",
+ });
+ return root;
+};
+
+cliTest(
+ "matches a fresh CLI process and reuses that process for the same environment",
+ async ({ cli, cliCommandSession }) => {
+ const sharedCliRun = requireSharedCli(cli);
+ const fresh = await cli.run({
+ arguments: ["--version"],
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ const shared = await sharedCliRun({
+ arguments: ["--version"],
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ expect(shared.exitCode).toBe(fresh.exitCode);
+ expect(shared.stdout).toBe(fresh.stdout);
+ expect(shared.stderr).toBe(fresh.stderr);
+ const pid = cliCommandSession.processId();
+ expect(pid).toEqual(expect.any(Number));
+ const again = await sharedCliRun({
+ arguments: ["--version"],
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ expect(again.stdout).toBe(fresh.stdout);
+ expect(cliCommandSession.processId()).toBe(pid);
+
+ const freshInvalid = await cli.run({
+ arguments: ["--version"],
+ environment: { REA_LOG_LEVEL: "not-a-level" },
+ });
+ const sharedInvalid = await sharedCliRun({
+ arguments: ["--version"],
+ environment: { REA_LOG_LEVEL: "not-a-level" },
+ });
+ expect(sharedInvalid.exitCode).toBe(freshInvalid.exitCode);
+ expect(sharedInvalid.exitCode).not.toBe(0);
+ expect(sharedInvalid.stdout).toBe(freshInvalid.stdout);
+ expect(sharedInvalid.stderr).toBe(freshInvalid.stderr);
+ expect(cliCommandSession.processId()).not.toBe(pid);
+
+ const restored = await sharedCliRun({
+ arguments: ["--version"],
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ expect(restored.exitCode).toBe(0);
+ expect(restored.stdout).toBe(fresh.stdout);
+ expect(cliCommandSession.processId()).not.toBe(pid);
+ },
+);
+
+cliTest(
+ "reuses one CLI process across JavaScript application directories",
+ async ({ cli, cliCommandSession }) => {
+ const sharedCliRun = requireSharedCli(cli);
+ const firstRoot = await writeApplication("rea-shared-cli-");
+ const fresh = await cli.run({
+ arguments: analyzeArguments(firstRoot),
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ const shared = await sharedCliRun({
+ arguments: analyzeArguments(firstRoot),
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ expect(shared.exitCode).toBe(0);
+ expect(normalized(shared)).toEqual(normalized(fresh));
+ const pid = cliCommandSession.processId();
+ expect(pid).toEqual(expect.any(Number));
+
+ const secondRoot = await writeApplication("rea-shared-cli-next-");
+ const freshNext = await cli.run({
+ arguments: analyzeArguments(secondRoot),
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ const sharedNext = await sharedCliRun({
+ arguments: analyzeArguments(secondRoot),
+ environment: { REA_LOG_LEVEL: "silent" },
+ });
+ expect(normalized(sharedNext)).toEqual(normalized(freshNext));
+ expect(normalized(sharedNext).input_path).not.toBe(
+ normalized(shared).input_path,
+ );
+ expect(cliCommandSession.processId()).toBe(pid);
+
+ const analyzed = await analyzeJavaScriptDirectory("CLI", cli, secondRoot);
+ expect(analyzed.input_path).toBe(normalized(freshNext).input_path);
+ expect(cliCommandSession.processId()).toBe(pid);
+ },
+);
diff --git a/tests/support/cli/cliCommandSession.ts b/tests/support/cli/cliCommandSession.ts
new file mode 100644
index 0000000000..1805836054
--- /dev/null
+++ b/tests/support/cli/cliCommandSession.ts
@@ -0,0 +1,448 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+import { z } from "zod";
+
+import { waitForExit } from "../process/processFixture.js";
+import {
+ createCliSessionFrameReader,
+ encodeCliSessionFrame,
+} from "./cliCommandSessionFrames.mjs";
+
+const workerPath = fileURLToPath(
+ new URL("./cliCommandSessionWorker.mjs", import.meta.url),
+);
+const SHUTDOWN_GRACE_MS = 1_000;
+/** Restart after this many commands so one file cannot retain every heap. */
+const COMMAND_REUSE_LIMIT = 32;
+/** Restart when the worker reports a resident set at least this large. */
+const RESIDENT_REUSE_LIMIT_BYTES = 1024 * 1024 * 1024;
+
+const responseSchema = z.object({
+ ready: z.boolean().optional(),
+ id: z.number().optional(),
+ exitCode: z.number().nullable().optional(),
+ stdout: z.string().optional(),
+ stderr: z.string().optional(),
+ pid: z.number().optional(),
+ rss: z.number().finite().nonnegative().optional(),
+ fatal: z.boolean().optional(),
+});
+
+/** One compiled CLI command sent to the shared process. */
+export interface CliCommandRequest {
+ readonly arguments: readonly string[];
+ readonly cwd: string;
+ readonly environment: Readonly>;
+}
+
+/** Captured result from one shared CLI command. */
+export interface CliCommandResult {
+ readonly exitCode: number | null;
+ readonly signal: NodeJS.Signals | null;
+ readonly stdout: string;
+ readonly stderr: string;
+ readonly json: unknown | undefined;
+}
+
+/** File-scoped compiled CLI process. */
+export interface CliCommandSession {
+ run(request: CliCommandRequest): Promise;
+ /** PID of the live process, when one is running. */
+ processId(): number | undefined;
+ close(): Promise;
+}
+
+interface CliCommandSessionDependencies {
+ readonly spawnWorker?: (
+ environment: Readonly>,
+ ) => ChildProcessWithoutNullStreams;
+ /** Commands served by one process before the next command starts a new one. */
+ readonly commandLimit?: number;
+ /** Resident-set bytes, reported by the worker, that start a new process. */
+ readonly residentLimitBytes?: number;
+}
+
+interface PendingCommand {
+ readonly id: number;
+ readonly resolve: (result: CliCommandResult) => void;
+ readonly reject: (error: unknown) => void;
+}
+
+type SessionResponse = z.infer;
+
+const closedError = (): Error => new Error("Shared CLI session is closed");
+
+const positiveLimit = (value: number | undefined, fallback: number): number =>
+ value !== undefined && Number.isSafeInteger(value) && value > 0
+ ? value
+ : fallback;
+
+const envKey = (environment: Readonly>): string =>
+ JSON.stringify(
+ Object.keys(environment)
+ .sort()
+ .map((key) => [key, environment[key]]),
+ );
+
+const parseCliStdout = (stdout: string): unknown | undefined => {
+ const document = stdout.trim();
+ if (document.length === 0) return undefined;
+ try {
+ return JSON.parse(document) as unknown;
+ } catch {
+ return undefined;
+ }
+};
+
+const signalChild = (
+ child: ChildProcessWithoutNullStreams,
+ signal: NodeJS.Signals,
+): void => {
+ try {
+ child.kill(signal);
+ } catch (error) {
+ const code =
+ error instanceof Error && "code" in error ? error.code : undefined;
+ if (code !== "ESRCH") throw error;
+ }
+};
+
+const stopChild = async (
+ child: ChildProcessWithoutNullStreams,
+): Promise => {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ signalChild(child, "SIGTERM");
+ if (await waitForExit(child, SHUTDOWN_GRACE_MS)) return;
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ signalChild(child, "SIGKILL");
+ if (await waitForExit(child, SHUTDOWN_GRACE_MS)) return;
+ throw new Error(
+ `shared CLI process ${String(child.pid)} did not exit after SIGKILL`,
+ );
+};
+
+const commandResult = (message: SessionResponse): CliCommandResult => ({
+ exitCode: message.exitCode ?? null,
+ signal: null,
+ stdout: message.stdout ?? "",
+ stderr: message.stderr ?? "",
+ json: parseCliStdout(message.stdout ?? ""),
+});
+
+const writeChild = (
+ child: ChildProcessWithoutNullStreams,
+ frame: Buffer,
+): Promise =>
+ new Promise((resolve, reject) => {
+ child.stdin.write(frame, (error) => {
+ if (error) reject(error);
+ else resolve();
+ });
+ });
+
+const defaultSpawnWorker = (
+ environment: Readonly>,
+): ChildProcessWithoutNullStreams =>
+ spawn(process.execPath, [workerPath], {
+ cwd: process.cwd(),
+ env: environment,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+
+class SharedCliProcess implements CliCommandSession {
+ readonly #spawnWorker: (
+ environment: Readonly>,
+ ) => ChildProcessWithoutNullStreams;
+
+ readonly #commandLimit: number;
+ readonly #residentLimitBytes: number;
+ #child: ChildProcessWithoutNullStreams | undefined;
+ #childEnvKey: string | undefined;
+ #commands = 0;
+ #residentBytes: number | undefined;
+ #retiring: Promise | undefined;
+ #closed = false;
+ #closing: Promise | undefined;
+ #starting: Promise | undefined;
+ #nextId = 1;
+ #pending: PendingCommand | undefined;
+ #resolveReady: (() => void) | undefined;
+ #rejectReady: ((error: unknown) => void) | undefined;
+ #queue: Promise = Promise.resolve();
+
+ constructor(dependencies: CliCommandSessionDependencies) {
+ this.#spawnWorker = dependencies.spawnWorker ?? defaultSpawnWorker;
+ this.#commandLimit = positiveLimit(
+ dependencies.commandLimit,
+ COMMAND_REUSE_LIMIT,
+ );
+ this.#residentLimitBytes = positiveLimit(
+ dependencies.residentLimitBytes,
+ RESIDENT_REUSE_LIMIT_BYTES,
+ );
+ }
+
+ run(request: CliCommandRequest): Promise {
+ const result = this.#queue.then(() => this.#invoke(request));
+ this.#queue = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ }
+
+ processId(): number | undefined {
+ const child = this.#child;
+ if (
+ child === undefined ||
+ child.exitCode !== null ||
+ child.signalCode !== null
+ ) {
+ return undefined;
+ }
+ return child.pid;
+ }
+
+ close(): Promise {
+ this.#closed = true;
+ if (this.#closing !== undefined) return this.#closing;
+ const attempt = this.#shutdown();
+ this.#closing = attempt;
+ return attempt;
+ }
+
+ async #shutdown(): Promise {
+ try {
+ const owner = this.#child;
+ if (owner !== undefined) await stopChild(owner);
+ const retiring = this.#retiring;
+ if (retiring !== undefined) await retiring;
+ } catch (error) {
+ this.#closing = undefined;
+ throw error;
+ }
+ const pendingStart = this.#starting;
+ if (pendingStart !== undefined) await pendingStart.catch(() => undefined);
+ }
+
+ async #invoke(request: CliCommandRequest): Promise {
+ if (this.#closed) throw closedError();
+ try {
+ return await this.#run(request);
+ } finally {
+ await this.#finishRetirement();
+ }
+ }
+
+ async #run(request: CliCommandRequest): Promise {
+ await this.#ensure(request.environment);
+ const owner = this.#child;
+ if (this.#closed || owner === undefined) throw closedError();
+ const id = this.#nextId;
+ this.#nextId += 1;
+ const result = new Promise((resolve, reject) => {
+ this.#pending = { id, resolve, reject };
+ });
+ try {
+ await writeChild(
+ owner,
+ encodeCliSessionFrame({
+ id,
+ arguments: request.arguments,
+ cwd: request.cwd,
+ env: request.environment,
+ }),
+ );
+ } catch (error) {
+ this.#failTransport(owner, error);
+ }
+ return await result;
+ }
+
+ async #finishRetirement(): Promise {
+ const retiring = this.#retiring;
+ if (retiring === undefined) return;
+ try {
+ await retiring;
+ } finally {
+ if (this.#retiring === retiring) this.#retiring = undefined;
+ }
+ }
+
+ #reusable(
+ current: ChildProcessWithoutNullStreams | undefined,
+ key: string,
+ ): current is ChildProcessWithoutNullStreams {
+ if (
+ current === undefined ||
+ current.exitCode !== null ||
+ current.signalCode !== null ||
+ this.#childEnvKey !== key
+ ) {
+ return false;
+ }
+ if (this.#commands >= this.#commandLimit) return false;
+ return !(
+ this.#residentBytes !== undefined &&
+ this.#residentBytes >= this.#residentLimitBytes
+ );
+ }
+
+ async #ensure(environment: Readonly>): Promise {
+ if (this.#closed) throw closedError();
+ const key = envKey(environment);
+ const current = this.#child;
+ if (this.#reusable(current, key)) return;
+ if (current !== undefined) await stopChild(current);
+ if (this.#closed) throw closedError();
+ await this.#start(environment);
+ }
+
+ #start(environment: Readonly>): Promise {
+ if (this.#closed) return Promise.reject(closedError());
+ const started = this.#spawnWorker(environment);
+ this.#child = started;
+ this.#childEnvKey = envKey(environment);
+ this.#commands = 0;
+ this.#residentBytes = undefined;
+ const ready = new Promise((resolve, reject) => {
+ this.#resolveReady = resolve;
+ this.#rejectReady = reject;
+ });
+ this.#starting = ready;
+ this.#watch(started);
+ if (this.#closed) {
+ signalChild(started, "SIGTERM");
+ this.#failReady(closedError());
+ }
+ return ready.finally(() => {
+ if (this.#starting === ready) this.#starting = undefined;
+ });
+ }
+
+ #watch(started: ChildProcessWithoutNullStreams): void {
+ const reader = createCliSessionFrameReader((frame) => {
+ this.#onFrame(started, frame);
+ });
+ started.stdout.on("data", (chunk: Buffer) => {
+ try {
+ reader(chunk);
+ } catch (error) {
+ this.#failTransport(started, error);
+ }
+ });
+ started.stderr.resume();
+ started.stdin.on("error", (error) => {
+ this.#failTransport(started, error);
+ });
+ started.once("error", (error) => {
+ this.#failTransport(started, error);
+ });
+ started.once("close", (code, signal) => {
+ this.#onClose(started, code, signal);
+ });
+ }
+
+ #onFrame(owner: ChildProcessWithoutNullStreams, value: unknown): void {
+ if (this.#child !== owner || this.#closed) return;
+ const parsed = responseSchema.safeParse(value);
+ if (!parsed.success) {
+ this.#failTransport(
+ owner,
+ new Error("CLI session response was not a command result"),
+ );
+ return;
+ }
+ const message = parsed.data;
+ if (message.ready === true && message.id === undefined) {
+ const resolve = this.#resolveReady;
+ this.#resolveReady = undefined;
+ this.#rejectReady = undefined;
+ resolve?.();
+ return;
+ }
+ const current = this.#pending;
+ if (current === undefined || message.id !== current.id) {
+ this.#failTransport(
+ owner,
+ new Error("CLI session response did not match the command"),
+ );
+ return;
+ }
+ if (message.exitCode === undefined) {
+ this.#failTransport(
+ owner,
+ new Error("CLI session response omitted its exit code"),
+ );
+ return;
+ }
+ this.#pending = undefined;
+ if (message.fatal === true) {
+ this.#retire(owner);
+ } else {
+ this.#commands += 1;
+ if (message.rss !== undefined) this.#residentBytes = message.rss;
+ }
+ current.resolve(commandResult(message));
+ }
+
+ #retire(owner: ChildProcessWithoutNullStreams): void {
+ if (this.#child === owner) {
+ this.#child = undefined;
+ this.#childEnvKey = undefined;
+ }
+ this.#commands = 0;
+ this.#residentBytes = undefined;
+ this.#retiring = stopChild(owner);
+ }
+
+ #onClose(
+ owner: ChildProcessWithoutNullStreams,
+ code: number | null,
+ signal: NodeJS.Signals | null,
+ ): void {
+ if (this.#child !== owner) return;
+ this.#child = undefined;
+ this.#childEnvKey = undefined;
+ const error = this.#closed
+ ? closedError()
+ : new Error(
+ `Shared CLI process exited (${String(code ?? signal ?? "unknown")})`,
+ );
+ this.#failReady(error);
+ this.#failPending(error);
+ }
+
+ #failTransport(owner: ChildProcessWithoutNullStreams, error: unknown): void {
+ if (this.#child !== owner) return;
+ this.#failReady(error);
+ this.#failPending(error);
+ // Clear the worker before the queued command runs. SIGTERM does not set
+ // signalCode until the process actually exits, so #reusable would still
+ // accept this process and write into it.
+ this.#retire(owner);
+ }
+
+ #failPending(error: unknown): void {
+ const current = this.#pending;
+ this.#pending = undefined;
+ current?.reject(error);
+ }
+
+ #failReady(error: unknown): void {
+ const reject = this.#rejectReady;
+ this.#resolveReady = undefined;
+ this.#rejectReady = undefined;
+ reject?.(error);
+ }
+}
+
+/**
+ * Keep one compiled CLI process for commands that share an environment.
+ * The process restarts after its command limit, after a reported resident-set
+ * limit, when the environment changes, or when a command fails fatally.
+ */
+export const createCliCommandSession = (
+ dependencies: CliCommandSessionDependencies = {},
+): CliCommandSession => new SharedCliProcess(dependencies);
diff --git a/tests/support/cli/cliCommandSessionFrames.d.mts b/tests/support/cli/cliCommandSessionFrames.d.mts
new file mode 100644
index 0000000000..85e6ae65f7
--- /dev/null
+++ b/tests/support/cli/cliCommandSessionFrames.d.mts
@@ -0,0 +1,13 @@
+/** Largest accepted session message, including the encoded command result. */
+export const MAX_CLI_SESSION_FRAME_BYTES: number;
+
+/** Length-prefixed JSON frame shared by the session parent and worker. */
+export function encodeCliSessionFrame(value: unknown): Buffer;
+
+/**
+ * Parse complete frames from a byte stream. An oversized or malformed frame
+ * fails the reader instead of resynchronizing on a later length.
+ */
+export function createCliSessionFrameReader(
+ onFrame: (value: unknown) => void,
+): (chunk: Buffer | string) => void;
diff --git a/tests/support/cli/cliCommandSessionFrames.mjs b/tests/support/cli/cliCommandSessionFrames.mjs
new file mode 100644
index 0000000000..4f88c70cdd
--- /dev/null
+++ b/tests/support/cli/cliCommandSessionFrames.mjs
@@ -0,0 +1,77 @@
+/** Largest accepted session message, including the encoded command result. */
+export const MAX_CLI_SESSION_FRAME_BYTES = 64 * 1024 * 1024;
+
+/** Length-prefixed JSON frame shared by the session parent and worker. */
+export const encodeCliSessionFrame = (value) => {
+ const body = Buffer.from(JSON.stringify(value));
+ if (body.length > MAX_CLI_SESSION_FRAME_BYTES) {
+ throw new Error(
+ `CLI session frame is ${body.length} bytes; the limit is ${MAX_CLI_SESSION_FRAME_BYTES}`,
+ );
+ }
+ const frame = Buffer.allocUnsafe(4 + body.length);
+ frame.writeUInt32BE(body.length, 0);
+ body.copy(frame, 4);
+ return frame;
+};
+
+const frameLimitError = (length) =>
+ new Error(
+ `CLI session frame is ${length} bytes; the limit is ${MAX_CLI_SESSION_FRAME_BYTES}`,
+ );
+
+/** Read a big-endian length from the start of a chunk list without joining it. */
+const readFrameLength = (chunks) => {
+ const first = chunks[0];
+ if (first.length >= 4) return first.readUInt32BE(0);
+ const header = Buffer.allocUnsafe(4);
+ let offset = 0;
+ for (const chunk of chunks) {
+ const take = Math.min(chunk.length, 4 - offset);
+ chunk.copy(header, offset, 0, take);
+ offset += take;
+ if (offset === 4) break;
+ }
+ return header.readUInt32BE(0);
+};
+
+/**
+ * Parse complete frames from a byte stream. An oversized or malformed frame
+ * fails the reader instead of resynchronizing on a later length. Incomplete
+ * chunks stay separate until one whole frame is buffered, then that frame is
+ * joined once.
+ */
+export const createCliSessionFrameReader = (onFrame) => {
+ /** @type {Buffer[]} */
+ const chunks = [];
+ let buffered = 0;
+ return (chunk) => {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ chunks.push(bytes);
+ buffered += bytes.length;
+ if (buffered < 4) return;
+ const length = readFrameLength(chunks);
+ if (length > MAX_CLI_SESSION_FRAME_BYTES) throw frameLimitError(length);
+ if (buffered < 4 + length) return;
+ const pending = Buffer.concat(chunks);
+ let offset = 0;
+ try {
+ while (offset + 4 <= pending.length) {
+ const frameLength = pending.readUInt32BE(offset);
+ if (frameLength > MAX_CLI_SESSION_FRAME_BYTES) {
+ throw frameLimitError(frameLength);
+ }
+ const frameEnd = offset + 4 + frameLength;
+ if (frameEnd > pending.length) break;
+ const body = pending.toString("utf8", offset + 4, frameEnd);
+ offset = frameEnd;
+ onFrame(JSON.parse(body));
+ }
+ } finally {
+ const rest = pending.subarray(offset);
+ chunks.length = 0;
+ buffered = rest.length;
+ if (rest.length > 0) chunks.push(Buffer.from(rest));
+ }
+ };
+};
diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs
new file mode 100644
index 0000000000..63ce69542c
--- /dev/null
+++ b/tests/support/cli/cliCommandSessionWorker.mjs
@@ -0,0 +1,263 @@
+import { Writable } from "node:stream";
+import { access } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ createCliSessionFrameReader,
+ encodeCliSessionFrame,
+} from "./cliCommandSessionFrames.mjs";
+
+// One-shot command sequence matches scripts/rea.mjs. Output is captured per
+// command so this process can serve the next invocation without restarting.
+const packageRoot = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../../..",
+);
+const { default: packageJson } = await import(
+ new URL("../../../package.json", import.meta.url),
+ { with: { type: "json" } }
+);
+
+const prepareCliProcess = () => {
+ process.env.REA_PACKAGE_VERSION = packageJson.version;
+ // Windows home lookup throws for an empty USERPROFILE. Dependencies resolve
+ // home during import, before REA's own resolver can apply its account fallback.
+ if (process.platform === "win32" && process.env.USERPROFILE === "") {
+ delete process.env.USERPROFILE;
+ }
+ if (
+ process.platform === "android" &&
+ process.env.PLAYWRIGHT_BROWSERS_PATH === undefined
+ ) {
+ const cacheRoot =
+ process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache");
+ process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
+ }
+};
+
+const runtimeFiles = [
+ "dist/cli-runtime/cli.js",
+ "dist/cli-runtime/cliOutput.js",
+ "dist/cli-runtime/cliResultOutput.js",
+];
+
+prepareCliProcess();
+if (!(await compiledRuntimeExists(runtimeFiles))) {
+ process.stderr.write(
+ `REA's compiled runtime is missing. Run \`npm ci && npm run build:cached\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`,
+ );
+ process.exit(1);
+}
+
+const { cliConfigurationError, createCli } =
+ await import("../../../dist/cli-runtime/cli.js");
+const { createCliResultOutput } =
+ await import("../../../dist/cli-runtime/cliResultOutput.js");
+const {
+ normalizeCliArguments,
+ renderCliOutputArgumentError,
+ renderEmptyFilteredCliOutput,
+ validateCliOutputArguments,
+} = await import("../../../dist/cli-runtime/cliOutput.js");
+
+const writeFrame = (value) =>
+ new Promise((resolveWrite, rejectWrite) => {
+ process.stdout.write(encodeCliSessionFrame(value), (error) => {
+ if (error) rejectWrite(error);
+ else resolveWrite();
+ });
+ });
+
+await writeFrame({ ready: true });
+
+let chain = Promise.resolve();
+const reader = createCliSessionFrameReader((value) => {
+ chain = chain.then(
+ () => handle(value),
+ () => handle(value),
+ );
+});
+process.stdin.on("data", (chunk) => {
+ try {
+ reader(chunk);
+ } catch (error) {
+ process.stderr.write(
+ `${error instanceof Error ? error.message : String(error)}\n`,
+ );
+ process.exit(1);
+ }
+});
+process.stdin.on("end", () => {
+ chain.finally(() => process.exit(0));
+});
+
+async function handle(value) {
+ const request = value;
+ const savedEnv = { ...process.env };
+ const savedCwd = process.cwd();
+ try {
+ if (
+ request === null ||
+ typeof request !== "object" ||
+ !Array.isArray(request.arguments) ||
+ typeof request.cwd !== "string" ||
+ request.env === null ||
+ typeof request.env !== "object"
+ ) {
+ throw new Error(
+ "CLI session command is missing its arguments, cwd, or environment",
+ );
+ }
+ for (const key of Object.keys(process.env)) delete process.env[key];
+ Object.assign(process.env, request.env);
+ prepareCliProcess();
+ process.chdir(request.cwd);
+ const result = await runOneShot(request.arguments);
+ await writeFrame({
+ id: request.id,
+ pid: process.pid,
+ rss: process.memoryUsage().rss,
+ ...result,
+ });
+ } catch (error) {
+ const id =
+ request !== null && typeof request === "object" ? request.id : undefined;
+ await writeFrame({
+ id,
+ pid: process.pid,
+ exitCode: 1,
+ stdout: "",
+ stderr:
+ error instanceof Error ? (error.stack ?? error.message) : String(error),
+ fatal: true,
+ });
+ process.exit(1);
+ } finally {
+ for (const key of Object.keys(process.env)) delete process.env[key];
+ Object.assign(process.env, savedEnv);
+ process.chdir(savedCwd);
+ }
+}
+
+async function runOneShot(args) {
+ const stdoutChunks = [];
+ const stderrChunks = [];
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
+ process.stdout.write = captureWrite(stdoutChunks);
+ process.stderr.write = captureWrite(stderrChunks);
+ const destination = new Writable({
+ write(chunk, encoding, callback) {
+ process.stdout.write(chunk, encoding, callback);
+ },
+ });
+ destination.isTTY = process.stdout.isTTY === true;
+ const previousExitCode = process.exitCode;
+ process.exitCode = undefined;
+ let explicitExit;
+ try {
+ const cliArgs = normalizeCliArguments(args);
+ const outputArguments = validateCliOutputArguments(cliArgs);
+ const configurationError = cliConfigurationError(process.env);
+ if (configurationError !== undefined && !isDoctorCommand(cliArgs)) {
+ process.stderr.write(`${configurationError}\n`);
+ process.exitCode = 1;
+ } else if (!outputArguments.ok) {
+ process.stdout.write(renderCliOutputArgumentError(outputArguments));
+ process.exitCode = 1;
+ } else {
+ let wroteOutput = false;
+ const resultOutput = createCliResultOutput(cliArgs, destination);
+ try {
+ await createCli(process.env, resultOutput).serve(cliArgs, {
+ stdout: (output) => {
+ if (resultOutput?.handled) {
+ if (resultOutput.failed) process.stderr.write(output);
+ return;
+ }
+ if (output.length > 0) wroteOutput = true;
+ process.stdout.write(output);
+ },
+ exit: (code) => {
+ explicitExit = code;
+ },
+ });
+ } catch (cause) {
+ if (wroteOutput) throw cause;
+ const recovered = await resultOutput?.recoverFormattingFailure(cause);
+ if (!recovered) throw cause;
+ process.exitCode = 1;
+ }
+ if (!wroteOutput && !resultOutput?.handled) {
+ const empty = renderEmptyFilteredCliOutput(cliArgs);
+ if (empty) process.stdout.write(empty);
+ }
+ }
+ } finally {
+ process.stdout.write = originalStdoutWrite;
+ process.stderr.write = originalStderrWrite;
+ }
+ const exitCode =
+ explicitExit ??
+ (typeof process.exitCode === "number" ? process.exitCode : 0);
+ process.exitCode = previousExitCode;
+ return {
+ exitCode,
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
+ };
+}
+
+function captureWrite(chunks) {
+ return function patched(chunk, encoding, callback) {
+ const encodingName = typeof encoding === "string" ? encoding : "utf8";
+ chunks.push(
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), encodingName),
+ );
+ const done = typeof encoding === "function" ? encoding : callback;
+ if (typeof done === "function") done();
+ return true;
+ };
+}
+
+function isDoctorCommand(arguments_) {
+ const valuedFlags = new Set([
+ "--format",
+ "--filter-output",
+ "--token-limit",
+ "--token-offset",
+ ]);
+ for (let index = 0; index < arguments_.length; index += 1) {
+ const argument = arguments_[index];
+ if (argument === "--") return arguments_[index + 1] === "doctor";
+ if (valuedFlags.has(argument)) {
+ index += 1;
+ continue;
+ }
+ if (argument.startsWith("-")) continue;
+ return argument === "doctor";
+ }
+ return false;
+}
+
+async function compiledRuntimeExists(paths) {
+ for (const path of paths) {
+ try {
+ await access(resolve(packageRoot, path));
+ } catch (cause) {
+ if (isMissing(cause)) return false;
+ throw cause;
+ }
+ }
+ return true;
+}
+
+function isMissing(cause) {
+ return (
+ typeof cause === "object" &&
+ cause !== null &&
+ "code" in cause &&
+ cause.code === "ENOENT"
+ );
+}
diff --git a/tests/support/cli/cliFixture.ts b/tests/support/cli/cliFixture.ts
index 9d093c7da4..db71ac40f0 100644
--- a/tests/support/cli/cliFixture.ts
+++ b/tests/support/cli/cliFixture.ts
@@ -10,6 +10,10 @@ import {
type ProcessResult,
type TestProcesses,
} from "../process/processFixture.js";
+import {
+ createCliCommandSession,
+ type CliCommandSession,
+} from "./cliCommandSession.js";
/** Caller-controlled values for one compiled REA CLI invocation. */
export interface CliInvocation {
@@ -40,6 +44,12 @@ export interface TestCli {
* drivers created with `createTestCli` alone.
*/
sharedMcpCall?(): Promise;
+ /**
+ * File-scoped compiled CLI process for commands with no stdin. Present on
+ * `cliTest` drivers. A different environment restarts the process. Stdin and
+ * explicit deadlines stay on `run`, which starts a fresh process.
+ */
+ sharedCliRun?(invocation: CliInvocation): Promise;
}
/** One file-scoped stdio MCP session. */
@@ -202,6 +212,34 @@ const parseJson = (stdout: string): unknown | undefined => {
}
};
+/** Child environment for one compiled CLI process, without the parent environment. */
+export const cliProcessEnvironment = (
+ environment: NodeJS.ProcessEnv | undefined,
+ compileCache?: string,
+): NodeJS.ProcessEnv => ({
+ PATH: process.env.PATH ?? "",
+ // Native Windows APIs need the system root and a writable temporary
+ // directory even when the command's application environment is empty.
+ ...(process.platform === "win32"
+ ? {
+ SystemRoot: process.env.SystemRoot,
+ TEMP: tmpdir(),
+ TMP: tmpdir(),
+ }
+ : {}),
+ ...cliCompileCacheEnvironment(environment, compileCache),
+});
+
+/** Keep string entries. A shared process cannot round-trip an absent variable as JSON. */
+export const definedCliEnvironment = (
+ environment: NodeJS.ProcessEnv,
+): Record =>
+ Object.fromEntries(
+ Object.entries(environment).flatMap(([key, value]) =>
+ typeof value === "string" ? [[key, value] as const] : [],
+ ),
+ );
+
/** Reuse CLI compilation without overriding caller-selected cache or coverage. */
export const cliCompileCacheEnvironment = (
environment: NodeJS.ProcessEnv | undefined,
@@ -230,19 +268,7 @@ export const createTestCli = (
[resolve("scripts/rea.mjs"), ...arguments_],
{
cwd: cwd ?? process.cwd(),
- env: {
- PATH: process.env.PATH ?? "",
- // Native Windows APIs need the system root and a writable temporary
- // directory even when the command's application environment is empty.
- ...(process.platform === "win32"
- ? {
- SystemRoot: process.env.SystemRoot,
- TEMP: tmpdir(),
- TMP: tmpdir(),
- }
- : {}),
- ...cliCompileCacheEnvironment(environment, compileCache),
- },
+ env: cliProcessEnvironment(environment, compileCache),
...(input === undefined ? {} : { input }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
},
@@ -254,7 +280,7 @@ export const createTestCli = (
/** Vitest API extended with an owned process registry and compiled CLI driver. */
export const cliTest = processTest.extend<{
$test: { cli: TestCli };
- $file: { mcpSession: SharedMcpSession };
+ $file: { mcpSession: SharedMcpSession; cliCommandSession: CliCommandSession };
$worker: { cliCompileCache: string };
}>({
cliCompileCache: [
@@ -280,11 +306,40 @@ export const cliTest = processTest.extend<{
},
{ scope: "file" },
],
- cli: async ({ processes, cliCompileCache, mcpSession }, use) => {
+ cliCommandSession: [
+ // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring.
+ async ({}, use) => {
+ const session = createCliCommandSession();
+ try {
+ await use(session);
+ } finally {
+ await session.close();
+ }
+ },
+ { scope: "file" },
+ ],
+ cli: async (
+ { processes, cliCompileCache, mcpSession, cliCommandSession },
+ use,
+ ) => {
const cli = createTestCli(processes, cliCompileCache);
await use({
...cli,
sharedMcpCall: () => mcpSession.call(),
+ sharedCliRun: (invocation) => {
+ if (
+ invocation.input !== undefined ||
+ invocation.timeoutMs !== undefined
+ )
+ return cli.run(invocation);
+ return cliCommandSession.run({
+ arguments: invocation.arguments,
+ cwd: invocation.cwd ?? process.cwd(),
+ environment: definedCliEnvironment(
+ cliProcessEnvironment(invocation.environment, cliCompileCache),
+ ),
+ });
+ },
});
},
});
diff --git a/tests/support/cli/javascriptApplication.ts b/tests/support/cli/javascriptApplication.ts
index 6a04b9592c..186953cb91 100644
--- a/tests/support/cli/javascriptApplication.ts
+++ b/tests/support/cli/javascriptApplication.ts
@@ -35,7 +35,11 @@ export const resolveJavaScriptApplicationMcpCall = async (
return (await connectLocalToolsMcp()).call;
};
-/** Analyze a directory; a supplied MCP caller reuses its test-owned connection. */
+/**
+ * Analyze a directory. A supplied MCP caller reuses its test-owned connection.
+ * CLI uses the driver's file-scoped process when present, and a fresh process
+ * otherwise.
+ */
export const analyzeJavaScriptDirectory = async (
surface: "CLI" | "stdio MCP",
cli: TestCli,
@@ -50,7 +54,8 @@ export const analyzeJavaScriptDirectory = async (
) => {
let document: unknown;
if (surface === "CLI") {
- const response = await cli.run({
+ const run = cli.sharedCliRun ?? ((invocation) => cli.run(invocation));
+ const response = await run({
arguments: [
"analyze-javascript-application",
root,
From 70835d3d5f030deadf606e1c79c70a60883e50c9 Mon Sep 17 00:00:00 2001
From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:04:05 +0800
Subject: [PATCH 07/45] perf(wasm): skip payload text during WAT framing
Fixes #5760
---
experiments/analysis-efficiency/README.md | 4 +
.../wasm-string-framing.json | 561 ++++++++++++++++++
.../wasm-string-framing.md | 107 ++++
.../wasm-string-framing.mjs | 184 ++++++
src/wasm/WabtInterface.ts | 6 +-
src/wasm/WabtString.test.ts | 56 ++
src/wasm/WabtString.ts | 28 +-
src/wasm/WabtWat.ts | 20 +-
8 files changed, 944 insertions(+), 22 deletions(-)
create mode 100644 experiments/analysis-efficiency/wasm-string-framing.json
create mode 100644 experiments/analysis-efficiency/wasm-string-framing.md
create mode 100644 experiments/analysis-efficiency/wasm-string-framing.mjs
create mode 100644 src/wasm/WabtString.test.ts
diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md
index 2f2f049544..b19989706f 100644
--- a/experiments/analysis-efficiency/README.md
+++ b/experiments/analysis-efficiency/README.md
@@ -1,5 +1,9 @@
# Analysis efficiency measurements
+The [WAT framing checkpoint](wasm-string-framing.md) measures structural scanning
+on real SQLite and VS Code components, with unchanged Evidence and token costs,
+small whole-task timing gains and no memory improvement.
+
The [native inventory view checkpoint](native-inventory-views.md) compares
complete delivery with summaries and selected rows on real `true` and jq
inventories, including oversized-response recovery, portable old Evidence,
diff --git a/experiments/analysis-efficiency/wasm-string-framing.json b/experiments/analysis-efficiency/wasm-string-framing.json
new file mode 100644
index 0000000000..d27ced7efa
--- /dev/null
+++ b/experiments/analysis-efficiency/wasm-string-framing.json
@@ -0,0 +1,561 @@
+{
+ "schema_version": 1,
+ "issue": 5760,
+ "baseline_source": "d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f",
+ "candidate_code_source": "9776d78f6c799cba8d8cf5c9b3139a86449eaebd",
+ "integration_base": "335c042d166b2c8b2c0600935cf77607ae11edbc",
+ "toolchain": {
+ "node": "24.18.0",
+ "npm": "11.16.0",
+ "wabt": "1.0.42",
+ "tokenizer": "0.12.0",
+ "encoding": "cl100k_base"
+ },
+ "catalog_sha256": "4586abd317171cbb307295cc687c24f7f45e603959538643325895b546a38242",
+ "catalog_tools": 212,
+ "package_lock_sha256": "699a14749e3825c62430faeb18224190fee1c9347760494e38e334e180bea6ec",
+ "runtime_mcp_sha256": {
+ "baseline": "fa6831f05d97b6b28642b709b5497e351d2ee632dd1abc0d2f87a648057afe87",
+ "candidate": "e4af82d847fe3d9816f4f7ae1f584ef129ba151bc5a6f95080a7519fd08be2cf"
+ },
+ "sources_sha256": {
+ "WabtString.ts": "398f3cd98c087740a553768d10c21912d28f33e13bdab589eec760da4ed0b9fd",
+ "WabtWat.ts": "70fb9acfbe747be4fa5d8be235012a88f94485bc0baf0fbba1fe22f18b87f44b",
+ "WabtInterface.ts": "1a63c834a46268bbaf97e6eb39a99b3ccd3f2893527749d65ee6f5026bc2782a"
+ },
+ "native_tools_sha256": {
+ "wasm-validate": "6b722ed0df68204cfc7a8effdf1fc2ddeac2d184bd211cdf7fdfa5f9d49d3eb5",
+ "wasm-objdump": "dad264def1a3b7755859c5bf48227f01259a95fa82b7c38358b12b9e70b27a41",
+ "wasm2wat": "4322d652f650c1ee117b549c9722f42fc93eb4676ff046d60ba033b5639697a5"
+ },
+ "artifacts": {
+ "small": {
+ "sha256": "f399acbd52258f950f24dd5ca4a82674f1f26cea60e7be7c5daaade568768fda",
+ "bytes": 80,
+ "wat_sha256": "918ecd8a14daf20b58707d0879efda8b38df37f219279688b3aa3a812ae5646a",
+ "wat_bytes": 270,
+ "answer_normalized_sha256": "8eb8aedcf17bac4a98d34f5a004b5541f671350f94918575e2fc4eee9fd2db29",
+ "parent_evidence_id": "ev_88b35d9f926672b9e42fc4e1514090eaf2f99bb918439f1270dd53f7fe684e74"
+ },
+ "sqlite": {
+ "sha256": "2ee8f3dab694532afc8840e07703127287662d08b74e6ff50491ce63f00d5752",
+ "bytes": 868907,
+ "wat_sha256": "7b1f8cd4fcbf310f4203ce64e15b4eb068ecdf29838551137245efecb7283f81",
+ "wat_bytes": 15382128,
+ "answer_normalized_sha256": "330b84e01541f70a964c826754da3e5f49a1bb1bd535ae1d1f00fb44a1157ff6",
+ "parent_evidence_id": "ev_899ecb126cd0a283169641085fa10e46460504bf8bc8eba69580fa6d641e431e"
+ },
+ "css": {
+ "sha256": "8a23977fe271357cce6f254ef88c9bebf3854602d8046605aef6a45c02135c59",
+ "bytes": 128668,
+ "wat_sha256": "c53273cb7da0867ac52490d083c7952fcc538b3848c6d0d1f2e2621e2ded23c0",
+ "wat_bytes": 13599679,
+ "answer_normalized_sha256": "3d301feaa48a646c4d9f2a026353544519cc4694c9840b1c764d81ec9ba7fb65",
+ "parent_evidence_id": "ev_27108db83ae4d0a9289c23dace9c0e9e6d573f72f1e982b490c7f45394ba702c"
+ }
+ },
+ "samples": [
+ {
+ "case": "small",
+ "lane": "baseline",
+ "round": 1,
+ "wall_seconds": 12.417,
+ "cpu_seconds": 12.114979,
+ "memory_peak_bytes": 823410688,
+ "transport_proxy_tokens": 894936,
+ "fresh_inspect_ms": 1781.0228269999998,
+ "repeat_inspect_ms": 1793.3802260000011,
+ "reply_bytes": 36180,
+ "raw_guard_log_sha256": "f4ab763c21c4dd22f07ab978690966fd1efc6961216e41e195316e7330c269d6"
+ },
+ {
+ "case": "small",
+ "lane": "candidate",
+ "round": 1,
+ "wall_seconds": 10.191,
+ "cpu_seconds": 9.868799,
+ "memory_peak_bytes": 829722624,
+ "transport_proxy_tokens": 894936,
+ "fresh_inspect_ms": 1298.6234909999998,
+ "repeat_inspect_ms": 1917.7533390000008,
+ "reply_bytes": 36182,
+ "raw_guard_log_sha256": "5cfd93018a2cabbe5b9fa4b9062fbed8e6da638ad5672fd566f5cf76182fecd5"
+ },
+ {
+ "case": "sqlite",
+ "lane": "baseline",
+ "round": 1,
+ "wall_seconds": 14.317,
+ "cpu_seconds": 13.845753,
+ "memory_peak_bytes": 1032982528,
+ "transport_proxy_tokens": 895044,
+ "fresh_inspect_ms": 3405.006531,
+ "repeat_inspect_ms": 3345.840587999999,
+ "reply_bytes": 37248,
+ "raw_guard_log_sha256": "46b1b2e9f02e41be7a7cbae8a311b799ab52148a150ec5c0c683e0745c44ad03"
+ },
+ {
+ "case": "sqlite",
+ "lane": "candidate",
+ "round": 1,
+ "wall_seconds": 13.311,
+ "cpu_seconds": 12.956772,
+ "memory_peak_bytes": 1029627904,
+ "transport_proxy_tokens": 895044,
+ "fresh_inspect_ms": 2838.048675,
+ "repeat_inspect_ms": 3233.226218,
+ "reply_bytes": 37250,
+ "raw_guard_log_sha256": "8933144b3f6fd100de72b1c1f22a791b7c4bef74c355bb9211be4c9e9383fe4b"
+ },
+ {
+ "case": "css",
+ "lane": "baseline",
+ "round": 1,
+ "wall_seconds": 13.211,
+ "cpu_seconds": 12.79919,
+ "memory_peak_bytes": 1099599872,
+ "transport_proxy_tokens": 895430,
+ "fresh_inspect_ms": 2823.246057,
+ "repeat_inspect_ms": 3302.541784000001,
+ "reply_bytes": 37426,
+ "raw_guard_log_sha256": "9f4b7dfcd97faf8c85463a4c17f0612b6c37b7e29843f5e52b43df27aaae0ac6"
+ },
+ {
+ "case": "css",
+ "lane": "candidate",
+ "round": 1,
+ "wall_seconds": 12.912,
+ "cpu_seconds": 12.46982,
+ "memory_peak_bytes": 1110261760,
+ "transport_proxy_tokens": 895430,
+ "fresh_inspect_ms": 2839.796418,
+ "repeat_inspect_ms": 3002.9903570000006,
+ "reply_bytes": 37428,
+ "raw_guard_log_sha256": "dc2ed426cd1191ca779a4e736bceb3d0001a5b8344dbba01544ecd91a6bb73b6"
+ },
+ {
+ "case": "small",
+ "lane": "candidate",
+ "round": 2,
+ "wall_seconds": 10.295,
+ "cpu_seconds": 9.986392,
+ "memory_peak_bytes": 807264256,
+ "transport_proxy_tokens": 894936,
+ "fresh_inspect_ms": 1687.949067,
+ "repeat_inspect_ms": 1907.0628659999993,
+ "reply_bytes": 36182,
+ "raw_guard_log_sha256": "376199e896af92b6351d2dde7c765b5aa4b1feac0e9664da934e24732eaa22ec"
+ },
+ {
+ "case": "small",
+ "lane": "baseline",
+ "round": 2,
+ "wall_seconds": 10.192,
+ "cpu_seconds": 9.86319,
+ "memory_peak_bytes": 826806272,
+ "transport_proxy_tokens": 894936,
+ "fresh_inspect_ms": 1621.7653269999996,
+ "repeat_inspect_ms": 1937.1286520000012,
+ "reply_bytes": 36180,
+ "raw_guard_log_sha256": "2d40d7309203eefcb7cbb5a5e81640e728839eb30f29cad017584738ea50616f"
+ },
+ {
+ "case": "sqlite",
+ "lane": "candidate",
+ "round": 2,
+ "wall_seconds": 13.52,
+ "cpu_seconds": 13.133355,
+ "memory_peak_bytes": 1211944960,
+ "transport_proxy_tokens": 895044,
+ "fresh_inspect_ms": 3167.13338,
+ "repeat_inspect_ms": 3425.1366179999986,
+ "reply_bytes": 37250,
+ "raw_guard_log_sha256": "a962200a36a2530b044548b475b28d7234f73b875ffbc3fe16ef4b36e61ae49a"
+ },
+ {
+ "case": "sqlite",
+ "lane": "baseline",
+ "round": 2,
+ "wall_seconds": 13.515,
+ "cpu_seconds": 13.170183,
+ "memory_peak_bytes": 1017561088,
+ "transport_proxy_tokens": 895044,
+ "fresh_inspect_ms": 3224.859665,
+ "repeat_inspect_ms": 3230.52117,
+ "reply_bytes": 37248,
+ "raw_guard_log_sha256": "0452dae8c26a023bd6220bca03dfd7a58425ef579f8e7964f04b4cb944723f84"
+ },
+ {
+ "case": "css",
+ "lane": "candidate",
+ "round": 2,
+ "wall_seconds": 12.112,
+ "cpu_seconds": 11.727334,
+ "memory_peak_bytes": 1186643968,
+ "transport_proxy_tokens": 895430,
+ "fresh_inspect_ms": 2662.82945,
+ "repeat_inspect_ms": 2489.199032999999,
+ "reply_bytes": 37428,
+ "raw_guard_log_sha256": "9d27bfa9720f4291324aa6fc47e44ffdee8b6fbc63ea29a2c82d297d0ba1f0c6"
+ },
+ {
+ "case": "css",
+ "lane": "baseline",
+ "round": 2,
+ "wall_seconds": 12.409,
+ "cpu_seconds": 12.038676,
+ "memory_peak_bytes": 1079783424,
+ "transport_proxy_tokens": 895430,
+ "fresh_inspect_ms": 2793.9973870000003,
+ "repeat_inspect_ms": 2541.021634999999,
+ "reply_bytes": 37426,
+ "raw_guard_log_sha256": "ddc43e4517a2ed634d5a969104b9051ac4e654485d3552f19d4c141a5834b205"
+ },
+ {
+ "case": "small",
+ "lane": "baseline",
+ "round": 3,
+ "wall_seconds": 9.797,
+ "cpu_seconds": 9.449418,
+ "memory_peak_bytes": 822263808,
+ "transport_proxy_tokens": 894936,
+ "fresh_inspect_ms": 1612.1414539999996,
+ "repeat_inspect_ms": 1833.922163000001,
+ "reply_bytes": 36180,
+ "raw_guard_log_sha256": "92aa46fe57caeae60cb3105b2ac370a8f8463afed4a7f666f46be885cad0ca4d"
+ },
+ {
+ "case": "small",
+ "lane": "candidate",
+ "round": 3,
+ "wall_seconds": 10.192,
+ "cpu_seconds": 9.868713,
+ "memory_peak_bytes": 829575168,
+ "transport_proxy_tokens": 894936,
+ "fresh_inspect_ms": 1629.0064629999997,
+ "repeat_inspect_ms": 1962.5034050000004,
+ "reply_bytes": 36182,
+ "raw_guard_log_sha256": "0451dacd244ced0ac56ceb0f73b39e1b86fa1517a0ec88391a3ab37d45b9bd20"
+ },
+ {
+ "case": "sqlite",
+ "lane": "baseline",
+ "round": 3,
+ "wall_seconds": 13.714,
+ "cpu_seconds": 13.319143,
+ "memory_peak_bytes": 1196531712,
+ "transport_proxy_tokens": 895044,
+ "fresh_inspect_ms": 3228.7134460000007,
+ "repeat_inspect_ms": 3397.271211000001,
+ "reply_bytes": 37248,
+ "raw_guard_log_sha256": "d3f7e1e572a4c3251ca479a3c018d2f024b80103e443a8daeb125bd896165d2a"
+ },
+ {
+ "case": "sqlite",
+ "lane": "candidate",
+ "round": 3,
+ "wall_seconds": 12.709,
+ "cpu_seconds": 12.349818,
+ "memory_peak_bytes": 1205280768,
+ "transport_proxy_tokens": 895044,
+ "fresh_inspect_ms": 3071.7333470000003,
+ "repeat_inspect_ms": 3024.3819120000007,
+ "reply_bytes": 37250,
+ "raw_guard_log_sha256": "57b405e4a4e8a9acbb4d9e4bb4663d931ad7ffec52a2b6a2969a7362287db892"
+ },
+ {
+ "case": "css",
+ "lane": "baseline",
+ "round": 3,
+ "wall_seconds": 12.315,
+ "cpu_seconds": 11.921752,
+ "memory_peak_bytes": 1081831424,
+ "transport_proxy_tokens": 895430,
+ "fresh_inspect_ms": 2798.227023,
+ "repeat_inspect_ms": 2563.7297770000005,
+ "reply_bytes": 37426,
+ "raw_guard_log_sha256": "2f6fca011f6718111b455d8104121ff6880131f58e0a82df069efef9445f5c07"
+ },
+ {
+ "case": "css",
+ "lane": "candidate",
+ "round": 3,
+ "wall_seconds": 11.901,
+ "cpu_seconds": 11.54646,
+ "memory_peak_bytes": 1123659776,
+ "transport_proxy_tokens": 895430,
+ "fresh_inspect_ms": 2600.1125399999996,
+ "repeat_inspect_ms": 2398.082381,
+ "reply_bytes": 37428,
+ "raw_guard_log_sha256": "180726dbb90af48c442109f75ccc50bf243ebcc8392908f9322eb91100a5f179"
+ }
+ ],
+ "parser_samples": [
+ {
+ "case": "small.decoded",
+ "lane": "baseline",
+ "round": 1,
+ "elapsed_ms": [
+ 2.9900399999999934, 1.9184900000000056, 0.3164599999999922,
+ 0.622399999999999, 0.6818110000000104, 1.7410099999999886
+ ],
+ "result_sha256": "beb5c7e4e8da08e3c104753b768c8ca40559d517dba80c483253991721b39c7f",
+ "wall_seconds": 0.422,
+ "cpu_seconds": 0.169929,
+ "memory_peak_bytes": 28078080
+ },
+ {
+ "case": "small.decoded",
+ "lane": "candidate",
+ "round": 1,
+ "elapsed_ms": [
+ 3.8333299999999895, 1.5887500000000045, 0.4467700000000008,
+ 0.3875499999999903, 0.3238100000000088, 0.21590999999999383
+ ],
+ "result_sha256": "beb5c7e4e8da08e3c104753b768c8ca40559d517dba80c483253991721b39c7f",
+ "wall_seconds": 0.323,
+ "cpu_seconds": 0.154998,
+ "memory_peak_bytes": 27508736
+ },
+ {
+ "case": "sqlite",
+ "lane": "baseline",
+ "round": 1,
+ "elapsed_ms": [
+ 248.049268, 224.29203599999994, 176.49019599999997, 155.392424,
+ 162.08057399999996, 172.70000600000003
+ ],
+ "result_sha256": "74eb7108ddd459dfa7bfb7a1fe653c8862eb43acd89d91ae033e7ac9158587ce",
+ "wall_seconds": 1.532,
+ "cpu_seconds": 1.326985,
+ "memory_peak_bytes": 71315456
+ },
+ {
+ "case": "sqlite",
+ "lane": "candidate",
+ "round": 1,
+ "elapsed_ms": [
+ 103.56140300000001, 103.344674, 56.359200999999985, 43.188242,
+ 44.79720200000003, 44.11639200000002
+ ],
+ "result_sha256": "74eb7108ddd459dfa7bfb7a1fe653c8862eb43acd89d91ae033e7ac9158587ce",
+ "wall_seconds": 0.827,
+ "cpu_seconds": 0.598442,
+ "memory_peak_bytes": 71557120
+ },
+ {
+ "case": "tree-sitter-css",
+ "lane": "baseline",
+ "round": 1,
+ "elapsed_ms": [
+ 133.568665, 122.39741400000003, 110.63244400000002, 111.27637299999998,
+ 111.761034, 111.34842400000002
+ ],
+ "result_sha256": "b59e5bdc32939c215443da6f3ce48e61fe2bfd86ff75b7a85d3c10a93b6fae7e",
+ "wall_seconds": 1.127,
+ "cpu_seconds": 0.882723,
+ "memory_peak_bytes": 55656448
+ },
+ {
+ "case": "tree-sitter-css",
+ "lane": "candidate",
+ "round": 1,
+ "elapsed_ms": [
+ 15.454230999999993, 15.1781, 17.94840099999999, 15.499510999999984,
+ 15.028279999999995, 2.9674699999999916
+ ],
+ "result_sha256": "b59e5bdc32939c215443da6f3ce48e61fe2bfd86ff75b7a85d3c10a93b6fae7e",
+ "wall_seconds": 0.424,
+ "cpu_seconds": 0.255557,
+ "memory_peak_bytes": 54427648
+ },
+ {
+ "case": "small.decoded",
+ "lane": "candidate",
+ "round": 2,
+ "elapsed_ms": [
+ 2.492229000000009, 1.6617090000000019, 0.4688189999999963,
+ 0.40376000000000545, 0.18850899999999626, 0.19684000000000879
+ ],
+ "result_sha256": "beb5c7e4e8da08e3c104753b768c8ca40559d517dba80c483253991721b39c7f",
+ "wall_seconds": 0.324,
+ "cpu_seconds": 0.14932,
+ "memory_peak_bytes": 27750400
+ },
+ {
+ "case": "small.decoded",
+ "lane": "baseline",
+ "round": 2,
+ "elapsed_ms": [
+ 3.0103999999999758, 2.4378910000000076, 0.30259000000000924,
+ 0.5248499999999865, 0.6185799999999801, 1.3529399999999896
+ ],
+ "result_sha256": "beb5c7e4e8da08e3c104753b768c8ca40559d517dba80c483253991721b39c7f",
+ "wall_seconds": 0.428,
+ "cpu_seconds": 0.219561,
+ "memory_peak_bytes": 30007296
+ },
+ {
+ "case": "sqlite",
+ "lane": "candidate",
+ "round": 2,
+ "elapsed_ms": [
+ 150.102906, 136.73758599999996, 85.33841300000006, 42.61153200000001,
+ 45.540752, 43.07188099999996
+ ],
+ "result_sha256": "74eb7108ddd459dfa7bfb7a1fe653c8862eb43acd89d91ae033e7ac9158587ce",
+ "wall_seconds": 0.959,
+ "cpu_seconds": 0.738843,
+ "memory_peak_bytes": 68399104
+ },
+ {
+ "case": "sqlite",
+ "lane": "baseline",
+ "round": 2,
+ "elapsed_ms": [
+ 236.412329, 219.61998900000003, 163.37286700000004, 152.177506,
+ 153.764226, 153.57296699999984
+ ],
+ "result_sha256": "74eb7108ddd459dfa7bfb7a1fe653c8862eb43acd89d91ae033e7ac9158587ce",
+ "wall_seconds": 1.532,
+ "cpu_seconds": 1.317278,
+ "memory_peak_bytes": 70189056
+ },
+ {
+ "case": "tree-sitter-css",
+ "lane": "candidate",
+ "round": 2,
+ "elapsed_ms": [
+ 15.074011000000013, 22.501490000000018, 23.890242, 9.095690999999988,
+ 25.85492100000002, 16.470331000000016
+ ],
+ "result_sha256": "b59e5bdc32939c215443da6f3ce48e61fe2bfd86ff75b7a85d3c10a93b6fae7e",
+ "wall_seconds": 0.524,
+ "cpu_seconds": 0.297941,
+ "memory_peak_bytes": 53497856
+ },
+ {
+ "case": "tree-sitter-css",
+ "lane": "baseline",
+ "round": 2,
+ "elapsed_ms": [
+ 135.69754600000002, 128.91107599999998, 115.180316, 115.23454600000002,
+ 117.18869600000005, 103.74374499999999
+ ],
+ "result_sha256": "b59e5bdc32939c215443da6f3ce48e61fe2bfd86ff75b7a85d3c10a93b6fae7e",
+ "wall_seconds": 1.13,
+ "cpu_seconds": 0.916205,
+ "memory_peak_bytes": 57880576
+ },
+ {
+ "case": "small.decoded",
+ "lane": "baseline",
+ "round": 3,
+ "elapsed_ms": [
+ 2.7721499999999963, 2.0805500000000023, 0.2946500000000043,
+ 0.4885200000000083, 0.37317999999999074, 1.052209999999988
+ ],
+ "result_sha256": "beb5c7e4e8da08e3c104753b768c8ca40559d517dba80c483253991721b39c7f",
+ "wall_seconds": 0.321,
+ "cpu_seconds": 0.161694,
+ "memory_peak_bytes": 28037120
+ },
+ {
+ "case": "small.decoded",
+ "lane": "candidate",
+ "round": 3,
+ "elapsed_ms": [
+ 2.4845909999999947, 1.5559899999999942, 1.9349599999999896,
+ 0.6082500000000124, 0.23478000000000065, 0.22989100000000917
+ ],
+ "result_sha256": "beb5c7e4e8da08e3c104753b768c8ca40559d517dba80c483253991721b39c7f",
+ "wall_seconds": 0.321,
+ "cpu_seconds": 0.154815,
+ "memory_peak_bytes": 27426816
+ },
+ {
+ "case": "sqlite",
+ "lane": "baseline",
+ "round": 3,
+ "elapsed_ms": [
+ 219.92179099999998, 204.47974999999997, 159.57980799999996,
+ 152.3218579999999, 150.58105799999998, 149.3406970000001
+ ],
+ "result_sha256": "74eb7108ddd459dfa7bfb7a1fe653c8862eb43acd89d91ae033e7ac9158587ce",
+ "wall_seconds": 1.428,
+ "cpu_seconds": 1.22534,
+ "memory_peak_bytes": 71987200
+ },
+ {
+ "case": "sqlite",
+ "lane": "candidate",
+ "round": 3,
+ "elapsed_ms": [
+ 103.150995, 103.714395, 58.592934000000014, 47.94820199999998,
+ 47.04763299999996, 47.239012
+ ],
+ "result_sha256": "74eb7108ddd459dfa7bfb7a1fe653c8862eb43acd89d91ae033e7ac9158587ce",
+ "wall_seconds": 0.828,
+ "cpu_seconds": 0.60423,
+ "memory_peak_bytes": 72187904
+ },
+ {
+ "case": "tree-sitter-css",
+ "lane": "baseline",
+ "round": 3,
+ "elapsed_ms": [
+ 126.407417, 121.22317700000002, 109.654766, 108.04800599999999,
+ 109.459836, 100.50628600000005
+ ],
+ "result_sha256": "b59e5bdc32939c215443da6f3ce48e61fe2bfd86ff75b7a85d3c10a93b6fae7e",
+ "wall_seconds": 1.029,
+ "cpu_seconds": 0.856826,
+ "memory_peak_bytes": 58101760
+ },
+ {
+ "case": "tree-sitter-css",
+ "lane": "candidate",
+ "round": 3,
+ "elapsed_ms": [
+ 14.889070999999987, 15.635390999999998, 17.013621, 15.000391000000008,
+ 17.56236100000001, 2.893010000000004
+ ],
+ "result_sha256": "b59e5bdc32939c215443da6f3ce48e61fe2bfd86ff75b7a85d3c10a93b6fae7e",
+ "wall_seconds": 0.422,
+ "cpu_seconds": 0.269288,
+ "memory_peak_bytes": 57966592
+ }
+ ],
+ "limits": {
+ "cpu_affinity": [15],
+ "cpu_quota_percent": 100,
+ "hard_memory_bytes": 4294967296,
+ "early_stop_bytes": 3221225472,
+ "swap_bytes": 0,
+ "task_ceiling": 192
+ },
+ "limitations": [
+ "Fresh server sessions; OS file caches warm/unflushed. Parser samples use one fresh and five repeated in-memory parses per process.",
+ "SDK transport proxy counts one complete catalog plus five named request/reply envelopes. Initialization/framing and actual model usage are unmeasured. No token saving is claimed.",
+ "Memory is kernel process-family memory.peak including file cache; final counters after transient scope removal remain unknown.",
+ "First string-only 18-task development cohort, one invalid-source-kind harness attempt, and the cache-hit build with unverified payload cleanup are preserved outside Git and excluded from final benefit claims.",
+ "Independent native WebAssembly.Module import/export oracle compiles without instantiating or executing target code. It does not prove every typed signature or unknown future proposal."
+ ],
+ "resource_qualification": {
+ "completed_mcp_tasks": 18,
+ "completed_parser_processes": 18,
+ "all_payload_exit_codes_zero": true,
+ "all_payload_cleanup_verified": true,
+ "all_payload_counters_verified": true,
+ "all_memory_high_hard_oom_and_task_limit_events_zero": true,
+ "final_whole_scope_counters": "unknown"
+ },
+ "call_sequence": [
+ "inspect_wasm_artifact(summary):fresh",
+ "inspect_analysis_view(exports,first4)",
+ "inspect_wasm_artifact(summary):repeat",
+ "inspect_analysis_view(exports,first4)",
+ "export_evidence_bundle"
+ ],
+ "transport_proxy_scope": "One complete SDK catalog and all five named request/reply envelopes, compact UTF-8 JSON encoded with cl100k_base; not actual model input."
+}
diff --git a/experiments/analysis-efficiency/wasm-string-framing.md b/experiments/analysis-efficiency/wasm-string-framing.md
new file mode 100644
index 0000000000..e4d3d4522e
--- /dev/null
+++ b/experiments/analysis-efficiency/wasm-string-framing.md
@@ -0,0 +1,107 @@
+# WAT framing costs on shipped WASM
+
+[Issue #5760](https://github.com/morluto/rea/issues/5760) follows the current
+WASM summary workflow; no new tool, selector, cache or default is needed.
+The [curated measurements](wasm-string-framing.json) pin source, runtime,
+artifact, producer and answer fingerprints, including every final sample.
+
+## Owner and preserved behavior
+
+WABT's WAT framing scanned instruction text character by character. Interface
+tokenization also decoded quoted bytes merely to obtain an end position.
+`WabtString` now owns a shared quoted-string boundary operation, preserving
+escaped-quote/backslash parity. The top-level framer searches for structural
+ASCII delimiters and skips string payloads. Name and custom-payload consumers
+still decode their bytes; unsupported typed signatures remain unknown.
+Nested/line comments, exact source forms and full retained WAT remain available.
+Data forms were already excluded from interface parsing; this does not add a
+new data filter or reinterpret raw WASM outside WABT.
+
+Baseline source is `d0067ba34a39`; candidate code is
+`9776d78f6c799cba8d8cf5c9b3139a86449eaebd`, based on latest main
+`335c042d166b` (the intervening main change is testing documentation).
+Linux x64, Node 24.18.0/npm 11.16.0, native WABT 1.0.42. Three alternating
+baseline/candidate, candidate/baseline, baseline/candidate pairs ran serially.
+Each task starts a fresh production stdio MCP server, discovers all 212 tools,
+inspects the selected artifact twice with existing summary delivery, reads the
+first four exact exports after each inspection, exports complete Evidence and
+closes. OS file caches are warm/unflushed; no cold-disk claim is made.
+
+The public SQLite Wasm 3.53.4-build1 and VS Code tree-sitter-wasm 0.3.1 CSS
+components match the pinned [corpus](corpus.json) archive and member digests.
+An 80-byte source-assembled module supplies the small control, with an escaped
+import name. Native `WebAssembly.Module` import/export metadata independently
+checks all returned names/kinds without instantiating or executing target code.
+A separate native `wasm2wat --enable-all` output checks exact full WAT and its
+SHA-256. Every before/after original Evidence ID, normalized report digest,
+source byte digest, selected export and catalog digest agrees. This does not
+independently prove every typed signature or future WASM proposal.
+
+## Completed-task costs
+
+Medians of three complete tasks per side; kernel process-family memory includes
+file cache. Wall time includes startup, discovery, all five investigation RPCs,
+client validation, independent checks, export and transport cleanup.
+
+| Artifact | Wall seconds, before → after | CPU seconds, before → after | Peak MiB, before → after |
+| ------------- | ---------------------------: | --------------------------: | -----------------------: |
+| Small control | 10.192 → 10.192 | 9.863 → 9.869 | 785.3 → 791.1 |
+| SQLite | 13.714 → 13.311 | 13.319 → 12.957 | 985.1 → 1149.4 |
+| CSS grammar | 12.409 → 12.112 | 12.039 → 11.727 | 1031.7 → 1071.6 |
+
+Whole-task median wall reductions are about 2.9%/2.4% for SQLite/CSS; small-task
+time is unchanged. Three pairs are descriptive observations, not statistical
+or cross-provider guarantees. Memory does not improve: SQLite's median is
+higher, with overlapping 970–1141 versus 982–1156 MiB ranges. No heap-retention
+cause or memory-saving claim is established.
+
+Separate fresh-process parser cohorts use one first parse and five repeated
+in-memory parses, checking the complete parsed-result digest after each parse.
+Median first/warm parse milliseconds: SQLite 236.41/153.76 → 103.56/45.54;
+CSS 133.57/111.35 → 15.07/15.64; small 2.99/0.62 → 2.49/0.40. These phase
+improvements do not include native WABT production or complete task costs.
+
+The unchanged catalog and all five named request/reply envelopes cost median
+894,936 / 895,044 / 895,430 `cl100k_base` tokens for small/SQLite/CSS in both
+implementations. This is a single serialized SDK transport proxy using
+tiktoken 0.12.0; actual model input/usage, repeated model namespaces and protocol
+initialization/framing are unmeasured. No token saving is claimed.
+
+All 18 final MCP tasks and 18 parser processes completed under one CPU,
+4 GiB hard/3 GiB early-stop memory, no swap and 192 tasks, with payload-exit
+counters and cleanup verified and no limit/OOM events. Final whole-scope counters
+after transient scope removal remain unknown. Raw binaries, exports,
+transcripts and logs stay outside Git. The earlier string-only 18-task
+cohort, an invalid source-kind harness attempt and a cache-hit build with
+unverified payload cleanup are retained and excluded from final benefit claims.
+
+## Reproduction
+
+Build/freeze each runtime with the pinned toolchain; keep the selected artifact,
+WABT directory and independently produced WAT paths identical across lanes:
+
+```sh
+python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \
+ node experiments/analysis-efficiency/wasm-string-framing.mjs \
+ --runtime /absolute/runtime --artifact /absolute/input.wasm \
+ --wat /absolute/native-output.wat --tools /absolute/wabt/bin \
+ --output /absolute/new-output-directory
+```
+
+For parser-only cohorts, import `dist/wasm/WabtWat.js` from each frozen runtime,
+load the same native WAT once, and call `parseWabtWat(text)` six times. Measure
+each call with `performance.now()`; outside that timer, hash
+`JSON.stringify(result)` and require the same digest for every call and lane.
+Keep each artifact in a separate fresh guarded process.
+
+Source verification passed 76 tests across nine files, including malformed
+strings, slash parity, comments, invalid names, capacity/partial facts,
+cancellation, cleanup retry and shared application behavior. `npm run check`
+passed typecheck, lint, formatting, Knip, metadata and architecture guards.
+Four compiled CLI/MCP/acceptance files passed another 14 tests. The native WABT
+artifact verifier passed 51 CLI/MCP cases; the native interface verifier passed
+nine cases, including all admitted kinds, escaped/BOM names, padded LEB128,
+signature changes, duplicate imports and unsupported reference semantics.
+The build used a separate 6 GiB hard/5 GiB early-stop guard; the full check used
+8/7 GiB, peaking at 5.37 GiB without resource events. These are build/check
+budgets; runtime tasks retain the smaller bounds above.
diff --git a/experiments/analysis-efficiency/wasm-string-framing.mjs b/experiments/analysis-efficiency/wasm-string-framing.mjs
new file mode 100644
index 0000000000..67cf0c634e
--- /dev/null
+++ b/experiments/analysis-efficiency/wasm-string-framing.mjs
@@ -0,0 +1,184 @@
+/** Compare unchanged public WASM tasks; raw inputs, exports and transcripts stay outside Git. */
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { createWriteStream } from "node:fs";
+import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { tmpdir } from "node:os";
+import { finished } from "node:stream/promises";
+import { parseArgs } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
+import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
+
+const { values } = parseArgs({
+ options: Object.fromEntries(
+ ["runtime", "artifact", "wat", "tools", "output"].map((name) => [
+ name,
+ { type: "string" },
+ ]),
+ ),
+});
+for (const name of ["runtime", "artifact", "wat", "tools", "output"])
+ assert.ok(
+ values[name] && values[name] === resolve(values[name]),
+ `${name} must be absolute`,
+ );
+const hash = (value) => createHash("sha256").update(value).digest("hex");
+const bytes = await readFile(values.artifact);
+const nativeWat = await readFile(values.wat, "utf8");
+// Native compilation validates the independent name inventory; no instance or target code runs.
+const module = new WebAssembly.Module(bytes);
+const nativeImports = WebAssembly.Module.imports(module);
+const nativeExports = WebAssembly.Module.exports(module);
+await mkdir(values.output, { mode: 0o700 });
+const account = await mkdtemp(join(tmpdir(), "rea-wat-framing-"));
+const client = new Client({ name: "wasm-string-framing", version: "1" });
+const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
+ env: {
+ HOME: account,
+ PATH: process.env.PATH,
+ NODE_OPTIONS: "--max-old-space-size=2048",
+ UV_THREADPOOL_SIZE: "1",
+ REA_LOG_LEVEL: "silent",
+ REA_WABT_BIN_DIRECTORY: values.tools,
+ TMPDIR: account,
+ },
+ stderr: "pipe",
+});
+const serverLog = createWriteStream(join(values.output, "server.log"));
+const serverLogFinished = finished(serverLog);
+serverLogFinished.catch(() => {});
+transport.stderr?.pipe(serverLog);
+const report = {
+ artifact_sha256: hash(bytes),
+ artifact_bytes: bytes.length,
+ calls: [],
+ answers: [],
+};
+const transcript = [];
+const call = createRetainedQueryCaller(client, (receipt, event) => {
+ report.calls.push(receipt);
+ transcript.push(event);
+});
+const observe = async (name, input, phase) => {
+ const reply = await call(name, input, phase);
+ assert.notEqual(reply.isError, true, JSON.stringify(reply));
+ assert.ok(reply.structuredContent);
+ return reply.structuredContent;
+};
+const started = performance.now();
+try {
+ await client.connect(transport);
+ const catalog = await client.listTools();
+ assert.equal(catalog.nextCursor, undefined);
+ await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
+ report.catalog_sha256 = hash(JSON.stringify(catalog));
+ report.catalog_tools = catalog.tools.length;
+ const definitions = new Map(
+ catalog.tools.map((definition) => [definition.name, definition]),
+ );
+ const originalCall = client.callTool.bind(client);
+ client.callTool = (input, options) =>
+ originalCall(input, {
+ ...options,
+ toolDefinition: definitions.get(input.name),
+ });
+ const observations = [];
+ for (const phase of ["fresh", "repeat"]) {
+ const summary = await observe(
+ "inspect_wasm_artifact",
+ { path: values.artifact, detail: "summary" },
+ phase,
+ );
+ const parentId = summary.normalized_result.parent_evidence_id;
+ const exports = await observe(
+ "inspect_analysis_view",
+ {
+ source: { kind: "retained-evidence", evidence_id: parentId },
+ view: { kind: "wasm", facet: "exports", offset: 0, limit: 4 },
+ },
+ `${phase}:exports`,
+ );
+ assert.equal(exports.normalized_result.parent_evidence_id, parentId);
+ observations.push({ summary, exports, parentId });
+ }
+ await observe(
+ "export_evidence_bundle",
+ { path: join(values.output, "evidence.json") },
+ "export",
+ );
+ const bundle = JSON.parse(
+ await readFile(join(values.output, "evidence.json"), "utf8"),
+ );
+ const kind = (value) => (value === "func" ? "function" : value);
+ for (const { summary, exports, parentId } of observations) {
+ const parent = bundle.records.find(
+ (record) => record.evidence_id === parentId,
+ );
+ assert.ok(parent);
+ assert.equal(parent.subject.digest.sha256, report.artifact_sha256);
+ const facts = parent.normalized_result;
+ assert.equal(facts.validation, "valid");
+ assert.equal(facts.wat.text, nativeWat);
+ assert.equal(facts.wat.sha256, hash(nativeWat));
+ assert.equal(facts.interface.state, "complete");
+ assert.deepEqual(
+ facts.interface.entries
+ .filter((entry) => entry.direction === "import")
+ .map((entry) => ({
+ module: entry.module,
+ name: entry.name,
+ kind: kind(entry.kind),
+ })),
+ nativeImports,
+ );
+ assert.deepEqual(
+ facts.interface.entries
+ .filter((entry) => entry.direction === "export")
+ .map((entry) => ({ name: entry.name, kind: kind(entry.kind) })),
+ nativeExports,
+ );
+ assert.deepEqual(exports.normalized_result.item, facts.exports.slice(0, 4));
+ assert.equal(
+ summary.normalized_result.summary.available_counts.imports,
+ nativeImports.length,
+ );
+ assert.equal(
+ summary.normalized_result.summary.available_counts.exports,
+ nativeExports.length,
+ );
+ assert.equal(facts.runtime_execution, "not-performed");
+ assert.equal(facts.network_fetch, "not-performed");
+ report.answers.push({
+ parent_evidence_id: parentId,
+ normalized_sha256: hash(JSON.stringify(facts)),
+ exports: exports.normalized_result.item,
+ });
+ }
+ assert.deepEqual(report.answers[0], report.answers[1]);
+ assert.equal(hash(await readFile(values.artifact)), report.artifact_sha256);
+ report.status = "passed";
+} catch (cause) {
+ report.status = "failed";
+ report.failure = String(cause?.stack ?? cause);
+ process.exitCode = 1;
+} finally {
+ report.elapsed_ms_before_cleanup = performance.now() - started;
+ if (
+ !(await finalizeRetainedQueryReceipt({
+ report,
+ client,
+ transport,
+ account,
+ serverLog,
+ serverLogFinished,
+ output: values.output,
+ transcript,
+ }))
+ )
+ process.exitCode = 1;
+}
diff --git a/src/wasm/WabtInterface.ts b/src/wasm/WabtInterface.ts
index 65e6fc0fa4..df47208995 100644
--- a/src/wasm/WabtInterface.ts
+++ b/src/wasm/WabtInterface.ts
@@ -7,7 +7,7 @@ import type {
WasmInterfaceSignature,
WasmInterfaceValueType,
} from "../domain/wasm/wasmInterface.js";
-import { watString } from "./WabtString.js";
+import { watString, watStringEnd } from "./WabtString.js";
/** Split direct WAT children only; function instruction bodies are never materialized. */
const parts = (form: string): string[] => {
@@ -40,12 +40,12 @@ const parts = (form: string): string[] => {
}
const start = i;
if (form[i] === '"') {
- i = watString(form, i + 1).end;
+ i = watStringEnd(form, i + 1);
} else if (form[i] === "(") {
let depth = 1;
i++;
while (i < form.length && depth > 0) {
- if (form[i] === '"') i = watString(form, i + 1).end;
+ if (form[i] === '"') i = watStringEnd(form, i + 1);
else {
if (form[i] === "(") depth++;
else if (form[i] === ")") depth--;
diff --git a/src/wasm/WabtString.test.ts b/src/wasm/WabtString.test.ts
new file mode 100644
index 0000000000..bdbaf40afe
--- /dev/null
+++ b/src/wasm/WabtString.test.ts
@@ -0,0 +1,56 @@
+import { expect, it } from "vitest";
+import { parseWabtWat } from "./WabtWat.js";
+import { watString, watStringEnd } from "./WabtString.js";
+
+it("frames escaped quotes and trailing backslashes without consuming the next form", () => {
+ const source = String.raw`prefix "a\"b\\" (export "next" (func 0))`;
+ const end = source.indexOf(" (export");
+ expect(watStringEnd(source, 8)).toBe(end);
+ expect(watString(source, 8)).toEqual({
+ bytes: Buffer.from('a"b\\'),
+ end,
+ });
+ expect(watStringEnd('""', 1)).toBe(2);
+ for (const count of [1, 2, 3, 4, 5]) {
+ for (const tail of ['"tail', ""]) {
+ const value = "\\".repeat(count) + tail;
+ const encoded = JSON.stringify(value);
+ const source = encoded + ' (export "next" (func 0))';
+ expect(watStringEnd(source, 1)).toBe(encoded.length);
+ expect(watString(source, 1).bytes).toEqual(Buffer.from(value));
+ }
+ }
+});
+
+it("rejects missing terminators, including a dangling escape or escaped last quote", () => {
+ for (const source of ['"', '"payload', '"payload\\', '"payload\\"']) {
+ expect(() => watStringEnd(source, 1)).toThrow("Incomplete WABT string");
+ expect(() => watString(source, 1)).toThrow("Incomplete WABT string");
+ expect(() => parseWabtWat(`(module (import ${source}`)).toThrow();
+ }
+});
+
+it("preserves quoted WAT punctuation, nested comments, escaped names and unknown signatures", () => {
+ const result = parseWabtWat(String.raw`(module
+ (; outer " (; inner ;) ;)
+ (import "env" "a\"b\\(;);;\00" (func (param i32)))
+ (memory 1 (future-proposal "ignored\ff"))
+ (export "memory" (memory 0))
+ (data (i32.const 0) "(; );) ;; \" \\"))`);
+ expect(result.interface.entries).toMatchObject([
+ {
+ direction: "import",
+ name: 'a"b\\(;);;\0',
+ signature: { kind: "func", params: ["i32"] },
+ },
+ {
+ direction: "export",
+ name: "memory",
+ signature: null,
+ issue: expect.any(String),
+ },
+ ]);
+ expect(() => parseWabtWat('(module (import "env" "\\q" (func)))')).toThrow(
+ "Unsupported WABT name escape",
+ );
+});
diff --git a/src/wasm/WabtString.ts b/src/wasm/WabtString.ts
index 79615b193d..715e4cb565 100644
--- a/src/wasm/WabtString.ts
+++ b/src/wasm/WabtString.ts
@@ -1,13 +1,21 @@
-/** Decode a producer WAT string to frame objdump's raw display text. */
-export const watString = (form: string, start: number) => {
- let end = start;
- while (end < form.length && form[end] !== '"') {
- if (form[end] === "\\") end++;
- end++;
+/** Locate a WAT string's exclusive end without decoding or copying its payload. */
+export const watStringEnd = (form: string, start: number): number => {
+ for (
+ let end = form.indexOf('"', start);
+ end !== -1;
+ end = form.indexOf('"', end + 1)
+ ) {
+ let escapes = end;
+ while (escapes > start && form[escapes - 1] === "\\") escapes--;
+ if ((end - escapes) % 2 === 0) return end + 1;
}
- if (end === form.length)
- throw new Error("Incomplete WABT custom-section name.");
- const name = form.slice(start, end);
+ throw new Error("Incomplete WABT string.");
+};
+
+/** Decode a producer WAT string only where its bytes are interpreted. */
+export const watString = (form: string, start: number) => {
+ const end = watStringEnd(form, start);
+ const name = form.slice(start, end - 1);
// Escapes shrink; literal UTF-8 has the same byte extent as its source.
const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8"));
let length = 0;
@@ -42,5 +50,5 @@ export const watString = (form: string, start: number) => {
index += 2;
}
}
- return { bytes: bytes.subarray(0, length), end: end + 1 };
+ return { bytes: bytes.subarray(0, length), end };
};
diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts
index 01b9f16bc5..669c04c8e5 100644
--- a/src/wasm/WabtWat.ts
+++ b/src/wasm/WabtWat.ts
@@ -1,5 +1,5 @@
import { parseWabtInterface } from "./WabtInterface.js";
-import { watString } from "./WabtString.js";
+import { watString, watStringEnd } from "./WabtString.js";
/** Read only module-name metadata from WABT's preserved name-section payload. */
const moduleNames = (bytes: Uint8Array): string[] => {
@@ -48,22 +48,24 @@ export const parseWabtWat = (text: string) => {
const debugModuleNames: string[] = [];
let depth = 0;
let start = 0;
- let quoted = false;
let commentDepth = 0;
let lineComment = false;
let closed = false;
+ const boundaries = /[()";]/gu;
for (let index = 0; index < text.length; index++) {
+ // Instructions and whitespace inside a form cannot change its framing.
+ if (depth > 0 && !lineComment && commentDepth === 0) {
+ boundaries.lastIndex = index;
+ const boundary = boundaries.exec(text);
+ if (boundary === null) break;
+ index = boundary.index;
+ }
const char = text[index];
const next = text[index + 1];
if (lineComment) {
if (char === "\n") lineComment = false;
continue;
}
- if (quoted) {
- if (char === "\\") index++;
- else if (char === '"') quoted = false;
- continue;
- }
if (commentDepth > 0) {
if (char === "(" && next === ";") {
commentDepth++;
@@ -87,7 +89,7 @@ export const parseWabtWat = (text: string) => {
if (char === '"') {
if (depth === 0)
throw new Error("Unexpected string outside WABT WAT module.");
- quoted = true;
+ index = watStringEnd(text, index + 1) - 1;
continue;
}
if (char === "(") {
@@ -133,7 +135,7 @@ export const parseWabtWat = (text: string) => {
} else if (depth === 0 && char !== undefined && !/\s/u.test(char))
throw new Error("Unexpected text outside WABT WAT module.");
}
- if (depth !== 0 || quoted || commentDepth !== 0)
+ if (depth !== 0 || commentDepth !== 0)
throw new Error("Incomplete WABT WAT output.");
return {
imports,
From 90062c092228dea9d33fadc0d7e1b968105b8b2c Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:04:29 +0800
Subject: [PATCH 08/45] fix(apple): trace legacy macOS dylibs with implicit
platforms
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Apply dyld’s implicit macOS compatibility rule only for a root executable with observed macOS platform and a supported candidate CPU type. Retain the absent platform fields as raw facts and expose the derivation on the candidate. Traverse the selected dylib and report missing required children; preserve unknown platform coverage and the later known fallback graph in other contexts.
Validation: 144 source/reader tests, 14 compiled CLI tests, 18 schema tests, full check:ci and docs:check passed. Four source-built app variants passed CLI and SDK-validated stdio MCP checks with unchanged fixture digests. No fixture executable or Apple dyld comparison was run. Independent review resolved a library-root authority gap before integration.
Sixteen cold/warm task-cost observations are retained locally with sampling limits; the catalog adds 66 bytes. Actual model tokens and maximum-sized inputs were not measured; no speedup is claimed.
Closes #5696
---
docs/apple-application-analysis.md | 29 ++-
.../apple/MachoLoadCommandReader.test.ts | 106 ++++++++++
src/domain/apple/dylibResolution.test.ts | 118 +++++++++--
src/domain/apple/dylibResolution.ts | 195 ++++++++++++------
4 files changed, 358 insertions(+), 90 deletions(-)
diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md
index 926aae0d41..1ea57eb270 100644
--- a/docs/apple-application-analysis.md
+++ b/docs/apple-application-analysis.md
@@ -216,17 +216,26 @@ parser coverage, such as big-endian images), `architecture-missing`,
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
-| Status | Meaning |
-| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
-| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. |
-| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
-| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
+| Status | Meaning |
+| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
+| `conditional` | An in-root image may load, but an earlier outside candidate, unmodeled environment input, or unknown platform compatibility can change which image dyld loads. |
+| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
+| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
-A `conditional` image is still traced, because it is the image the analyzed
-root supplies. Every edge below it has `loader_conditional: true`, and a
-request that reuses it by install name is `conditional` too. Findings on those
-edges say that they apply only if the conditional image loads.
+A `conditional` image is still traced, because it is an in-root candidate
+that may load. Every edge below it has `loader_conditional: true`, and a
+request that reuses it by install name is `conditional` too. An in-root dylib
+with no platform load command remains unknown unless the root is an executable
+with observed macOS platform and the candidate’s CPU type is x86_64, i386, or arm64. In that macOS context,
+[Apple's dyld](https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/mach_o/UnsafeHeader.cpp#L1137-L1145) supplies an implicit macOS platform to legacy dylibs on those CPU
+types. The candidate records `platform_basis: "macos-dyld-implicit"`; the
+reported slice fields `platform: null` and `platforms: []` remain the parsed
+facts. For other process contexts the platform remains unknown, coverage is
+partial, and the candidate is not treated as incompatible. If a later known
+candidate resolves, it remains the traced image with conditional resolution;
+the trace does not claim the unknown candidate's dependency graph. Findings on
+conditional edges say that they apply only if the conditional image loads.
Absolute install names that name a path beneath the analyzed root are checked
within that root, with symlinks and `..` resolved by the same bounded tree
diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts
index 94dd4891c1..c2008e599c 100644
--- a/src/artifacts/apple/MachoLoadCommandReader.test.ts
+++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts
@@ -142,6 +142,112 @@ it("uses legacy simulator and device platform facts when resolving dylibs", asyn
);
});
+it.each([false, true] as const)(
+ "applies macOS dyld's implicit platform to a platform-less dylib and traces its required child present=%s",
+ async (childPresent) => {
+ const root = await createTestTempDirectory("rea-dylib-platform-unknown-");
+ const executablePath = "Contents/MacOS/probe";
+ const dylibPath = "Contents/Frameworks/libA.dylib";
+ const fallbackPath = "Contents/Fallbacks/libA.dylib";
+ const childPath = "Contents/Frameworks/libMissing.dylib";
+ const fallbackChildPath = "Contents/Fallbacks/libFallbackOnly.dylib";
+ const executable = machoImage({
+ cpu: CPU.arm64,
+ commands: [
+ buildVersionCommand(1),
+ rpathCommand("@executable_path/../Frameworks"),
+ rpathCommand("@executable_path/../Fallbacks"),
+ dylibCommand(LC.LOAD_DYLIB, "@rpath/libA.dylib"),
+ ],
+ });
+ const dylib = machoImage({
+ cpu: CPU.arm64,
+ fileType: FILE_TYPE.dylib,
+ commands: [
+ rpathCommand("@loader_path"),
+ dylibCommand(LC.LOAD_DYLIB, "@rpath/libMissing.dylib"),
+ ],
+ });
+ const child = machoImage({
+ cpu: CPU.arm64,
+ fileType: FILE_TYPE.dylib,
+ commands: [buildVersionCommand(1)],
+ });
+ const fallback = machoImage({
+ cpu: CPU.arm64,
+ fileType: FILE_TYPE.dylib,
+ commands: [
+ rpathCommand("@loader_path"),
+ dylibCommand(LC.LOAD_DYLIB, "@rpath/libFallbackOnly.dylib"),
+ ],
+ });
+ const fallbackChild = machoImage({
+ cpu: CPU.arm64,
+ fileType: FILE_TYPE.dylib,
+ commands: [buildVersionCommand(1)],
+ });
+ await mkdir(join(root, "Contents/MacOS"), { recursive: true });
+ await mkdir(join(root, "Contents/Frameworks"), { recursive: true });
+ await mkdir(join(root, "Contents/Fallbacks"), { recursive: true });
+ await writeFile(join(root, executablePath), executable);
+ await writeFile(join(root, dylibPath), dylib);
+ await writeFile(join(root, fallbackPath), fallback);
+ await writeFile(join(root, fallbackChildPath), fallbackChild);
+ if (childPresent) await writeFile(join(root, childPath), child);
+
+ const trace = await traceDylibResolution({
+ rootPath: root,
+ targetPath: join(root, executablePath),
+ targetSha256: createHash("sha256").update(executable).digest("hex"),
+ enumerateRoots: false,
+ parameters: { roots: [executablePath] },
+ });
+
+ expect(
+ trace.images.find(({ path }) => path === executablePath)?.slices[0],
+ ).toMatchObject({ platform: 1, platforms: [1] });
+ expect(
+ trace.images.find(({ path }) => path === dylibPath)?.slices[0],
+ ).toMatchObject({ platform: null, platforms: [] });
+ expect(trace.edges[0]).toMatchObject({
+ loader: executablePath,
+ install_name: "@rpath/libA.dylib",
+ candidates: [
+ {
+ outcome: "resolved",
+ resolved_path: dylibPath,
+ platform_basis: "macos-dyld-implicit",
+ },
+ ],
+ resolution: { status: "resolved", image: dylibPath },
+ });
+ expect(trace.edges[1]).toMatchObject({
+ loader: dylibPath,
+ install_name: "@rpath/libMissing.dylib",
+ resolution: childPresent
+ ? { status: "resolved", image: childPath }
+ : { status: "unresolved", image: null },
+ loader_conditional: false,
+ });
+ expect(trace.images.map(({ path }) => path)).toEqual(
+ childPresent
+ ? [executablePath, childPath, dylibPath].sort()
+ : [executablePath, dylibPath].sort(),
+ );
+ expect(trace.images.map(({ path }) => path)).not.toContain(fallbackPath);
+ expect(trace.images.map(({ path }) => path)).not.toContain(
+ fallbackChildPath,
+ );
+ expect(trace.coverage.status).toBe("complete");
+ expect(trace.limitations).toContainEqual(
+ expect.stringContaining(dylibPath),
+ );
+ expect(trace.findings.map(({ kind }) => kind)).toEqual(
+ childPresent ? [] : ["required-load-unresolved"],
+ );
+ },
+);
+
describe("absolute dyld candidate paths at the filesystem reader boundary", () => {
it.each(["install-name", "rpath"] as const)(
"resolves an absolute in-root %s while preserving its source path",
diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts
index dd4c18df28..577ce5d9a5 100644
--- a/src/domain/apple/dylibResolution.test.ts
+++ b/src/domain/apple/dylibResolution.test.ts
@@ -441,26 +441,6 @@ describe("dyld resolution outcomes: checking candidate format and compatibility"
});
});
- it("preserves unknown platform compatibility before a later known candidate", async () => {
- const trace = await traceDylibLoading(
- memoryView({
- [MAIN]: executable({
- rpaths: ["@executable_path/a", "@executable_path/b"],
- dependencies: [dependency("@rpath/lib.dylib")],
- }),
- "Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })),
- "Contents/MacOS/b/lib.dylib": parsed(slice()),
- }),
- { roots: [MAIN] },
- );
- const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
- expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([
- "undetermined",
- "resolved",
- ]);
- expect(edge.resolution.status).toBe("conditional");
- });
-
it("applies dyld Catalyst and zippered platform compatibility", async () => {
const catalystProcess = await traceDylibLoading(
memoryView({
@@ -506,6 +486,104 @@ describe("dyld resolution outcomes: checking candidate format and compatibility"
});
});
+describe("platform-less dylib compatibility", () => {
+ it("keeps an unknown candidate unresolved and traces the later known fallback", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ platform: 6,
+ platforms: [6],
+ rpaths: ["@executable_path/a", "@executable_path/b"],
+ dependencies: [dependency("@rpath/lib.dylib")],
+ }),
+ "Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })),
+ "Contents/MacOS/b/lib.dylib": parsed(
+ slice({
+ platform: 6,
+ platforms: [6],
+ dependencies: [dependency("@loader_path/libChild.dylib")],
+ }),
+ ),
+ "Contents/MacOS/b/libChild.dylib": parsed(
+ slice({ platform: 6, platforms: [6] }),
+ ),
+ }),
+ { roots: [MAIN] },
+ );
+ const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
+ expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([
+ "undetermined",
+ "resolved",
+ ]);
+ expect(edge.resolution).toEqual({
+ status: "conditional",
+ image: "Contents/MacOS/b/lib.dylib",
+ });
+ expect(edge.candidates[0]).not.toHaveProperty("platform_basis");
+ expect(trace.edges.map(({ loader }) => loader)).toContain(
+ "Contents/MacOS/b/lib.dylib",
+ );
+ expect(trace.images.map(({ path }) => path)).toContain(
+ "Contents/MacOS/b/libChild.dylib",
+ );
+ expect(trace.coverage.status).toBe("partial");
+ expect(trace.limitations).toContainEqual(
+ expect.stringContaining("Contents/MacOS/a/lib.dylib"),
+ );
+ });
+
+ it("does not infer a platform-less image's platform when process platform is unknown", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ platform: null,
+ platforms: [],
+ rpaths: ["@executable_path/a"],
+ dependencies: [dependency("@rpath/lib.dylib")],
+ }),
+ "Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })),
+ }),
+ { roots: [MAIN] },
+ );
+ const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
+ expect(edge.candidates[0]).toMatchObject({
+ outcome: "undetermined",
+ resolved_path: "Contents/MacOS/a/lib.dylib",
+ });
+ expect(edge.candidates[0]).not.toHaveProperty("platform_basis");
+ expect(edge.resolution).toEqual({ status: "undetermined", image: null });
+ expect(trace.coverage.status).toBe("partial");
+ });
+
+ it("does not treat a selected dylib root as an executable process context", async () => {
+ const library = "Contents/Frameworks/libRoot.dylib";
+ const child = "Contents/Frameworks/libChild.dylib";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [library]: parsed(
+ slice({
+ file_type: "dylib",
+ file_type_code: 6,
+ platform: 1,
+ platforms: [1],
+ dependencies: [dependency("@loader_path/libChild.dylib")],
+ }),
+ ),
+ [child]: parsed(slice({ platform: null })),
+ }),
+ { roots: [library] },
+ );
+ const edge = edgeFor(trace, library, "@loader_path/libChild.dylib");
+ expect(edge.candidates[0]).toMatchObject({
+ outcome: "undetermined",
+ resolved_path: child,
+ });
+ expect(edge.candidates[0]).not.toHaveProperty("platform_basis");
+ expect(edge.resolution).toEqual({ status: "undetermined", image: null });
+ expect(trace.coverage.status).toBe("partial");
+ });
+});
+
describe("dyld load order", () => {
it("loads each image once per process and reuses matching install names", async () => {
const a = "Contents/Frameworks/libA.dylib";
diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts
index 1adfdc79fc..3fdf5a518c 100644
--- a/src/domain/apple/dylibResolution.ts
+++ b/src/domain/apple/dylibResolution.ts
@@ -51,6 +51,13 @@ const dependencyCommandSchema = z.enum([
"LC_LOAD_UPWARD_DYLIB",
]);
+const IMPLICIT_MACOS_PLATFORM = 1;
+const MACOS_DYLD_IMPLICIT_PLATFORM_CPU_TYPES = new Set([
+ 7, // CPU_TYPE_I386
+ 0x01000007, // CPU_TYPE_X86_64
+ 0x0100000c, // CPU_TYPE_ARM64, including arm64 subtypes
+]);
+
const dependencySchema = z.strictObject({
command: dependencyCommandSchema,
encoding: z.enum([
@@ -145,6 +152,7 @@ const candidateSchema = z.strictObject({
"undetermined",
]),
resolved_path: z.string().nullable(),
+ platform_basis: z.enum(["macos-dyld-implicit"]).optional(),
});
const edgeSchema = z.strictObject({
@@ -227,7 +235,7 @@ interface LoadedImage {
readonly slice: MachoSlice;
/** Images from the process root to this image; the rpath stack, outermost first. */
readonly chain: readonly string[];
- /** Some edge on the chain was conditional: an unknown earlier candidate may load instead. */
+ /** Some edge on the chain has unresolved candidate or platform facts. */
readonly conditional: boolean;
}
@@ -240,6 +248,8 @@ interface ProcessContext {
>;
readonly searchOverrides: readonly DyldSearchOverride[];
readonly environmentRoots: Set;
+ readonly unknownPlatformCandidates: Set;
+ readonly implicitMacOSPlatformCandidates: Set;
readonly platforms: readonly number[];
readonly executable: string | null;
readonly loaded: Map;
@@ -346,47 +356,75 @@ const evaluateCandidate = async (
// also admits main executables and bundles for different operations.
else if (slice.file_type_code !== 6) outcome = "not-loadable";
else {
- const platformResult = platformLoadability(
- context.platforms,
- slice.platforms,
- slice.file_type_code,
- context.processIdentity.architecture,
- );
+ const platformResult = platformLoadability(context, slice);
outcome =
- platformResult === "compatible"
+ platformResult.outcome === "compatible"
? "resolved"
- : platformResult === "incompatible"
+ : platformResult.outcome === "incompatible"
? "platform-mismatch"
: "undetermined";
+ if (outcome === "undetermined")
+ context.unknownPlatformCandidates.add(lookup.path);
+ if (platformResult.basis !== undefined) {
+ context.implicitMacOSPlatformCandidates.add(lookup.path);
+ return {
+ ...base,
+ outcome,
+ resolved_path: lookup.path,
+ platform_basis: platformResult.basis,
+ };
+ }
}
}
return { ...base, outcome, resolved_path: lookup.path };
};
type PlatformLoadability = "compatible" | "incompatible" | "unknown";
+type PlatformLoadabilityResult = {
+ readonly outcome: PlatformLoadability;
+ readonly basis?: Candidate["platform_basis"];
+};
/** Apply dyld's documented cross-platform load cases and retain uncertain host cases. */
const platformLoadability = (
- processPlatforms: readonly number[],
- imagePlatforms: readonly number[],
- fileType: number,
- architecture: string,
-): PlatformLoadability => {
- if (processPlatforms.length === 0 || imagePlatforms.length === 0)
- return "unknown";
+ context: Pick,
+ image: MachoSlice,
+): PlatformLoadabilityResult => {
+ const { platforms: processPlatforms } = context;
+ const { file_type_code: fileType, platforms: imagePlatforms } = image;
+ let effectiveImagePlatforms = imagePlatforms;
+ let basis: Candidate["platform_basis"];
+ if (imagePlatforms.length === 0) {
+ // macOS dyld supplies an implicit macOS platform to old dylibs on these
+ // CPU types. Keep the parsed platform facts untouched; this is only the
+ // compatibility interpretation used for an explicitly macOS process.
+ if (
+ context.executable !== null &&
+ processPlatforms.length === 1 &&
+ processPlatforms[0] === IMPLICIT_MACOS_PLATFORM &&
+ MACOS_DYLD_IMPLICIT_PLATFORM_CPU_TYPES.has(image.cpu_type)
+ ) {
+ effectiveImagePlatforms = [IMPLICIT_MACOS_PLATFORM];
+ basis = "macos-dyld-implicit";
+ } else return { outcome: "unknown" };
+ }
+ if (processPlatforms.length === 0) return { outcome: "unknown" };
const outcomes = processPlatforms.map((processPlatform) =>
imageLoadabilityForPlatform(
processPlatform,
- imagePlatforms,
+ effectiveImagePlatforms,
fileType,
- architecture,
+ image.architecture,
),
);
if (outcomes.every((outcome) => outcome === "compatible"))
- return "compatible";
+ return { outcome: "compatible", ...(basis === undefined ? {} : { basis }) };
if (outcomes.every((outcome) => outcome === "incompatible"))
- return "incompatible";
- return "unknown";
+ return {
+ outcome: "incompatible",
+ ...(basis === undefined ? {} : { basis }),
+ };
+ return { outcome: "unknown", ...(basis === undefined ? {} : { basis }) };
};
const imageLoadabilityForPlatform = (
@@ -572,7 +610,7 @@ const traceProcess = async (
context.loaded.set(image, {
slice,
chain: [...current.chain, image],
- // dyld may load an unknown earlier candidate instead of this fallback.
+ // A modeled earlier search uncertainty can make this image conditional.
conditional:
current.conditional || edge.resolution.status === "conditional",
});
@@ -589,6 +627,69 @@ const traceProcess = async (
return edges;
};
+const projectTraceResult = (input: {
+ readonly images: ReadonlyMap;
+ readonly roots: DylibTrace["roots"];
+ readonly rootSlices: readonly TracedRootSlice[];
+ readonly edges: readonly Edge[];
+ readonly withoutArchitecture: readonly string[];
+ readonly environmentRoots: ReadonlySet;
+ readonly unknownPlatformCandidates: ReadonlySet;
+ readonly implicitMacOSPlatformCandidates: ReadonlySet;
+}): DylibTrace => {
+ const reported = [...input.images.entries()]
+ .filter(([, facts]) => facts.status !== "not-mach-o")
+ .sort(([left], [right]) => compare(left, right));
+ const unparsed = reported
+ .filter(([, facts]) => facts.status !== "parsed")
+ .map(([path]) => path);
+ return {
+ roots: input.roots,
+ images: reported.map(([path, facts]) => ({
+ path,
+ parse_status:
+ facts.status === "not-mach-o" ? "unsupported" : facts.status,
+ reason:
+ facts.status === "parsed" || facts.status === "not-mach-o"
+ ? null
+ : facts.reason,
+ slices:
+ facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
+ })),
+ edges: [...input.edges],
+ findings: deriveFindings(input.edges, input.rootSlices),
+ coverage: {
+ status:
+ unparsed.length === 0 &&
+ input.withoutArchitecture.length === 0 &&
+ input.environmentRoots.size === 0 &&
+ input.unknownPlatformCandidates.size === 0
+ ? "complete"
+ : "partial",
+ unparsed_images: unparsed,
+ roots_without_architecture: [...input.withoutArchitecture],
+ },
+ limitations: [
+ ...DYLIB_RESOLUTION_LIMITATIONS,
+ ...(input.environmentRoots.size === 0
+ ? []
+ : [
+ `Roots ${[...input.environmentRoots].join(", ")} request applicable image-selection inputs through LC_DYLD_ENVIRONMENT that are not modeled; affected dependencies or inserted images remain uncertain.`,
+ ]),
+ ...(input.unknownPlatformCandidates.size === 0
+ ? []
+ : [
+ `In-root dylib candidates ${[...input.unknownPlatformCandidates].sort(compare).join(", ")} have unknown platform compatibility and are not treated as incompatible. Their dependent graphs may be incomplete.`,
+ ]),
+ ...(input.implicitMacOSPlatformCandidates.size === 0
+ ? []
+ : [
+ `In-root dylib candidates ${[...input.implicitMacOSPlatformCandidates].sort(compare).join(", ")} have no observed platform load command; macOS compatibility was derived from dyld's implicit-platform rule for an executable root with explicit macOS platform and a supported CPU type. Reported platform facts remain unchanged.`,
+ ]),
+ ],
+ };
+};
+
/** Trace dyld load paths from each process root through the analyzed root. */
export const traceDylibLoading = async (
view: DylibTreeView,
@@ -608,6 +709,8 @@ export const traceDylibLoading = async (
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
const environmentRoots = new Set();
+ const unknownPlatformCandidates = new Set();
+ const implicitMacOSPlatformCandidates = new Set();
for (const root of request.roots) {
const facts = await view.image(root);
images.set(root, facts);
@@ -647,6 +750,8 @@ export const traceDylibLoading = async (
},
searchOverrides,
environmentRoots,
+ unknownPlatformCandidates,
+ implicitMacOSPlatformCandidates,
platforms: slice.platforms,
executable,
loaded: new Map(),
@@ -659,46 +764,16 @@ export const traceDylibLoading = async (
);
}
}
- const reported = [...images.entries()]
- .filter(([, facts]) => facts.status !== "not-mach-o")
- .sort(([left], [right]) => compare(left, right));
- const unparsed = reported
- .filter(([, facts]) => facts.status !== "parsed")
- .map(([path]) => path);
- return {
+ return projectTraceResult({
+ images,
roots,
- images: reported.map(([path, facts]) => ({
- path,
- parse_status:
- facts.status === "not-mach-o" ? "unsupported" : facts.status,
- reason:
- facts.status === "parsed" || facts.status === "not-mach-o"
- ? null
- : facts.reason,
- slices:
- facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
- })),
+ rootSlices,
edges,
- findings: deriveFindings(edges, rootSlices),
- coverage: {
- status:
- unparsed.length === 0 &&
- withoutArchitecture.length === 0 &&
- environmentRoots.size === 0
- ? "complete"
- : "partial",
- unparsed_images: unparsed,
- roots_without_architecture: withoutArchitecture,
- },
- limitations: [
- ...DYLIB_RESOLUTION_LIMITATIONS,
- ...(environmentRoots.size === 0
- ? []
- : [
- `Roots ${[...environmentRoots].join(", ")} request applicable image-selection inputs through LC_DYLD_ENVIRONMENT that are not modeled; affected dependencies or inserted images remain uncertain.`,
- ]),
- ],
- };
+ withoutArchitecture,
+ environmentRoots,
+ unknownPlatformCandidates,
+ implicitMacOSPlatformCandidates,
+ });
};
const withoutDependencies = (
From f1126ff7bfef9d8a928ed0edb9978b4440b9121d Mon Sep 17 00:00:00 2001
From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:25:28 +0800
Subject: [PATCH 09/45] fix(wasm): retain actionable provider configuration
diagnostics
Fixes #5767.
Retain exact WABT version-probe output and tool identity, including nonzero command failures, and provide condition-specific configuration recovery. Verified with 54 focused tests, the final 51-case real WABT CLI/MCP lane, actual Codex recovery trials, and independent source reviews. No provider installation, target execution or admission relaxation.
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
---
docs/wasm-artifacts.md | 11 +++++
src/wasm/WabtArtifactProvider.test.ts | 71 ++++++++++++++++++++++++++-
src/wasm/WabtArtifactProvider.ts | 48 ++++++++++++++----
src/wasm/WabtFailures.test.ts | 68 +++++++++++++++++++++++++
src/wasm/WabtFailures.ts | 48 +++++++++++++++---
src/wasm/WabtRelease.ts | 3 +-
6 files changed, 231 insertions(+), 18 deletions(-)
diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md
index 7093df0a06..b8cd0582ff 100644
--- a/docs/wasm-artifacts.md
+++ b/docs/wasm-artifacts.md
@@ -26,6 +26,17 @@ execution validates tool files and version banners. Host process-ownership and
private-workspace controls must also be available. The real-tool profile has
been exercised on macOS arm64; other hosts require their own verification.
+Configuration failures do not establish anything about the selected WASM bytes.
+For a rejected version probe, `details.diagnostics` identifies the executable,
+tool, expected version and configuration key; `details.captured_output` preserves
+its actual stdout and stderr, including empty banners or warnings. Fix the
+reported directory, executable access or release rather than changing the module
+or repeating an unchanged call. All three tools must report 1.0.42 on stdout with
+empty stderr. For CLI calls, set the environment before the next invocation. For
+MCP, change the server's launch environment and restart/reconnect that server;
+exporting a variable in a different shell does not update a running server.
+REA never installs tools or relaxes version admission to recover.
+
```bash
REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin rea inspect-wasm-artifact \
/artifacts/module.wasm --glue_paths /artifacts/glue.js \
diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts
index b09d6ea2be..16462cc0fc 100644
--- a/src/wasm/WabtArtifactProvider.test.ts
+++ b/src/wasm/WabtArtifactProvider.test.ts
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
-import { access, chmod, readFile, writeFile } from "node:fs/promises";
+import { access, chmod, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
@@ -247,6 +247,75 @@ it("returns actionable unavailable without creating a workspace when tools are a
});
});
+it.each([
+ { tool: "wasm-validate", stdout: "1.0.41\n", stderr: "" },
+ { tool: "wasm-objdump", stdout: "", stderr: "" },
+ { tool: "wasm2wat", stdout: "1.0.42\n", stderr: "launcher warning\n" },
+ {
+ tool: "wasm-objdump",
+ stdout: "",
+ stderr: "version launcher failed\n",
+ exitCode: 2,
+ },
+])(
+ "retains the exact rejected $tool version probe and configuration recovery",
+ async ({ tool, stdout, stderr, exitCode = 0 }) => {
+ const root = await realpath(
+ await createTestTempDirectory("rea-wabt-version-recovery-"),
+ );
+ const suffix = process.platform === "win32" ? ".exe" : "";
+ for (const name of ["wasm-validate", "wasm-objdump", "wasm2wat"])
+ await writeFile(join(root, name + suffix), "owned executable seam", {
+ mode: 0o700,
+ });
+ const commands: string[] = [];
+ const provider = new WabtArtifactProvider(
+ { REA_WABT_BIN_DIRECTORY: root },
+ (input) => {
+ expect(input.arguments).toEqual(["--version"]);
+ commands.push(input.command);
+ const selected = input.command === join(root, tool + suffix);
+ return spawnOwnedProviderProcess({
+ ...input,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ `process.stdout.write(${JSON.stringify(selected ? stdout : "1.0.42\n")}); process.stderr.write(${JSON.stringify(selected ? stderr : "")}); process.exit(${String(selected ? exitCode : 0)});`,
+ ],
+ expectedCommand: null,
+ });
+ },
+ );
+ try {
+ const result = await provider.inspect({
+ path: "/must-not-read-before-configuration.wasm",
+ glue_paths: [],
+ candidate_paths: [],
+ });
+ if (result.ok) throw new Error("Expected WABT configuration rejection");
+ const projected = projectAnalysisError(result.error);
+ expect(projected).toMatchObject({
+ code: "capability_unavailable",
+ retryable: false,
+ details: {
+ diagnostics: {
+ configuration_key: "REA_WABT_BIN_DIRECTORY",
+ executable_path: join(root, tool + suffix),
+ tool,
+ expected_version: "1.0.42",
+ },
+ captured_output: { stdout, stderr, truncated: false },
+ },
+ });
+ expect(projected.remediation.action).toContain("--version");
+ expect(projected.remediation.action).toContain("restart");
+ expect(commands.at(-1)).toBe(join(root, tool + suffix));
+ } finally {
+ await provider.close();
+ }
+ },
+);
+
it("retries a retained input descriptor before admitting the next WABT operation", async () => {
const root = await createTestTempDirectory("rea-wabt-read-cleanup-");
const path = join(root, "selected.wasm");
diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts
index 2436a387f0..c9901d4c3c 100644
--- a/src/wasm/WabtArtifactProvider.ts
+++ b/src/wasm/WabtArtifactProvider.ts
@@ -39,8 +39,16 @@ import { err, ok, type Result } from "../domain/result.js";
import { runOwnedCommand } from "../process/OwnedCommand.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { WabtRuntime } from "./WabtRuntime.js";
-import { WABT_PROVIDER_IDENTITY, WABT_LIMITS } from "./WabtRelease.js";
-import { wabtFailure } from "./WabtFailures.js";
+import {
+ WABT_PROVIDER_IDENTITY,
+ WABT_LIMITS,
+ WABT_VERSION,
+} from "./WabtRelease.js";
+import {
+ wabtFailure,
+ wabtConfigurationRecoveryAction,
+ type WabtVersionProbe,
+} from "./WabtFailures.js";
import { parseWabtOutput } from "./WabtOutput.js";
import { parseWabtWat } from "./WabtWat.js";
import { associateWasmGlue } from "./WasmGlueAdapter.js";
@@ -102,6 +110,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
let phase: "configuration" | "artifact" | "producer" = "configuration";
let result: Result;
let capturedOutput: AnalysisCapturedOutput | undefined;
+ let versionProbe: WabtVersionProbe | undefined;
try {
if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? "";
@@ -206,15 +215,36 @@ export class WabtArtifactProvider implements WasmArtifactPort {
return snapshot;
};
for (const command of commands) {
+ versionProbe = {
+ configuration_key: "REA_WABT_BIN_DIRECTORY",
+ executable_path: command.path,
+ tool: command.tool,
+ expected_version: WABT_VERSION,
+ };
const version = await execute(command, ["--version"]);
if (
- version.stdout.text.trim() !== "1.0.42" ||
+ version.stdout.text.trim() !== WABT_VERSION ||
version.stderr.text !== ""
- )
- throw new Error(
- `Unsupported ${command.tool} version banner; expected 1.0.42.`,
+ ) {
+ const reason = `Unsupported ${command.tool} version banner; expected ${WABT_VERSION}.`;
+ throw new AnalysisCapabilityUnavailableError(
+ WABT_PROVIDER_IDENTITY.id,
+ OPERATION,
+ reason,
+ {
+ userMessage: reason,
+ diagnostics: versionProbe,
+ capturedOutput: {
+ stdout: version.stdout.text,
+ stderr: version.stderr.text,
+ truncated: version.diagnosticTruncated === true,
+ },
+ remediationAction: wabtConfigurationRecoveryAction(),
+ },
);
+ }
command.version_banner = version.stdout.text;
+ versionProbe = undefined;
}
phase = "artifact";
const selected = await readStableArtifact(
@@ -300,7 +330,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
validation: "rejected-by-selected-profile",
tool_profile: {
source: "https://github.com/WebAssembly/wabt",
- release: "1.0.42",
+ release: WABT_VERSION,
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
commands,
},
@@ -393,7 +423,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
},
tool_profile: {
source: "https://github.com/WebAssembly/wabt",
- release: "1.0.42",
+ release: WABT_VERSION,
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
commands,
timeout_ms: WABT_LIMITS.timeoutMs,
@@ -417,7 +447,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
}),
);
} catch (cause: unknown) {
- const failure = wabtFailure(cause, phase, input.path);
+ const failure = wabtFailure(cause, phase, input.path, versionProbe);
runtime?.retainCommandFailure(cause, failure, this.#resources);
result = err(
failure instanceof AnalysisOutputError &&
diff --git a/src/wasm/WabtFailures.test.ts b/src/wasm/WabtFailures.test.ts
index e7c8b09cd2..e58363036d 100644
--- a/src/wasm/WabtFailures.test.ts
+++ b/src/wasm/WabtFailures.test.ts
@@ -3,6 +3,74 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { wabtFailure } from "./WabtFailures.js";
import { WABT_LIMITS } from "./WabtRelease.js";
+import { OwnedCommandFailure } from "../process/OwnedCommand.js";
+
+it.each([
+ { reason: "timeout" as const, cleanup: null, code: "provider_timeout" },
+ { reason: "cancelled" as const, cleanup: null, code: "cancelled" },
+ {
+ reason: "process" as const,
+ cleanup: "owned stop failed",
+ code: "cleanup_incomplete",
+ },
+])(
+ "retains version-probe facts without changing $reason cleanup=$cleanup",
+ ({ reason, cleanup, code }) => {
+ const cause = new OwnedCommandFailure(
+ reason,
+ "version command failed",
+ null,
+ cleanup,
+ );
+ const probe = {
+ configuration_key: "REA_WABT_BIN_DIRECTORY" as const,
+ executable_path: "/tools/wasm2wat",
+ tool: "wasm2wat",
+ expected_version: "1.0.42" as const,
+ };
+ const failure = wabtFailure(
+ cause,
+ "configuration",
+ "/selected.wasm",
+ probe,
+ );
+ const original = wabtFailure(cause, "configuration", "/selected.wasm");
+ expect(failure._tag).toBe(original._tag);
+ expect(failure.cleanupIncomplete).toBe(original.cleanupIncomplete);
+ expect(failure.cause).toBe(original.cause);
+ expect(projectAnalysisError(failure)).toMatchObject({
+ code,
+ details: { diagnostics: probe },
+ });
+ },
+);
+
+it.each([
+ { code: "EACCES", action: "read and execute access" },
+ {
+ code: "ENOENT",
+ action: "contains wasm-validate, wasm-objdump and wasm2wat",
+ },
+ { code: "ENOTDIR", action: "directory, not an individual executable" },
+])(
+ "explains $code configuration recovery without blaming the WASM input",
+ ({ code, action }) => {
+ const failure = wabtFailure(
+ Object.assign(new Error(`tool admission failed: ${code}`), { code }),
+ "configuration",
+ "/selected.wasm",
+ );
+ const projected = projectAnalysisError(failure);
+ expect(projected).toMatchObject({
+ code: "capability_unavailable",
+ retryable: false,
+ remediation: { action: expect.stringContaining(action) },
+ });
+ expect(projected.remediation.action).toContain("REA_WABT_BIN_DIRECTORY");
+ expect(projected.remediation.action).toContain("restart");
+ expect(projected.remediation.action).toContain("Do not repeat");
+ },
+);
it.each([false, true])(
"preserves the file-size boundary with cleanup=%s when an input exceeds its fixed allowance",
diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts
index 8dfb9b774e..5a089befeb 100644
--- a/src/wasm/WabtFailures.ts
+++ b/src/wasm/WabtFailures.ts
@@ -19,9 +19,36 @@ import {
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
-import { WABT_LIMITS, WABT_PROVIDER_IDENTITY } from "./WabtRelease.js";
+import {
+ WABT_LIMITS,
+ WABT_PROVIDER_IDENTITY,
+ WABT_VERSION,
+} from "./WabtRelease.js";
const OUTPUT_LIMIT_EXCERPT_BYTES = 4096;
const OUTPUT_LIMIT_REASON = `WABT output exceeded the ${String(WABT_LIMITS.outputBytes)}-byte output budget`;
+
+/** Facts owned before dispatch, even when a version command does not return successfully. */
+export type WabtVersionProbe = {
+ readonly configuration_key: "REA_WABT_BIN_DIRECTORY";
+ readonly executable_path: string;
+ readonly tool: string;
+ readonly expected_version: typeof WABT_VERSION;
+};
+
+/** Configuration is captured by the server; fixing files and changing its environment are distinct repairs. */
+export const wabtConfigurationRecoveryAction = (cause?: unknown): string => {
+ const code =
+ cause instanceof Error && "code" in cause ? cause.code : undefined;
+ const action =
+ code === "EACCES" || code === "EPERM"
+ ? "Check this REA process has read and execute access to the reported caller-supplied tools, or select an accessible trusted copy. "
+ : code === "ENOTDIR"
+ ? "Select the tools' containing directory, not an individual executable. "
+ : code === "ENOENT"
+ ? "The reported configured tool is missing. "
+ : "";
+ return `${action}Set absolute REA_WABT_BIN_DIRECTORY to a directory that contains wasm-validate, wasm-objdump and wasm2wat from WABT ${WABT_VERSION}; each --version must report ${WABT_VERSION} on stdout with empty stderr. Correct the reported tool path, access or release before retrying, not the WASM input. Set this in the shell for the next CLI call, or in the MCP server launch environment and restart that server; a shell export does not update a running MCP server. Do not repeat the unchanged call or install tools implicitly.`;
+};
/** Past the output budget, retained text is a prefix of an oversized stream; keep a bounded excerpt of each. */
const outputLimitExcerpt = (snapshot: ProviderProcessSnapshot) => ({
stdout: boundedUtf8Text(snapshot.stdout.text, OUTPUT_LIMIT_EXCERPT_BYTES)
@@ -40,8 +67,9 @@ export const wabtFailure = (
cause: unknown,
phase: "configuration" | "artifact" | "producer",
path: string,
+ versionProbe?: WabtVersionProbe,
): AnalysisError => {
- const failure = classifyWabtFailure(cause, phase, path);
+ const failure = classifyWabtFailure(cause, phase, path, versionProbe);
bindArtifactFileCleanup(cause, failure);
return failure;
};
@@ -50,6 +78,7 @@ const classifyWabtFailure = (
cause: unknown,
phase: "configuration" | "artifact" | "producer",
path: string,
+ versionProbe: WabtVersionProbe | undefined,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
const artifactCleanup =
@@ -79,13 +108,17 @@ const classifyWabtFailure = (
stderr: cause.snapshot.stderr.text,
truncated: cause.snapshot.diagnosticTruncated ?? false,
};
- const options =
- capturedOutput === undefined ? { cause } : { cause, capturedOutput };
+ const options = {
+ cause,
+ ...(capturedOutput === undefined ? {} : { capturedOutput }),
+ ...(versionProbe === undefined ? {} : { diagnostics: versionProbe }),
+ };
if (cause.cleanupFailure !== null)
return new ProviderCleanupError(
WABT_PROVIDER_IDENTITY.id,
cause.resources,
{
+ ...versionProbe,
reason: cause.cleanupFailure,
previous_error:
cause.reason === "output-limit"
@@ -128,8 +161,8 @@ const classifyWabtFailure = (
reason,
{
...options,
- userMessage:
- "Configured WABT could not start. Check executable/interpreter access and WABT 1.0.42 profile; inspect retained diagnostics.",
+ userMessage: `Configured WABT could not start. Check executable/interpreter access and WABT ${WABT_VERSION} profile; inspect retained diagnostics.`,
+ remediationAction: wabtConfigurationRecoveryAction(cause),
},
);
return new AnalysisOutputError("inspect_wasm_artifact", reason, options);
@@ -149,7 +182,8 @@ const classifyWabtFailure = (
{
cause,
...cleanupOptions,
- userMessage: `${reason} Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.`,
+ userMessage: reason,
+ remediationAction: wabtConfigurationRecoveryAction(cause),
},
);
if (
diff --git a/src/wasm/WabtRelease.ts b/src/wasm/WabtRelease.ts
index 5920d95235..9e553bd4a0 100644
--- a/src/wasm/WabtRelease.ts
+++ b/src/wasm/WabtRelease.ts
@@ -1,8 +1,9 @@
/** Exact upstream release whose producer output is admitted by this adapter. */
+export const WABT_VERSION = "1.0.42";
export const WABT_PROVIDER_IDENTITY = {
id: "wabt-artifact",
name: "REA WABT artifact adapter",
- version: "wabt@1.0.42",
+ version: `wabt@${WABT_VERSION}`,
} as const;
export const WABT_LIMITS = {
inputBytes: 8 * 1024 * 1024,
From 3b67b362aa2cd79eac5af57d0965e81a7d0b8c2b Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:34:05 +0800
Subject: [PATCH 10/45] fix(apple): explain present rejected dylib candidates
Derive unresolved findings from stored candidate paths and outcomes while preserving weak, lazy and conditional load consequences. Document not-loadable and platform-mismatch outcomes and later-path resolution.
Validation: 126 resolver/CLI/MCP tests, check:ci, docs:check, and seven compiled CLI plus SDK-validated stdio MCP fixture cases. Source-built and mutated inert Mach-O inputs retained identical hashes; no target execution or dyld runtime comparison.
Fixes #5747
Fixes #5694
---
docs/apple-application-analysis.md | 12 ++++-
src/domain/apple/dylibResolution.test.ts | 3 ++
src/domain/apple/dylibResolutionFindings.ts | 51 ++++++++++++++++++++-
3 files changed, 63 insertions(+), 3 deletions(-)
diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md
index 1ea57eb270..3f718d4cc7 100644
--- a/docs/apple-application-analysis.md
+++ b/docs/apple-application-analysis.md
@@ -213,8 +213,16 @@ Resolution follows dyld:
Each edge lists every candidate path tried, with its outcome: `resolved`,
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
parser coverage, such as big-endian images), `architecture-missing`,
-`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
-graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
+`not-loadable`, `platform-mismatch`, `outside-target`, `escapes-target`, or
+`undetermined`. `not-loadable` means a
+matching-architecture Mach-O image was found but it is not a dylib and cannot
+satisfy a dylib dependency. `platform-mismatch` means a matching-architecture
+dylib was found but its observed platform is incompatible with the process.
+These outcomes reject that candidate; dyld search may continue and resolve a
+later candidate. Candidate outcomes describe individual search paths, while
+`resolution.status` describes the final edge. Slices follow dyld's graded
+architectures, so an `x86_64h` process also loads `x86_64`. The edge's
+`resolution` is one of:
| Status | Meaning |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts
index 577ce5d9a5..c143738225 100644
--- a/src/domain/apple/dylibResolution.test.ts
+++ b/src/domain/apple/dylibResolution.test.ts
@@ -398,6 +398,9 @@ describe("dyld resolution outcomes: checking candidate format and compatibility"
({ outcome }) => outcome,
),
).toEqual(["not-mach-o", "malformed", "architecture-missing"]);
+ expect(trace.findings[0]?.explanation).toContain(
+ "No usable candidate for @rpath/lib.dylib exists in the analyzed root; found Contents/MacOS/a/lib.dylib (not a Mach-O image), Contents/MacOS/b/lib.dylib (malformed), Contents/MacOS/c/lib.dylib (missing the process architecture)",
+ );
expect(trace.coverage).toEqual({
status: "partial",
unparsed_images: ["Contents/MacOS/b/lib.dylib"],
diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts
index 7783ab91db..c7f37bb935 100644
--- a/src/domain/apple/dylibResolutionFindings.ts
+++ b/src/domain/apple/dylibResolutionFindings.ts
@@ -70,7 +70,10 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
basis: "derived",
} as const;
const lazy = isLazyLoadCommand(edge.command);
- const missing = `No candidate for ${dependencyPhrase(edge, lazy)} exists in the analyzed root`;
+ const missing = missingCandidateExplanation(
+ edge,
+ dependencyPhrase(edge, lazy),
+ );
// Below a conditional fallback, every consequence applies only if it loads.
const when = edge.loader_conditional
? `. ${edge.loader} loads only conditionally; if it loads, `
@@ -94,6 +97,52 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
};
};
+/** Use the resolver's physical-candidate facts without rechecking source paths. */
+const missingCandidateExplanation = (
+ edge: Edge,
+ dependency: string,
+): string => {
+ const present = edge.candidates.flatMap(({ outcome, resolved_path }) =>
+ resolved_path === null ? [] : [{ outcome, path: resolved_path }],
+ );
+ if (present.length === 0)
+ return `No candidate for ${dependency} exists in the analyzed root`;
+ const descriptions = present.map(
+ ({ outcome, path }) => `${path} (${candidateRejection(outcome)})`,
+ );
+ return `No usable candidate for ${dependency} exists in the analyzed root; found ${descriptions.join(", ")}`;
+};
+
+/** Explain a found candidate from the resolver's outcome, not by reopening it. */
+const candidateRejection = (
+ outcome: Edge["candidates"][number]["outcome"],
+): string => {
+ switch (outcome) {
+ case "not-mach-o":
+ return "not a Mach-O image";
+ case "malformed":
+ return "malformed";
+ case "architecture-missing":
+ return "missing the process architecture";
+ case "not-loadable":
+ return "not a loadable dylib";
+ case "platform-mismatch":
+ return "incompatible with the process platform";
+ case "unsupported":
+ return "unsupported by the parser";
+ case "resolved":
+ return "loadable";
+ case "absent":
+ return "absent";
+ case "outside-target":
+ return "outside the analyzed root";
+ case "escapes-target":
+ return "escapes the analyzed root";
+ case "undetermined":
+ return "of undetermined loadability";
+ }
+};
+
/** Name the dependency the way the finding's consequence treats it. */
const dependencyPhrase = (edge: Edge, lazy: boolean): string => {
if (lazy && edge.weak)
From 044f180222d9b20ef1c23a0a5e4dac32002cd696 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:37:26 +0800
Subject: [PATCH 11/45] test(cli): accept opaque Kuna doctor run tokens (#5677)
* test(cli): accept opaque Kuna doctor run tokens
The doctor cancellation fixture parsed REA_PROCESS_RUN_ID as a UUID.
Kuna version probes publish rea-kuna-version-, so both signal
cases failed before checking cancellation. Accept that opaque token
and keep the JADX ownership check on a UUID.
Co-authored-by: morluto
* test(cli): require the Kuna doctor run token
An omitted REA_PROCESS_RUN_ID disappears from the ready JSON. The Kuna
cancellation case now rejects that record unless runId is a non-empty
ownership token. JADX still requires a UUID.
Co-authored-by: morluto
---------
Co-authored-by: Cursor Agent
Co-authored-by: morluto
---
tests/boundary/cli/doctorCancellation.test.ts | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/tests/boundary/cli/doctorCancellation.test.ts b/tests/boundary/cli/doctorCancellation.test.ts
index 530427dcc6..7ebcfeae86 100644
--- a/tests/boundary/cli/doctorCancellation.test.ts
+++ b/tests/boundary/cli/doctorCancellation.test.ts
@@ -40,13 +40,11 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for(
const executable =
provider === "kuna" ? join(root, "kuna") : join(jdk, "bin", "java");
const probeArgument = provider === "kuna" ? "--version" : "--list-modules";
- const runIdField =
- provider === "jadx" ? ",runId:process.env.REA_PROCESS_RUN_ID" : "";
await writeFile(
executable,
`#!${process.execPath}
if (!process.argv.slice(2).includes(${JSON.stringify(probeArgument)})) process.exit(7);
-require("node:fs").writeFileSync(${JSON.stringify(ready)}, JSON.stringify({pid: process.pid${runIdField}}));
+require("node:fs").writeFileSync(${JSON.stringify(ready)}, JSON.stringify({pid: process.pid, runId: process.env.REA_PROCESS_RUN_ID}));
process.on("SIGTERM", () => {});
setInterval(() => { if (require("node:fs").existsSync(${JSON.stringify(release)})) process.exit(0); }, 25).unref();
setTimeout(() => process.exit(99), 20000);
@@ -97,7 +95,10 @@ setTimeout(() => process.exit(99), 20000);
)
.toBe(true);
const readyRecord: unknown = JSON.parse(await readFile(ready, "utf8"));
- const observed: { readonly pid: number; readonly runId?: string } =
+ // JADX publishes a UUID. Kuna publishes an opaque
+ // `rea-kuna-version-` token. Both are required: JSON.stringify
+ // drops an undefined REA_PROCESS_RUN_ID, and that omission must fail.
+ const observed =
provider === "jadx"
? z
.strictObject({
@@ -106,11 +107,12 @@ setTimeout(() => process.exit(99), 20000);
})
.parse(readyRecord)
: z
- .strictObject({ pid: z.number().int().positive() })
+ .strictObject({
+ pid: z.number().int().positive(),
+ runId: z.string().min(1),
+ })
.parse(readyRecord);
if (provider === "jadx") {
- if (observed.runId === undefined)
- throw new Error("Missing JADX run token");
ownership = {
leaderPid: observed.pid,
processGroupId: observed.pid,
@@ -131,8 +133,7 @@ setTimeout(() => process.exit(99), 20000);
state: "empty",
});
} else {
- // Kuna's version probe uses a joined direct execFile child, without a
- // detached run-token group. Do not invent a group owner from its PID.
+ // Do not invent a group owner from the probe PID.
expect(() => process.kill(observed.pid, 0)).toThrowError(
expect.objectContaining({ code: "ESRCH" }),
);
From 264862bf2e3612c9cb6fcfb861843cbba4e1893f Mon Sep 17 00:00:00 2001
From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:41:50 +0800
Subject: [PATCH 12/45] fix(ida): preserve registration recovery facts in
readiness
Fixes #5771.
Preserve the configuration owner's parsed registration settings and remediation in IDA readiness, retain caller-selected paths, and distinguish configuration selection from validity and live connection. Verified with 84 relevant tests, CLI/MCP negative-case parity, static/docs gates, independent source review and repeated actual Codex recovery tasks. No real IDA engine/GUI analysis qualification.
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
---
docs/ida-provider.md | 9 +++++
src/ida/IdaProvider.test.ts | 78 ++++++++++++++++++++++++++++++++++++-
src/ida/IdaProvider.ts | 65 +++++++++++++++++++------------
3 files changed, 126 insertions(+), 26 deletions(-)
diff --git a/docs/ida-provider.md b/docs/ida-provider.md
index f4a2c95dd6..ecaa2c66d8 100644
--- a/docs/ida-provider.md
+++ b/docs/ida-provider.md
@@ -41,6 +41,15 @@ rea function /absolute/path/to/program main --json
rea decompile /absolute/path/to/program main --json
```
+If the registration cannot be read or validated, provider diagnostics retain its
+selected `registration_path` and the configuration owner's setting/constraint
+facts in `configuration_details.settings`, plus `configuration_remediation`.
+`configured` means a path was selected, not that it is valid or an IDA connection
+was established; failed registrations remain unavailable. Correct
+`REA_IDA_MCP_CONFIG` in the next CLI invocation's environment or the MCP server's
+launch environment. Restart/reconnect that server after changing its environment;
+an export in another shell does not update a running server.
+
Open the **original input binary** in REA, not an `.idb` or `.i64` database. Its SHA-256 must match IDA's recorded input hash. REA checks the attached identity and image base around each analysis request. A mismatch or target switch fails with recovery guidance. The original input must remain readable by REA; WSL can use its mounted alias while the GUI runs on Windows. Attached profiles, including native mode, refuse universal Mach-O inputs at admission: every slice shares the recorded whole-file hash, so REA cannot verify which slice the GUI loaded. Open a thin file extracted with `lipo -thin` in both IDA and REA, or use headless mode, which analyzes the selected slice.
REA's close operation releases its MCP connection or proxy. It never saves or closes the existing GUI database. External edits remain visible because results are live and are not replayed from snapshots. These checks do not lock the GUI or prove that the database is unpatched.
diff --git a/src/ida/IdaProvider.test.ts b/src/ida/IdaProvider.test.ts
index e0d5a2180e..9ce6d84045 100644
--- a/src/ida/IdaProvider.test.ts
+++ b/src/ida/IdaProvider.test.ts
@@ -1,8 +1,84 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { parseConfig } from "../config/parseConfig.js";
import { IdaProvider } from "./IdaProvider.js";
+it.each([
+ { state: "unconfigured", selected: false, content: undefined },
+ { state: "missing", selected: true, content: undefined },
+ { state: "malformed", selected: true, content: "{" },
+ { state: "invalid", selected: true, content: "{}" },
+])(
+ "preserves the owning registration setting and selection for $state IDA readiness",
+ async ({ selected, content }) => {
+ const root = await createTestTempDirectory("rea-ida-recovery-");
+ const path = join(root, "registration.json");
+ if (content !== undefined) await writeFile(path, content);
+ const config = parseConfig(selected ? { REA_IDA_MCP_CONFIG: path } : {});
+ if (!config.ok) throw config.error;
+ const connect = vi.fn(() => {
+ throw new Error("Readiness must not connect to IDA");
+ });
+ const provider = new IdaProvider(config.value, {}, connect);
+ try {
+ const snapshot = await provider.inspectReadinessSnapshot();
+ expect(snapshot.availability).toMatchObject({
+ status: "unavailable",
+ code: selected ? "open_options_invalid" : "not_configured",
+ reason: expect.stringContaining("REA_IDA_MCP_CONFIG"),
+ diagnostics: {
+ configured: selected,
+ registration_path: selected ? path : null,
+ live_connection_probed: false,
+ configuration_details: {
+ settings: [
+ { setting: "REA_IDA_MCP_CONFIG", constraint: expect.any(String) },
+ ],
+ },
+ configuration_remediation:
+ expect.stringContaining("REA_IDA_MCP_CONFIG"),
+ },
+ });
+ expect(connect).not.toHaveBeenCalled();
+ } finally {
+ const closed = await provider.closeOwnedResources();
+ if (!closed.ok) throw closed.error;
+ }
+ },
+);
+
+it("keeps a selected valid registration distinct from a live IDA connection", async () => {
+ const root = await createTestTempDirectory("rea-ida-configured-");
+ const path = join(root, "registration.json");
+ await writeFile(
+ path,
+ JSON.stringify({ mode: "attached", url: "http://127.0.0.1:13337/mcp" }),
+ );
+ const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
+ if (!config.ok) throw config.error;
+ const connect = vi.fn(() => {
+ throw new Error("Readiness must not connect to IDA");
+ });
+ const provider = new IdaProvider(config.value, {}, connect);
+ try {
+ expect(await provider.inspectAvailability()).toMatchObject({
+ status: "available",
+ diagnostics: {
+ configured: true,
+ registration_path: path,
+ live_connection_probed: false,
+ },
+ });
+ expect(connect).not.toHaveBeenCalled();
+ } finally {
+ const closed = await provider.closeOwnedResources();
+ if (!closed.ok) throw closed.error;
+ }
+});
+
describe("IDA provider target support", () => {
it("declines headerless raw-binary targets", () => {
const config = parseConfig({});
diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts
index f48dbccc58..88bc8a3b30 100644
--- a/src/ida/IdaProvider.ts
+++ b/src/ida/IdaProvider.ts
@@ -7,6 +7,7 @@ import {
} from "../domain/binaryTargetTypes.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type {
AnalysisClient,
AnalysisProviderBoundClientFactory,
@@ -171,7 +172,31 @@ export class IdaProvider implements AnalysisProviderCandidate {
config.value.protocol === "native",
)
: this.capabilities();
- if (config.ok && config.value.mode === "headless") {
+ const registrationDiagnostics = {
+ configured: this.config.idaMcpConfigPath !== undefined,
+ registration_path: this.config.idaMcpConfigPath ?? null,
+ live_connection_probed: false,
+ };
+ if (!config.ok) {
+ const failure = projectAnalysisError(config.error);
+ return {
+ availability: {
+ status: "unavailable",
+ code:
+ this.config.idaMcpConfigPath === undefined
+ ? "not_configured"
+ : "open_options_invalid",
+ reason: failure.message,
+ diagnostics: {
+ ...registrationDiagnostics,
+ configuration_details: failure.details ?? null,
+ configuration_remediation: failure.remediation.action,
+ },
+ },
+ capabilities,
+ };
+ }
+ if (config.value.mode === "headless") {
const privacy = privateRuntimeRootCapability();
if (!privacy.available)
return {
@@ -180,9 +205,8 @@ export class IdaProvider implements AnalysisProviderCandidate {
code: privacy.code,
reason: privacy.reason,
diagnostics: {
- configured: true,
+ ...registrationDiagnostics,
mode: "headless",
- live_connection_probed: false,
private_workspace_available: false,
windows_native_failure: {
stage: privacy.failureStage,
@@ -193,28 +217,19 @@ export class IdaProvider implements AnalysisProviderCandidate {
capabilities,
};
}
- const availability: ProviderAvailability = config.ok
- ? {
- status: "available" as const,
- code: null,
- reason: null,
- diagnostics: {
- configured: true,
- mode: config.value.mode,
- transport: "command" in config.value ? "stdio" : "http",
- live_connection_probed: false,
- },
- }
- : {
- status: "unavailable" as const,
- code:
- this.config.idaMcpConfigPath === undefined
- ? "not_configured"
- : "open_options_invalid",
- reason: config.error.message,
- diagnostics: { configured: false, live_connection_probed: false },
- };
- return { availability, capabilities };
+ return {
+ availability: {
+ status: "available" as const,
+ code: null,
+ reason: null,
+ diagnostics: {
+ ...registrationDiagnostics,
+ mode: config.value.mode,
+ transport: "command" in config.value ? "stdio" : "http",
+ },
+ },
+ capabilities,
+ };
}
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport {
const diagnostics = {
From 22d5a40a2e654e968efc01b7606c32c37954980e Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:46:29 +0800
Subject: [PATCH 13/45] fix(pe): inspect resource records at their declared
offsets
Remove unsupported DWORD alignment refusals from resource data entries and adjacent directory tables. Preserve original record locations, UTF-16 name alignment, bounds, mapping, overlap checks and payload hashes.
Validation: 49 focused parser/provider/filesystem/lifecycle and compiled CLI/stdio MCP tests; check:ci; independent pefile agreement for the reported layout, padded control and PE32/PE32+ word-aligned directories. Public CLI and SDK-validated MCP retain truncated-input refusals and unchanged source hashes. Synthetic static inspection only; Windows loading was not tested.
Fixes #5750
---
src/native/pe/PeResourceParser.test.ts | 53 +++++++++++++++
src/native/pe/PeResourceParser.ts | 2 -
src/native/pe/PeResources.fixture.ts | 5 +-
.../applications/peResources.test.ts | 64 +++++++++++++++++++
4 files changed, 120 insertions(+), 4 deletions(-)
diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts
index 300c054c13..ecf3ec4195 100644
--- a/src/native/pe/PeResourceParser.test.ts
+++ b/src/native/pe/PeResourceParser.test.ts
@@ -22,6 +22,59 @@ const parse = (bytes: Buffer, maxEntries = 4096, signal?: AbortSignal) =>
signal,
);
+describe("word-aligned PE resource records", () => {
+ it.each([false, true])(
+ "preserves word-aligned resource records after UTF-16 names (PE32+ = %s)",
+ async (plus) => {
+ for (const identities of [
+ { type: 10, name: 1, language: "AB" },
+ { type: "AB", name: 1, language: 1033 },
+ ]) {
+ const payload = Buffer.from("opaque resource");
+ const fixture = peResourceFixture(
+ plus,
+ [{ ...identities, payload }],
+ 65536,
+ 2,
+ );
+ const dataOffset = fixture.dataOffsets[0];
+ if (dataOffset === undefined)
+ throw new Error("Missing fixture data entry");
+ expect(fixture.dataOffsets.some((at) => at % 4 === 2)).toBe(true);
+ if (typeof identities.type === "string")
+ expect(fixture.directoryOffsets.some((at) => at % 4 === 2)).toBe(
+ true,
+ );
+ const result = peResourcesSchema.parse(await parse(fixture.bytes));
+ expect(
+ result.directories.map(({ location }) => location.offset),
+ ).toEqual(fixture.directoryOffsets);
+ expect(result.coverage).toMatchObject({
+ status: "complete",
+ resources: 1,
+ });
+ expect(result.resources[0]?.data_entry_location).toEqual({
+ offset: dataOffset,
+ bytes: 16,
+ });
+ expect(result.resources[0]?.payload.sha256).toBe(
+ createHash("sha256").update(payload).digest("hex"),
+ );
+ expect(result.resources[0]?.payload.location.offset).toBe(
+ fixture.bytes.readUInt32LE(dataOffset) - 0x1c00,
+ );
+ fixture.bytes.writeUInt32LE(
+ dataOffset - 0x400 + 15,
+ fixture.directoryAt + 4,
+ );
+ await expect(parse(fixture.bytes)).rejects.toMatchObject({
+ reason: "format",
+ });
+ }
+ },
+ );
+});
+
describe("portable PE resource parsing", () => {
it.each([false, true])(
"preserves original identities, ranges, hashes and icon candidates (PE32+ = %s)",
diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts
index 1f266defb3..6a56ac15c2 100644
--- a/src/native/pe/PeResourceParser.ts
+++ b/src/native/pe/PeResourceParser.ts
@@ -120,7 +120,6 @@ export const parsePeResources = async (
ancestors: ReadonlySet,
): Promise => {
if (ancestors.has(at)) peFailure("Cyclic PE resource directory.");
- if (at % 4 !== 0) peFailure("Unaligned PE resource directory.");
const header = relative(at, 16);
const named = bytes.readUInt16LE(header + 12);
const ids = bytes.readUInt16LE(header + 14);
@@ -177,7 +176,6 @@ export const parsePeResources = async (
"Unsupported resource leaf outside the type/name/language profile.",
);
const data = relative(offset, 16);
- if (offset % 4 !== 0) peFailure("Unaligned resource data entry.");
const rva = bytes.readUInt32LE(data);
const size = bytes.readUInt32LE(data + 4);
const payloadOffset = layout.map(rva, size);
diff --git a/src/native/pe/PeResources.fixture.ts b/src/native/pe/PeResources.fixture.ts
index d50136e291..45c2e51f57 100644
--- a/src/native/pe/PeResources.fixture.ts
+++ b/src/native/pe/PeResources.fixture.ts
@@ -38,6 +38,7 @@ export const peResourceFixture = (
{ type: 14, name: 42, language: 1033, payload: iconGroup() },
],
sectionBytes = 65536,
+ recordAlignment: 2 | 4 = 4,
) => {
const tree: Tree = { children: new Map() };
for (const resource of resources) {
@@ -61,7 +62,7 @@ export const peResourceFixture = (
}
const section = Buffer.alloc(sectionBytes);
let cursor = 0;
- const allocate = (size: number, alignment = 4): number => {
+ const allocate = (size: number, alignment = recordAlignment): number => {
cursor = Math.ceil(cursor / alignment) * alignment;
const at = cursor;
cursor += size;
@@ -110,7 +111,7 @@ export const peResourceFixture = (
};
emit(tree);
for (const { dataAt, resource } of leaves) {
- const payloadAt = allocate(resource.payload.length);
+ const payloadAt = allocate(resource.payload.length, 4);
resource.payload.copy(section, payloadAt);
section.writeUInt32LE(0x2000 + payloadAt, dataAt);
section.writeUInt32LE(resource.payload.length, dataAt + 4);
diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts
index 5e2d7cc88d..73a49a144c 100644
--- a/tests/acceptance/applications/peResources.test.ts
+++ b/tests/acceptance/applications/peResources.test.ts
@@ -92,6 +92,59 @@ const expectPeInputFailuresAcrossAdapters = async (input: {
const contract = toolContract("inspect_pe_resources");
+const expectPeWordAlignedRecordsAcrossAdapters = async (input: {
+ readonly cli: TestCli;
+ readonly client: Client;
+ readonly root: string;
+ readonly definition: Awaited<
+ ReturnType
+ >["tools"][number];
+}): Promise => {
+ const { cli, client, root, definition } = input;
+ for (const plus of [false, true]) {
+ for (const [index, identities] of [
+ { type: 10, name: 1, language: "AB" },
+ { type: "AB", name: 1, language: 1033 },
+ ].entries()) {
+ const fixture = peResourceFixture(
+ plus,
+ [{ ...identities, payload: Buffer.from("opaque resource") }],
+ 65536,
+ 2,
+ );
+ const path = join(root, `word-aligned-${plus}-${index}.exe`);
+ await writeFile(path, fixture.bytes);
+ const result = await cli.run({
+ arguments: ["inspect-pe-resources", path, "--json"],
+ });
+ expect(result.exitCode, result.stderr).toBe(0);
+ const evidence = contract.outputSchema.parse(result.json);
+ const report = peResourcesSchema.parse(evidence.normalized_result);
+ expect(report.coverage).toMatchObject({
+ status: "complete",
+ resources: 1,
+ });
+ expect(report.resources[0]?.data_entry_location).toEqual({
+ offset: fixture.dataOffsets[0],
+ bytes: 16,
+ });
+ expect(report.resources[0]?.payload.sha256).toBe(
+ createHash("sha256").update("opaque resource").digest("hex"),
+ );
+ const response = await client.callTool(
+ { name: definition.name, arguments: { path } },
+ { toolDefinition: definition },
+ );
+ expect(response.isError).not.toBe(true);
+ expect(
+ contract.outputSchema.parse(response.structuredContent)
+ .normalized_result,
+ ).toEqual(evidence.normalized_result);
+ expect(await readFile(path)).toEqual(fixture.bytes);
+ }
+ }
+};
+
const expectPeMappingOwnershipAcrossAdapters = async (
cli: TestCli,
client: Client,
@@ -284,6 +337,11 @@ cliTest(
try {
await client.connect(transport);
const inventory = await client.listTools();
+ const definition = inventory.tools.find(
+ ({ name }) => name === contract.name,
+ );
+ if (definition === undefined)
+ throw new Error("Missing PE resource tool contract");
expect(inventory.tools).toContainEqual(
expect.objectContaining({
name: contract.name,
@@ -301,6 +359,12 @@ cliTest(
"inspect_pe_resources",
);
await expectPeInventoriesAcrossAdapters(cli, client, root);
+ await expectPeWordAlignedRecordsAcrossAdapters({
+ cli,
+ client,
+ root,
+ definition,
+ });
await expectPeMappingOwnershipAcrossAdapters(cli, client, root);
await expectPePaddingFailureAcrossAdapters({
cli,
From afa5da745e0b139e68029a96b776ae30e48ea2b2 Mon Sep 17 00:00:00 2001
From: Matt Van Horn
Date: Tue, 20 Oct 2026 21:48:29 -0700
Subject: [PATCH 14/45] feat(artifacts): inspect and extract PyInstaller
archives
---
.../skills/reverse-engineer-anything/SKILL.md | 12 +-
README.md | 59 +-
docs/.vitepress/config.ts | 4 +
docs/index.md | 26 +-
docs/python-packaged-applications.md | 128 ++
docs/readme/README_ar.md | 1 +
docs/readme/README_de.md | 1 +
docs/readme/README_el.md | 1 +
docs/readme/README_es.md | 1 +
docs/readme/README_fa.md | 1 +
docs/readme/README_fr.md | 1 +
docs/readme/README_id.md | 1 +
docs/readme/README_it.md | 1 +
docs/readme/README_ja.md | 1 +
docs/readme/README_ko.md | 1 +
docs/readme/README_ml.md | 1 +
docs/readme/README_pl.md | 1 +
docs/readme/README_pt-BR.md | 1 +
docs/readme/README_ru.md | 1 +
docs/readme/README_th.md | 1 +
docs/readme/README_tr.md | 1 +
docs/readme/README_uk.md | 1 +
docs/readme/README_vi.md | 1 +
docs/readme/README_zh-TW.md | 1 +
docs/readme/README_zh.md | 1 +
src/artifacts/ArtifactPaths.test.ts | 31 +
src/artifacts/ArtifactPaths.ts | 40 +-
src/artifacts/ArtifactReader.ts | 3 +-
src/artifacts/PyInstallerArtifactReader.ts | 608 +++++++++
.../extraction/ArtifactExtraction.test.ts | 54 +
.../extraction/ArtifactExtraction.ts | 34 +-
.../ArtifactGraphConstruction.test.ts | 6 +-
.../inventory/ArtifactGraphConstruction.ts | 48 +-
src/artifacts/inventory/classify.ts | 16 +-
src/artifacts/inventory/reader.ts | 3 +
src/artifacts/inventory/scanReader.ts | 9 +-
src/artifacts/pyinstaller/CArchive.fixture.ts | 211 +++
src/artifacts/pyinstaller/CArchiveLayout.ts | 389 ++++++
src/artifacts/pyinstaller/CArchiveMembers.ts | 340 +++++
.../pyinstaller/MarshalSubset.test.ts | 62 +
src/artifacts/pyinstaller/MarshalSubset.ts | 282 ++++
.../PyInstallerAdmissionBoundaries.test.ts | 139 ++
.../PyInstallerArtifactReader.test.ts | 1178 ++++++++++++++++
.../pyinstaller/PyInstallerBoundaries.test.ts | 673 +++++++++
src/artifacts/pyinstaller/PyInstallerBytes.ts | 60 +
.../pyinstaller/PyInstallerFailure.ts | 10 +
.../pyinstaller/PyInstallerFileAdmission.ts | 36 +
.../PyInstallerFormatBoundaries.test.ts | 1213 +++++++++++++++++
.../pyinstaller/PyInstallerMemberStream.ts | 147 ++
.../pyinstaller/PyInstallerMetadata.ts | 179 +++
.../pyinstaller/PyInstallerPyzStore.ts | 255 ++++
src/artifacts/pyinstaller/PythonBytecode.ts | 33 +
src/artifacts/pyinstaller/PyzArchive.test.ts | 137 ++
src/artifacts/pyinstaller/PyzArchive.ts | 266 ++++
src/domain/analysisSnapshot.ts | 1 +
.../analysisView/artifactInventoryView.ts | 55 +-
src/domain/artifactFormatMetadata.ts | 61 +
src/domain/artifactGraph.ts | 3 +-
src/domain/artifactInventoryEvidence.ts | 25 +-
src/domain/evidence.ts | 1 +
tests/acceptance/pyinstallerArchive.test.ts | 203 +++
.../mcp/pyinstallerArtifactMcp.test.ts | 277 ++++
tests/fixtures/pyinstallerReader.ts | 31 +
63 files changed, 7254 insertions(+), 114 deletions(-)
create mode 100644 docs/python-packaged-applications.md
create mode 100644 src/artifacts/PyInstallerArtifactReader.ts
create mode 100644 src/artifacts/extraction/ArtifactExtraction.test.ts
create mode 100644 src/artifacts/pyinstaller/CArchive.fixture.ts
create mode 100644 src/artifacts/pyinstaller/CArchiveLayout.ts
create mode 100644 src/artifacts/pyinstaller/CArchiveMembers.ts
create mode 100644 src/artifacts/pyinstaller/MarshalSubset.test.ts
create mode 100644 src/artifacts/pyinstaller/MarshalSubset.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerAdmissionBoundaries.test.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerBoundaries.test.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerBytes.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerFailure.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerFileAdmission.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerMemberStream.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerMetadata.ts
create mode 100644 src/artifacts/pyinstaller/PyInstallerPyzStore.ts
create mode 100644 src/artifacts/pyinstaller/PythonBytecode.ts
create mode 100644 src/artifacts/pyinstaller/PyzArchive.test.ts
create mode 100644 src/artifacts/pyinstaller/PyzArchive.ts
create mode 100644 tests/acceptance/pyinstallerArchive.test.ts
create mode 100644 tests/boundary/mcp/pyinstallerArtifactMcp.test.ts
create mode 100644 tests/fixtures/pyinstallerReader.ts
diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md
index e283e35b62..ef06652078 100644
--- a/.agents/skills/reverse-engineer-anything/SKILL.md
+++ b/.agents/skills/reverse-engineer-anything/SKILL.md
@@ -91,9 +91,15 @@ explicit path or endpoint and do not need it.
- ASAR or extracted JavaScript/Electron tree:
`analyze_javascript_application`.
-- Archive/package member inventory (ZIP/APK/IPA/MSIX/AppX or DMG):
- `open_binary` with the supplied local path; use `inspect_artifact` when its
- graph and findings help answer the question.
+- Archive/package member inventory (ZIP/APK/IPA/MSIX/AppX, DMG, or a PyInstaller
+ executable): `open_binary` with the supplied local path; use `inspect_artifact`
+ when its graph and findings help answer the question. A PyInstaller CArchive
+ is the application layer; the bootloader is only the native prefix. Start with
+ `detail: "summary"` for large inventories, then use retained inventory views
+ for selected member fields/pages. A complete small inventory can cost less
+ than extra calls and schemas. Runtime options are metadata, not files.
+ Compact delivery still inventories and hashes members. See the
+ [Python packaged applications guide](https://github.com/morluto/rea/blob/main/docs/python-packaged-applications.md).
- Apple app plist, Interface Builder, asset catalog, or keyed archive resources:
`open_bundle_resources`, then the selected resource tool. This does not
require `CFBundleExecutable` or a deep provider. Resource sessions have no
diff --git a/README.md b/README.md
index a4da4bb818..ff4777a55b 100644
--- a/README.md
+++ b/README.md
@@ -174,35 +174,36 @@ CLI commands use the same workflows.
REA requires Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+, plus npm.
Additional tools and host support depend on the target:
-| Target | What REA returns | Requirements and guide |
-| ---------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
-| Desktop installations | Runtime candidates, supporting files, component markers and next probes | Built-in bounded directory/header inspection; [desktop applications](docs/desktop-applications.md) |
-| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) |
-| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
-| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
-| Model artifacts | GGUF / safetensors metadata, tensor declarations and derived totals | Built-in header inspection; optional full-file hash; [model files](docs/model-artifacts.md) |
-| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
-| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
-| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
-| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
-| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
-| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
-| Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) |
-| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; Linux: procps-compatible `ps`; [application analysis](docs/javascript-application-workflows.md) |
-| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](docs/browser-observation.md) |
-| Saved network captures | Requests, responses, exposed payloads and source locations | HAR or Fiddler Everywhere SAZ; mitmdump on Linux for native mitmproxy; [capture guide](docs/web-network-captures.md) |
-| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) |
-| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
-| Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) |
-| JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) |
-| Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) |
-| Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) |
-| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
-| Recorded packets | Selected fields, repeated values, filters and decode-as rules | Linux: TShark 4.x + prlimit; [capture guide](docs/packet-captures.md) |
-| Packet summaries | Endpoints, DNS, TLS SNI, protocol distribution and HTTP requests | Caller-supplied tshark and capinfos (Linux-verified); [pcap guide](docs/pcap-analysis.md) |
-| Recorded logic signals | Decoder annotations, sample ranges and diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [signal recordings](docs/signal-recordings.md) |
-| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
-| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
+| Target | What REA returns | Requirements and guide |
+| ----------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| Desktop installations | Runtime candidates, supporting files, component markers and next probes | Built-in bounded directory/header inspection; [desktop applications](docs/desktop-applications.md) |
+| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) |
+| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
+| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
+| Model artifacts | GGUF / safetensors metadata, tensor declarations and derived totals | Built-in header inspection; optional full-file hash; [model files](docs/model-artifacts.md) |
+| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
+| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
+| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
+| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
+| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
+| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
+| Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) |
+| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; Linux: procps-compatible `ps`; [application analysis](docs/javascript-application-workflows.md) |
+| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](docs/browser-observation.md) |
+| Saved network captures | Requests, responses, exposed payloads and source locations | HAR or Fiddler Everywhere SAZ; mitmdump on Linux for native mitmproxy; [capture guide](docs/web-network-captures.md) |
+| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) |
+| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
+| Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) |
+| JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) |
+| Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) |
+| Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) |
+| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
+| Recorded packets | Selected fields, repeated values, filters and decode-as rules | Linux: TShark 4.x + prlimit; [capture guide](docs/packet-captures.md) |
+| Packet summaries | Endpoints, DNS, TLS SNI, protocol distribution and HTTP requests | Caller-supplied tshark and capinfos (Linux-verified); [pcap guide](docs/pcap-analysis.md) |
+| Recorded logic signals | Decoder annotations, sample ranges and diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [signal recordings](docs/signal-recordings.md) |
+| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
+| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
+| PyInstaller executables | CArchive members, Python version, entry scripts and extracted bytecode | None (static only); [Python packaged applications](docs/python-packaged-applications.md) |
Static JavaScript and .NET inspection read the supplied files without running
the application. Runtime capture runs or interacts with the selected target
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 561b9f2213..86d825c13d 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -50,6 +50,10 @@ export default defineConfig({
{ text: "Electron fuses", link: "/electron-fuses" },
{ text: "Process capture", link: "/process-capture" },
{ text: ".NET assemblies", link: "/managed-code-analysis" },
+ {
+ text: "Python packaged applications",
+ link: "/python-packaged-applications",
+ },
{ text: "Android applications", link: "/android-analysis" },
{ text: "Apple applications", link: "/apple-application-analysis" },
{ text: "Desktop installations", link: "/desktop-applications" },
diff --git a/docs/index.md b/docs/index.md
index 74c72ef059..070b8aad51 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -47,19 +47,19 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
## Choose a guide
-| Task | Guides |
-| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
-| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
-| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
-| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
-| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
-| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [.NET](managed-code-analysis.md), [Android](android-analysis.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) |
-| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) |
-| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) |
-| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) |
-| Capture and compare a command | [Process capture](process-capture.md) |
-| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) |
+| Task | Guides |
+| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
+| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
+| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
+| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
+| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
+| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) |
+| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) |
+| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) |
+| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) |
+| Capture and compare a command | [Process capture](process-capture.md) |
+| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) |
For development, use [testing](testing.md), [client verification](client-verification.md),
[tool design](tool-design.md), and [releasing](releasing.md).
diff --git a/docs/python-packaged-applications.md b/docs/python-packaged-applications.md
new file mode 100644
index 0000000000..5785def1fb
--- /dev/null
+++ b/docs/python-packaged-applications.md
@@ -0,0 +1,128 @@
+# Python packaged applications
+
+REA statically inventories PyInstaller executables with an appended CArchive.
+`inspect_artifact` and `extract_artifact` share the same reader through CLI and
+MCP. They read archive bytes without launching the application. Bytecode
+source decompilation, encrypted PYZ recovery, zipapp and PEX analysis are outside
+this capability.
+
+## Facts and focused queries
+
+The root occurrence's `format_metadata` records the CArchive layout (2.0 or
+2.1), archive and cookie offsets, table extent, entry count, raw Python version,
+normalized major/minor when known, library name and encryption marker. Member
+metadata retains the original name, table index, type, compression flag,
+relative offset and known directory record extent. Logical paths use archive
+path rules; the source name retains its spelling, including backslashes.
+`relative_offset` is relative to the CArchive start for CArchive rows, or to
+the decoded PYZ image for PYZ rows. A CArchive directory record starts at
+`archive_start + toc_offset + directory_record_offset`; its size is
+`directory_record_size`. PYZ row directory extents remain unknown.
+
+Namespace packages remain directories. Their `relative_offset` and
+`compressed_size` preserve any payload declared by older producers, while
+`declared_size` is zero because no bytecode is materialized for a namespace.
+Every declared span must end before the PYZ table of contents.
+PYZ `compression_flag` is derived from storage: nonempty payloads are compressed,
+including legacy namespaces; an empty namespace has no compressed payload.
+
+CArchive type `o` entries are runtime options. They are metadata occurrences,
+with no content artifact or extractable file. Their query paths are
+`__pyinstaller__/runtime-options/
`; the option text is
+`format_metadata.source_name`. Repeated declarations retain distinct occurrence
+identities. These paths do not claim output filesystem names.
+
+Type `d` entries are multipackage dependencies. They are also metadata, under
+`__pyinstaller__/dependencies/
`, and never materialize an empty file.
+The reader preserves the raw reference and splits its first colon into
+`dependency_archive` and `dependency_member`. `dependency_status` remains
+`unresolved`, or `invalid-reference` with unknown parts for malformed references.
+`external_dependency_count` summarizes these declarations. REA does not open
+another executable or claim its contents are available. A real file with the
+same logical path as metadata remains independently extractable.
+Both declaration types require empty payloads and no compression. Their query
+paths do not participate in filesystem collision checks, directory ancestry or
+source-map target lookup; their parent identifies the declaring container.
+Complete authenticated inventory page sets preserve producer identities in any
+merge order, including metadata/file aliases. Malformed TOC diagnostics name the
+table index; original names remain available in the supplied artifact.
+Path failures use bounded escaped previews and report the original length;
+accepted paths and retained source names are not shortened.
+
+Request `detail: "summary"` for a compact inspection, then query retained
+Evidence with `inspect_analysis_view`. An inventory view can select
+`collection: "occurrences"`, `entry_kinds: ["metadata"]`, exact logical paths or
+occurrence IDs, requested `fields`, and `offset`/`limit`. Filters intersect;
+identities, parent Evidence and coverage survive projection. Full inventory
+Evidence remains available for export. A compact reply reduces returned context;
+it still performs the complete member inventory and hashing work.
+Focused views add calls and tool-schema costs. A complete small inventory can
+cost less overall; prefer compact delivery when the full member graph would
+overwhelm the answer.
+
+```bash
+rea inspect-artifact /absolute/path/hello --detail summary --evidence_output /absolute/new/evidence.json --json
+rea extract-artifact /absolute/path/hello --json
+```
+
+## Decoding and evidence limits
+
+Inventory hashes members individually through bounded streams. Stored PYZ
+images use descriptor ranges; outer-compressed images stream to private files,
+which the reader owns through cleanup and any cleanup retry. Extraction binds
+the selected carrier to its inventoried digest and checks source identity and
+state before and after reading. It does not retain expanded module bodies.
+Opaque outer-compressed images reuse that verified private image when opened,
+instead of inflating the carrier again. Direct source spans describe stored
+CArchive bytes; decompressed content has no direct span even when its encoded
+and decoded sizes coincide. Original offsets and compression remain metadata.
+
+Only the PYZ header and marshal directory are loaded for listing. PYZ tables
+have a 16 MiB budget; CArchive tables have a 32 MiB budget. Parsed facts have a
+32 MiB serialized budget. Projected entries share a 64 MiB retained budget,
+including metadata, bookkeeping and conservative reservations for namespace
+objects and possible case-collision annotations at each path component. These
+reservations happen before normalization or trie allocation.
+Outer-compressed PYZ images have a separate per-reader capacity of 128 private
+files, bounding retained inodes and image identities. The complete image count
+is admitted before any temporary file is created; excess images return a
+capacity failure. This is a resource policy, not a PyInstaller format limit.
+Only a zero-byte read establishes EOF; short successful descriptor reads fill
+the bounded range. Compressed members must consume their complete declared
+input span; trailing junk and concatenated streams are refused.
+Plaintext expansion has an aggregate archive-derived budget. Exceeding a budget
+returns a resource failure.
+
+Supported final Python bytecode magic values cover Python 3.7 through 3.14.
+Supported marshal directory values are lists, tuples, strings, integers and
+bytes. An unsupported magic or directory stays an opaque member with a
+limitation and `pyz_status: "opaque"`; its module contents remain unknown.
+Any opaque PYZ makes producer traversal coverage unknown, including retained
+summaries and exhausted queries. Per-archive reasons remain on the opaque
+occurrences; the coverage observation summarizes their count without repeating
+every limitation. An empty result does not establish module absence.
+Observed `pyz_python_magic` bytes remain available even when their Python version
+is unknown.
+The directory declares compressed module lengths, so uncompressed declared
+sizes and source spans inside decoded data remain unknown.
+
+PYZ modules receive a derived 16-byte `.pyc` header from their own supported
+magic. CArchive bytecode receives that header only when every sibling PYZ magic
+and the cookie's Python version agree. Otherwise it retains its original bytes
+and `bytecode_header: "unknown"`. `bytecode_magic` and graph derivation edges
+identify synthesized headers; member digests cover the resulting derived files.
+These headers do not establish successful execution or decompilation.
+
+The nonempty CArchive Python module `pyimod00_crypto_key` (`m`/`M`) is recorded
+as an encryption marker; a same-named data file or script is not. Affected
+PYZ blobs are reported without decoding; extraction refuses encrypted entries.
+The raw PYZ container remains readable and hashable, including when its module
+table is opaque. Its marker stays in metadata and opaque child coverage stays
+unknown; extracting the container does not decrypt its payloads.
+The marker also applies to nonempty legacy namespace payloads. Empty namespaces
+have no payload to encrypt and retain a null encryption source.
+
+Detection searches the last 16 MiB for a cookie, allowing a trailing macOS code
+signature within that bound. Member offsets are relative to the archive start:
+cookie position plus cookie size minus package length. Native bootloader
+analysis remains available through the native tools.
diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md
index 99e7614769..86d7943131 100644
--- a/docs/readme/README_ar.md
+++ b/docs/readme/README_ar.md
@@ -152,6 +152,7 @@ npx rea-agents@latest setup
| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](../web-network-captures.md) |
| التقاط الحزم | نقاط النهاية وDNS وTLS SNI وتوزيع البروتوكولات وطلبات HTTP | tshark وcapinfos يوفّرهما المُستدعي؛ [دليل pcap](../pcap-analysis.md) |
| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](../android-analysis.md) |
| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](../adb-device-analysis.md) |
| مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md
index 42cb9eaec1..13c5a56592 100644
--- a/docs/readme/README_de.md
+++ b/docs/readme/README_de.md
@@ -152,6 +152,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
| Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](../web-network-captures.md) |
| Paketerfassungen | Endpunkte, DNS, TLS SNI, Protokollverteilung und HTTP-Anfragen | Vom Aufrufer bereitgestellte tshark- und capinfos-Binaries; [pcap-Leitfaden](../pcap-analysis.md) |
| .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](../android-analysis.md) |
| Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](../adb-device-analysis.md) |
| JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](../jeb-analysis.md) |
diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md
index 9912a8fea9..b7e9e19f7b 100644
--- a/docs/readme/README_el.md
+++ b/docs/readme/README_el.md
@@ -158,6 +158,7 @@ npx rea-agents@latest setup
| Ιστότοποι | Δομή σελίδων, scripts, παρατηρήσεις δικτύου και ζητούμενα στιγμιότυπα οθόνης | Πρόγραμμα περιήγησης της οικογένειας Chrome· [ανάλυση μέσω προγράμματος περιήγησης](../browser-observation.md) |
| Αποθηκευμένες καταγραφές δικτύου | Αιτήματα, αποκρίσεις, διαθέσιμα δεδομένα φορτίου και θέσεις στην πηγή | HAR ή SAZ του Fiddler Everywhere· mitmdump σε Linux για την εγγενή μορφή mitmproxy· [οδηγός καταγραφών](../web-network-captures.md) |
| Assemblies .NET | Μεταδεδομένα, εντολές CIL, δηλωμένες εξαρτήσεις από κώδικα μηχανής και συγκρίσεις builds | Καμία πρόσθετη απαίτηση (μόνο στατική ανάλυση)· [οδηγός διαχειριζόμενου κώδικα](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APK Android | Δηλώσεις manifest, κλάσεις, απομεταγλωττισμένες μέθοδοι και αναφορές | JADX χωρίς γραφικό περιβάλλον και πλήρες JDK σε Linux/macOS/Windows x64· [οδηγός Android](../android-analysis.md) |
| Συσκευές Android | Συσκευές, πακέτα, διεργασίες, logs, dumps, στιγμιότυπα οθόνης και μεταφορές APK/αρχείων | adb που παρέχει ο χρήστης (εξομοιωτής ή φυσική συσκευή)· [οδηγός ADB](../adb-device-analysis.md) |
| Έργα JEB | Μονάδες έργου από τη μηχανή ανάλυσης, ψηφιακά αποτυπώματα αρχείων και ψευδοκώδικα τύπων/μεθόδων | Client JEB που έχει ξεκινήσει ο χρήστης και παρέχει MCP, επιλεγμένος μέσω REA_JEB_MCP_URL· [οδηγός JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md
index d7a79ade22..26f197ff62 100644
--- a/docs/readme/README_es.md
+++ b/docs/readme/README_es.md
@@ -152,6 +152,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
| Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](../web-network-captures.md) |
| Capturas de paquetes | Endpoints, DNS, TLS SNI, distribución de protocolos y solicitudes HTTP | tshark y capinfos suministrados por el autor de la llamada; [guía pcap](../pcap-analysis.md) |
| Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](../android-analysis.md) |
| Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](../adb-device-analysis.md) |
| Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md
index 8f88b2e4e1..fa23c15949 100644
--- a/docs/readme/README_fa.md
+++ b/docs/readme/README_fa.md
@@ -143,6 +143,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
| وبسایتها | ساختار صفحه، اسکریپتها، مشاهدات شبکه و اسکرینشاتهای درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](../browser-observation.md) |
| دادههای ضبطشدهٔ شبکه | درخواستها، پاسخها، payloadهای آشکارشده و مکان آنها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبطهای بومی mitmproxy؛ [راهنمای ضبط شبکه](../web-network-captures.md) |
| اسمبلیهای .NET | فراداده، دستورالعملهای CIL، وابستگیهای بومی اعلامشده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریتشده](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| فایلهای APK اندروید | تعاریف Manifest، کلاسها، متدهای دیکامپایلشده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](../android-analysis.md) |
| دستگاههای اندروید | دستگاهها، بستهها، فرایندها، لاگها، دامپها، اسکرینشاتها و انتقال APK/فایل | adb تأمینشده توسط فراخوانکننده (شبیهساز یا دستگاه)؛ [راهنمای ADB](../adb-device-analysis.md) |
| پروژههای JEB | واحدهای پروژه از موتور، هشهای مصنوع و شبهکدِ نوع/متد | یک کلاینت JEB که توسط فراخوانکننده اجرا شده و MCP ارائه میدهد و با REA_JEB_MCP_URL انتخاب میشود؛ [راهنمای JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md
index c912ffb02a..db2a7051bb 100644
--- a/docs/readme/README_fr.md
+++ b/docs/readme/README_fr.md
@@ -156,6 +156,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
| Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](../web-network-captures.md) |
| Captures de paquets | Points de terminaison, DNS, TLS SNI, distribution des protocoles et requêtes HTTP | tshark et capinfos fournis par l'appelant ; [guide pcap](../pcap-analysis.md) |
| Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](../android-analysis.md) |
| Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](../adb-device-analysis.md) |
| Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md
index d4eafc7881..d824477757 100644
--- a/docs/readme/README_id.md
+++ b/docs/readme/README_id.md
@@ -159,6 +159,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](../web-network-captures.md) |
| Capturan paket | Endpoint, DNS, TLS SNI, distribusi protokol, dan permintaan HTTP | tshark dan capinfos buatan pemanggil; [panduan pcap](../pcap-analysis.md) |
| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](../android-analysis.md) |
| Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](../adb-device-analysis.md) |
| Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md
index 5678c813c1..7f951545a2 100644
--- a/docs/readme/README_it.md
+++ b/docs/readme/README_it.md
@@ -156,6 +156,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
| Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](../web-network-captures.md) |
| Captured di pacchetti | Endpoint, DNS, TLS SNI, distribuzione dei protocolli e richieste HTTP | tshark e capinfos forniti dal chiamante; [guida pcap](../pcap-analysis.md) |
| Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APK Android | Dichiarazioni manifest, classi, metodi decompilati e riferimenti | JADX headless e un JDK completo su Linux/macOS/Windows x64; [guida Android](../android-analysis.md) |
| Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](../adb-device-analysis.md) |
| Progetti JEB | Unità di progetto dal motore, digest degli artifact e pseudocodice di tipi/metodi | Client JEB avviato dal chiamante via MCP, selezionato con REA_JEB_MCP_URL; [guida JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md
index 2022f088f0..f4595b6824 100644
--- a/docs/readme/README_ja.md
+++ b/docs/readme/README_ja.md
@@ -152,6 +152,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](../web-network-captures.md) |
| パケットキャプチャ | エンドポイント、DNS、TLS SNI、プロトコル分布、HTTPリクエスト | 呼び出し側が用意した tshark と capinfos;[pcapガイド](../pcap-analysis.md) |
| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](../android-analysis.md) |
| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](../adb-device-analysis.md) |
| JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](../jeb-analysis.md) |
diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md
index 14ad6cbb96..3fa5f2569a 100644
--- a/docs/readme/README_ko.md
+++ b/docs/readme/README_ko.md
@@ -173,6 +173,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) |
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) |
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md
index 1d715eb2b8..5e07f3663f 100644
--- a/docs/readme/README_ml.md
+++ b/docs/readme/README_ml.md
@@ -160,6 +160,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ
| Websites | Page structure, scripts, network observations, ആവശ്യപ്പെട്ട screenshots | Chrome-family browser; [browser analysis](../browser-observation.md) |
| Saved network captures | Requests, responses, പുറത്തുകാണുന്ന payload-ുകൾ, source locations | HAR അല്ലെങ്കിൽ Fiddler Everywhere SAZ; native mitmproxy-നായി Linux-ൽ mitmdump; [capture guide](../web-network-captures.md) |
| .NET assemblies | Metadata, CIL instructions, പ്രഖ്യാപിച്ച native dependencies, build comparisons | ഒന്നും ആവശ്യമില്ല (static മാത്രം); [managed-code guide](../managed-code-analysis.md) |
+| Python packaged apps | CArchive/PYZ entries, Python version, bytecode, resources and runtime options | Static PyInstaller parsing; [Python guide](../python-packaged-applications.md) |
| Android APKs | Manifest declarations, classes, decompiled methods, references | Linux/macOS/Windows x64-ൽ Headless JADX-ഉം full JDK-ഉം; [Android guide](../android-analysis.md) |
| Android devices | Devices, packages, processes, logs, dumps, screen captures, APK/file transfers | Caller നൽകുന്ന adb (emulator അല്ലെങ്കിൽ hardware); [ADB guide](../adb-device-analysis.md) |
| JEB projects | Engine-backed project units, artifact digests, type/method pseudo-code | MCP സേവനം നൽകുന്ന caller ആരംഭിച്ച JEB client; REA_JEB_MCP_URL ഉപയോഗിച്ച് തിരഞ്ഞെടുക്കുക; [JEB guide](../jeb-analysis.md) |
diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md
index e19413fd87..4c692d2364 100644
--- a/docs/readme/README_pl.md
+++ b/docs/readme/README_pl.md
@@ -152,6 +152,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
| Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](../web-network-captures.md) |
| Przechwytywanie pakietów | Punkty końcowe, DNS, TLS SNI, dystrybucja protokołów i żądania HTTP | Dostarczone przez wywołującego tshark i capinfos; [przewodnik pcap](../pcap-analysis.md) |
| Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](../android-analysis.md) |
| Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](../adb-device-analysis.md) |
| Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md
index efbd4e9b41..019bba1129 100644
--- a/docs/readme/README_pt-BR.md
+++ b/docs/readme/README_pt-BR.md
@@ -152,6 +152,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
| Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](../web-network-captures.md) |
| Capturas de pacotes | Endpoints, DNS, TLS SNI, distribuição de protocolos e requisições HTTP | tshark e capinfos fornecidos pelo chamador; [guia pcap](../pcap-analysis.md) |
| Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](../android-analysis.md) |
| Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](../adb-device-analysis.md) |
| Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md
index 12f06df7bf..22ff426b8a 100644
--- a/docs/readme/README_ru.md
+++ b/docs/readme/README_ru.md
@@ -162,6 +162,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](../signal-recordings.md) |
| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](../javascript-artifact-reconstruction.md), [приложения Apple](../apple-application-analysis.md) |
| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](../process-capture.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
Статическое исследование JavaScript и .NET читает предоставленные файлы, не запуская приложение. Захват во время выполнения запускает выбранную цель или взаимодействует с ней с правами вашего пользователя; соответствующие руководства описывают последствия.
diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md
index 304e0d5437..fb8c3ff604 100644
--- a/docs/readme/README_th.md
+++ b/docs/readme/README_th.md
@@ -152,6 +152,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
| ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](../web-network-captures.md) |
| การจับแพ็กเก็ต | จุดเชื่อมต่อ, DNS, TLS SNI, การกระจายโปรโตคอล และคำขอ HTTP | tshark และ capinfos ที่ผู้เรียกจัดเตรียม; [คู่มือ pcap](../pcap-analysis.md) |
| แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](../android-analysis.md) |
| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](../adb-device-analysis.md) |
| โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md
index b0ba8f343b..2cb89a5496 100644
--- a/docs/readme/README_tr.md
+++ b/docs/readme/README_tr.md
@@ -156,6 +156,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](../browser-observation.md) |
| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR veya Fiddler Everywhere SAZ; Linux'ta native mitmproxy için mitmdump; [ağ yakalama rehberi](../web-network-captures.md) |
| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](../android-analysis.md) |
| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](../adb-device-analysis.md) |
| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](../jeb-analysis.md) |
diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md
index 4a33ac96d4..eac65f273e 100644
--- a/docs/readme/README_uk.md
+++ b/docs/readme/README_uk.md
@@ -152,6 +152,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
| Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](../web-network-captures.md) |
| Захоплення пакетів | Кінцеві точки, DNS, TLS SNI, розподіл протоколів та HTTP-запити | Надані викликаючою стороною tshark та capinfos; [підручник pcap](../pcap-analysis.md) |
| Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](../android-analysis.md) |
| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](../adb-device-analysis.md) |
| Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](../jeb-analysis.md) |
diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md
index 19f18c8ca2..cfba9f3c47 100644
--- a/docs/readme/README_vi.md
+++ b/docs/readme/README_vi.md
@@ -162,6 +162,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
| Tín hiệu logic đã ghi | Chú thích bộ giải mã, phạm vi mẫu và chẩn đoán | Linux: sigrok-cli 0.7.2 + prlimit; [hướng dẫn tín hiệu](../signal-recordings.md) |
| Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](../javascript-artifact-reconstruction.md), [ứng dụng Apple](../apple-application-analysis.md) |
| Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](../process-capture.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
Kiểm tra tĩnh JavaScript và .NET đọc các tệp được cung cấp mà không chạy ứng dụng. Ghi lại khi chạy sẽ khởi chạy hoặc tương tác với mục tiêu đã chọn bằng quyền người dùng của bạn; từng hướng dẫn mô tả tác động của nó.
diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md
index 403cf3d2a4..635bcbc9af 100644
--- a/docs/readme/README_zh-TW.md
+++ b/docs/readme/README_zh-TW.md
@@ -156,6 +156,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) |
| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) |
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) |
diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md
index 51275f4c08..313262ae53 100644
--- a/docs/readme/README_zh.md
+++ b/docs/readme/README_zh.md
@@ -156,6 +156,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) |
| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) |
+| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) |
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) |
diff --git a/src/artifacts/ArtifactPaths.test.ts b/src/artifacts/ArtifactPaths.test.ts
index 5957bb825e..b57a37b3f1 100644
--- a/src/artifacts/ArtifactPaths.test.ts
+++ b/src/artifacts/ArtifactPaths.test.ts
@@ -4,13 +4,44 @@ import {
ArtifactPathRegistry,
annotateEnglishUnicodeCaseCollisions,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
+ normalizeArtifactPath,
} from "./ArtifactPaths.js";
const NOTE =
"a case-insensitive destination cannot store both. This comparison is not a filesystem observation.";
+it.each(["drive", "escaped", "normalization", "prefix", "collision"])(
+ "bounds path failure diagnostics at the owning boundary: %s",
+ (kind) => {
+ const name = "x".repeat(1024 * 1024);
+ const registry = new ArtifactPathRegistry();
+ const fail = () => {
+ if (kind === "drive") normalizeArtifactPath("C:" + name);
+ else if (kind === "escaped")
+ normalizeArtifactPath("\0".repeat(name.length));
+ else if (kind === "normalization")
+ normalizeArtifactPath(name + "/../child");
+ else {
+ registry.add(name, "file");
+ registry.add(kind === "prefix" ? name + "/child" : name, "directory");
+ }
+ };
+ const failure = (() => {
+ try {
+ fail();
+ } catch (cause: unknown) {
+ return cause;
+ }
+ return undefined;
+ })();
+ expect(failure).toMatchObject({ reason: "path" });
+ expect((failure as Error).message.length).toBeLessThan(1024);
+ },
+);
+
const annotate = (paths: readonly string[]) => {
const occurrences = paths.map((logical_path) => ({
+ entry_kind: "file" as const,
logical_path,
limitations: [] as string[],
}));
diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts
index 2bc22f7642..f29c1faa9f 100644
--- a/src/artifacts/ArtifactPaths.ts
+++ b/src/artifacts/ArtifactPaths.ts
@@ -2,10 +2,25 @@ import { posix } from "node:path";
import { logicalPathNfc } from "../domain/artifactIdentity.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
-import { ArtifactReaderFailure } from "./ArtifactReader.js";
+import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js";
const DRIVE_PREFIX = /^[A-Za-z]:/u;
+// Bound diagnostic JSON expansion to 768 characters before formatting. This is
+// a presentation budget, never a limit on accepted paths or retained facts.
+const PATH_DIAGNOSTIC_CODE_UNITS = 128;
+const pathDiagnostic = (path: string): string =>
+ path.length <= PATH_DIAGNOSTIC_CODE_UNITS
+ ? JSON.stringify(path)
+ : `${JSON.stringify(path.slice(0, PATH_DIAGNOSTIC_CODE_UNITS))}… (${String(path.length)} UTF-16 code units)`;
+
+type DestinationEntryKind = Exclude;
+
+/** Declarations have query identities, but never claim a destination path. */
+export const hasDestinationPath = (
+ kind: ArtifactEntry["kind"],
+): kind is DestinationEntryKind => kind !== "metadata";
+
/**
* English Unicode case folding used only to describe logical names a
* case-insensitive destination might collapse. It is not a filesystem case table.
@@ -25,7 +40,7 @@ export const normalizeArtifactPath = (input: string): string => {
if (input.includes("\0") || input.startsWith("/") || DRIVE_PREFIX.test(input))
throw new ArtifactReaderFailure(
"path",
- `Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
+ `Artifact path is absolute or unsafe: ${pathDiagnostic(input)}`,
);
const normalized = logicalPathNfc(input).replace(/\/+$/u, "");
const parts = normalized.split("/");
@@ -36,7 +51,7 @@ export const normalizeArtifactPath = (input: string): string => {
)
throw new ArtifactReaderFailure(
"path",
- `Artifact path is not normalized: ${JSON.stringify(input)}`,
+ `Artifact path is not normalized: ${pathDiagnostic(input)}`,
);
return normalized;
};
@@ -45,14 +60,15 @@ export const normalizeArtifactPath = (input: string): string => {
export class ArtifactPathRegistry {
readonly #root: PathTrieNode = { kind: undefined, children: new Map() };
- add(path: string, kind: "file" | "directory" | "symlink" | "slice"): void {
+ add(path: string, kind: ArtifactEntry["kind"]): void {
+ if (!hasDestinationPath(kind)) return;
const parts = path.split("/");
let node = this.#root;
for (const [index, part] of parts.entries()) {
if (node.kind !== undefined && node.kind !== "directory")
throw new ArtifactReaderFailure(
"path",
- `Artifact prefix conflict: ${path}`,
+ `Artifact prefix conflict: ${pathDiagnostic(path)}`,
);
let child = node.children.get(part);
if (child === undefined) {
@@ -64,12 +80,12 @@ export class ArtifactPathRegistry {
if (node.kind !== undefined && node.kind !== kind)
throw new ArtifactReaderFailure(
"path",
- `Artifact path collision: ${path}`,
+ `Artifact path collision: ${pathDiagnostic(path)}`,
);
if (kind !== "directory" && node.children.size > 0)
throw new ArtifactReaderFailure(
"path",
- `Artifact prefix conflict: ${path}`,
+ `Artifact prefix conflict: ${pathDiagnostic(path)}`,
);
}
}
@@ -78,7 +94,7 @@ export class ArtifactPathRegistry {
}
interface PathTrieNode {
- kind: "file" | "directory" | "symlink" | "slice" | undefined;
+ kind: DestinationEntryKind | undefined;
readonly children: Map;
}
@@ -103,15 +119,19 @@ const englishCaseFold = (part: string): string =>
*/
export const annotateEnglishUnicodeCaseCollisions = (
occurrences: readonly {
+ readonly entry_kind: ArtifactEntry["kind"];
readonly logical_path: string;
limitations: string[];
}[],
): string | undefined => {
+ const destinations = occurrences.filter(({ entry_kind }) =>
+ hasDestinationPath(entry_kind),
+ );
const collisions = englishUnicodeCaseCollisions(
- occurrences.map(({ logical_path }) => logical_path),
+ destinations.map(({ logical_path }) => logical_path),
);
if (collisions.size === 0) return undefined;
- for (const occurrence of occurrences)
+ for (const occurrence of destinations)
for (const collision of collisions.get(occurrence.logical_path) ?? [])
occurrence.limitations.push(
caseCollisionLimitation(occurrence.logical_path, collision),
diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts
index 1a2a5a6147..33cb771ea2 100644
--- a/src/artifacts/ArtifactReader.ts
+++ b/src/artifacts/ArtifactReader.ts
@@ -33,7 +33,7 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions {
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
export interface ArtifactEntry {
readonly path: string;
- readonly kind: "file" | "directory" | "symlink" | "slice";
+ readonly kind: "file" | "directory" | "symlink" | "slice" | "metadata";
readonly declaredSize: number | null;
readonly compressedSize: number | null;
readonly executable: boolean;
@@ -167,6 +167,7 @@ export interface ArtifactReader {
| ZipPackageFormat
| "asar"
| "godot-pck"
+ | "pyinstaller"
| "file";
entries(
signal?: AbortSignal,
diff --git a/src/artifacts/PyInstallerArtifactReader.ts b/src/artifacts/PyInstallerArtifactReader.ts
new file mode 100644
index 0000000000..9f79d5f513
--- /dev/null
+++ b/src/artifacts/PyInstallerArtifactReader.ts
@@ -0,0 +1,608 @@
+import type { Readable } from "node:stream";
+import { finished } from "node:stream/promises";
+import { setImmediate } from "node:timers/promises";
+import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
+import type { JsonValue } from "../domain/jsonValue.js";
+import { normalizeArtifactPath } from "./ArtifactPaths.js";
+import { lstat, type FileHandle } from "node:fs/promises";
+
+import type { PyInstallerMetadata } from "../domain/artifactFormatMetadata.js";
+import {
+ cookieMetadata,
+ scriptPythonMagic,
+} from "./pyinstaller/PyInstallerMetadata.js";
+import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
+import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
+import {
+ RegularFileAdmissionFailure,
+ openRegularFile,
+ sameRegularFileState,
+ type StableRegularFileDescriptor,
+} from "../filesystem/RegularFile.js";
+import {
+ ArtifactReaderFailure,
+ sameArtifactEntry,
+ type ArtifactEntry,
+ type ArtifactReader,
+ type ArtifactReaderTraversal,
+ type ArtifactTraversalOptions,
+} from "./ArtifactReader.js";
+import { abortIfNeeded, hashReadable } from "./ArtifactHash.js";
+import { readArtifactFileChunks } from "./readArtifactFileChunks.js";
+import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
+import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
+import { PyInstallerPyzStore } from "./pyinstaller/PyInstallerPyzStore.js";
+import { pyInstallerMemberStream } from "./pyinstaller/PyInstallerMemberStream.js";
+import {
+ findPyInstallerCookie,
+ readCArchiveToc,
+ type CArchiveEntry,
+ type PyInstallerCookie,
+} from "./pyinstaller/CArchiveLayout.js";
+import {
+ hasEncryptionMarker,
+ projectPyzContainer,
+ projectPyzMember,
+ projectStoredMember,
+ sealMember,
+ type PyInstallerMember,
+ type PyzImage,
+} from "./pyinstaller/CArchiveMembers.js";
+import { readPyzArchive } from "./pyinstaller/PyzArchive.js";
+import type { PyInstallerFailure } from "./pyinstaller/PyInstallerFailure.js";
+import { pyInstallerFileAdmissionFailure } from "./pyinstaller/PyInstallerFileAdmission.js";
+
+const MAX_PARSED_FACT_BYTES = 32 * 1024 * 1024;
+const MAX_RETAINED_ENTRY_BYTES = 64 * 1024 * 1024;
+const RECORD_OWNERSHIP_RESERVATION = 256;
+// Conservative allowance for registry/case trie nodes, maps and traversal state.
+// Repeated prefixes are charged again rather than underestimating retained work.
+const PATH_COMPONENT_RESERVATION = 1024;
+
+interface LoadedArchive {
+ readonly members: readonly PyInstallerMember[];
+ readonly traversal: ArtifactReaderTraversal;
+}
+
+/**
+ * Read-only inventory of a PyInstaller CArchive appended to a native executable.
+ * Encrypted PYZ entries are reported and not decoded. Code members are not decompiled.
+ */
+export class PyInstallerArtifactReader implements ArtifactReader {
+ readonly format = "pyinstaller" as const;
+ #source: StableRegularFileDescriptor | undefined;
+ #opening: Promise | undefined;
+ #closed = false;
+ readonly #scope = new ArtifactResourceScope();
+ readonly #cleanup = new ArtifactResourceScope();
+ readonly #lifecycle = new AbortController();
+ #boundSha256: string | undefined;
+ #loaded: Promise | undefined;
+ #budget = 0;
+ #charged = 0;
+ #parsedFactBytes = 0;
+ #retainedEntryBytes = 0;
+ #metadata: PyInstallerMetadata | undefined;
+ readonly #pyzStore = new PyInstallerPyzStore();
+ readonly #members = new Map();
+ readonly #openedPyzSizes = new Map();
+
+ constructor(
+ private readonly path: string,
+ source?: StableRegularFileDescriptor,
+ ) {
+ this.#source = source;
+ this.#cleanup.retain({
+ kind: "lease",
+ resource: "PyInstaller private PYZ images",
+ lease: { release: () => this.#pyzStore.close() },
+ });
+ }
+
+ formatMetadata(): PyInstallerMetadata | undefined {
+ return this.#metadata === undefined ? undefined : { ...this.#metadata };
+ }
+
+ async prepareContainer(
+ expectedSha256: string,
+ signal?: AbortSignal,
+ ): Promise {
+ const active = this.#signal(signal);
+ await this.#scope.run(async () => {
+ const handle = await this.#openHandle(active);
+ await this.#verifySource();
+ const observed =
+ this.#boundSha256 ??
+ (
+ await hashReadable(
+ readArtifactFileChunks(handle, { signal: active }),
+ active,
+ )
+ ).sha256;
+ await this.#verifySource();
+ abortIfNeeded(active);
+ if (observed !== expectedSha256)
+ throw new ArtifactReaderFailure(
+ "integrity",
+ "PyInstaller carrier changed after inventory",
+ undefined,
+ artifactIntegrityDetails(
+ ".",
+ { algorithm: "sha256", digest: expectedSha256 },
+ observed,
+ false,
+ ),
+ );
+ this.#boundSha256 = observed;
+ });
+ }
+
+ async *entries(
+ signal?: AbortSignal,
+ options?: ArtifactTraversalOptions,
+ ): AsyncIterable {
+ const active = this.#signal(signal);
+ const loaded = await this.#scope.run(async () => {
+ const value = await this.#load(active);
+ await this.#verifySource();
+ this.#members.clear();
+ return value;
+ });
+ options?.onCoverage?.(loaded.traversal);
+ for (const member of loaded.members) {
+ abortIfNeeded(active);
+ const sealed = sealMember(member);
+ this.#members.set(sealed.entry.adapterKey, sealed);
+ yield sealed.entry;
+ }
+ }
+
+ async open(entry: ArtifactEntry, signal?: AbortSignal): Promise {
+ const active = this.#signal(signal);
+ return this.#scope.run(async () => {
+ const stored = this.#members.get(entry.adapterKey);
+ if (stored === undefined || !sameArtifactEntry(entry, stored.entry))
+ throw new ArtifactReaderFailure(
+ "integrity",
+ `PyInstaller entry was not produced by this reader: ${entry.path}`,
+ );
+ if (entry.kind !== "file")
+ throw new ArtifactReaderFailure(
+ "format",
+ `PyInstaller entry is not a regular file: ${entry.path}`,
+ );
+ if (entry.encrypted)
+ throw new ArtifactReaderFailure(
+ "unavailable",
+ `PYZ entry is encrypted and was not decoded: ${entry.path}`,
+ );
+ const read = stored.read;
+ if (read === undefined)
+ throw new ArtifactReaderFailure(
+ "format",
+ `PyInstaller entry has no readable bytes: ${entry.path}`,
+ );
+ await this.#verifySource();
+ const knownSize =
+ read.kind === "pyz"
+ ? (read.uncompressedSize ??
+ this.#openedPyzSizes.get(entry.adapterKey))
+ : undefined;
+ if (read.kind === "pyz" && knownSize === undefined)
+ this.#charge(read.header?.length ?? 0);
+ const parts = await this.#pyzStore.memberSource(
+ read,
+ this.#source?.handle,
+ active,
+ );
+ if (active.aborted) {
+ parts.source.destroy();
+ await finished(parts.source, { cleanup: true }).catch(() => undefined);
+ abortIfNeeded(active);
+ }
+ // Keep one close attempt in this read. Failed owners stay in the store
+ // for reader.close() to retry, without losing the original failure.
+ let closeAttempt: Promise | undefined;
+ const closeSource = (primary?: unknown) =>
+ (closeAttempt ??= parts.close?.(primary));
+ const stream = pyInstallerMemberStream(parts.source, {
+ sourceBytes:
+ read.kind === "carchive" ? read.source.compressedSize : read.length,
+ compressed:
+ read.kind === "pyz"
+ ? read.compressed
+ : read.source.compressionFlag === 1,
+ header: read.header,
+ expectedBytes:
+ read.kind === "carchive" ? read.source.uncompressedSize : knownSize,
+ maxBytes:
+ read.kind === "carchive"
+ ? read.source.uncompressedSize
+ : (knownSize ?? this.#budget - this.#charged),
+ signal: active,
+ ...(read.kind === "pyz" && knownSize === undefined
+ ? { charge: (bytes: number) => this.#charge(bytes) }
+ : {}),
+ complete: async (bytes) => {
+ try {
+ await parts.verify?.();
+ await this.#verifySource();
+ abortIfNeeded(active);
+ } catch (cause: unknown) {
+ await closeSource(cause);
+ throw cause;
+ }
+ await closeSource();
+ abortIfNeeded(active);
+ if (read.kind === "pyz" && knownSize === undefined)
+ this.#openedPyzSizes.set(entry.adapterKey, bytes);
+ },
+ });
+ void this.#scope
+ .run(async () => {
+ let primary: unknown;
+ try {
+ await stream.completion;
+ } catch (cause: unknown) {
+ primary = cause;
+ }
+ await closeSource(primary);
+ if (primary !== undefined) throw primary;
+ })
+ .catch((cause: unknown) =>
+ stream.output.destroy(
+ cause instanceof Error
+ ? cause
+ : new Error("PyInstaller stream failed", { cause }),
+ ),
+ );
+ return stream.output;
+ });
+ }
+
+ provenance(): readonly [] {
+ return [];
+ }
+
+ async close(): Promise {
+ this.#closed = true;
+ const closing = new ArtifactReaderFailure(
+ "unavailable",
+ "PyInstaller reader closed",
+ );
+ this.#lifecycle.abort(closing);
+ await this.#scope.close();
+ await this.#cleanup.close();
+ this.#source = undefined;
+ this.#closed = true;
+ this.#loaded = undefined;
+ this.#members.clear();
+ this.#openedPyzSizes.clear();
+ }
+
+ #signal(signal?: AbortSignal): AbortSignal {
+ abortIfNeeded(signal);
+ if (this.#closed)
+ throw new ArtifactReaderFailure(
+ "unavailable",
+ "PyInstaller archive is closed",
+ );
+ return signal === undefined
+ ? this.#lifecycle.signal
+ : AbortSignal.any([signal, this.#lifecycle.signal]);
+ }
+
+ async #load(signal?: AbortSignal): Promise {
+ this.#loaded ??= this.#parse(signal);
+ return this.#loaded;
+ }
+
+ async #parse(signal?: AbortSignal): Promise {
+ const handle = await this.#openHandle(signal);
+ await this.#verifySource();
+ const size = (await handle.stat()).size;
+ const cookie = await this.#cookie(handle, size, signal);
+ this.#budget = artifactExpansionBudgetBytes(cookie.packageLength);
+ const toc = await this.#toc(handle, cookie, signal);
+ for (const entry of toc) {
+ this.#charge(entry.uncompressedSize);
+ this.#reserveMetadata({ ...entry }, false);
+ }
+ const images = await this.#pyzImages(handle, cookie, toc, signal);
+ const magic = scriptPythonMagic(
+ cookie,
+ [...images.values()].map((image) => image.archive),
+ );
+ const encrypted = hasEncryptionMarker(toc);
+ const members = await this.#project(
+ toc,
+ cookie,
+ images,
+ magic,
+ encrypted,
+ signal,
+ );
+ for (const member of members)
+ if (member.read?.kind === "carchive")
+ this.#charge(member.read.header?.length ?? 0);
+ await this.#verifySource();
+ this.#metadata = cookieMetadata(cookie, toc, encrypted);
+ let opaqueImages = 0;
+ for (const image of images.values())
+ if (image.archive.kind === "opaque") opaqueImages++;
+ return {
+ members,
+ traversal: {
+ status: opaqueImages === 0 ? "complete" : "unknown",
+ limitations:
+ opaqueImages === 0
+ ? []
+ : [
+ `${String(opaqueImages)} PYZ archives remain opaque; module traversal is unknown. See opaque PYZ occurrences for source magic and per-archive limitations.`,
+ ],
+ },
+ };
+ }
+
+ async #openHandle(signal?: AbortSignal): Promise {
+ abortIfNeeded(signal);
+ if (this.#closed)
+ throw new ArtifactReaderFailure(
+ "unavailable",
+ "PyInstaller archive is closed",
+ );
+ if (this.#source !== undefined) return this.#source.handle;
+ // Admission belongs to the reader. A departing caller cannot cancel a peer
+ // or replace a late descriptor owner; every caller joins the same admission.
+ this.#opening ??= this.#admit();
+ let handle: FileHandle;
+ try {
+ handle = await this.#opening;
+ } catch (cause: unknown) {
+ if (
+ cause instanceof ArtifactReaderFailure &&
+ cause.reason === "cancelled" &&
+ signal?.aborted
+ )
+ throw new ArtifactReaderFailure("cancelled", cause.message, {
+ cause: signal.reason,
+ cleanup: cause.cleanup,
+ cleanupLeases: cause.cleanupLeases,
+ });
+ throw cause;
+ }
+ abortIfNeeded(signal);
+ return handle;
+ }
+
+ async #admit(): Promise {
+ try {
+ const handle = await openRegularFile(this.path, {
+ symlinks: "reject",
+ signal: this.#lifecycle.signal,
+ });
+ this.#retainCarrier(new OwnedFileHandle(handle));
+ this.#source = { handle, initial: await handle.stat() };
+ return handle;
+ } catch (cause: unknown) {
+ if (cause instanceof RegularFileAdmissionFailure)
+ this.#retainCarrier(cause.owner);
+ throw pyInstallerFileAdmissionFailure(this.path, cause);
+ }
+ }
+
+ #retainCarrier(handle: OwnedFileHandle): void {
+ this.#cleanup.retain({ kind: "file-handle", resource: this.path, handle });
+ }
+
+ async #cookie(
+ handle: FileHandle,
+ size: number,
+ signal?: AbortSignal,
+ ): Promise {
+ const found = await findPyInstallerCookie(handle, size, signal);
+ if (found === undefined)
+ throw new ArtifactReaderFailure(
+ "format",
+ "PyInstaller CArchive cookie was not found",
+ );
+ if (!found.ok) throw failure(found.error);
+ return found.value;
+ }
+
+ async #toc(
+ handle: FileHandle,
+ cookie: PyInstallerCookie,
+ signal?: AbortSignal,
+ ): Promise {
+ const toc = await readCArchiveToc(handle, cookie, signal);
+ if (!toc.ok) throw failure(toc.error);
+ return toc.value;
+ }
+
+ async #pyzImages(
+ handle: FileHandle,
+ cookie: PyInstallerCookie,
+ toc: readonly CArchiveEntry[],
+ signal: AbortSignal | undefined,
+ ): Promise> {
+ const images = new Map();
+ // Admit the complete filesystem footprint before the first private write.
+ this.#pyzStore.reserveImages(
+ toc.reduce(
+ (count, entry) =>
+ count +
+ (entry.typeCode === "z" && entry.compressionFlag === 1 ? 1 : 0),
+ 0,
+ ),
+ );
+ for (const entry of toc) {
+ if (entry.typeCode !== "z") continue;
+ abortIfNeeded(signal);
+ const spooled =
+ entry.compressionFlag === 0
+ ? undefined
+ : await this.#pyzStore.spool(
+ handle,
+ cookie.archiveStart,
+ entry,
+ signal,
+ );
+ const source = spooled ?? {
+ handle,
+ start: cookie.archiveStart + entry.offset,
+ };
+ const parsed = await readPyzArchive(
+ source.handle,
+ source.start,
+ entry.uncompressedSize,
+ signal,
+ );
+ if (!parsed.ok) throw failure(parsed.error);
+ if (parsed.value.kind === "entries")
+ for (const item of parsed.value.entries)
+ this.#reserveMetadata({ ...item, container: entry.name }, false);
+ if (spooled !== undefined) await this.#pyzStore.release(spooled.handle);
+ images.set(entry, {
+ archive: parsed.value,
+ handle: source.handle,
+ start: source.start,
+ });
+ }
+ return images;
+ }
+
+ async #project(
+ toc: readonly CArchiveEntry[],
+ cookie: PyInstallerCookie,
+ images: ReadonlyMap,
+ magic: Buffer | undefined,
+ encrypted: boolean,
+ signal: AbortSignal | undefined,
+ ): Promise {
+ const members: PyInstallerMember[] = [];
+ const append = (member: PyInstallerMember): void => {
+ this.#reservePathComponents(member.entry.path);
+ const entry = {
+ ...member.entry,
+ path: normalizeArtifactPath(member.entry.path),
+ };
+ this.#reserveMetadata(
+ {
+ path: entry.path,
+ adapter_key: entry.adapterKey,
+ format_metadata: entry.formatMetadata ?? null,
+ limitations: [...entry.limitations],
+ },
+ true,
+ );
+ members.push({ ...member, entry });
+ };
+ for (const entry of toc) {
+ if (members.length % 256 === 0) await setImmediate();
+ abortIfNeeded(signal);
+ const image = entry.typeCode === "z" ? images.get(entry) : undefined;
+ if (image === undefined) {
+ append(projectStoredMember(entry, cookie.archiveStart, magic));
+ continue;
+ }
+ append(projectPyzContainer(entry, cookie.archiveStart, image, encrypted));
+ if (image.archive.kind !== "entries") continue;
+ for (const item of image.archive.entries) {
+ if (members.length % 256 === 0) await setImmediate();
+ abortIfNeeded(signal);
+ append(
+ projectPyzMember(
+ entry,
+ item,
+ encrypted,
+ image.archive.pythonMagic,
+ item.namespace || encrypted
+ ? undefined
+ : {
+ handle: image.handle,
+ offset: image.start + item.offset,
+ length: item.length,
+ },
+ ),
+ );
+ }
+ }
+ return members;
+ }
+
+ async #verifySource(): Promise {
+ const source = this.#source;
+ if (source === undefined)
+ throw new ArtifactReaderFailure(
+ "unavailable",
+ "PyInstaller archive is closed",
+ );
+ const [opened, currentPath] = await Promise.all([
+ source.handle.stat(),
+ lstat(this.path),
+ ]);
+ if (
+ !sameRegularFileState(source.initial, opened) ||
+ !sameRegularFileState(source.initial, currentPath)
+ )
+ throw new ArtifactReaderFailure(
+ "integrity",
+ `PyInstaller source changed during reading: ${this.path}`,
+ );
+ }
+
+ #reservePathComponents(path: string): void {
+ const remaining = MAX_RETAINED_ENTRY_BYTES - this.#retainedEntryBytes;
+ // Case annotations can carry the path and two prefixes at every component.
+ // Reserve escaped text growth as well as registry/case trie nodes and maps.
+ const componentCost =
+ PATH_COMPONENT_RESERVATION + 6 * jsonValueByteLength(path);
+ let reservation = componentCost;
+ for (
+ let index = 0;
+ index < path.length && reservation <= remaining;
+ index++
+ )
+ if (path.charCodeAt(index) === 47) reservation += componentCost;
+ if (reservation > remaining)
+ throw new ArtifactReaderFailure(
+ "limit",
+ "PyInstaller paths exceed their retained namespace budget",
+ { boundary: "memory" },
+ );
+ this.#retainedEntryBytes += reservation;
+ }
+
+ #reserveMetadata(value: JsonValue, projected: boolean): void {
+ const used = projected ? this.#retainedEntryBytes : this.#parsedFactBytes;
+ const limit = projected ? MAX_RETAINED_ENTRY_BYTES : MAX_PARSED_FACT_BYTES;
+ const remaining = limit - used - RECORD_OWNERSHIP_RESERVATION;
+ const bytes = remaining < 0 ? 0 : jsonValueByteLength(value, remaining);
+ if (remaining < 0 || bytes > remaining)
+ throw new ArtifactReaderFailure(
+ "limit",
+ "PyInstaller directory facts exceed their retained metadata budget",
+ { boundary: "memory" },
+ );
+ if (projected)
+ this.#retainedEntryBytes += bytes + RECORD_OWNERSHIP_RESERVATION;
+ else this.#parsedFactBytes += bytes + RECORD_OWNERSHIP_RESERVATION;
+ }
+
+ #charge(bytes: number): void {
+ if (
+ !Number.isSafeInteger(bytes) ||
+ bytes < 0 ||
+ this.#charged + bytes > this.#budget
+ )
+ throw new ArtifactReaderFailure(
+ "limit",
+ `PyInstaller members declare ${String(this.#charged + Math.max(0, bytes))} uncompressed bytes, exceeding the decompression budget`,
+ );
+ this.#charged += bytes;
+ }
+}
+
+const failure = (error: PyInstallerFailure): ArtifactReaderFailure =>
+ new ArtifactReaderFailure(error.reason, error.message);
diff --git a/src/artifacts/extraction/ArtifactExtraction.test.ts b/src/artifacts/extraction/ArtifactExtraction.test.ts
new file mode 100644
index 0000000000..d22855d6e6
--- /dev/null
+++ b/src/artifacts/extraction/ArtifactExtraction.test.ts
@@ -0,0 +1,54 @@
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { expect, it, vi } from "vitest";
+
+import { ArtifactReaderFailure } from "../ArtifactReader.js";
+import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
+import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
+import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
+import { extractArtifact } from "./ArtifactExtraction.js";
+
+vi.mock("../pyinstaller/CArchiveLayout.js", async (importOriginal) => ({
+ ...(await importOriginal