diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index 8c9d29357c..b44d1f28bf 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -52,7 +52,11 @@ export const inspectJadxAvailability = async ( signal?: AbortSignal, ): Promise => { 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>, operation: AndroidOperation, signal?: AbortSignal, ): Promise => { 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>, + operation: AndroidOperation, + signal?: AbortSignal, +): Promise => { + 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>, signal?: AbortSignal, ): Promise => { + 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>, + signal?: AbortSignal, +): Promise => { + 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 ( diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 261c75bf4e..6fda5a143e 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -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, diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index ed3ac93cef..2d01d9c7ee 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -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}`, + }, + ], + }); + } +}); diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index e099527698..60eaaed135 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -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.`, diff --git a/src/artifacts/ArtifactPaths.test.ts b/src/artifacts/ArtifactPaths.test.ts index 4834cf2d4e..2300c63bbb 100644 --- a/src/artifacts/ArtifactPaths.test.ts +++ b/src/artifacts/ArtifactPaths.test.ts @@ -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(""), + ); diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index 3950b4c6e2..ea81ea41fa 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -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); } diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 96770f85ec..99d76e9f14 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -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; } diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 58cf9aeef0..838f606f64 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -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 & { diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index 9a99872d4f..17d03a1d1c 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -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"); diff --git a/src/domain/javascript/javascriptSemanticQuery.ts b/src/domain/javascript/javascriptSemanticQuery.ts index 1870162570..54c4071020 100644 --- a/src/domain/javascript/javascriptSemanticQuery.ts +++ b/src/domain/javascript/javascriptSemanticQuery.ts @@ -283,15 +283,32 @@ const relevantCandidateRelationCount = ( nodeIds: ReadonlySet, input: JavaScriptSemanticQueryInput, ): number => { - const adjacency = buildAdjacency( - graph.relations.filter(({ resolution }) => resolution === "candidate"), - { ...input, include_ambiguous_dynamic_edges: true }, - ); - const relevant = new Set(); - 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 = ( diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 3d79b281d4..8f6ea72239 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -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); }); }); diff --git a/src/process/ProcessOwnership.observation.test.ts b/src/process/ProcessOwnership.observation.test.ts index e29aa5965e..58085eb21a 100644 --- a/src/process/ProcessOwnership.observation.test.ts +++ b/src/process/ProcessOwnership.observation.test.ts @@ -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>( + (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() + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101)) + .mockResolvedValueOnce(rows); + const runTokens = vi.fn>( + (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>( + () => 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, ), diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 6969cf834d..db53e08c72 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -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 => { + 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 => { - try { - return !liveProcesses(await host.listProcesses()).some( - (process) => process.pid === pid, - ); - } catch (cause: unknown) { - void cause; - return false; - } -}; diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index 2d6dd7275f..5364e4e2f7 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -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() + .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 () => { diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 43fc65e52b..db5c7cb3a9 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -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 => { +): Promise> => { 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( + 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; }; /** diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 19ac0173a9..e4e59d1716 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -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); diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index e29bcee495..57beee669d 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -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[0], + operation: Parameters[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", ), diff --git a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts index 64c8e2f7c7..04cb7b7079 100644 --- a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts +++ b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts @@ -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"); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 93ffa475bc..e8c296c670 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -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", diff --git a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts index ec59b35c7c..9e90a1da4d 100644 --- a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts +++ b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts @@ -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, + }; +}; diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index 8e8ac48847..69dc86be52 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -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; + }, }; };