import { constants } from "node:fs"; import { access, lstat, realpath } from "node:fs/promises"; import { setImmediate } from "node:timers/promises"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { AnalysisAccessDeniedError, AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js"; import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSourceSelection } from "../../domain/javascript/javascriptSourceSelection.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; import { isSkippedJavaScriptAnalysis, javascriptArtifactAnalysisScopes, } from "./JavaScriptArtifactAnalysisTypes.js"; import { createJavaScriptSourceAnalysis } from "../../artifacts/javascript/JavaScriptSourceAnalysis.js"; import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js"; import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js"; import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js"; import { javascriptArtifactReconstructionInputSchema, type JavaScriptArtifactReconstructionInput, } from "./JavaScriptArtifactReconstructionInput.js"; import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js"; import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import type { ProgressReporter } from "../ProgressReporter.js"; import { checkpointJavaScriptAnalysis, completeJavaScriptAnalysisSteps, } from "./JavaScriptAnalysisControl.js"; /** Application-layer result retaining local diagnostics outside the canonical graph. */ export interface JavaScriptArtifactReconstructionResult { readonly input_path: string; readonly format: "asar" | "directory"; readonly root_artifact_sha256: string; readonly inventory_manifest_id: string; readonly inventory_graph_sha256: string; readonly integrity_contradictions: ArtifactInventorySnapshot["integrity_contradictions"]; readonly source_selection?: JavaScriptSourceSelection; readonly graph: JavaScriptApplicationGraph; readonly semantic_graph: JavaScriptSemanticGraph; readonly electron_summary: ElectronBoundarySummary; readonly statistics: { readonly relevant_files: number; readonly nested_asar_containers: number; readonly text_bytes_read: number; readonly invalid_utf8_files: number; readonly parsed_javascript_files: number; readonly skipped_javascript_files: number; readonly visited_ast_nodes: number; readonly findings: number; readonly modules: number; readonly parse_failures: number; }; readonly limitations: readonly string[]; } /** Reconstruct one local ASAR or extracted directory without executing code. */ export const reconstructJavaScriptArtifact = async ( rawInput: unknown, scope: ArtifactResourceScope, signal?: AbortSignal, progress?: ProgressReporter, ): Promise => scope.run(async () => { const reportPhase = async ( phase: string, message: string, ): Promise => { 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 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); const readerOwner = { kind: "reader" as const, reader, resource: `JavaScript artifact reader for ${path}`, }; let readerOpen = true; let reconstructionOutcome: | { 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, scope, signal, input.source_paths, ); // Release acquisition before analysis consumes these owned file facts. const earlyCleanup = await scope.release(readerOwner); readerOpen = false; if (earlyCleanup.kind === "failed") throw ArtifactReaderFailure.withCleanup( earlyCleanup.cause, ArtifactReaderFailure.cleanupObservation( earlyCleanup.cause, readerOwner.resource, ), { kind: "artifact-inventory", inventory: snapshot }, ); await reportPhase( "parse_javascript_sources", `Processing ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application files`, ); const semanticProjection = createJavaScriptSemanticGraphProjection({ ...(input.max_semantic_nodes === undefined ? {} : { totalNodes: input.max_semantic_nodes }), ...(input.max_semantic_nodes_per_file === undefined ? {} : { fileNodes: input.max_semantic_nodes_per_file }), }); const worker = createJavaScriptSourceAnalysis( { heapMb: input.max_heap_mb ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb, timeoutMs: input.analysis_timeout_ms ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs, }, files.files.filter( (file) => file.kind === "javascript" && file.text.included, ).length, ); let outcome; let cleanupFailure: AnalysisError | undefined; try { outcome = await analyzeAndAdoptJavaScriptArtifactFiles( files, worker, semanticProjection, async (file, completed, total) => { abortIfNeeded(signal); const ast = javascriptArtifactAnalysisScopes[file.kind]?.authority === "ast-static-analysis"; await progress?.report({ phase: ast ? "parse_javascript_source" : "process_javascript_artifact_metadata", completed: 0, total: 1, message: `${ast ? "Parsing and projecting" : "Processing metadata for"} ${file.path} (${String(completed + 1)}/${String(total)})`, }); abortIfNeeded(signal); }, signal, ); } finally { try { const cleanup = await scope.release({ kind: "javascript-source-analysis", analysis: worker, resource: `JavaScript source analyzer for ${path}`, }); if (cleanup.kind === "failed") throw cleanup.cause; } catch (cause: unknown) { if (!(cause instanceof AnalysisError)) throw cause; cleanupFailure = cause; } } const analysis = outcome.analysis; let interruption = cleanupFailure ?? outcome.interruption; // Source facts are owned now. Finish their projection cooperatively so a // later cancellation can retain Evidence instead of discarding those facts. const checkpointCompletedAnalysis = async (): Promise => { await setImmediate(); if (interruption == null && signal?.aborted === true) interruption = new ArtifactOperationError(OPERATION, "cancelled"); }; const reportCompletedPhase = async ( phase: string, message: string, ): Promise => { await checkpointCompletedAnalysis(); if (interruption == null) await progress?.report({ phase, completed: 0, total: 1, message }); await checkpointCompletedAnalysis(); }; await reportCompletedPhase( "build_javascript_application_graph", "Constructing application and Electron boundary relationships", ); const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps( snapshot, files, analysis, ); await reportCompletedPhase( "seal_javascript_application_graph", "Sealing the validated application graph", ); const graph = await completeJavaScriptAnalysisSteps( applicationGraphSteps, ); await reportCompletedPhase( "build_javascript_semantic_graph", "Binding and validating static semantic relationships", ); const semanticGraphSteps = semanticProjection.finishImmutableSteps( snapshot.manifest.root_sha256, graph, ); await reportCompletedPhase( "seal_javascript_semantic_graph", "Validating and sealing the semantic graph", ); const semanticGraph = await completeJavaScriptAnalysisSteps(semanticGraphSteps); await checkpointCompletedAnalysis(); const completed = { 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, ...(files.source_selection === undefined ? {} : { source_selection: files.source_selection }), graph, semantic_graph: semanticGraph, electron_summary: summarizeElectronBoundaries( analysis, new Set( snapshot.occurrences .filter(({ entry_kind }) => entry_kind === "directory") .map(({ logical_path }) => logical_path), ), ), statistics: { relevant_files: files.files.length + files.unavailable_relevant_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, skipped_javascript_files: analysis.files.filter( isSkippedJavaScriptAnalysis, ).length, visited_ast_nodes: analysis.visited_ast_nodes, findings: analysis.findings, modules: analysis.modules, parse_failures: analysis.parse_failures, }, limitations: analysis.limitations, }; if (interruption !== null && interruption !== undefined) { const { electron_summary: summary, ...application } = completed; const partial = await completeJavaScriptAnalysisSteps( parseOwnedJavaScriptApplicationAnalysisSteps({ ...application, summary, limitations: completed.graph.limitations, }), ); const evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively( input, partial, ); throw interruption.retainPartialObservation(evidence); } reconstructionOutcome = { kind: "completed", result: completed }; } catch (cause: unknown) { reconstructionOutcome = { kind: "failed", cause }; } if (readerOpen) { const cleanupAttempt = await scope.release(readerOwner); if (cleanupAttempt.kind === "failed") { const cleanup = ArtifactReaderFailure.cleanupObservation( cleanupAttempt.cause, readerOwner.resource, ); const partialObservation: ArtifactInventoryPartialObservation = { kind: "artifact-inventory", inventory: snapshot, }; throw ArtifactReaderFailure.withCleanup( reconstructionOutcome.kind === "failed" ? reconstructionOutcome.cause : cleanupAttempt.cause, cleanup, partialObservation, ); } } if (reconstructionOutcome.kind === "failed") throw reconstructionOutcome.cause; return reconstructionOutcome.result; }); const OPERATION = "analyze_javascript_application"; /** * Resolve the caller-selected input. A missing or unreadable selection is a * caller or host-permission failure, not a damaged artifact. */ const resolveSelectedInput = async (inputPath: string): Promise => { try { const path = await realpath(inputPath); await access(path, constants.R_OK); return path; } catch (cause: unknown) { const code = cause instanceof Error && "code" in cause ? String(cause.code) : ""; if (code === "EACCES" || code === "EPERM") throw new AnalysisAccessDeniedError(OPERATION, inputPath, code, { cause, }); if (code === "ENOENT" || code === "ENOTDIR") throw new AnalysisInputError(OPERATION, { cause }, [ { path: ["input_path"], reason: "invalid_value", message: `No file or directory exists at the selected input path (${code}): ${inputPath}`, }, ]); throw cause; } }; /** The selected input's kind, checked against the caller's requested format. */ const resolveFormat = async ( path: string, input: JavaScriptArtifactReconstructionInput, ): Promise<"asar" | "directory"> => { const metadata = await lstat(path); const observed = metadata.isDirectory() ? "directory" : metadata.isFile() && path.toLowerCase().endsWith(".asar") ? "asar" : undefined; if (observed === undefined) throw new AnalysisUnsupportedTargetError( OPERATION, input.input_path, "JavaScript application analysis accepts only a directory or an .asar file", { remediationAction: "Select an extracted application directory, an app bundle, or an .asar file such as Contents/Resources/app.asar.", }, ); if (input.format !== "auto" && input.format !== observed) throw new AnalysisInputError(OPERATION, undefined, [ { path: ["format"], reason: "invalid_value", message: `Requested ${input.format} input, but the selected input is ${observed === "asar" ? "an .asar file" : "a directory"}: ${input.input_path}`, expected: ["auto", observed], }, ]); return observed; }; const abortIfNeeded = (signal?: AbortSignal): void => { if (signal?.aborted === true) throw new ArtifactReaderFailure( "cancelled", "JavaScript artifact reconstruction cancelled", ); };