fix(artifacts)!: retain cleanup owners through client shutdown
Keep failed reader, root descriptor, and output-tree cleanup with the caller. Drain admitted work before close, retry the same retained owners, and block new resource work while cleanup is incomplete. Preserve primary failures and complete inventory observations. Thread ownership through JavaScript reconstruction, the CLI's one-shot scope, MCP server shutdown, verifier scripts, and test fixtures. Remove redundant ownership flags and the unused validated test wrapper. Validate with 850 passing focused tests across 90 suites (two skipped), targeted classification and cleanup retries, static and unused-code checks, and real macOS CLI/MCP directory, ZIP, and DMG workflows. Native Windows and deep-analysis providers were not verified. BREAKING CHANGE: Direct artifact inventory/extraction and JavaScript analysis APIs require an explicit caller-owned ArtifactResourceScope. CLI and MCP input contracts are unchanged.
This commit is contained in:
parent
87d5ec93d5
commit
ffe0574e17
|
|
@ -0,0 +1,28 @@
|
|||
import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
|
||||
|
||||
/** Analyze one JavaScript application and close its caller-owned resources. */
|
||||
export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
let outcome;
|
||||
try {
|
||||
const result = await analyzeJavaScriptApplication(input, scope);
|
||||
outcome = result.ok
|
||||
? { kind: "completed", value: result.value }
|
||||
: { kind: "failed", cause: result.error };
|
||||
} catch (cause) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
try {
|
||||
await scope.close();
|
||||
} catch (cleanupCause) {
|
||||
if (outcome.kind === "failed")
|
||||
throw new AggregateError(
|
||||
[outcome.cause, cleanupCause],
|
||||
"JavaScript analysis and artifact cleanup both failed",
|
||||
);
|
||||
throw cleanupCause;
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
};
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||
import {
|
||||
compareApplicationVersionsEvidence,
|
||||
compareJavaScriptExportShapesEvidence,
|
||||
|
|
@ -199,9 +199,5 @@ function selectorKey(selector) {
|
|||
}
|
||||
|
||||
async function analyze(path) {
|
||||
const result = await analyzeJavaScriptApplication({
|
||||
input_path: path,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
return analyzeJavaScriptApplicationWithOwnedScope({ input_path: path });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||
import {
|
||||
completeVerifierRun,
|
||||
createVerifierRun,
|
||||
|
|
@ -44,14 +44,13 @@ const canonicalLayers = await Promise.all(
|
|||
);
|
||||
const staticLayers = [];
|
||||
for (const layer of canonicalLayers) {
|
||||
const analyzed = await analyzeJavaScriptApplication({
|
||||
const analyzed = await analyzeJavaScriptApplicationWithOwnedScope({
|
||||
input_path: layer.path,
|
||||
format: "auto",
|
||||
});
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
staticLayers.push({
|
||||
role: layer.role,
|
||||
analysis: analyzed.value,
|
||||
analysis: analyzed,
|
||||
runtime_mappings: [],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
describe("JavaScript analysis cancellation before publication", () => {
|
||||
it("interrupts semantic graph commitment before result validation", async () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascri
|
|||
import { matchJavaScriptApplicationVersions } from "../../domain/javascript/javascriptApplicationVersionKeys.js";
|
||||
import { findApplicationFeatureSeeds } from "../../domain/javascript/javascriptFeatureSeed.js";
|
||||
import { traceApplicationFeatureInputSchema } from "../../domain/javascript/javascriptFeatureTraceSchemas.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
const analyzeSource = async (
|
||||
source: string,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { join } from "node:path";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../tests/fixtures/artifactInventory.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
describe("JavaScript application failure diagnostics", () => {
|
||||
it("identifies the rejected result field in caller-visible diagnostics", async () => {
|
||||
|
|
@ -153,3 +155,41 @@ describe("JavaScript application failure diagnostics", () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("JavaScript application cleanup diagnostics", () => {
|
||||
it("preserves cleanup and a complete inventory when translating reader failures", async () => {
|
||||
const inputPath = await createTestTempDirectory("rea-js-cleanup-failure-");
|
||||
await writeFile(join(inputPath, "main.js"), "export const value = 1;\n");
|
||||
const inventory = await scanCanonicalArtifactInventory(inputPath);
|
||||
const cleanup = {
|
||||
reason: "reader remains open",
|
||||
resources: [`JavaScript artifact reader for ${inputPath}`],
|
||||
};
|
||||
const partialObservation = {
|
||||
kind: "artifact-inventory" as const,
|
||||
inventory,
|
||||
};
|
||||
const failure = new ArtifactReaderFailure("io", "Reader cleanup failed", {
|
||||
cleanup,
|
||||
partialObservation,
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: inputPath, format: "directory" },
|
||||
{
|
||||
progress: {
|
||||
report: async () => {
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.ok) throw new Error("Expected reader failure");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "ArtifactOperationError",
|
||||
cleanup,
|
||||
partialObservation,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps,
|
||||
|
|
@ -32,6 +33,7 @@ const OPERATION = "analyze_javascript_application" as const;
|
|||
/** Statically analyze one local JavaScript/Electron application. */
|
||||
export const analyzeJavaScriptApplication = async (
|
||||
rawInput: unknown,
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput);
|
||||
|
|
@ -39,12 +41,13 @@ export const analyzeJavaScriptApplication = async (
|
|||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
|
||||
);
|
||||
return analyzeJavaScriptApplicationValidated(parsed.data, options);
|
||||
return analyzeJavaScriptApplicationValidated(parsed.data, scope, options);
|
||||
};
|
||||
|
||||
/** Analyze input already parsed by a trusted adapter boundary. */
|
||||
export const analyzeJavaScriptApplicationValidated = async (
|
||||
input: z.output<typeof analyzeJavaScriptApplicationInputSchema>,
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
try {
|
||||
|
|
@ -62,6 +65,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
format: input.format,
|
||||
integrity_policy: input.integrity_policy,
|
||||
},
|
||||
scope,
|
||||
options.signal,
|
||||
options.progress,
|
||||
);
|
||||
|
|
@ -114,14 +118,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
)
|
||||
return err(cause);
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
new ArtifactOperationError(
|
||||
OPERATION,
|
||||
cause.reason,
|
||||
cause.details,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
return err(artifactFailureToOperationError(cause));
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(
|
||||
new AnalysisOutputError(OPERATION, describeResultSchemaFailure(cause), {
|
||||
|
|
@ -153,6 +150,23 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
}
|
||||
};
|
||||
|
||||
const artifactFailureToOperationError = (
|
||||
cause: ArtifactReaderFailure,
|
||||
): ArtifactOperationError =>
|
||||
new ArtifactOperationError(
|
||||
OPERATION,
|
||||
cause.reason,
|
||||
cause.details,
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
||||
...(cause.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: cause.partialObservation }),
|
||||
},
|
||||
);
|
||||
|
||||
const describeResultSchemaFailure = (cause: z.ZodError): string => {
|
||||
const issue = cause.issues[0];
|
||||
if (issue === undefined) return "Result schema rejected an unknown issue";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect
|
|||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { javaScriptSemanticQueryResultSchema } from "../../domain/javascript/javascriptSemanticQuerySchemas.js";
|
||||
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
const graphIdentities = async (source: string) => {
|
||||
const inputPath = await createTestTempDirectory("rea-js-template-literal-");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { canonicalJson } from "../../domain/comparisonSemantics.js";
|
|||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
type ApplicationAnalysis = ReturnType<
|
||||
typeof javascriptApplicationAnalysisResultSchema.parse
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascr
|
|||
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
|
||||
import { analyzeAndProjectJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js";
|
||||
|
|
@ -19,7 +20,8 @@ import {
|
|||
javascriptArtifactReconstructionInputSchema,
|
||||
type JavaScriptArtifactReconstructionInput,
|
||||
} from "./JavaScriptArtifactReconstructionInput.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/scanCanonical.js";
|
||||
import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
|
||||
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
|
||||
import type { ProgressReporter } from "../ProgressReporter.js";
|
||||
|
|
@ -56,118 +58,156 @@ export interface JavaScriptArtifactReconstructionResult {
|
|||
/** Reconstruct one local ASAR or extracted directory without executing code. */
|
||||
export const reconstructJavaScriptArtifact = async (
|
||||
rawInput: unknown,
|
||||
scope: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
progress?: ProgressReporter,
|
||||
): Promise<JavaScriptArtifactReconstructionResult> => {
|
||||
const reportPhase = async (phase: string, message: string): Promise<void> => {
|
||||
await progress?.report({ phase, completed: 0, total: 1, message });
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
const path = await resolveSelectedInput(input.input_path);
|
||||
const format = await resolveFormat(path, input);
|
||||
const snapshot = await scanCanonicalArtifactInventory(path, {
|
||||
signal,
|
||||
integrity: { mode: input.integrity_policy },
|
||||
});
|
||||
if (snapshot.manifest.root_format !== format)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`,
|
||||
);
|
||||
const reader = createReader(path, format);
|
||||
try {
|
||||
await reportPhase(
|
||||
"read_javascript_artifacts",
|
||||
"Reading inventoried JavaScript application sources",
|
||||
);
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot, signal);
|
||||
await reportPhase(
|
||||
"parse_javascript_sources",
|
||||
`Parsing and projecting ${String(files.files.length)} application source files`,
|
||||
);
|
||||
const semanticProjection = createJavaScriptSemanticGraphProjection();
|
||||
const analysis = await analyzeAndProjectJavaScriptArtifactFiles(
|
||||
files,
|
||||
semanticProjection.projectFileSteps,
|
||||
async (file, completed, total) => {
|
||||
abortIfNeeded(signal);
|
||||
await progress?.report({
|
||||
phase: "parse_javascript_source",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`,
|
||||
});
|
||||
abortIfNeeded(signal);
|
||||
},
|
||||
signal,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
abortIfNeeded(signal);
|
||||
await reportPhase(
|
||||
"build_javascript_application_graph",
|
||||
"Constructing application and Electron boundary relationships",
|
||||
);
|
||||
const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
files,
|
||||
analysis,
|
||||
);
|
||||
await reportPhase(
|
||||
"seal_javascript_application_graph",
|
||||
"Sealing the validated application graph",
|
||||
);
|
||||
const graph = await completeJavaScriptAnalysisSteps(
|
||||
applicationGraphSteps,
|
||||
signal,
|
||||
);
|
||||
await reportPhase(
|
||||
"build_javascript_semantic_graph",
|
||||
"Binding and validating static semantic relationships",
|
||||
);
|
||||
const semanticGraphSteps = semanticProjection.finishImmutableSteps(
|
||||
snapshot.manifest.root_sha256,
|
||||
graph,
|
||||
);
|
||||
await reportPhase(
|
||||
"seal_javascript_semantic_graph",
|
||||
"Validating and sealing the semantic graph",
|
||||
);
|
||||
const semanticGraph = await completeJavaScriptAnalysisSteps(
|
||||
semanticGraphSteps,
|
||||
signal,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
return {
|
||||
input_path: path,
|
||||
format,
|
||||
root_artifact_sha256: snapshot.manifest.root_sha256,
|
||||
inventory_manifest_id: snapshot.manifest.manifest_id,
|
||||
inventory_graph_sha256: snapshot.manifest.graph_sha256,
|
||||
integrity_contradictions: snapshot.integrity_contradictions,
|
||||
graph,
|
||||
semantic_graph: semanticGraph,
|
||||
electron_summary: summarizeElectronBoundaries(analysis),
|
||||
statistics: {
|
||||
relevant_files: files.files.length,
|
||||
nested_asar_containers: files.containers.length,
|
||||
text_bytes_read: files.text_bytes_read,
|
||||
invalid_utf8_files: files.invalid_utf8_files,
|
||||
parsed_javascript_files: analysis.files.filter(
|
||||
({ javascript }) => javascript !== null,
|
||||
).length,
|
||||
visited_ast_nodes: analysis.visited_ast_nodes,
|
||||
findings: analysis.findings,
|
||||
modules: analysis.modules,
|
||||
parse_failures: analysis.parse_failures,
|
||||
},
|
||||
limitations: analysis.limitations,
|
||||
): Promise<JavaScriptArtifactReconstructionResult> =>
|
||||
scope.run(async () => {
|
||||
const reportPhase = async (
|
||||
phase: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await progress?.report({ phase, completed: 0, total: 1, message });
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
};
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
const path = await resolveSelectedInput(input.input_path);
|
||||
const format = await resolveFormat(path, input);
|
||||
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
|
||||
resourceScope: scope,
|
||||
signal,
|
||||
integrity: { mode: input.integrity_policy },
|
||||
});
|
||||
if (snapshot.manifest.root_format !== format)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`,
|
||||
);
|
||||
const reader = createReader(path, format);
|
||||
let outcome:
|
||||
| {
|
||||
readonly kind: "completed";
|
||||
readonly result: JavaScriptArtifactReconstructionResult;
|
||||
}
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
await reportPhase(
|
||||
"read_javascript_artifacts",
|
||||
"Reading inventoried JavaScript application sources",
|
||||
);
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot, signal);
|
||||
await reportPhase(
|
||||
"parse_javascript_sources",
|
||||
`Parsing and projecting ${String(files.files.length)} application source files`,
|
||||
);
|
||||
const semanticProjection = createJavaScriptSemanticGraphProjection();
|
||||
const analysis = await analyzeAndProjectJavaScriptArtifactFiles(
|
||||
files,
|
||||
semanticProjection.projectFileSteps,
|
||||
async (file, completed, total) => {
|
||||
abortIfNeeded(signal);
|
||||
await progress?.report({
|
||||
phase: "parse_javascript_source",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`,
|
||||
});
|
||||
abortIfNeeded(signal);
|
||||
},
|
||||
signal,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
abortIfNeeded(signal);
|
||||
await reportPhase(
|
||||
"build_javascript_application_graph",
|
||||
"Constructing application and Electron boundary relationships",
|
||||
);
|
||||
const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
files,
|
||||
analysis,
|
||||
);
|
||||
await reportPhase(
|
||||
"seal_javascript_application_graph",
|
||||
"Sealing the validated application graph",
|
||||
);
|
||||
const graph = await completeJavaScriptAnalysisSteps(
|
||||
applicationGraphSteps,
|
||||
signal,
|
||||
);
|
||||
await reportPhase(
|
||||
"build_javascript_semantic_graph",
|
||||
"Binding and validating static semantic relationships",
|
||||
);
|
||||
const semanticGraphSteps = semanticProjection.finishImmutableSteps(
|
||||
snapshot.manifest.root_sha256,
|
||||
graph,
|
||||
);
|
||||
await reportPhase(
|
||||
"seal_javascript_semantic_graph",
|
||||
"Validating and sealing the semantic graph",
|
||||
);
|
||||
const semanticGraph = await completeJavaScriptAnalysisSteps(
|
||||
semanticGraphSteps,
|
||||
signal,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
result: {
|
||||
input_path: path,
|
||||
format,
|
||||
root_artifact_sha256: snapshot.manifest.root_sha256,
|
||||
inventory_manifest_id: snapshot.manifest.manifest_id,
|
||||
inventory_graph_sha256: snapshot.manifest.graph_sha256,
|
||||
integrity_contradictions: snapshot.integrity_contradictions,
|
||||
graph,
|
||||
semantic_graph: semanticGraph,
|
||||
electron_summary: summarizeElectronBoundaries(analysis),
|
||||
statistics: {
|
||||
relevant_files: files.files.length,
|
||||
nested_asar_containers: files.containers.length,
|
||||
text_bytes_read: files.text_bytes_read,
|
||||
invalid_utf8_files: files.invalid_utf8_files,
|
||||
parsed_javascript_files: analysis.files.filter(
|
||||
({ javascript }) => javascript !== null,
|
||||
).length,
|
||||
visited_ast_nodes: analysis.visited_ast_nodes,
|
||||
findings: analysis.findings,
|
||||
modules: analysis.modules,
|
||||
parse_failures: analysis.parse_failures,
|
||||
},
|
||||
limitations: analysis.limitations,
|
||||
},
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const owner = {
|
||||
kind: "reader" as const,
|
||||
reader,
|
||||
resource: `JavaScript artifact reader for ${path}`,
|
||||
};
|
||||
const cleanupAttempt = await scope.release(owner);
|
||||
if (cleanupAttempt.kind === "failed") {
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupAttempt.cause,
|
||||
owner.resource,
|
||||
);
|
||||
const partialObservation: ArtifactInventoryPartialObservation = {
|
||||
kind: "artifact-inventory",
|
||||
inventory: snapshot,
|
||||
};
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
outcome.kind === "failed" ? outcome.cause : cleanupAttempt.cause,
|
||||
cleanup,
|
||||
partialObservation,
|
||||
);
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.result;
|
||||
});
|
||||
|
||||
const OPERATION = "analyze_javascript_application";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { staticPath } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
const urlCases = [
|
||||
["./preload%20name.cjs", "preload name.cjs", "main.mjs"],
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { promisify } from "node:util";
|
|||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
|
||||
const filesFor = (paths: readonly string[]) =>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
it("classifies overloaded open calls from lexical receiver facts", async () => {
|
||||
const inputPath = await createTestTempDirectory(
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
|||
import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGraph.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
import { artifactCapabilities } from "./ArtifactProviderMetadata.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createArtifactInspection } from "../domain/artifactInspection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
/** Read-only inventory and exclusively owned extraction provider. */
|
||||
export class ArtifactProvider implements AnalysisProvider {
|
||||
|
|
@ -67,6 +69,8 @@ export class ArtifactProvider implements AnalysisProvider {
|
|||
}
|
||||
|
||||
class ArtifactClient implements AnalysisClient {
|
||||
readonly #resourceScope = new ArtifactResourceScope();
|
||||
|
||||
constructor(
|
||||
private readonly target: BinaryTarget,
|
||||
private readonly environment: Readonly<NodeJS.ProcessEnv>,
|
||||
|
|
@ -184,6 +188,7 @@ class ArtifactClient implements AnalysisClient {
|
|||
outputRoot: parsed.output_root,
|
||||
environment: this.environment,
|
||||
integrityPolicy: parsed.integrity_policy,
|
||||
resourceScope: this.#resourceScope,
|
||||
},
|
||||
options?.signal,
|
||||
);
|
||||
|
|
@ -220,7 +225,25 @@ class ArtifactClient implements AnalysisClient {
|
|||
}
|
||||
|
||||
close(): Promise<Result<null, AnalysisError>> {
|
||||
return Promise.resolve(ok(null));
|
||||
return this.#resourceScope.close().then(
|
||||
() => ok(null),
|
||||
(cause: unknown) => {
|
||||
const failure =
|
||||
cause instanceof ArtifactReaderFailure ? cause : undefined;
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
ARTIFACT_GRAPH_PROVIDER.id,
|
||||
failure?.cleanup?.resources ?? ["artifact resources"],
|
||||
{
|
||||
reason:
|
||||
failure?.cleanup?.reason ??
|
||||
(cause instanceof Error ? cause.message : String(cause)),
|
||||
},
|
||||
{ cause, operation: "close_binary" },
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The active target's kind is outside this operation's supported targets. */
|
||||
|
|
@ -353,6 +376,7 @@ class ArtifactClient implements AnalysisClient {
|
|||
options?: ExecutionOptions,
|
||||
) {
|
||||
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
|
||||
resourceScope: this.#resourceScope,
|
||||
environment: this.environment,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
integrity: { mode: parsed.integrity_policy },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import type { ArtifactReader } from "./ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
it("waits for admitted operations and shares concurrent close", async () => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
const started = deferred<void>();
|
||||
const finish = deferred<void>();
|
||||
const operation = scope.run(async () => {
|
||||
started.resolve();
|
||||
await finish.promise;
|
||||
});
|
||||
await started.promise;
|
||||
|
||||
const close = scope.close();
|
||||
expect(scope.close()).toBe(close);
|
||||
await expect(scope.run(async () => undefined)).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
message: "Artifact resource scope is closed",
|
||||
});
|
||||
let closed = false;
|
||||
void close.then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(closed).toBe(false);
|
||||
|
||||
finish.resolve();
|
||||
await operation;
|
||||
await close;
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the same failed reader owner and retries before admitting work", async () => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
let closeAttempts = 0;
|
||||
const reader = {
|
||||
async close() {
|
||||
closeAttempts += 1;
|
||||
if (closeAttempts < 4) throw new Error(`close attempt ${closeAttempts}`);
|
||||
},
|
||||
} as unknown as ArtifactReader;
|
||||
const owner = { kind: "reader" as const, resource: "fixture reader", reader };
|
||||
|
||||
expect(await scope.release(owner)).toMatchObject({ kind: "failed" });
|
||||
expect(closeAttempts).toBe(1);
|
||||
|
||||
await expect(scope.run(async () => "must not run")).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
message: "Artifact cleanup remains incomplete: fixture reader",
|
||||
});
|
||||
expect(closeAttempts).toBe(2);
|
||||
await expect(scope.close()).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
});
|
||||
expect(closeAttempts).toBe(3);
|
||||
await scope.close();
|
||||
expect(closeAttempts).toBe(4);
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
import type { ArtifactReader } from "./ArtifactReader.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import type { SafeOutputTree } from "./SafeOutputTree.js";
|
||||
|
||||
/** One artifact resource and its format-specific cleanup owner. */
|
||||
export type ArtifactResourceOwner =
|
||||
| {
|
||||
readonly kind: "reader";
|
||||
readonly resource: string;
|
||||
readonly reader: ArtifactReader;
|
||||
}
|
||||
| {
|
||||
readonly kind: "file-handle";
|
||||
readonly resource: string;
|
||||
readonly handle: FileHandle;
|
||||
}
|
||||
| {
|
||||
readonly kind: "output-tree";
|
||||
readonly resource: string;
|
||||
readonly tree: SafeOutputTree;
|
||||
};
|
||||
|
||||
export type ArtifactCleanupAttempt =
|
||||
| { readonly kind: "released" }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
|
||||
/** Caller-owned scope that retains failed artifact cleanup for a later close. */
|
||||
export class ArtifactResourceScope {
|
||||
readonly #pending = new Set<ArtifactResourceOwner>();
|
||||
readonly #active = new Set<Promise<unknown>>();
|
||||
#draining: Promise<void> | undefined;
|
||||
#closed = false;
|
||||
|
||||
/** Admit a resource operation while the scope remains open. */
|
||||
async run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
while (true) {
|
||||
if (this.#closed)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Artifact resource scope is closed",
|
||||
);
|
||||
if (this.#draining !== undefined) await this.#draining;
|
||||
if (this.#closed)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Artifact resource scope is closed",
|
||||
);
|
||||
if (this.#pending.size > 0) {
|
||||
await this.#retryCleanup();
|
||||
continue;
|
||||
}
|
||||
const running = Promise.resolve().then(operation);
|
||||
this.#active.add(running);
|
||||
try {
|
||||
return await running;
|
||||
} finally {
|
||||
this.#active.delete(running);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt cleanup now and retain the same owner if it fails. */
|
||||
async release(owner: ArtifactResourceOwner): Promise<ArtifactCleanupAttempt> {
|
||||
try {
|
||||
await cleanup(owner);
|
||||
this.#pending.delete(owner);
|
||||
return { kind: "released" };
|
||||
} catch (cause: unknown) {
|
||||
this.#pending.add(owner);
|
||||
return { kind: "failed", cause };
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry retained owners without closing resource admission. */
|
||||
#retryCleanup(): Promise<void> {
|
||||
if (this.#draining !== undefined) return this.#draining;
|
||||
this.#draining = this.#drainAndRetry().finally(() => {
|
||||
this.#draining = undefined;
|
||||
});
|
||||
return this.#draining;
|
||||
}
|
||||
|
||||
/** Stop admission, wait for active operations, and retry every retained owner. */
|
||||
close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
return this.#retryCleanup();
|
||||
}
|
||||
|
||||
async #drainAndRetry(): Promise<void> {
|
||||
while (this.#active.size > 0) await Promise.allSettled(this.#active);
|
||||
const failures: {
|
||||
readonly owner: ArtifactResourceOwner;
|
||||
readonly cause: unknown;
|
||||
}[] = [];
|
||||
for (const owner of this.#pending) {
|
||||
try {
|
||||
await cleanup(owner);
|
||||
this.#pending.delete(owner);
|
||||
} catch (cause: unknown) {
|
||||
failures.push({ owner, cause });
|
||||
}
|
||||
}
|
||||
if (failures.length === 0) return;
|
||||
const observations = failures.map(({ owner, cause }) =>
|
||||
ArtifactReaderFailure.cleanupObservation(cause, owner.resource),
|
||||
);
|
||||
const resources = [
|
||||
...new Set(observations.flatMap(({ resources }) => resources)),
|
||||
];
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Artifact cleanup remains incomplete: ${resources.join(", ")}`,
|
||||
{
|
||||
cause: new AggregateError(
|
||||
failures.map(({ cause }) => cause),
|
||||
"One or more artifact resources remain owned",
|
||||
),
|
||||
cleanup: {
|
||||
reason: observations.map(({ reason }) => reason).join("; "),
|
||||
resources,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = async (owner: ArtifactResourceOwner): Promise<void> => {
|
||||
switch (owner.kind) {
|
||||
case "reader":
|
||||
await owner.reader.close();
|
||||
return;
|
||||
case "file-handle":
|
||||
await owner.handle.close();
|
||||
return;
|
||||
case "output-tree":
|
||||
await owner.tree.rollback();
|
||||
}
|
||||
};
|
||||
|
|
@ -26,9 +26,13 @@ import {
|
|||
type IntegrityContradiction,
|
||||
} from "../../domain/artifactGraph.js";
|
||||
import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
type ArtifactResourceOwner,
|
||||
type ArtifactResourceScope,
|
||||
} from "../ArtifactResourceScope.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import { scanArtifactInventory } from "../inventory/ArtifactInventory.js";
|
||||
import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js";
|
||||
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
||||
|
||||
/** Local extraction input with the output root chosen by the adapter. */
|
||||
|
|
@ -38,23 +42,36 @@ export interface ArtifactExtractionInput {
|
|||
readonly outputRoot: string;
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly integrityPolicy: ArtifactIntegrityPolicyName;
|
||||
readonly resourceScope: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
/** Extract every regular inventory occurrence into an exclusively owned absent root. */
|
||||
export const extractArtifact = async (
|
||||
input: ArtifactExtractionInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactExtractionResult> =>
|
||||
input.resourceScope.run(() => extractArtifactInScope(input, signal));
|
||||
|
||||
const extractArtifactInScope = async (
|
||||
input: ArtifactExtractionInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactExtractionResult> => {
|
||||
// Cancellation wins over every later refusal, including unsupported formats.
|
||||
abortIfNeeded(signal);
|
||||
const sourcePath = await realpath(input.inputPath);
|
||||
await requireExtractableFormat(sourcePath, input);
|
||||
const snapshot = await scanArtifactInventory(sourcePath, {
|
||||
const snapshot = await scanArtifactInventoryInScope(sourcePath, {
|
||||
resourceScope: input.resourceScope,
|
||||
signal,
|
||||
environment: input.environment,
|
||||
integrity: { mode: input.integrityPolicy },
|
||||
});
|
||||
return materializeArtifactInventory(input, sourcePath, snapshot, signal);
|
||||
return materializeArtifactInventoryInScope(
|
||||
input,
|
||||
sourcePath,
|
||||
snapshot,
|
||||
signal,
|
||||
);
|
||||
};
|
||||
|
||||
/** Materialize a scanned inventory, verifying every current occurrence against it. */
|
||||
|
|
@ -63,6 +80,17 @@ export const materializeArtifactInventory = async (
|
|||
sourcePath: string,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactExtractionResult> => {
|
||||
return input.resourceScope.run(() =>
|
||||
materializeArtifactInventoryInScope(input, sourcePath, snapshot, signal),
|
||||
);
|
||||
};
|
||||
|
||||
const materializeArtifactInventoryInScope = async (
|
||||
input: ArtifactExtractionInput,
|
||||
sourcePath: string,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactExtractionResult> => {
|
||||
abortIfNeeded(signal);
|
||||
const selectedOccurrences = snapshot.occurrences.filter(
|
||||
|
|
@ -185,53 +213,27 @@ const materializeSelection = async ({
|
|||
selected.map((item) => [item.occurrence.logical_path, item]),
|
||||
);
|
||||
const reader = await createReader(sourcePath, input);
|
||||
const readerOwner: ArtifactResourceOwner = {
|
||||
kind: "reader" as const,
|
||||
reader,
|
||||
resource: `artifact reader for ${sourcePath}`,
|
||||
};
|
||||
let localReaderOwner: ArtifactResourceOwner | undefined = readerOwner;
|
||||
let output: SafeOutputTree | undefined;
|
||||
let readerCloseAttempted = false;
|
||||
let readerClosed = false;
|
||||
const extracted: ExtractedOccurrence[] = [];
|
||||
try {
|
||||
output = await SafeOutputTree.create(input.outputRoot);
|
||||
const registry = new ArtifactPathRegistry();
|
||||
for await (const entry of reader.entries(signal)) {
|
||||
const path = normalizeArtifactPath(entry.path);
|
||||
registry.add(path, entry.kind);
|
||||
const selectedItem = byPath.get(path);
|
||||
if (selectedItem === undefined) {
|
||||
if (entry.kind === "file" || entry.kind === "slice")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Regular artifact entry is missing from inventory: ${path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
preflight(entry);
|
||||
const stream = await reader.open(entry, signal);
|
||||
const written = await output.write(
|
||||
path,
|
||||
stream,
|
||||
{
|
||||
sha256: selectedItem.node.sha256,
|
||||
bytes: selectedItem.node.size,
|
||||
},
|
||||
signal,
|
||||
await writeSelectedEntries({ reader, output, byPath, extracted, signal });
|
||||
localReaderOwner = undefined;
|
||||
const closeAttempt = await input.resourceScope.release(readerOwner);
|
||||
if (closeAttempt.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
closeAttempt.cause,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
closeAttempt.cause,
|
||||
readerOwner.resource,
|
||||
),
|
||||
);
|
||||
extracted.push({
|
||||
artifact_id: selectedItem.node.artifact_id,
|
||||
relative_path: written.relativePath,
|
||||
sha256: written.sha256,
|
||||
bytes_written: written.bytesWritten,
|
||||
created: true,
|
||||
});
|
||||
byPath.delete(path);
|
||||
}
|
||||
if (byPath.size > 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Inventoried regular artifact entries were not materialized: ${[...byPath.keys()].sort(compareUnicodeCodePoints).join(", ")}`,
|
||||
);
|
||||
readerCloseAttempted = true;
|
||||
await reader.close();
|
||||
readerClosed = true;
|
||||
extracted.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.relative_path, right.relative_path),
|
||||
);
|
||||
|
|
@ -244,36 +246,12 @@ const materializeSelection = async ({
|
|||
await output.commit();
|
||||
return result;
|
||||
} catch (cause: unknown) {
|
||||
const cleanupFailures: {
|
||||
readonly cause: unknown;
|
||||
readonly resource: string;
|
||||
}[] = [];
|
||||
if (readerCloseAttempted && !readerClosed)
|
||||
cleanupFailures.push({
|
||||
cause,
|
||||
resource: `artifact reader for ${sourcePath}`,
|
||||
});
|
||||
if (!readerCloseAttempted) {
|
||||
try {
|
||||
readerCloseAttempted = true;
|
||||
await reader.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
cleanupFailures.push({
|
||||
cause: cleanupCause,
|
||||
resource: `artifact reader for ${sourcePath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (output !== undefined) {
|
||||
try {
|
||||
await output.rollback();
|
||||
} catch (cleanupCause: unknown) {
|
||||
cleanupFailures.push({
|
||||
cause: cleanupCause,
|
||||
resource: input.outputRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
const cleanupFailures = await releaseExtractionOwners(
|
||||
input.resourceScope,
|
||||
localReaderOwner,
|
||||
output,
|
||||
input.outputRoot,
|
||||
);
|
||||
if (cleanupFailures.length > 0) {
|
||||
const observations = cleanupFailures.map(
|
||||
({ cause: cleanupCause, resource }) =>
|
||||
|
|
@ -290,6 +268,84 @@ const materializeSelection = async ({
|
|||
}
|
||||
};
|
||||
|
||||
const writeSelectedEntries = async ({
|
||||
reader,
|
||||
output,
|
||||
byPath,
|
||||
extracted,
|
||||
signal,
|
||||
}: {
|
||||
readonly reader: ArtifactReader;
|
||||
readonly output: SafeOutputTree;
|
||||
readonly byPath: Map<string, SelectedOccurrence>;
|
||||
readonly extracted: ExtractedOccurrence[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<void> => {
|
||||
const registry = new ArtifactPathRegistry();
|
||||
for await (const entry of reader.entries(signal)) {
|
||||
const path = normalizeArtifactPath(entry.path);
|
||||
registry.add(path, entry.kind);
|
||||
const selectedItem = byPath.get(path);
|
||||
if (selectedItem === undefined) {
|
||||
if (entry.kind === "file" || entry.kind === "slice")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Regular artifact entry is missing from inventory: ${path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
preflight(entry);
|
||||
const stream = await reader.open(entry, signal);
|
||||
const written = await output.write(
|
||||
path,
|
||||
stream,
|
||||
{
|
||||
sha256: selectedItem.node.sha256,
|
||||
bytes: selectedItem.node.size,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
extracted.push({
|
||||
artifact_id: selectedItem.node.artifact_id,
|
||||
relative_path: written.relativePath,
|
||||
sha256: written.sha256,
|
||||
bytes_written: written.bytesWritten,
|
||||
created: true,
|
||||
});
|
||||
byPath.delete(path);
|
||||
}
|
||||
if (byPath.size > 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Inventoried regular artifact entries were not materialized: ${[...byPath.keys()].sort(compareUnicodeCodePoints).join(", ")}`,
|
||||
);
|
||||
};
|
||||
|
||||
const releaseExtractionOwners = async (
|
||||
resourceScope: ArtifactResourceScope,
|
||||
readerOwner: ArtifactResourceOwner | undefined,
|
||||
output: SafeOutputTree | undefined,
|
||||
outputRoot: string,
|
||||
): Promise<{ readonly cause: unknown; readonly resource: string }[]> => {
|
||||
const failures: { readonly cause: unknown; readonly resource: string }[] = [];
|
||||
if (readerOwner !== undefined) {
|
||||
const attempt = await resourceScope.release(readerOwner);
|
||||
if (attempt.kind === "failed")
|
||||
failures.push({ cause: attempt.cause, resource: readerOwner.resource });
|
||||
}
|
||||
if (output !== undefined) {
|
||||
const owner: ArtifactResourceOwner = {
|
||||
kind: "output-tree",
|
||||
tree: output,
|
||||
resource: outputRoot,
|
||||
};
|
||||
const attempt = await resourceScope.release(owner);
|
||||
if (attempt.kind === "failed")
|
||||
failures.push({ cause: attempt.cause, resource: owner.resource });
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
const createExtractionResult = (
|
||||
input: ArtifactExtractionInput,
|
||||
inventory: ExtractionInventory,
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import {
|
|||
type ArtifactInventoryResult,
|
||||
} from "../../domain/artifactGraph.js";
|
||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import { scanCanonicalArtifactInventory } from "./scanCanonical.js";
|
||||
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
|
||||
import type { ArtifactInventoryOptions } from "./types.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
|
||||
/** Inventory one local artifact and return every graph collection inline. */
|
||||
export const inventoryArtifact = async (
|
||||
inputPath: string,
|
||||
options: ArtifactInventoryOptions = {},
|
||||
options: ArtifactInventoryOptions,
|
||||
): Promise<ArtifactInventoryResult> => {
|
||||
const snapshot = await scanArtifactInventory(inputPath, options);
|
||||
return artifactInventoryResultSchema.parse(snapshot);
|
||||
|
|
@ -21,9 +21,17 @@ export const inventoryArtifact = async (
|
|||
/** Scan an artifact once and retain the complete immutable graph for projection. */
|
||||
export const scanArtifactInventory = async (
|
||||
inputPath: string,
|
||||
options: ArtifactInventoryOptions = {},
|
||||
options: ArtifactInventoryOptions,
|
||||
): Promise<ArtifactInventorySnapshot> =>
|
||||
options.resourceScope.run(() =>
|
||||
scanArtifactInventoryInScope(inputPath, options),
|
||||
);
|
||||
|
||||
export const scanArtifactInventoryInScope = async (
|
||||
inputPath: string,
|
||||
options: ArtifactInventoryOptions,
|
||||
): Promise<ArtifactInventorySnapshot> => {
|
||||
abortIfNeeded(options.signal);
|
||||
const path = await realpath(inputPath);
|
||||
return scanCanonicalArtifactInventory(path, options);
|
||||
return scanCanonicalArtifactInventoryInScope(path, options);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "./ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../../tests/fixtures/artifactInventory.js";
|
||||
import { compareArtifacts } from "../../domain/artifactComparison.js";
|
||||
import { projectAppleApplication } from "../../domain/apple/appleApplication.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import {
|
|||
zipPackageFormatForPath,
|
||||
} from "../../domain/zipPackageFormat.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { type ArtifactResourceOwner } from "../ArtifactResourceScope.js";
|
||||
import type { HashResult } from "../ArtifactHash.js";
|
||||
import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js";
|
||||
import type { ArtifactInventoryOptions } from "./types.js";
|
||||
|
||||
interface RootClassification {
|
||||
readonly format: ArtifactOccurrence["artifact_format"];
|
||||
|
|
@ -32,19 +34,34 @@ export const classifyAndHashRoot = async (
|
|||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: Stats,
|
||||
signal?: AbortSignal,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
): Promise<RootClassification> => {
|
||||
const result = await classifyAndHashRootForInventory(
|
||||
path,
|
||||
directory,
|
||||
expectedMetadata,
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
return options.resourceScope.run(async () => {
|
||||
const result = await classifyAndHashRootForInventory(
|
||||
path,
|
||||
directory,
|
||||
expectedMetadata,
|
||||
options,
|
||||
);
|
||||
const rootSource = result.rootSource;
|
||||
if (rootSource !== undefined) {
|
||||
const owner: ArtifactResourceOwner = {
|
||||
kind: "file-handle",
|
||||
handle: rootSource.handle,
|
||||
resource: `root artifact descriptor for ${path}`,
|
||||
};
|
||||
const cleanupAttempt = await options.resourceScope.release(owner);
|
||||
if (cleanupAttempt.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
cleanupAttempt.cause,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupAttempt.cause,
|
||||
owner.resource,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { format: result.format, digest: result.digest };
|
||||
} finally {
|
||||
await result.rootSource?.handle.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Classify a root and retain its admitted ZIP descriptor for child inventory. */
|
||||
|
|
@ -52,12 +69,22 @@ export const classifyAndHashRootForInventory = async (
|
|||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: Stats,
|
||||
signal?: AbortSignal,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
if (directory)
|
||||
return { format: "directory", digest: null, rootSource: undefined };
|
||||
const handle = await openRootFile(path, signal);
|
||||
let transferHandle = false;
|
||||
const handle = await openRootFile(path, options.signal);
|
||||
const owner: ArtifactResourceOwner = {
|
||||
kind: "file-handle",
|
||||
handle,
|
||||
resource: `root artifact descriptor for ${path}`,
|
||||
};
|
||||
let outcome:
|
||||
| {
|
||||
readonly kind: "completed";
|
||||
readonly value: RootInventoryClassification;
|
||||
}
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
const initial = await handle.stat();
|
||||
if (!sameRegularFileState(expectedMetadata, initial))
|
||||
|
|
@ -70,18 +97,33 @@ export const classifyAndHashRootForInventory = async (
|
|||
path,
|
||||
handle,
|
||||
initial,
|
||||
signal,
|
||||
options.signal,
|
||||
);
|
||||
const retainSource = isZipFormat(format) || format === "mach-o-universal";
|
||||
if (retainSource) transferHandle = true;
|
||||
return {
|
||||
format,
|
||||
digest,
|
||||
rootSource: retainSource ? { handle, initial } : undefined,
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
value: {
|
||||
format,
|
||||
digest,
|
||||
rootSource: retainSource ? { handle, initial } : undefined,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (!transferHandle) await handle.close();
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
if (outcome.kind === "failed" || outcome.value.rootSource === undefined) {
|
||||
const cleanupAttempt = await options.resourceScope.release(owner);
|
||||
if (cleanupAttempt.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
outcome.kind === "failed" ? outcome.cause : cleanupAttempt.cause,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupAttempt.cause,
|
||||
owner.resource,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
};
|
||||
|
||||
const isZipFormat = (format: ArtifactOccurrence["artifact_format"]): boolean =>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ArtifactReaderFailure,
|
||||
type ArtifactReader,
|
||||
} from "../ArtifactReader.js";
|
||||
import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js";
|
||||
import {
|
||||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
|
|
@ -50,7 +51,16 @@ import {
|
|||
|
||||
export const scanCanonicalArtifactInventory = async (
|
||||
path: string,
|
||||
options: ArtifactInventoryOptions = {},
|
||||
options: ArtifactInventoryOptions,
|
||||
readerFactory: typeof createReader = createReader,
|
||||
): Promise<ArtifactInventorySnapshot> =>
|
||||
options.resourceScope.run(() =>
|
||||
scanCanonicalArtifactInventoryInScope(path, options, readerFactory),
|
||||
);
|
||||
|
||||
export const scanCanonicalArtifactInventoryInScope = async (
|
||||
path: string,
|
||||
options: ArtifactInventoryOptions,
|
||||
readerFactory: typeof createReader = createReader,
|
||||
): Promise<ArtifactInventorySnapshot> => {
|
||||
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
|
||||
|
|
@ -63,7 +73,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||
path,
|
||||
metadata.isDirectory(),
|
||||
metadata,
|
||||
options.signal,
|
||||
options,
|
||||
);
|
||||
let reader: ArtifactReader | undefined;
|
||||
let readerCreationFailure: { readonly cause: unknown } | undefined;
|
||||
|
|
@ -78,12 +88,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||
readerCreationFailure = { cause };
|
||||
}
|
||||
const ownedReaders: ArtifactReader[] = reader === undefined ? [] : [reader];
|
||||
let outcome:
|
||||
| {
|
||||
readonly kind: "completed";
|
||||
readonly snapshot: ArtifactInventorySnapshot;
|
||||
}
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
let outcome: InventoryScanOutcome;
|
||||
try {
|
||||
if (readerCreationFailure !== undefined) throw readerCreationFailure.cause;
|
||||
if (reader instanceof AsarArtifactReader)
|
||||
|
|
@ -113,48 +118,69 @@ export const scanCanonicalArtifactInventory = async (
|
|||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
let cleanupFailure: ArtifactReaderFailure | undefined;
|
||||
for (const owned of ownedReaders.reverse()) {
|
||||
try {
|
||||
await owned.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupCause,
|
||||
`artifact reader for ${path}`,
|
||||
);
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ??
|
||||
(outcome.kind === "failed" ? outcome.cause : cleanupCause),
|
||||
cleanup,
|
||||
outcome.kind === "completed"
|
||||
? { kind: "artifact-inventory", inventory: outcome.snapshot }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rootSource !== undefined) {
|
||||
try {
|
||||
await rootSource.handle.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupCause,
|
||||
`root artifact descriptor for ${path}`,
|
||||
);
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ??
|
||||
(outcome.kind === "failed" ? outcome.cause : cleanupCause),
|
||||
cleanup,
|
||||
outcome.kind === "completed"
|
||||
? { kind: "artifact-inventory", inventory: outcome.snapshot }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
const cleanupFailure = await cleanupInventoryOwners({
|
||||
path,
|
||||
options,
|
||||
rootSource,
|
||||
ownedReaders,
|
||||
outcome,
|
||||
});
|
||||
if (cleanupFailure !== undefined) throw cleanupFailure;
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.snapshot;
|
||||
};
|
||||
|
||||
type InventoryScanOutcome =
|
||||
| {
|
||||
readonly kind: "completed";
|
||||
readonly snapshot: ArtifactInventorySnapshot;
|
||||
}
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
|
||||
const cleanupInventoryOwners = async ({
|
||||
path,
|
||||
options,
|
||||
rootSource,
|
||||
ownedReaders,
|
||||
outcome,
|
||||
}: {
|
||||
readonly path: string;
|
||||
readonly options: ArtifactInventoryOptions;
|
||||
readonly rootSource: StableRegularFileDescriptor | undefined;
|
||||
readonly ownedReaders: readonly ArtifactReader[];
|
||||
readonly outcome: InventoryScanOutcome;
|
||||
}): Promise<ArtifactReaderFailure | undefined> => {
|
||||
let cleanupFailure: ArtifactReaderFailure | undefined;
|
||||
const owners: ArtifactResourceOwner[] = [...ownedReaders]
|
||||
.reverse()
|
||||
.map((reader) => ({
|
||||
kind: "reader" as const,
|
||||
reader,
|
||||
resource: `artifact reader for ${path}`,
|
||||
}));
|
||||
if (rootSource !== undefined)
|
||||
owners.push({
|
||||
kind: "file-handle",
|
||||
handle: rootSource.handle,
|
||||
resource: `root artifact descriptor for ${path}`,
|
||||
});
|
||||
for (const owner of owners) {
|
||||
const attempt = await options.resourceScope.release(owner);
|
||||
if (attempt.kind === "released") continue;
|
||||
const primary =
|
||||
cleanupFailure ??
|
||||
(outcome.kind === "failed" ? outcome.cause : attempt.cause);
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
primary,
|
||||
ArtifactReaderFailure.cleanupObservation(attempt.cause, owner.resource),
|
||||
outcome.kind === "completed"
|
||||
? { kind: "artifact-inventory", inventory: outcome.snapshot }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
return cleanupFailure;
|
||||
};
|
||||
|
||||
interface SnapshotBuildInput {
|
||||
readonly path: string;
|
||||
readonly metadata: Stats;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
|
||||
/** Resolved integrity behavior admitted to the artifact scanner. */
|
||||
export type ArtifactIntegrityPolicy =
|
||||
| { readonly mode: "fail" }
|
||||
|
|
@ -9,6 +11,7 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
|
|||
|
||||
/** Options shared by artifact inventory scans. */
|
||||
export interface ArtifactInventoryOptions {
|
||||
readonly resourceScope: ArtifactResourceScope;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly integrity?: ArtifactIntegrityPolicy | undefined;
|
||||
readonly environment?: Readonly<NodeJS.ProcessEnv> | undefined;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { analyzeJavaScriptApplication } from "../application/javascript/JavaScriptApplicationService.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { createProgressReporter } from "../application/ProgressReporter.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
/** Execute the shared one-shot CLI boundary for static JavaScript analysis. */
|
||||
export const runCliJavaScriptApplicationAnalysis = async (
|
||||
|
|
@ -14,10 +19,34 @@ export const runCliJavaScriptApplicationAnalysis = async (
|
|||
},
|
||||
{ minimumIntervalMs: 0 },
|
||||
);
|
||||
const result = await analyzeJavaScriptApplication(input, {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const result = await analyzeJavaScriptApplication(input, resources, {
|
||||
progress,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
try {
|
||||
await resources.close();
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
"JavaScript application resources",
|
||||
);
|
||||
const failure = new ProviderCleanupError(
|
||||
JAVASCRIPT_APPLICATION_PROVIDER.id,
|
||||
cleanup.resources,
|
||||
{ reason: cleanup.reason },
|
||||
{ cause, operation: "analyze_javascript_application" },
|
||||
);
|
||||
return cliError(
|
||||
result.ok
|
||||
? failure
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
failure,
|
||||
"analyze_javascript_application",
|
||||
),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptR
|
|||
import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js";
|
||||
import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js";
|
||||
import { registerElectronTools } from "./registerElectronTools.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
||||
import { registerManagedTools } from "./registerManagedTools.js";
|
||||
|
|
@ -210,6 +211,7 @@ export const createServer = (
|
|||
options.webSourceLocation ?? createWebSourceLocationService(environment);
|
||||
const javascriptRecovery =
|
||||
options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment);
|
||||
const javascriptApplicationResources = new ArtifactResourceScope();
|
||||
const firmwareAnalysis = new FirmwareAnalysisService(
|
||||
options.firmwareAnalysis ?? createFirmwareAnalysisProvider(environment),
|
||||
);
|
||||
|
|
@ -246,6 +248,7 @@ export const createServer = (
|
|||
evmInterface.close(),
|
||||
webSourceLocation.close(),
|
||||
javascriptRecovery.close?.() ?? Promise.resolve(),
|
||||
javascriptApplicationResources.close(),
|
||||
firmwareAnalysis.close(),
|
||||
options.browserScenarioCapture?.close?.() ?? Promise.resolve(),
|
||||
])
|
||||
|
|
@ -286,6 +289,7 @@ export const createServer = (
|
|||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
withAdmittedAnalysis: analysisAdmission,
|
||||
javascriptApplicationResources,
|
||||
};
|
||||
registerBinaryAnalysisTools(toolContext);
|
||||
const previousOnclose = server.server.onclose;
|
||||
|
|
@ -497,6 +501,7 @@ const createSessionRecorders = (
|
|||
});
|
||||
|
||||
interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
|
||||
readonly javascriptApplicationResources: ArtifactResourceScope;
|
||||
readonly server: EvidenceMcpServer;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly options: CreateServerOptions;
|
||||
|
|
@ -567,6 +572,7 @@ const registerObservationTools = ({
|
|||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
withAdmittedAnalysis,
|
||||
javascriptApplicationResources,
|
||||
}: ServerToolContext): void => {
|
||||
const common = { logger, recordEvidence, withAdmittedAnalysis };
|
||||
registerWebScriptTool(server, common);
|
||||
|
|
@ -582,6 +588,7 @@ const registerObservationTools = ({
|
|||
});
|
||||
registerElectronTools(server, {
|
||||
...common,
|
||||
javascriptApplicationResources,
|
||||
evidenceById,
|
||||
electron: options.electronObservation,
|
||||
electronActive: options.electronActiveObservation,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
listElectronTargets,
|
||||
} from "../application/javascript/ElectronObservationService.js";
|
||||
import { analyzeJavaScriptApplicationValidated } from "../application/javascript/JavaScriptApplicationService.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { reconcileJavaScriptRuntimeEvidenceValidated } from "../application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
|
|
@ -34,6 +35,7 @@ import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
|||
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
|
||||
|
||||
interface ElectronToolRegistration {
|
||||
readonly javascriptApplicationResources: ArtifactResourceScope;
|
||||
readonly logger: Logger;
|
||||
readonly observationLoadFailure?: OptionalProviderLoadFailure | undefined;
|
||||
readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined;
|
||||
|
|
@ -104,6 +106,7 @@ export const registerElectronTools = (
|
|||
async ({ detail, ...request }, { signal, progress }) => {
|
||||
const analyzed = await analyzeJavaScriptApplicationValidated(
|
||||
request,
|
||||
options.javascriptApplicationResources,
|
||||
{ signal, progress },
|
||||
);
|
||||
return detail === "summary" && analyzed.ok
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
} from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js";
|
||||
import { JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE } from "../../../src/contracts/javascript/javascriptExportShapeComparisonExample.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import {
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Filter } from "incur";
|
|||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { z } from "zod";
|
|||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { compareArtifacts } from "../../../src/domain/artifactComparison.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js";
|
||||
import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js";
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { compareArtifacts } from "../../../src/domain/artifactComparison.js";
|
||||
import type { ArtifactInventoryResult } from "../../../src/domain/artifactGraph.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
import { createPackage } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import {
|
||||
artifactOccurrenceAt,
|
||||
artifactParentPaths,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { promisify } from "node:util";
|
|||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { classifyAndHashRoot } from "../../../src/artifacts/inventory/classify.js";
|
||||
import {
|
||||
classifyAndHashRoot,
|
||||
inventoryArtifact,
|
||||
} from "../../fixtures/artifactInventory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
|
|
@ -10,9 +10,10 @@ import {
|
|||
ArtifactReaderFailure,
|
||||
type ArtifactReader,
|
||||
} from "../../../src/artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { scanCanonicalArtifactInventory as scanCanonicalArtifactInventoryOwned } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
|
||||
describe("artifact inventory snapshot", () => {
|
||||
|
|
@ -35,6 +36,9 @@ describe("artifact inventory snapshot", () => {
|
|||
const root = await createTestTempDirectory("rea-inventory-cleanup-");
|
||||
await writeFile(join(root, "observed.txt"), "observed");
|
||||
const directory = new DirectoryArtifactReader(root);
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
onTestFinished(() => resourceScope.close());
|
||||
let closeAttempts = 0;
|
||||
const reader: ArtifactReader = {
|
||||
format: directory.format,
|
||||
entries: (signal) => directory.entries(signal),
|
||||
|
|
@ -42,6 +46,8 @@ describe("artifact inventory snapshot", () => {
|
|||
provenance: () => directory.provenance(),
|
||||
async close() {
|
||||
await directory.close();
|
||||
closeAttempts += 1;
|
||||
if (closeAttempts > 1) return;
|
||||
throw new ArtifactReaderFailure("unavailable", "mount still attached", {
|
||||
cleanup: {
|
||||
reason: "DMG detach failed",
|
||||
|
|
@ -50,9 +56,9 @@ describe("artifact inventory snapshot", () => {
|
|||
});
|
||||
},
|
||||
};
|
||||
const failure = await scanCanonicalArtifactInventory(
|
||||
const failure = await scanCanonicalArtifactInventoryOwned(
|
||||
root,
|
||||
{},
|
||||
{ resourceScope },
|
||||
() => reader,
|
||||
).catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
|
|
@ -79,5 +85,7 @@ describe("artifact inventory snapshot", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
await resourceScope.close();
|
||||
expect(closeAttempts).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { classifyArtifactContent } from "../../../src/artifacts/inventory/ArtifactGraphConstruction.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/artifacts/ArtifactHash.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../fixtures/artifactInventory.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import {
|
||||
artifactOccurrenceAt,
|
||||
artifactParentPaths,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { Readable } from "node:stream";
|
|||
import { buffer } from "node:stream/consumers";
|
||||
|
||||
import { createPackage, listPackage, uncache } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { scanCanonicalArtifactInventory as scanCanonicalArtifactInventoryOwned } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const CONTENT = "module.exports = 42;\n";
|
||||
|
|
@ -175,27 +176,39 @@ describe("ASAR captured-byte identity", () => {
|
|||
const fixture = await archiveFixture();
|
||||
const originalSize = (await lstat(fixture.archive)).size;
|
||||
const directory = new DirectoryArtifactReader(fixture.bundle);
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(fixture.bundle, {}, () => ({
|
||||
format: directory.format,
|
||||
entries: (signal) => directory.entries(signal),
|
||||
provenance: () => directory.provenance(),
|
||||
close: () => directory.close(),
|
||||
async open(entry, signal) {
|
||||
const source = await directory.open(entry, signal);
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield* source;
|
||||
await fixture.replace();
|
||||
expect((await lstat(fixture.archive)).size).toBe(originalSize);
|
||||
})(),
|
||||
);
|
||||
},
|
||||
})),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
message: expect.stringContaining("changed before interpretation"),
|
||||
});
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
onTestFinished(() => resourceScope.close());
|
||||
try {
|
||||
await expect(
|
||||
scanCanonicalArtifactInventoryOwned(
|
||||
fixture.bundle,
|
||||
{ resourceScope },
|
||||
() => ({
|
||||
format: directory.format,
|
||||
entries: (signal) => directory.entries(signal),
|
||||
provenance: () => directory.provenance(),
|
||||
close: () => directory.close(),
|
||||
async open(entry, signal) {
|
||||
const source = await directory.open(entry, signal);
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield* source;
|
||||
await fixture.replace();
|
||||
expect((await lstat(fixture.archive)).size).toBe(
|
||||
originalSize,
|
||||
);
|
||||
})(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
message: expect.stringContaining("changed before interpretation"),
|
||||
});
|
||||
} finally {
|
||||
await resourceScope.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates interleaved readers from replacement headers and packed bytes", async () => {
|
||||
|
|
@ -250,10 +263,12 @@ describe("ASAR captured-byte identity", () => {
|
|||
await rm(path, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
onTestFinished(() => resourceScope.close());
|
||||
try {
|
||||
const failure: unknown = await scanCanonicalArtifactInventory(
|
||||
const failure: unknown = await scanCanonicalArtifactInventoryOwned(
|
||||
fixture.archive,
|
||||
{},
|
||||
{ resourceScope },
|
||||
() => reader,
|
||||
).catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({
|
||||
|
|
@ -273,11 +288,11 @@ describe("ASAR captured-byte identity", () => {
|
|||
const root = attempts[0];
|
||||
if (root === undefined) throw new Error("Expected owned snapshot");
|
||||
expect((await lstat(root)).isDirectory()).toBe(true);
|
||||
await reader.close();
|
||||
await resourceScope.close();
|
||||
expect(attempts).toEqual([root, root]);
|
||||
await expect(lstat(root)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await reader.close();
|
||||
await resourceScope.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Readable } from "node:stream";
|
|||
import { buffer } from "node:stream/consumers";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
import {
|
||||
closeAsarHandle,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { describe, expect, it, onTestFinished } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
|
||||
const analyzeFailure = async (input: Record<string, unknown>) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { compareApplicationVersionsRequestSchema } from "../../../src/contracts/javascript/applicationWorkflowInputContracts.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import {
|
||||
compareApplicationVersionsEvidence,
|
||||
traceApplicationFeatureEvidence,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import {
|
||||
bomSourceCases,
|
||||
createBomArtifact,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
import { createPackage } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { expect, it } from "vitest";
|
|||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
|
||||
import {
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { parse } from "@babel/parser";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import {
|
||||
typeScriptDialectCases,
|
||||
createTypeScriptDialectArtifact,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { compareJavaScriptExportShapesEvidence } from "../../../src/application/javascript/JavaScriptApplicationWorkflowService.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
||||
import { compareJavaScriptExportShapes } from "../../../src/domain/javascript/javascriptExportShapeComparison.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "parse5";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const cases = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { dirname, join, relative, sep } from "node:path";
|
|||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const cases: readonly {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import { join } from "node:path";
|
|||
import { createPackage, createPackageWithOptions } from "@electron/asar";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../fixtures/artifactInventory.js";
|
||||
import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { promisify } from "node:util";
|
|||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const cases: readonly {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { promisify } from "node:util";
|
|||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const cases: readonly {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join, relative, sep } from "node:path";
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("creates source-map graph relationships only for real comments", async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
parseJavaScriptApplicationGraph,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
||||
import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js";
|
||||
import { findApplicationFeatureSeeds } from "../../../src/domain/javascript/javascriptFeatureSeed.js";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { promisify } from "node:util";
|
|||
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
|
||||
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
|
||||
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import {
|
|||
} from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { classifyAndHashRoot } from "../../../src/artifacts/inventory/classify.js";
|
||||
import {
|
||||
classifyAndHashRoot,
|
||||
inventoryArtifact,
|
||||
} from "../../fixtures/artifactInventory.js";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { artifactOccurrenceAt } from "../../fixtures/artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
|
|||
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js";
|
||||
import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { z } from "zod";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { compareJavaScriptExportShapesEvidence } from "../../../src/application/javascript/JavaScriptApplicationWorkflowService.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
projectedExportReturnShapesSchema,
|
||||
} from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { join } from "node:path";
|
|||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
|
@ -13,7 +13,7 @@ import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js";
|
|||
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import type { ElectronActiveObservationPort } from "../../../src/application/javascript/ElectronActiveObservationPort.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
|
@ -24,11 +24,64 @@ import {
|
|||
import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js";
|
||||
import { createElectronActiveObservationFixtureResult } from "../../../src/domain/javascript/electronActiveObservation.fixture.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
const temporary: string[] = [];
|
||||
|
||||
it("retains failed JavaScript inventory cleanup through server shutdown retries", async () => {
|
||||
const { archive } = await createStrippedAsarAddon();
|
||||
const session = createTestBinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(resultOk(null)),
|
||||
}));
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "javascript-cleanup-owner", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const originalClose = AsarArtifactReader.prototype.close;
|
||||
const owners = new Set<AsarArtifactReader>();
|
||||
let blocked = true;
|
||||
const close = vi
|
||||
.spyOn(AsarArtifactReader.prototype, "close")
|
||||
.mockImplementation(function (this: AsarArtifactReader) {
|
||||
owners.add(this);
|
||||
if (blocked) return Promise.reject(new Error("snapshot cleanup blocked"));
|
||||
return originalClose.call(this);
|
||||
});
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: archive,
|
||||
integrity_policy: "record-and-continue",
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(response)).toMatchObject({
|
||||
error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
resources: [expect.stringContaining(archive)],
|
||||
partial_observation: { kind: "artifact-inventory" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(owners.size).toBe(1);
|
||||
await expect(server.close()).rejects.toThrow();
|
||||
blocked = false;
|
||||
await server.close();
|
||||
expect(owners.size).toBe(1);
|
||||
expect(close.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
} finally {
|
||||
blocked = false;
|
||||
await Promise.all([client.close(), server.close(), session.close()]);
|
||||
close.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(resources.splice(0).map(async (item) => item.close()));
|
||||
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { afterEach, expect, it } from "vitest";
|
|||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { createElectronEvidence } from "../../../src/application/javascript/ElectronEvidence.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import {
|
||||
parseRuntimeReconciliationInput,
|
||||
reconcileJavaScriptRuntime,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createPackage } from "@electron/asar";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const deferred = () => {
|
||||
let resolve: () => void = () => {};
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const createArchive = async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-cleanup-retry-");
|
||||
const contents = join(root, "contents");
|
||||
await mkdir(contents);
|
||||
await writeFile(join(contents, "payload.txt"), "payload\n");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackage(contents, archive);
|
||||
return { root, archive };
|
||||
};
|
||||
|
||||
const createClient = (path: string) =>
|
||||
new ArtifactProvider(process.env).createClient({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
});
|
||||
|
||||
it("retains a failed reader close, blocks new work, and retries the same reader", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const originalClose = AsarArtifactReader.prototype.close;
|
||||
const closeOwners: AsarArtifactReader[] = [];
|
||||
let readerCloseBlocked = true;
|
||||
onTestFinished(async () => {
|
||||
readerCloseBlocked = false;
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
vi.spyOn(AsarArtifactReader.prototype, "close").mockImplementation(
|
||||
async function (this: AsarArtifactReader) {
|
||||
closeOwners.push(this);
|
||||
if (closeOwners[1] === this && readerCloseBlocked)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"injected reader close failure",
|
||||
);
|
||||
await originalClose.call(this);
|
||||
},
|
||||
);
|
||||
|
||||
const extraction = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extraction).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: "unavailable",
|
||||
cleanup: { resources: [expect.stringContaining("artifact reader")] },
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
expect(closeOwners).toHaveLength(2);
|
||||
|
||||
const blocked = await client.execute("inventory_artifact", {});
|
||||
expect(blocked).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "unavailable" },
|
||||
});
|
||||
expect(closeOwners).toHaveLength(3);
|
||||
expect(closeOwners[2]).toBe(closeOwners[1]);
|
||||
|
||||
const incompleteClose = await client.close();
|
||||
expect(incompleteClose.ok).toBe(false);
|
||||
if (incompleteClose.ok) throw new Error("Expected reader cleanup failure");
|
||||
expect(incompleteClose.error).toBeInstanceOf(ProviderCleanupError);
|
||||
expect(closeOwners).toHaveLength(4);
|
||||
expect(closeOwners[3]).toBe(closeOwners[1]);
|
||||
readerCloseBlocked = false;
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(closeOwners).toHaveLength(5);
|
||||
expect(closeOwners[4]).toBe(closeOwners[1]);
|
||||
const closed = await client.execute("inventory_artifact", {});
|
||||
expect(closed).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "unavailable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a failed rollback owner and preserves the materialization failure", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const originalWrite = SafeOutputTree.prototype.write;
|
||||
const originalRollback = SafeOutputTree.prototype.rollback;
|
||||
const rollbackOwners: SafeOutputTree[] = [];
|
||||
let failRollback = true;
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
vi.spyOn(SafeOutputTree.prototype, "write").mockImplementation(
|
||||
async function (
|
||||
this: SafeOutputTree,
|
||||
...args: Parameters<SafeOutputTree["write"]>
|
||||
) {
|
||||
const [relativePath, stream, expected, signal] = args;
|
||||
const written = await originalWrite.call(
|
||||
this,
|
||||
relativePath,
|
||||
stream,
|
||||
expected,
|
||||
signal,
|
||||
);
|
||||
expect(written.bytesWritten).toBeGreaterThan(0);
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"primary materialization failure",
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.spyOn(SafeOutputTree.prototype, "rollback").mockImplementation(
|
||||
async function (this: SafeOutputTree) {
|
||||
rollbackOwners.push(this);
|
||||
if (failRollback) {
|
||||
failRollback = false;
|
||||
throw new Error("injected rollback failure");
|
||||
}
|
||||
return originalRollback.call(this);
|
||||
},
|
||||
);
|
||||
|
||||
const extraction = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extraction).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: "unavailable",
|
||||
detail: expect.stringContaining("primary materialization failure"),
|
||||
cleanup: { resources: [output] },
|
||||
},
|
||||
});
|
||||
expect(rollbackOwners).toHaveLength(1);
|
||||
await access(output);
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(rollbackOwners).toHaveLength(2);
|
||||
expect(rollbackOwners[1]).toBe(rollbackOwners[0]);
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("waits for admitted extraction before close and rejects later work", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const originalWrite = SafeOutputTree.prototype.write;
|
||||
const started = deferred();
|
||||
const finish = deferred();
|
||||
let writeCalls = 0;
|
||||
let closeFinished = false;
|
||||
onTestFinished(async () => {
|
||||
finish.resolve();
|
||||
await client.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
vi.spyOn(SafeOutputTree.prototype, "write").mockImplementation(
|
||||
async function (
|
||||
this: SafeOutputTree,
|
||||
...args: Parameters<SafeOutputTree["write"]>
|
||||
) {
|
||||
const [relativePath, stream, expected, signal] = args;
|
||||
writeCalls += 1;
|
||||
const written = await originalWrite.call(
|
||||
this,
|
||||
relativePath,
|
||||
stream,
|
||||
expected,
|
||||
signal,
|
||||
);
|
||||
started.resolve();
|
||||
await finish.promise;
|
||||
return written;
|
||||
},
|
||||
);
|
||||
|
||||
const extraction = client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
await started.promise;
|
||||
const closing = client.close().then((result) => {
|
||||
closeFinished = true;
|
||||
return result;
|
||||
});
|
||||
const concurrent = await client.execute("extract_artifact", {
|
||||
output_root: join(root, "rejected-output"),
|
||||
});
|
||||
expect(concurrent).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "unavailable" },
|
||||
});
|
||||
expect(writeCalls).toBe(1);
|
||||
await Promise.resolve();
|
||||
expect(closeFinished).toBe(false);
|
||||
finish.resolve();
|
||||
const completed = await extraction;
|
||||
expect(completed.ok).toBe(true);
|
||||
expect(await closing).toMatchObject({ ok: true });
|
||||
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe("payload\n");
|
||||
});
|
||||
|
|
@ -14,6 +14,26 @@ import {
|
|||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
|
||||
describe("artifact extraction", () => {
|
||||
it("keeps committed files after the owning client closes", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-client-close-");
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "payload.txt"), "committed\n");
|
||||
const output = join(root, "output");
|
||||
const client = new ArtifactProvider(process.env).createClient(
|
||||
target(source, "directory"),
|
||||
);
|
||||
|
||||
const result = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
await client.close();
|
||||
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
|
||||
"committed\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts all regular occurrences through an exclusively owned output tree", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-");
|
||||
const source = join(root, "source");
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { createPackage, createPackageWithOptions } from "@electron/asar";
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { materializeArtifactInventory } from "../../../../src/artifacts/extraction/ArtifactExtraction.js";
|
||||
import { scanArtifactInventory } from "../../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import {
|
||||
materializeArtifactInventory,
|
||||
scanArtifactInventory,
|
||||
} from "../../../fixtures/artifactInventory.js";
|
||||
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { describe, expect, it, onTestFinished } from "vitest";
|
|||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { execFileOutput } from "../../../../src/process/ExecFileOutput.js";
|
||||
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
NativeDmgArtifactReader,
|
||||
type NativeDmgHost,
|
||||
|
|
@ -209,9 +210,11 @@ describe("DMG inventory reader ownership", () => {
|
|||
|
||||
let reader: NativeDmgArtifactReader | undefined;
|
||||
let closeCalls = 0;
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
onTestFinished(() => resourceScope.close());
|
||||
const failure = await scanCanonicalArtifactInventory(
|
||||
path,
|
||||
{},
|
||||
{ resourceScope },
|
||||
(inputPath, _format, environment) => {
|
||||
reader = new NativeDmgArtifactReader(inputPath, environment, host);
|
||||
const closeReader = reader.close.bind(reader);
|
||||
|
|
@ -240,7 +243,8 @@ describe("DMG inventory reader ownership", () => {
|
|||
expect(await realpath(mountRoot)).toBe(mountRoot);
|
||||
|
||||
infoAvailable = true;
|
||||
await reader.close();
|
||||
await resourceScope.close();
|
||||
expect(closeCalls).toBe(2);
|
||||
expect(attached).toBe(false);
|
||||
await expect(realpath(mountRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(calls.map(([operation]) => operation)).toEqual([
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import {
|
||||
artifactComparisonResultSchema,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
||||
import { analyzeJavaScriptApplicationInputSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { writeFile } from "node:fs/promises";
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import {
|
||||
analyzeJavaScriptArtifactFiles,
|
||||
analyzeAndProjectJavaScriptArtifactFiles,
|
||||
|
|
@ -23,8 +23,10 @@ import {
|
|||
} from "../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js";
|
||||
import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import {
|
||||
scanCanonicalArtifactInventory,
|
||||
scanArtifactInventory,
|
||||
} from "../../fixtures/artifactInventory.js";
|
||||
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("resolves empty-path HTML script URLs against their canonical base path", async () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|||
|
||||
import { createElectronEvidence } from "../../../src/application/javascript/ElectronEvidence.js";
|
||||
import { createElectronActiveEvidence } from "../../../src/application/javascript/ElectronActiveEvidence.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { onTestFinished } from "vitest";
|
||||
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
||||
import { classifyAndHashRoot as classifyAndHashRootOwned } from "../../src/artifacts/inventory/classify.js";
|
||||
import {
|
||||
inventoryArtifact as inventoryArtifactOwned,
|
||||
scanArtifactInventory as scanArtifactInventoryOwned,
|
||||
} from "../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { scanCanonicalArtifactInventory as scanCanonicalArtifactInventoryOwned } from "../../src/artifacts/inventory/scanCanonical.js";
|
||||
import type { createReader } from "../../src/artifacts/inventory/reader.js";
|
||||
import {
|
||||
materializeArtifactInventory as materializeArtifactInventoryOwned,
|
||||
type ArtifactExtractionInput,
|
||||
} from "../../src/artifacts/extraction/ArtifactExtraction.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../src/domain/artifactInventorySnapshot.js";
|
||||
import type { ArtifactInventoryOptions } from "../../src/artifacts/inventory/types.js";
|
||||
|
||||
type InventoryOptions = Omit<ArtifactInventoryOptions, "resourceScope">;
|
||||
|
||||
export const inventoryArtifact = (
|
||||
path: string,
|
||||
options: InventoryOptions = {},
|
||||
) =>
|
||||
withArtifactResourceScope((resourceScope) =>
|
||||
inventoryArtifactOwned(path, { ...options, resourceScope }),
|
||||
);
|
||||
|
||||
export const classifyAndHashRoot = (
|
||||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: import("node:fs").Stats,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
withArtifactResourceScope((resourceScope) =>
|
||||
classifyAndHashRootOwned(path, directory, expectedMetadata, {
|
||||
resourceScope,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const scanArtifactInventory = (
|
||||
path: string,
|
||||
options: InventoryOptions = {},
|
||||
) =>
|
||||
withArtifactResourceScope((resourceScope) =>
|
||||
scanArtifactInventoryOwned(path, { ...options, resourceScope }),
|
||||
);
|
||||
|
||||
export const scanCanonicalArtifactInventory = (
|
||||
path: string,
|
||||
options: InventoryOptions = {},
|
||||
readerFactory?: typeof createReader,
|
||||
) =>
|
||||
withArtifactResourceScope((resourceScope) =>
|
||||
scanCanonicalArtifactInventoryOwned(
|
||||
path,
|
||||
{ ...options, resourceScope },
|
||||
readerFactory,
|
||||
),
|
||||
);
|
||||
|
||||
export const materializeArtifactInventory = (
|
||||
input: Omit<ArtifactExtractionInput, "resourceScope">,
|
||||
sourcePath: string,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
withArtifactResourceScope((resourceScope) =>
|
||||
materializeArtifactInventoryOwned(
|
||||
{ ...input, resourceScope },
|
||||
sourcePath,
|
||||
snapshot,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
|
||||
export const withArtifactResourceScope = async <T>(
|
||||
operation: (resourceScope: ArtifactResourceScope) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
onTestFinished(() => resourceScope.close());
|
||||
let outcome:
|
||||
| { readonly kind: "completed"; readonly value: T }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
value: await operation(resourceScope),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
try {
|
||||
await resourceScope.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
if (outcome.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
outcome.cause,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupCause,
|
||||
"artifact resources",
|
||||
),
|
||||
);
|
||||
throw cleanupCause;
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { reconstructJavaScriptArtifact } from "../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { reconstructJavaScriptArtifact } from "./javascriptApplicationScope.js";
|
||||
import type {
|
||||
ApplicationEdge,
|
||||
ApplicationNode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { withArtifactResourceScope } from "../fixtures/artifactInventory.js";
|
||||
import type { ExecutionOptions } from "../../src/application/AnalysisProvider.js";
|
||||
import { analyzeJavaScriptApplication as analyzeOwned } from "../../src/application/javascript/JavaScriptApplicationService.js";
|
||||
import { reconstructJavaScriptArtifact as reconstructOwned } from "../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import type { ProgressReporter } from "../../src/application/ProgressReporter.js";
|
||||
|
||||
/** Run a JavaScript analysis with an awaited, test-owned artifact scope. */
|
||||
export const analyzeJavaScriptApplication = (
|
||||
input: unknown,
|
||||
options: ExecutionOptions = {},
|
||||
) => withArtifactResourceScope((scope) => analyzeOwned(input, scope, options));
|
||||
|
||||
/** Reconstruct a fixture with an awaited, test-owned artifact scope. */
|
||||
export const reconstructJavaScriptArtifact = (
|
||||
rawInput: unknown,
|
||||
signal?: AbortSignal,
|
||||
progress?: ProgressReporter,
|
||||
) =>
|
||||
withArtifactResourceScope((scope) =>
|
||||
reconstructOwned(rawInput, scope, signal, progress),
|
||||
);
|
||||
Loading…
Reference in New Issue