Merge remote-tracking branch 'origin/main' into test/maintenance-boundary-contracts-20261011

This commit is contained in:
N0zoM1z0 committed 2026-10-11 02:38:48 +08:00
commit 1ae4bec45b
21 files changed
+949 -193

No files matched your search

+64 -29
View File
@@ -52,7 +52,11 @@ export const inspectJadxAvailability = async (
signal?: AbortSignal,
): Promise<ProviderAvailability> => {
try {
const configuration = await readJadxConfiguration(environment, signal);
const configuration = await readJadxConfigurationInputs(
environment,
signal,
);
await validateJadxConfigurationContents(configuration, environment, signal);
return {
status: "available",
code: null,
@@ -77,52 +81,83 @@ export const inspectJadxAvailability = async (
}
};
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
export const resolveJadxConfiguration = async (
/** Resolve caller-selected paths and JVM options without probing their contents. */
export const resolveJadxConfigurationInputs = async (
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
try {
return await readJadxConfiguration(environment, signal);
return await readJadxConfigurationInputs(environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
const output = cause.diagnostics;
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" &&
typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
throw projectConfigurationFailure(operation, cause);
}
};
const readJadxConfiguration = async (
/** Probe Java and archive prerequisites before starting a new engine session. */
export const validateJadxConfiguration = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<void> => {
try {
await validateJadxConfigurationContents(configuration, environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
throw projectConfigurationFailure(operation, cause);
}
};
const readJadxConfigurationInputs = async (
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
signal?.throwIfAborted();
requireSupportedHost();
const jar = await resolveJar(environment);
const java = await resolveJava(environment, signal);
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
await requireEngineArchive(jar, signal);
return { jar, java, jvmArguments };
signal?.throwIfAborted();
return { jar, java, jvmArguments: resolveJvmArguments(environment) };
};
const validateJadxConfigurationContents = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<void> => {
signal?.throwIfAborted();
await inspectJavaRuntime(configuration.java, environment, signal);
await requireEngineArchive(configuration.jar, signal);
};
const projectConfigurationFailure = (
operation: AndroidOperation,
cause: JadxConfigurationFailure,
): AnalysisCapabilityUnavailableError => {
const output = cause.diagnostics;
return new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" && typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
};
const requireEngineArchive = async (
+9 -2
View File
@@ -27,7 +27,8 @@ import { hashStableFile } from "../filesystem/StableFileHash.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
inspectJadxAvailability,
resolveJadxConfiguration,
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "./JadxConfiguration.js";
import type { JadxLauncher } from "./JadxMcpTransport.js";
@@ -333,7 +334,7 @@ export class JadxProvider implements AndroidAnalysisPort {
request.operation,
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
);
const configuration = await resolveJadxConfiguration(
const configuration = await resolveJadxConfigurationInputs(
this.environment,
request.operation,
signal,
@@ -382,6 +383,12 @@ export class JadxProvider implements AndroidAnalysisPort {
}
let retained = this.#retained;
if (retained === undefined) {
await validateJadxConfiguration(
configuration,
this.environment,
request.operation,
signal,
);
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
const engine = await snapshotAndroidEngine(
configuration.jar,
@@ -1,3 +1,11 @@
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import {
createJavaScriptSemanticGraph,
createJavaScriptSemanticGraphNode,
JavaScriptSemanticEvidenceContextRegistry,
} from "../domain/javascript/javascriptSemanticGraph.js";
import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js";
import { describe, expect, it } from "vitest";
import { createEvidence } from "../domain/evidence.js";
@@ -171,3 +179,115 @@ describe("reconstruction obligation candidates", () => {
);
});
});
it("keeps each semantic candidate bound to its own file and authority context", () => {
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
const base = javascriptApplicationAnalysisResultSchema.parse(
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
);
const sourceRecords = [
proofEvidence("first-file"),
proofEvidence("second-file"),
];
const module = createJavaScriptSemanticGraphNode(
{
kind: "module",
identity: {
artifact_sha256: base.root_artifact_sha256,
module_path: "app.js",
source_range: null,
role_key: "module",
},
function_node_id: null,
application_node_ids: [],
label: "app.js",
properties: {},
evidence: artifactEvidence(base.root_artifact_sha256, "app.js"),
},
contexts,
);
const requests = sourceRecords.flatMap((source, fileIndex) =>
[1, 2].map((line) => {
const sha256 = String(fileIndex + 4).repeat(64);
const path = `file-${String(fileIndex)}.js`;
return createJavaScriptSemanticGraphNode(
{
kind: "request",
identity: {
artifact_sha256: sha256,
module_path: path,
source_range: {
start: { line, column: 0 },
end: { line, column: 8 },
},
role_key: `request-${String(line)}`,
},
function_node_id: null,
application_node_ids: [],
label: `${path}:${String(line)}`,
properties: {},
evidence: {
...artifactEvidence(sha256, path, "ast-static-analysis"),
evidence_ids: [source.evidence_id],
},
},
contexts,
);
}),
);
const { graph_id: _graphId, ...semantic } = base.semantic_graph;
const semanticGraph = createJavaScriptSemanticGraph({
...semantic,
root_node_ids: [module.node_id],
nodes: [module, ...requests],
evidence_contexts: contexts.contexts,
});
const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE;
const application = createEvidence(
{
path: base.input_path,
sha256: base.root_artifact_sha256,
format: base.format,
},
example.provider,
{
predicateType: example.predicate_type,
operation: example.operation,
parameters: example.parameters,
result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }),
confidence: "derived",
authority: "shipped-artifact",
evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id),
},
);
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle([...sourceRecords, application]),
[],
);
expect(generated.limitations).toEqual([]);
expect(generated.candidates).toHaveLength(requests.length);
for (const [index, node] of requests.entries()) {
const source = sourceRecords[Math.floor(index / 2)];
expect(
generated.candidates.find(
({ target }) => target.semantic_node_id === node.node_id,
),
).toMatchObject({
title: node.label,
family: "request",
source_state: "candidate",
target: {
artifact_sha256: node.identity.artifact_sha256,
semantic_node_id: node.node_id,
},
authority_references: [
{
evidence_id: source?.evidence_id,
authority: "controlled-replay",
state: "candidate",
location: `${semanticGraph.graph_id}/node/${node.node_id}`,
},
],
});
}
});
@@ -12,7 +12,6 @@ import type {
ReconstructionObligation,
ReviewedReconstructionObligation,
} from "../domain/reconstructionObligationLedgerSchemas.js";
import { resolveJavaScriptSemanticEvidence } from "../domain/javascript/javascriptSemanticGraph.js";
import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js";
import {
applicationObligationPolicy,
@@ -115,14 +114,21 @@ const deriveApplicationCandidates = (
}),
);
}
if (source.semanticGraph !== null)
if (source.semanticGraph !== null) {
const evidenceContexts = new Map(
source.semanticGraph.evidence_contexts.map((context) => [
context.context_id,
context,
]),
);
for (const node of source.semanticGraph.nodes) {
const candidatePolicy = semanticObligationPolicy(node.kind);
if (candidatePolicy === undefined) continue;
const evidenceContext = resolveJavaScriptSemanticEvidence(
source.semanticGraph,
node.evidence,
);
const evidenceContext = evidenceContexts.get(node.evidence.context_id);
if (evidenceContext === undefined)
throw new TypeError(
"Semantic evidence reference names an absent context",
);
addCandidate(
candidates,
generatedCandidate({
@@ -144,6 +150,7 @@ const deriveApplicationCandidates = (
}),
);
}
}
if (source.graph.coverage.status !== "complete")
limitations.add(
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
+33
View File
@@ -60,6 +60,30 @@ describe("English Unicode case collisions", () => {
]);
});
it("retains every spelling in a high-cardinality folded sibling group", () => {
const count = 8_192;
const variants = caseVariants("abcdefghijklm", count);
const { limitation, byPath } = annotate(variants);
const ordered = [...variants].sort();
const first = ordered[0];
const second = ordered[1];
const original = variants[0];
if (first === undefined || second === undefined || original === undefined) {
throw new Error("Expected at least two case variants");
}
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
expect(byPath.size).toBe(count);
expect(byPath.get(first)).toEqual([
`Logical path ${first} collides under English (en-US) Unicode case folding with ${second} and ${String(count - 2)} other spellings; ${NOTE}`,
]);
expect(byPath.get(original)?.[0]).toContain(
`with ${first} and ${String(count - 2)} other spellings;`,
);
expect(
[...byPath.values()].every((limitations) => limitations.length === 1),
).toBe(true);
});
it("stays linear for large case-variant subtrees", () => {
const count = 5_000;
const paths = Array.from({ length: count }, (_, index) => [
@@ -81,3 +105,12 @@ describe("English Unicode case collisions", () => {
expect(byPath.get(deep)).toEqual([]);
});
});
const caseVariants = (lowercase: string, count: number): string[] =>
Array.from({ length: count }, (_, mask) =>
[...lowercase]
.map((character, index) =>
(mask & (1 << index)) === 0 ? character : character.toUpperCase(),
)
.join(""),
);
+4 -4
View File
@@ -177,12 +177,12 @@ const englishUnicodeCaseCollisions = (
if (path.length === 0 || path === ".") continue;
let node = root;
for (const part of path.split("/")) {
const folded = englishCaseFold(part);
const spellings = node.spellingsByFold.get(folded);
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
else if (!spellings.includes(part)) spellings.push(part);
let child = node.bySpelling.get(part);
if (child === undefined) {
const folded = englishCaseFold(part);
const spellings = node.spellingsByFold.get(folded);
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
else spellings.push(part);
child = emptyCaseNode();
node.bySpelling.set(part, child);
}
@@ -325,15 +325,20 @@ export const resolveOccurrenceParents = (
branch.occurrence = occurrence;
}
for (const occurrence of occurrences) {
const segments = occurrence.logical_path.split("/");
const path = occurrence.logical_path;
let branch = root;
let parent: MutableOccurrence | undefined;
// The final segment identifies this occurrence, not one of its parents.
for (const segment of segments.slice(0, -1)) {
// Walk parent segments only. Stop scanning the path once no observed
// directory or expanded container can be an ancestor.
for (let start = 0; ;) {
const slash = path.indexOf("/", start);
if (slash < 0) break;
const segment = path.slice(start, slash);
const child = branch.children.get(segment);
if (child === undefined) break;
branch = child;
parent = branch.occurrence ?? parent;
start = slash + 1;
}
occurrence.parent_occurrence_id = parent?.occurrence_id ?? null;
}
@@ -62,9 +62,7 @@ const classifyLayer = (
options.reconciliationComplete &&
layer.graph.coverage.status === "complete" &&
scopedEntities.length > 0 &&
scopedEntities.every(({ capture }) =>
captureScriptsComplete(capture.evidence.evidence_id, runtimeEntities),
);
scopedEntities.every(({ capture }) => capture.scriptsCompleteWithinScope);
const states = nodes.map((node) => {
const runtimeNodeIds = uniqueSorted(direct.get(node.node_id) ?? []);
if (runtimeNodeIds.length > 0)
@@ -134,13 +132,6 @@ const append = (
else current.push(value);
};
const captureScriptsComplete = (
evidenceId: string,
entities: readonly RuntimeReconciliationEntity[],
): boolean =>
entities.find(({ capture }) => capture.evidence.evidence_id === evidenceId)
?.capture.scriptsCompleteWithinScope === true;
const isLoadStateNode = (
node: ApplicationNode,
): node is ApplicationNode & {
@@ -4,6 +4,7 @@ import { z } from "zod";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js";
import {
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
JAVASCRIPT_SEMANTIC_NODE_KINDS,
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
JAVASCRIPT_SEMANTIC_RELATIONS,
@@ -27,6 +28,7 @@ import {
parseJavaScriptSemanticGraph,
serializeJavaScriptSemanticGraph,
} from "./javascriptSemanticGraphSerialization.js";
import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js";
const SHA = "a".repeat(64);
const JAG_ID = `jag_${"b".repeat(64)}`;
@@ -323,6 +325,104 @@ const registryForGraph = (
return registry;
};
const graphWithCandidateRelations = (): JavaScriptSemanticGraph => {
const base = fixtureGraph();
const evidenceContexts = registryForGraph(base);
const relations = base.relations.map(
({ relation_id: _relationId, ...relation }) =>
createJavaScriptSemanticGraphRelation(
{
...relation,
resolution: "candidate",
evidence: resolveJavaScriptSemanticEvidence(base, relation.evidence),
},
evidenceContexts,
),
);
const binding = base.nodes.find(({ label }) => label === "binding");
const callable = base.nodes.find(({ label }) => label === "function");
if (binding === undefined || callable === undefined)
throw new Error("Expected candidate-cycle endpoints");
for (const [source, target] of [
[binding, callable],
[callable, binding],
] as const)
relations.push(
createJavaScriptSemanticGraphRelation(
{
source_node_id: source.node_id,
target_node_id: target.node_id,
relation: "owns",
resolution: "candidate",
properties: {},
evidence: evidence("inferred"),
},
evidenceContexts,
),
);
const { graph_id: _graphId, ...input } = base;
return createJavaScriptSemanticGraph({
...input,
evidence_contexts: evidenceContexts.contexts,
relations,
coverage: {
...input.coverage,
families: input.coverage.families.map((family) => ({
...family,
retained_relations: relations.filter(
({ relation }) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family.family,
).length,
})),
},
});
};
it("counts candidate edges by query direction and relation filter", () => {
const graph = graphWithCandidateRelations();
const binding = graph.nodes.find(({ label }) => label === "binding");
if (binding === undefined) throw new Error("Expected binding seed");
const query = (
direction: "forward-influence" | "backward-provenance",
allowedRelations: (typeof JAVASCRIPT_SEMANTIC_RELATIONS)[number][],
) =>
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction,
allowed_relations: allowedRelations,
});
expect(query("forward-influence", ["captures"]).status).toBe("ambiguous");
expect(query("forward-influence", ["defines"]).status).toBe("found");
expect(query("backward-provenance", ["defines"]).status).toBe("ambiguous");
expect(query("backward-provenance", ["captures"]).status).toBe("found");
});
it("counts candidate ownership cycles once and traverses them finitely", () => {
const graph = graphWithCandidateRelations();
const binding = graph.nodes.find(({ label }) => label === "binding");
if (binding === undefined) throw new Error("Expected binding seed");
const result = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction: "ownership",
allowed_relations: ["owns"],
include_ambiguous_dynamic_edges: true,
});
expect(result.status).toBe("ambiguous");
expect(result.relations).toHaveLength(2);
expect(result.summary.traversed_nodes).toBe(2);
expect(result.summary.traversed_relations).toBe(2);
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction: "ownership",
allowed_relations: ["constructs-request"],
}).status,
).toBe("found");
});
it("defines every required relation family and canonicalizes records", () => {
expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter");
expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task");
@@ -283,15 +283,32 @@ const relevantCandidateRelationCount = (
nodeIds: ReadonlySet<string>,
input: JavaScriptSemanticQueryInput,
): number => {
const adjacency = buildAdjacency(
graph.relations.filter(({ resolution }) => resolution === "candidate"),
{ ...input, include_ambiguous_dynamic_edges: true },
);
const relevant = new Set<string>();
for (const nodeId of nodeIds)
for (const { relation } of adjacency.get(nodeId) ?? [])
relevant.add(relation.relation_id);
return relevant.size;
const allowed =
input.allowed_relations === undefined
? null
: new Set(input.allowed_relations);
let relevant = 0;
for (const relation of graph.relations) {
if (
relation.resolution !== "candidate" ||
(allowed !== null && !allowed.has(relation.relation))
)
continue;
const sourceIsRelevant = nodeIds.has(relation.source_node_id);
const targetIsRelevant = nodeIds.has(relation.target_node_id);
if (
(input.direction === "callers" &&
relation.relation === "calls" &&
targetIsRelevant) ||
(input.direction === "ownership" &&
ownershipRelation(relation.relation) &&
(sourceIsRelevant || targetIsRelevant)) ||
(input.direction === "backward-provenance" && targetIsRelevant) ||
(input.direction === "forward-influence" && sourceIsRelevant)
)
relevant += 1;
}
return relevant;
};
const expectedMatchesFor = (
+18 -13
View File
@@ -377,7 +377,7 @@ describe("process identity during token validation", () => {
expect(reusedDuringFinalRead).toBe(true);
});
it("fails closed when a candidate PID changes identity during token reading", async () => {
it("fails closed when candidate PIDs change identity during token reading", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 911,
@@ -386,8 +386,10 @@ describe("process identity during token validation", () => {
state: "S",
command: "reused-during-token-read",
};
const processes = [process, { ...process, pid: 912, processGroupId: 912 }];
const listProcesses = vi.fn(async () => processes);
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
listProcesses,
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
identityReads += 1;
@@ -414,21 +416,24 @@ describe("process identity during token validation", () => {
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: process.pid, identity: "old-start" },
]),
verifyNoTokenOwnedProcesses(
"run-token",
host,
processes.map(({ pid }) => ({ pid, identity: "old-start" })),
),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [
{
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
},
],
failures: processes.map(({ pid }) => ({
pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
})),
});
// Initial observation, final token classification, then one fresh recheck
// shared by both changed identities.
expect(listProcesses).toHaveBeenCalledTimes(3);
});
});
@@ -4,7 +4,7 @@ import {
type ProcessOwnershipHost,
} from "./ProcessOwnership.js";
import {
observeOwnedProcessGroup,
observeOwnedProcessGroups,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
@@ -17,6 +17,158 @@ const processes = [100, 101].map((pid) => ({
command: "fixture",
}));
describe("multi-group ownership observation", () => {
it("observes multiple groups from one process snapshot and token batch", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "other-owned-group",
},
{
pid: 300,
parentPid: 1,
processGroupId: 300,
state: "Z",
command: "[exited]",
},
];
const listProcesses = vi.fn(() => Promise.resolve(rows));
const environment = vi.fn(() => Promise.resolve({}));
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 200 ? "other-run" : ownership.runId,
},
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200, 300], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{ state: "unverifiable", reason: "process ownership did not match" },
],
[300, { state: "empty" }],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledWith(
rows.filter(({ state }) => state !== "Z"),
);
expect(environment).not.toHaveBeenCalled();
});
it("rechecks unavailable members individually before classifying their groups", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "unreadable-group",
},
];
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(rows)
.mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101))
.mockResolvedValueOnce(rows);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 101 || pid === 200
? {
state: "unavailable" as const,
reason: `unreadable-${String(pid)}`,
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], {
listProcesses,
environment: vi.fn(() => Promise.resolve({})),
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{
state: "unverifiable",
reason:
"process ownership could not be revalidated for PID 200: unreadable-200",
},
],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(3);
expect(runTokens).toHaveBeenCalledTimes(1);
});
});
describe("multi-group observation short-circuit", () => {
it("stops missing-row fallbacks once every live group is unverifiable", async () => {
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 100 ? "other-run" : ownership.runId,
}),
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
() => Promise.resolve(new Map()),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[
100,
{ state: "unverifiable", reason: "process ownership did not match" },
],
]),
);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(environment.mock.calls).toEqual([[100]]);
expect(listProcesses).toHaveBeenCalledTimes(1);
});
});
describe("batched ownership observation", () => {
it("observes group and lineage with one batch each and falls back only for missing rows", async () => {
const { adapter } = host({
@@ -32,8 +184,14 @@ describe("batched ownership observation", () => {
);
const observedHost = { ...adapter, environment, runTokens };
await expect(
observeOwnedProcessGroup(ownership, observedHost),
).resolves.toEqual({ state: "alive" });
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
observedHost,
),
).resolves.toEqual(
new Map([[ownership.processGroupId, { state: "alive" }]]),
);
await expect(
observeOwnedProcessLineage(ownership, observedHost),
).resolves.toMatchObject({
@@ -67,8 +225,9 @@ describe("batched ownership observation", () => {
},
);
await expect(
observeOwnedProcessGroup(
ownership,
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
{ listProcesses, environment, runTokens, signalGroup: vi.fn() },
controller.signal,
),
+47 -38
View File
@@ -628,6 +628,7 @@ const processOwnershipFailures = async (
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
@@ -636,8 +637,7 @@ const processOwnershipFailures = async (
current?.state !== "readable" ||
current.identity !== expected
) {
if (await processIsGone(host, member.pid)) continue;
failures.push({
identityFailures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
@@ -645,6 +645,7 @@ const processOwnershipFailures = async (
});
}
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
}
failures.push(...(await processRunTokenFailures(members, runId, host)));
if (
@@ -656,6 +657,7 @@ const processOwnershipFailures = async (
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
@@ -665,14 +667,14 @@ const processOwnershipFailures = async (
current.identity === expected
)
continue;
if (await processIsGone(host, member.pid)) continue;
failures.push({
identityFailures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
}
return failures;
};
@@ -692,24 +694,43 @@ const processRunTokenFailures = async (
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
continue;
}
try {
const live = liveProcesses(await host.listProcesses());
if (!live.some(({ pid }) => pid === member.pid)) continue;
} catch (recheckCause: unknown) {
failures.push({
pid: member.pid,
reason: "environment-unreadable",
diagnostic: `${observation.reason}; process liveness recheck failed: ${errorMessage(recheckCause)}`,
});
continue;
}
failures.push({
pid: member.pid,
reason: "environment-unreadable",
diagnostic: observation.reason,
});
}
return failures;
return recheckOwnershipFailures(failures, host);
};
/**
* Take a fresh liveness snapshot after each ownership-read phase. Only an
* absent PID resolves an unreadable observation; a readable token mismatch
* remains a failure. Never reuse this snapshot across identity/token reads.
*/
const recheckOwnershipFailures = async (
failures: readonly ProcessOwnershipValidationFailure[],
host: ProcessOwnershipHost,
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
if (!failures.some(({ reason }) => reason !== "run-token-mismatch"))
return failures;
try {
const live = new Set(
liveProcesses(await host.listProcesses()).map(({ pid }) => pid),
);
return failures.filter(
({ pid, reason }) => reason === "run-token-mismatch" || live.has(pid),
);
} catch (cause: unknown) {
return failures.map((failure) =>
failure.reason === "run-token-mismatch"
? failure
: {
...failure,
diagnostic: `${failure.diagnostic}; process liveness recheck failed: ${errorMessage(cause)}`,
},
);
}
};
interface TokenOwnedProcessScan {
@@ -785,18 +806,19 @@ const scanTokenOwnedProcesses = async (
const failures: ProcessOwnershipValidationFailure[] = [];
if (captureBaseline !== undefined && host.processIdentities !== undefined) {
const stableCandidates: ProcessTableEntry[] = [];
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const process of candidates) {
if (candidateIdentities.get(process.pid)?.state === "readable") {
stableCandidates.push(process);
continue;
}
if (!(await processIsGone(host, process.pid)))
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
});
identityFailures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
candidates = stableCandidates;
}
const owned: ProcessTableEntry[] = [];
@@ -882,6 +904,7 @@ const scanTokenOwnedProcesses = async (
if (candidates.length > 0 && host.processIdentities !== undefined) {
const afterRead = await host.processIdentities(candidates);
const stillOwned: ProcessTableEntry[] = [];
const identityFailures: ProcessOwnershipValidationFailure[] = [];
const ownedPids = new Set(owned.map(({ pid }) => pid));
for (const process of candidates) {
const before = candidateIdentities.get(process.pid);
@@ -894,14 +917,14 @@ const scanTokenOwnedProcesses = async (
if (ownedPids.has(process.pid)) stillOwned.push(process);
continue;
}
if (await processIsGone(host, process.pid)) continue;
failures.push({
identityFailures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
return {
owned: stillOwned,
failures,
@@ -963,17 +986,3 @@ const settleUnreadableTokenCandidates = async (
}
return remaining;
};
const processIsGone = async (
host: ProcessOwnershipHost,
pid: number,
): Promise<boolean> => {
try {
return !liveProcesses(await host.listProcesses()).some(
(process) => process.pid === pid,
);
} catch (cause: unknown) {
void cause;
return false;
}
};
+115 -6
View File
@@ -6,7 +6,7 @@ import {
type WindowsProcessTreeHost,
} from "./ProcessOwnership.js";
import {
observeOwnedProcessGroup,
observeOwnedProcessGroups,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
@@ -115,6 +115,101 @@ describe("owned process-group cleanup validation: ownership and lineage", () =>
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("owned process-group cleanup liveness rechecks", () => {
it("rechecks all unreadable members after the token phase in one fresh snapshot", async () => {
const members = [100, 101, 102, 103].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
}));
const tokenReads: number[] = [];
const listProcesses = vi.fn(async () => {
if (tokenReads.length > 0)
expect(tokenReads).toEqual([100, 101, 102, 103]);
return members;
});
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses,
environment: async (pid) => {
tokenReads.push(pid);
throw new Error(`unreadable ${pid}`);
},
signalGroup,
};
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
{
cleaned: false,
reason:
"process ownership token could not be read for 4 live process(es): other_unavailable=4; live candidates 100=other_unavailable, 101=other_unavailable, 102=other_unavailable, 103=other_unavailable",
failures: members.map(({ pid }) => ({
pid,
reason: "environment-unreadable",
diagnostic: `unreadable ${pid}`,
})),
},
);
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(signalGroup).not.toHaveBeenCalled();
});
it.each([false, true])(
"retains mismatches and fails closed when the liveness recheck fails: %s",
async (recheckFails) => {
const members = [100, 101, 102].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
}));
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(members);
if (recheckFails)
listProcesses.mockRejectedValue(new Error("process table denied"));
else listProcesses.mockResolvedValue([]);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses,
environment: async (pid) => {
if (pid === 101) return { REA_PROCESS_RUN_ID: "other-run" };
throw new Error(`unreadable ${pid}`);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(ownership, adapter),
).resolves.toEqual({
cleaned: false,
reason: "process tree contains an unowned or PID-reused process",
failures: recheckFails
? [
{
pid: 100,
reason: "environment-unreadable",
diagnostic:
"unreadable 100; process liveness recheck failed: process table denied",
},
{ pid: 101, reason: "run-token-mismatch" },
{
pid: 102,
reason: "environment-unreadable",
diagnostic:
"unreadable 102; process liveness recheck failed: process table denied",
},
]
: [{ pid: 101, reason: "run-token-mismatch" }],
});
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(signalGroup).not.toHaveBeenCalled();
},
);
it("accepts a member that exits during ownership revalidation", async () => {
const liveLauncher = {
pid: 100,
@@ -160,7 +255,12 @@ describe("owned process-group cleanup validation: exited members", () => {
},
};
await expect(
observeOwnedProcessGroup(ownership, adapter, controller.signal),
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
adapter,
controller.signal,
),
).rejects.toBe(reason);
});
it("does not inspect a process group after observation was cancelled", async () => {
@@ -177,7 +277,12 @@ describe("owned process-group cleanup validation: exited members", () => {
},
};
await expect(
observeOwnedProcessGroup(ownership, adapter, controller.signal),
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
adapter,
controller.signal,
),
).rejects.toBe(reason);
});
it("ignores exited zombie members during live ownership checks", async () => {
@@ -229,9 +334,13 @@ describe("owned process-group cleanup validation: exited members", () => {
environment,
signalGroup: vi.fn(),
};
expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({
state: "empty",
});
expect(
await observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
adapter,
),
).toEqual(new Map([[ownership.processGroupId, { state: "empty" }]]));
expect(environment).not.toHaveBeenCalled();
});
it("is idempotent when the owned group has already exited", async () => {
+47 -20
View File
@@ -16,51 +16,78 @@ import type {
ProcessTableEntry,
} from "./ProcessOwnership.js";
/** Observe one group without signaling it, failing closed on identity doubt. */
export const observeOwnedProcessGroup = async (
ownership: OwnedProcessGroup,
/** Observe multiple groups from one process snapshot and one token batch. */
export const observeOwnedProcessGroups = async (
runId: string,
processGroupIds: readonly number[],
host: ProcessOwnershipHost = systemProcessOwnershipHost,
signal?: AbortSignal,
): Promise<ProcessGroupObservation> => {
): Promise<ReadonlyMap<number, ProcessGroupObservation>> => {
signal?.throwIfAborted();
let members: readonly ProcessTableEntry[];
const groupIds = [...new Set(processGroupIds)];
if (groupIds.length === 0) return new Map();
let processes: readonly ProcessTableEntry[];
try {
members = (await host.listProcesses(signal)).filter(
({ processGroupId }) => processGroupId === ownership.processGroupId,
);
processes = liveProcesses(await host.listProcesses(signal));
} catch (cause: unknown) {
signal?.throwIfAborted();
return {
state: "unverifiable",
const failure = {
state: "unverifiable" as const,
reason: `process group could not be inspected: ${errorMessage(cause)}`,
};
return new Map(groupIds.map((groupId) => [groupId, failure]));
}
signal?.throwIfAborted();
const liveMembers = liveProcesses(members);
if (liveMembers.length === 0) return { state: "empty" };
const membersByGroup = new Map(
groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]),
);
for (const process of processes)
membersByGroup.get(process.processGroupId)?.push(process);
const observations = new Map<number, ProcessGroupObservation>(
groupIds.map((groupId) => [
groupId,
(membersByGroup.get(groupId)?.length ?? 0) > 0
? { state: "alive" }
: { state: "empty" },
]),
);
const liveMembers = groupIds.flatMap(
(groupId) => membersByGroup.get(groupId) ?? [],
);
const pendingGroups = new Set(
groupIds.filter(
(groupId) => (membersByGroup.get(groupId)?.length ?? 0) > 0,
),
);
for await (const { process: member, observation } of readProcessRunTokens(
host,
liveMembers,
signal,
)) {
if (!pendingGroups.has(member.processGroupId)) continue;
if (observation.state === "readable") {
if (observation.runId !== ownership.runId)
return {
if (observation.runId !== runId) {
observations.set(member.processGroupId, {
state: "unverifiable",
reason: "process ownership did not match",
};
});
pendingGroups.delete(member.processGroupId);
}
} else {
if (!(await processIsGone(host, member.pid))) {
signal?.throwIfAborted();
return {
const gone = await processIsGone(host, member.pid);
signal?.throwIfAborted();
if (!gone) {
observations.set(member.processGroupId, {
state: "unverifiable",
reason: `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
};
});
pendingGroups.delete(member.processGroupId);
}
}
if (pendingGroups.size === 0) break;
}
signal?.throwIfAborted();
return { state: "alive" };
return observations;
};
/**
+10 -11
View File
@@ -25,7 +25,7 @@ import {
type ProcessOwnershipBaseline,
} from "../ProcessOwnership.js";
import {
observeOwnedProcessGroup,
observeOwnedProcessGroups,
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../ProcessOwnershipObservation.js";
@@ -616,21 +616,20 @@ export const observeSettlement = async (
while (!deadlineReached) {
onLiveProgress?.();
assertNotCancelled(signal);
const observations = await Promise.all(
[...new Set(processGroupIds)].map((processGroupId) =>
observeOwnedProcessGroup(
{ runId, leaderPid: processGroupId, processGroupId },
undefined,
signal,
),
),
const observations = await observeOwnedProcessGroups(
runId,
processGroupIds,
undefined,
signal,
);
assertNotCancelled(signal);
if (observations.some(({ state }) => state === "unverifiable")) {
if (
[...observations.values()].some(({ state }) => state === "unverifiable")
) {
recordEvent("lifecycle", 1);
return { state: "unverifiable", elapsed_ms: Date.now() - started };
}
if (observations.every(({ state }) => state === "empty")) {
if ([...observations.values()].every(({ state }) => state === "empty")) {
consecutiveEmpty += 1;
if (consecutiveEmpty >= 2) {
recordEvent("lifecycle", 1);
@@ -5,7 +5,8 @@ import { expect, it as test } from "vitest";
import {
inspectJadxAvailability,
resolveJadxConfiguration,
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "../../../src/android/JadxConfiguration.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -14,6 +15,18 @@ import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
const resolveConfiguration = async (
environment: Parameters<typeof resolveJadxConfigurationInputs>[0],
operation: Parameters<typeof resolveJadxConfigurationInputs>[1],
) => {
const configuration = await resolveJadxConfigurationInputs(
environment,
operation,
);
await validateJadxConfiguration(configuration, environment, operation);
return configuration;
};
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
const javaHome = await createFakeJdk(root);
@@ -29,7 +42,7 @@ it("rejects malformed or unrelated engine archives with JAR-specific recovery",
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
});
await expect(
resolveJadxConfiguration(environment, "inspect_android_package"),
resolveConfiguration(environment, "inspect_android_package"),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
@@ -45,7 +58,7 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () =
await writeJadxJarInventory(jar);
await expect(
resolveJadxConfiguration(
resolveConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
),
@@ -62,7 +75,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => {
await writeJadxJarInventory(jar);
await expect(
resolveJadxConfiguration(
resolveConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
),
@@ -78,7 +91,7 @@ it("configures heap and visible processors independently without forcing either"
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeJadxJarInventory(jar);
const configuration = await resolveJadxConfiguration(
const configuration = await resolveConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
@@ -92,7 +105,7 @@ it("configures heap and visible processors independently without forcing either"
"-XX:ActiveProcessorCount=4",
]);
await expect(
resolveJadxConfiguration(
resolveConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
@@ -107,7 +120,7 @@ it("configures heap and visible processors independently without forcing either"
for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"])
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
await expect(
resolveJadxConfiguration(
resolveConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
@@ -127,7 +140,7 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => {
await writeJadxJarInventory(jar);
const jreHome = await createFakeJdk(root, false);
await expect(
resolveJadxConfiguration(
resolveConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome },
"inspect_android_package",
),
@@ -175,7 +188,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
);
await chmod(java, 0o700);
const failure = await resolveJadxConfiguration(
const failure = await resolveConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
).then(
@@ -241,7 +254,7 @@ it("distinguishes an unreported Java version from an unsupported old version", a
);
await chmod(join(oldHome, "bin", "java"), 0o700);
await expect(
resolveJadxConfiguration(
resolveConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome },
"inspect_android_package",
),
@@ -73,25 +73,29 @@ describe("artifact inventory entry order", () => {
const root = await createTestTempDirectory("rea-deep-archive-parent-");
const archive = join(root, "deep.zip");
// Fits the ZIP filename field while exceeding the formerly expensive depth.
const member = `observed/${"d/".repeat(30_000)}data.txt`;
await writeOrderedZip(archive, [member, "observed/", "unrelated/"]);
const members = ["data.txt", "second.txt", "third.bin"].map(
(name) => `observed/${"d/".repeat(30_000)}${name}`,
);
await writeOrderedZip(archive, [...members, "observed/", "unrelated/"]);
const inventory = await inventoryArtifact(archive);
expect(artifactParentPaths(inventory)).toEqual({
".": null,
observed: ".",
unrelated: ".",
[member]: "observed",
...Object.fromEntries(members.map((member) => [member, "observed"])),
});
const occurrence = artifactOccurrenceAt(inventory, member);
expect(occurrence.hash_status).toBe("verified");
expect(inventory.edges).toContainEqual(
expect.objectContaining({
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: artifactOccurrenceAt(inventory, "observed")
.artifact_id,
relation: "contains",
}),
);
const observedParent = artifactOccurrenceAt(inventory, "observed");
for (const member of members) {
const occurrence = artifactOccurrenceAt(inventory, member);
expect(occurrence.hash_status).toBe("verified");
expect(inventory.edges).toContainEqual(
expect.objectContaining({
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: observedParent.artifact_id,
relation: "contains",
}),
);
}
});
it.each([
@@ -154,6 +158,61 @@ describe("entry-order validation and reader controls", () => {
}
});
it("retains high-cardinality case-folded directories and their descendants", async () => {
const root = await createTestTempDirectory(
"rea-entry-order-case-capacity-",
);
const archive = join(root, "case-capacity.zip");
const variants = Array.from({ length: 256 }, (_, mask) =>
[..."abcdefgh"]
.map((character, index) =>
(mask & (1 << index)) === 0 ? character : character.toUpperCase(),
)
.join(""),
);
await writeOrderedZip(
archive,
variants.flatMap((variant) => [variant + "/", variant + "/entry.txt"]),
);
const inventory = await inventoryArtifact(archive);
const directories = variants.map((variant) =>
artifactOccurrenceAt(inventory, variant),
);
const descendants = variants.map((variant) =>
artifactOccurrenceAt(inventory, `${variant}/entry.txt`),
);
const ordered = [...variants].sort();
const first = ordered[0];
const second = ordered[1];
expect(inventory.occurrences).toHaveLength(1 + variants.length * 2);
expect(
new Set(inventory.occurrences.map(({ logical_path }) => logical_path))
.size,
).toBe(1 + variants.length * 2);
expect(
new Set(
[...directories, ...descendants].map(
({ occurrence_id }) => occurrence_id,
),
).size,
).toBe(variants.length * 2);
expect(
directories.every(({ limitations }) => limitations.length === 1),
).toBe(true);
expect(
descendants.every(({ limitations }) => limitations.length === 1),
).toBe(true);
expect(
directories.find(({ logical_path }) => logical_path === first)
?.limitations[0],
).toContain(`with ${second} and 254 other spellings;`);
expect(descendants[0]?.limitations[0]).toContain(
`is under ${variants[0]}, which collides under English (en-US) Unicode case folding with ${first} and 254 other spellings;`,
);
});
it("preserves ordinary directory and ASAR structure", async () => {
const root = await createTestTempDirectory("rea-entry-order-readers-");
const directory = join(root, "source");
@@ -91,8 +91,9 @@ it("reuses immutable engine state while keeping each Evidence observation separa
await verifyCleanup(launches);
});
it("bounds output per operation across a retained session rather than accumulating earlier captures", async () => {
const { service, apk, launches, provider } = await setup("large-manifest");
it("bounds output per operation without repeating prerequisite probes for a retained session", async () => {
const { service, apk, launches, provider, javaProbeCount } =
await setup("large-manifest");
const expectedManifest = "x".repeat(1024 * 1024);
for (let index = 0; index < 34; index += 1) {
const result = await service.execute("inspect_android_package", {
@@ -106,12 +107,14 @@ it("bounds output per operation across a retained session rather than accumulati
).toBe(expectedManifest);
}
expect(launches).toHaveLength(1);
expect(await javaProbeCount()).toBe(1);
await provider.close();
await verifyCleanup(launches);
});
it("retire sessions when APK bytes, engine bytes or JVM options change", async () => {
const { service, apk, jar, launches, provider, environment } = await setup();
const { service, apk, jar, launches, provider, environment, javaProbeCount } =
await setup();
const inspect = async () => {
const result = await service.execute("inspect_android_package", {
path: apk,
@@ -133,6 +136,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async (
environment._JAVA_OPTIONS = "-XX:+UseG1GC";
await inspect();
expect(launches).toHaveLength(4);
expect(await javaProbeCount()).toBe(4);
expect(launches[3]).toMatchObject({
javaToolOptions: "-Xmx8g",
legacyJavaOptions: "-XX:+UseG1GC",
@@ -186,6 +186,40 @@ it("matches renderer, frame, script bytes, and worker without claiming execution
);
});
it("keeps static load states unknown when one scoped capture omits scripts", async () => {
const fixture = await applicationFixture();
const staticEvidence = await analyzeFixture(fixture);
const result = reconcileInput({
static_layers: [{ role: "application", analysis: staticEvidence }],
runtime_observations: [
electronRuntimeEvidence(fixture, SOURCE, {
includeWorker: false,
targetId: "target-complete",
}),
electronRuntimeEvidence(fixture, SOURCE, {
includeWorker: false,
scriptsUnavailable: true,
targetId: "target-incomplete",
}),
],
});
expect(
result.runtime_captures.map(
({ scripts_complete_within_scope }) => scripts_complete_within_scope,
),
).toEqual(expect.arrayContaining([false, true]));
expect(result.summary.static_not_observed).toBe(0);
expect(result.static_load_states).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "unknown",
reason: "static-or-runtime-coverage-incomplete",
}),
]),
);
});
it("reports a captured digest disagreement instead of accepting a path match", async () => {
const fixture = await applicationFixture();
const staticEvidence = await analyzeFixture(fixture);
@@ -420,6 +454,7 @@ const electronRuntimeEvidence = (
readonly targetId?: string;
readonly sourceIncluded?: boolean;
readonly workersUnavailable?: boolean;
readonly scriptsUnavailable?: boolean;
} = {},
) => {
const scriptFile = options.scriptFile ?? "app.js";
@@ -455,15 +490,7 @@ const electronRuntimeEvidence = (
ended_at: "2026-07-15T00:00:00.100Z",
observation_ms: 100,
},
completeness: options.workersUnavailable
? {
...completeCapture(),
status: "attach_limited" as const,
conditions: ["attach_limited" as const],
attach_limited_sections: ["workers" as const],
unavailable_sections: ["workers" as const],
}
: completeCapture(),
completeness: captureCompleteness(options),
frames: [
{
frame_id: "frame-main",
@@ -532,3 +559,21 @@ const completeCapture = () => ({
total: 0,
},
});
const captureCompleteness = (options: {
readonly workersUnavailable?: boolean;
readonly scriptsUnavailable?: boolean;
}) => {
const unavailableSections = [
...(options.scriptsUnavailable ? (["scripts"] as const) : []),
...(options.workersUnavailable ? (["workers"] as const) : []),
];
if (unavailableSections.length === 0) return completeCapture();
return {
...completeCapture(),
status: "attach_limited" as const,
conditions: ["attach_limited" as const],
attach_limited_sections: unavailableSections,
unavailable_sections: unavailableSections,
};
};
+14 -2
View File
@@ -1,4 +1,11 @@
import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises";
import {
access,
chmod,
mkdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished } from "vitest";
@@ -27,10 +34,11 @@ export const createJadxProtocolFixture = async (
const javaHome = join(root, "jdk");
const javaBin = join(javaHome, "bin");
const java = join(javaBin, "java");
const javaProbeLog = join(root, "java-probes.log");
await mkdir(javaBin, { recursive: true });
await writeFile(
java,
`#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") console.log("java.base@21.0.0\\njdk.compiler@21.0.0");\n`,
`#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") { require("node:fs").appendFileSync(${JSON.stringify(javaProbeLog)}, "probe\\n"); console.log("java.base@21.0.0\\njdk.compiler@21.0.0"); }\n`,
);
await chmod(java, 0o755);
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
@@ -115,6 +123,10 @@ export const createJadxProtocolFixture = async (
jar,
environment,
launches,
javaProbeCount: async () => {
const contents = await readFile(javaProbeLog, "utf8");
return contents.split("\n").filter(Boolean).length;
},
};
};