Merge remote-tracking branch 'origin/main' into test/maintenance-boundary-contracts-20261011
This commit is contained in:
commit
1ae4bec45b
21 files changed
+949
-193
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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(""),
|
||||
);
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Vendored
+14
-2
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user