From 779d548c4e553f01cb1aaf61d6949885c6f02806 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:12:28 +0800 Subject: [PATCH] fix(artifacts): preserve selected ASAR companion paths --- src/application/BinaryTargetResolver.ts | 15 +- .../binary/BinarySessionExecution.ts | 7 +- src/application/binary/BinarySessionOpen.ts | 3 + .../JavaScriptArtifactReconstruction.ts | 35 ++- src/artifacts/ArtifactProvider.ts | 13 +- src/artifacts/AsarArtifactReader.ts | 25 +- src/artifacts/createArtifactReader.ts | 14 +- .../extraction/ArtifactExtraction.ts | 8 +- src/artifacts/inventory/ArtifactInventory.ts | 9 +- src/artifacts/inventory/classify.ts | 6 +- src/artifacts/inventory/reader.ts | 2 + src/artifacts/inventory/scanCanonical.ts | 27 +- src/artifacts/inventory/types.ts | 2 + .../javascript/JavaScriptArtifactReader.ts | 11 +- src/domain/binaryTargetTypes.ts | 8 + .../asarSelectedCompanionCli.test.ts | 78 ++++++ .../filesystem/asarSelectedCompanion.test.ts | 256 ++++++++++++++++++ ...ascriptApplicationAsarSelectedPath.test.ts | 80 ++++++ .../binarySessionAsarSelectedPath.test.ts | 82 ++++++ 19 files changed, 643 insertions(+), 38 deletions(-) create mode 100644 tests/acceptance/asarSelectedCompanionCli.test.ts create mode 100644 tests/boundary/filesystem/asarSelectedCompanion.test.ts create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptApplicationAsarSelectedPath.test.ts create mode 100644 tests/composition/analysis-sessions/binarySessionAsarSelectedPath.test.ts diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 73f21da777..07610a7131 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -166,7 +166,7 @@ export const parseBinaryTarget = async ( try { return await readOpenedBinaryTarget( source, - { path, sourcePath, ...bundle }, + { path, sourcePath, selectedPath: candidate, ...bundle }, options, ); } catch (cause: unknown) { @@ -245,12 +245,13 @@ const readOpenedBinaryTarget = async ( selected: { readonly path: string; readonly sourcePath: string; + readonly selectedPath: string; readonly bundleInfoPlist?: string; }, options: BinaryTargetResolverOptions, ): Promise => { const { handle, initial: metadata } = source; - const { path, sourcePath, bundleInfoPlist } = selected; + const { path, sourcePath, selectedPath, bundleInfoPlist } = selected; const bundle = bundleInfoPlist === undefined ? {} : { bundleInfoPlist }; const hostArchitecture = options.hostArchitecture ?? process.arch; const { targetKind, formatHint, signal, architecture, entryPoint } = options; @@ -371,10 +372,17 @@ const readOpenedBinaryTarget = async ( } // Probe WASM content before suffix-only database routing; an explicit // database selection keeps the caller's chosen interpretation. - const artifactFormat = + let artifactFormat = targetKind === "database" ? undefined : await detectArtifactFormat(path, source, signal); + if ( + targetKind !== "database" && + artifactFormat === undefined && + selectedPath.toLowerCase().endsWith(".asar") && + !(await claimsLeadingExecutableHeader(handle, metadata.size, signal)) + ) + artifactFormat = "asar"; if ( targetKind === "database" || (targetKind === undefined && path.toLowerCase().endsWith(".hop")) @@ -390,6 +398,7 @@ const readOpenedBinaryTarget = async ( const identity = { path, sourcePath, + ...(artifactFormat === "asar" ? { asarSelectedPath: selectedPath } : {}), sha256: await sha256Handle(handle, path, metadata, signal), }; return isArchiveFormat(artifactFormat) diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index 7f42ba3259..d0c7f3f0c3 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -1,5 +1,8 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; -import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { + artifactTargetSelectedPath, + type BinaryTarget, +} from "../../domain/binaryTargetTypes.js"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { NoBinaryOpenError } from "../../domain/configurationErrors.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; @@ -158,4 +161,4 @@ const isArtifactBundleSubject = ( (operation === "inventory_artifact" || operation === "inspect_artifact" || operation === "extract_artifact") && - subject.path === (target.sourcePath ?? target.path); + subject.path === artifactTargetSelectedPath(target); diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 15c0925fbe..43b7dca677 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -3,6 +3,7 @@ import type { BinaryArchitecture, BinaryTarget, } from "../../domain/binaryTargetTypes.js"; +import { artifactTargetSelectedPath } from "../../domain/binaryTargetTypes.js"; import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import { @@ -151,6 +152,8 @@ const sameSessionTarget = ( current.path === target.path && // Native admission is bound to the requested path, not just its realpath. (current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) && + // ASAR companion lookup depends on the selected archive spelling. + artifactTargetSelectedPath(current) === artifactTargetSelectedPath(target) && snapshotMatchesTarget(snapshotTarget(current), target); interface ValidateSnapshotInput { diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index ae9ce8f8f7..47c61ec1b5 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,4 +1,5 @@ import type { AnalysisSettingValues } from "../../domain/analysisSettings.js"; +import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import { DEFAULT_ASAR_HEADER_ADMISSION, type AsarHeaderAdmission, @@ -103,20 +104,31 @@ export const reconstructJavaScriptArtifact = async ( await checkpointJavaScriptAnalysis(signal); }; abortIfNeeded(signal); - const { path, metadata } = await resolveSelectedInput(input.input_path); - const format = resolveFormat(path, metadata, input); - const snapshot = await scanCanonicalArtifactInventoryInScope(path, { - resourceScope: scope, - signal, - integrity: { mode: input.integrity_policy }, - asarHeaderAdmission, - }); + const selectedPath = absoluteCallerPath(input.input_path); + const { path, metadata } = await resolveSelectedInput(selectedPath); + const format = resolveFormat(path, metadata, input, selectedPath); + const snapshot = await scanCanonicalArtifactInventoryInScope( + path, + { + resourceScope: scope, + signal, + integrity: { mode: input.integrity_policy }, + asarHeaderAdmission, + }, + undefined, + selectedPath, + ); 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, asarHeaderAdmission); + const reader = createReader( + path, + format, + asarHeaderAdmission, + selectedPath, + ); const readerOwner = { kind: "reader" as const, reader, @@ -483,10 +495,13 @@ const resolveFormat = ( path: string, metadata: Stats, input: AnalyzeJavaScriptApplicationInput, + selectedPath: string, ): "asar" | "directory" => { const observed = metadata.isDirectory() ? "directory" - : metadata.isFile() && path.toLowerCase().endsWith(".asar") + : metadata.isFile() && + (selectedPath.toLowerCase().endsWith(".asar") || + path.toLowerCase().endsWith(".asar")) ? "asar" : undefined; if (observed === undefined) diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 717eb366eb..d147342676 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -27,7 +27,10 @@ import { artifactExtractionExecutionSchema, type ArtifactAnalysisOperation, } from "../contracts/artifactToolContracts.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + artifactTargetSelectedPath, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, @@ -307,6 +310,7 @@ class ArtifactClient implements AnalysisClient { const result = await extractArtifact( { inputPath: this.target.sourcePath ?? this.target.path, + selectedPath: this.target.asarSelectedPath, inputFormat: this.target.format, outputRoot: parsed.output_root, environment: this.environment, @@ -324,7 +328,7 @@ class ArtifactClient implements AnalysisClient { rawResult: null, limitations: result.limitations, subject: subjectFor( - this.target.sourcePath ?? this.target.path, + artifactTargetSelectedPath(this.target), result.manifest, ), // Each extracted file's path is already in artifacts. @@ -339,7 +343,7 @@ class ArtifactClient implements AnalysisClient { rawResult: null, limitations: result.limitations, subject: subjectFor( - this.target.sourcePath ?? this.target.path, + artifactTargetSelectedPath(this.target), result.manifest, ), // Each occurrence's logical path is already in the result. @@ -461,7 +465,7 @@ class ArtifactClient implements AnalysisClient { await this.inventory(inventoryParameters, options), ); const subject = subjectFor( - this.target.sourcePath ?? this.target.path, + artifactTargetSelectedPath(this.target), inventory.manifest, ); const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, { @@ -501,6 +505,7 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { return inventoryArtifact(this.target.sourcePath ?? this.target.path, { + selectedPath: this.target.asarSelectedPath, resourceScope: this.#resourceScope, environment: this.environment, asarHeaderAdmission: runtimeAsarHeaderAdmission( diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 5d52140608..bda90dd089 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -1,3 +1,4 @@ +import { appendCallerPath } from "../filesystem/callerPath.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js"; import { constants, createWriteStream, type Stats } from "node:fs"; @@ -46,6 +47,7 @@ import { readAsarListing, type AsarListedEntry } from "./AsarListing.js"; export class AsarArtifactReader implements ArtifactReader { readonly format = "asar" as const; #archiveSize: number | undefined; + #selectedCanonicalSource: string | undefined; #headerSize: number | undefined; readonly #entries = new Map(); readonly #ownedHandles = new Map(); @@ -278,6 +280,7 @@ export class AsarArtifactReader implements ArtifactReader { ): Promise { await this.#withSnapshotOperation(async () => { assertAsarReadActive(signal); + await this.#validateSelectedArchive(); if (this.#snapshot === undefined && this.#snapshotRoot !== undefined) await this.#closeSnapshot(); if (this.#snapshot === undefined) await this.#captureSnapshot(signal); @@ -427,13 +430,29 @@ export class AsarArtifactReader implements ArtifactReader { return []; } + /** A selected companion cannot silently migrate to another packed target. */ + async #validateSelectedArchive(): Promise { + const selected = this.options.selectedPath; + if (selected === undefined) return; + // The packed pathname is fixed for this reader; member checks only need + // to re-observe the caller's independently selected alias. + this.#selectedCanonicalSource ??= await realpath(this.path); + const current = await realpath(selected); + if (this.#selectedCanonicalSource !== current) + throw new ArtifactReaderFailure( + "integrity", + `Selected ASAR archive no longer resolves to its admitted source: ${selected}`, + ); + } + async #openUnpackedStream( entry: ArtifactEntry, signal?: AbortSignal, ): Promise { - const unpackedRoot = `${this.path}.unpacked`; + const unpackedRoot = `${this.options.selectedPath ?? this.path}.unpacked`; let handle: SharedAsarFileHandle | undefined; try { + await this.#validateSelectedArchive(); const rootMetadata = await lstat(unpackedRoot); if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) throw new ArtifactReaderFailure( @@ -441,7 +460,7 @@ export class AsarArtifactReader implements ArtifactReader { "ASAR unpacked companion is not a regular directory", ); const canonicalRoot = await realpath(unpackedRoot); - const candidate = join(unpackedRoot, entry.adapterKey); + const candidate = appendCallerPath(unpackedRoot, entry.adapterKey); const canonical = await realpath(candidate); const relativePath = relative(canonicalRoot, canonical); if ( @@ -612,6 +631,8 @@ type OwnedAsarHandle = { export type AsarArtifactReaderOptions = { /** Header/index admission resolved by the caller's composition root; default otherwise. */ readonly headerAdmission?: AsarHeaderAdmission; + /** Companion selected by the caller archive spelling, independently of packed identity. */ + readonly selectedPath?: string; readonly openFile?: OpenAsarFile; readonly openUnpackedFile?: OpenAsarFile; readonly removeSnapshot?: (path: string) => Promise; diff --git a/src/artifacts/createArtifactReader.ts b/src/artifacts/createArtifactReader.ts index 76d1305131..96bf9888f7 100644 --- a/src/artifacts/createArtifactReader.ts +++ b/src/artifacts/createArtifactReader.ts @@ -35,6 +35,8 @@ interface ArtifactReaderConstruction { | NodeSeaAdmission | undefined; readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined; + /** Caller-selected root spelling; only ASAR companion lookup consumes this. */ + readonly selectedPath?: string | undefined; } export function createArtifactReader( @@ -64,12 +66,14 @@ export function createArtifactReader( case "directory": return new DirectoryArtifactReader(path, options.unreadableDirectories); case "asar": - return new AsarArtifactReader( - path, - options.asarHeaderAdmission === undefined + return new AsarArtifactReader(path, { + ...(options.asarHeaderAdmission === undefined ? {} - : { headerAdmission: options.asarHeaderAdmission }, - ); + : { headerAdmission: options.asarHeaderAdmission }), + ...(options.selectedPath === undefined + ? {} + : { selectedPath: options.selectedPath }), + }); case "pyinstaller": return new PyInstallerArtifactReader(path, rootSource); case "node-sea": diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 68bf6c2c25..2191297b1b 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -1,3 +1,4 @@ +import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { createArtifactReader, @@ -55,6 +56,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity export interface ArtifactExtractionInput { readonly asarHeaderAdmission?: AsarHeaderAdmission; readonly inputPath: string; + /** Selected ASAR spelling; packed I/O remains on inputPath. */ + readonly selectedPath?: string | undefined; readonly inputFormat: BinaryTarget["format"]; readonly outputRoot: string; readonly environment: Readonly; @@ -79,9 +82,10 @@ const extractArtifactInScope = async ( const admission = await requireExtractableFormat(sourcePath, input, signal); const { snapshot, zipSource } = await scanArtifactInventoryForExtractionInScope( - sourcePath, + input.inputPath, { resourceScope: input.resourceScope, + selectedPath: input.selectedPath, signal, environment: input.environment, asarHeaderAdmission: @@ -606,6 +610,7 @@ const createReader = async ( input: Pick< ArtifactExtractionInput, | "inputPath" + | "selectedPath" | "inputFormat" | "environment" | "resourceScope" @@ -639,6 +644,7 @@ const createReader = async ( return createArtifactReader(path, format, { environment: input.environment, unreadableDirectories: "fail", + selectedPath: absoluteCallerPath(input.selectedPath ?? input.inputPath), admission: transferredZip ? zipSource.source : undefined, asarHeaderAdmission: format === "asar" diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index acd576c6c7..a8959683b7 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -1,3 +1,4 @@ +import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import { realpath } from "node:fs/promises"; import { @@ -31,7 +32,12 @@ export const scanArtifactInventoryInScope = async ( ): Promise => { abortIfNeeded(options.signal); const path = await realpath(inputPath); - return scanCanonicalArtifactInventoryInScope(path, options); + return scanCanonicalArtifactInventoryInScope( + path, + options, + undefined, + absoluteCallerPath(options.selectedPath ?? inputPath), + ); }; export const scanArtifactInventoryForExtractionInScope = async ( @@ -48,5 +54,6 @@ export const scanArtifactInventoryForExtractionInScope = async ( path, options, probeFacts, + absoluteCallerPath(options.selectedPath ?? inputPath), ); }; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 4ab15ea248..ef5ad2bdbb 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -64,7 +64,7 @@ export const classifyAndHashRootForInventory = async ( expectedMetadata: Stats, options: Pick< ArtifactInventoryOptions, - "resourceScope" | "signal" | "asarHeaderAdmission" + "resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath" >, probeFacts?: ArtifactEmbeddedFormatProbeFacts, ): Promise => { @@ -111,6 +111,7 @@ export const classifyAndHashRootForInventory = async ( ), options.signal, options.asarHeaderAdmission, + options.selectedPath, ); const format = typeof classified === "string" ? classified : classified.kind; @@ -164,6 +165,7 @@ const classifyRootFormat = async ( embeddedProbes: ArtifactEmbeddedFormatProbes, signal?: AbortSignal, asarHeaderAdmission?: AsarHeaderAdmission, + selectedPath?: string, ): Promise => { const { handle } = source; const probe = createArtifactClassificationProbe(asarHeaderAdmission); @@ -226,7 +228,7 @@ const classifyRootFormat = async ( ? fatEvidence : { ...fatEvidence, mzWindowsHeaderSignature }; const format = classifyArtifactContent( - path, + selectedPath?.toLowerCase().endsWith(".asar") ? selectedPath : path, classificationEvidence, archiveBytes, ).format; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 00729108dd..9cf3cfe18e 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -21,6 +21,7 @@ export const createReader = ( environment: Readonly, readerAdmission?: StableRegularFileDescriptor | NodeSeaAdmission, asarHeaderAdmission?: AsarHeaderAdmission, + selectedPath?: string, ): ArtifactReader | undefined => { if ( (format === "mach-o-universal" || format === "dmg") && @@ -32,6 +33,7 @@ export const createReader = ( unreadableDirectories: "omit", admission: readerAdmission, asarHeaderAdmission, + selectedPath, }); }; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 6eba5726e4..0bbda27539 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -61,18 +61,37 @@ export const scanCanonicalArtifactInventoryInScope = async ( path: string, options: ArtifactInventoryOptions, readerFactory: typeof createReader = createReader, + selectedPath: string = path, ): Promise => - (await scanCanonical(path, options, readerFactory, "release")).snapshot; + ( + await scanCanonical( + path, + options, + readerFactory, + "release", + undefined, + selectedPath, + ) + ).snapshot; /** Keep the admitted ZIP descriptor alive for extraction of this inventory. */ export const scanCanonicalArtifactInventoryForExtractionInScope = async ( path: string, options: ArtifactInventoryOptions, probeFacts?: ArtifactEmbeddedFormatProbeFacts, + selectedPath: string = path, ): Promise<{ readonly snapshot: ArtifactInventorySnapshot; readonly zipSource: TransferredZipInventorySource | undefined; -}> => scanCanonical(path, options, createReader, "transfer-zip", probeFacts); +}> => + scanCanonical( + path, + options, + createReader, + "transfer-zip", + probeFacts, + selectedPath, + ); const scanCanonical = async ( path: string, @@ -80,6 +99,7 @@ const scanCanonical = async ( readerFactory: typeof createReader, rootDisposition: "release" | "transfer-zip", probeFacts?: ArtifactEmbeddedFormatProbeFacts, + selectedPath: string = path, ): Promise<{ readonly snapshot: ArtifactInventorySnapshot; readonly zipSource: TransferredZipInventorySource | undefined; @@ -95,7 +115,7 @@ const scanCanonical = async ( path, metadata.isDirectory(), metadata, - options, + { ...options, selectedPath }, probeFacts, ); let reader: ArtifactReader | undefined; @@ -107,6 +127,7 @@ const scanCanonical = async ( options.environment ?? {}, readerAdmission, options.asarHeaderAdmission, + selectedPath, ); } catch (cause: unknown) { readerCreationFailure = { cause }; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index fdab64a68a..21fb73dc14 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = { /** Options shared by artifact inventory scans. */ export interface ArtifactInventoryOptions { readonly resourceScope: ArtifactResourceScope; + /** Independently selected root spelling used only for ASAR companion lookup. */ + readonly selectedPath?: string | undefined; readonly signal?: AbortSignal | undefined; readonly integrity?: ArtifactIntegrityPolicy | undefined; readonly environment?: Readonly | undefined; diff --git a/src/artifacts/javascript/JavaScriptArtifactReader.ts b/src/artifacts/javascript/JavaScriptArtifactReader.ts index 4f946b2ef5..f5c91fad35 100644 --- a/src/artifacts/javascript/JavaScriptArtifactReader.ts +++ b/src/artifacts/javascript/JavaScriptArtifactReader.ts @@ -8,12 +8,13 @@ export const createJavaScriptArtifactReader = ( path: string, format: "asar" | "directory", asarHeaderAdmission?: AsarHeaderAdmission, + selectedPath?: string, ): ArtifactReader => format === "asar" - ? new AsarArtifactReader( - path, - asarHeaderAdmission === undefined + ? new AsarArtifactReader(path, { + ...(asarHeaderAdmission === undefined ? {} - : { headerAdmission: asarHeaderAdmission }, - ) + : { headerAdmission: asarHeaderAdmission }), + ...(selectedPath === undefined ? {} : { selectedPath }), + }) : new DirectoryArtifactReader(path, "omit"); diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 630c96cf16..fb556f092e 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -128,6 +128,8 @@ export interface UniversalMachOSlice { interface BinaryTargetIdentity { readonly path: string; readonly sourcePath?: string; + /** Selected ASAR spelling determines its external unpacked companion, not packed identity. */ + readonly asarSelectedPath?: string; /** Info.plist of the app bundle the target was resolved from. */ readonly bundleInfoPlist?: string; readonly sha256: string; @@ -277,3 +279,9 @@ export const isMachOFormat = ( format: unknown, ): format is "mach-o" | "mach-o-universal" => format === "mach-o" || format === "mach-o-universal"; + +/** Result-affecting artifact selection; canonical packed identity remains target.path. */ +export const artifactTargetSelectedPath = (target: BinaryTarget): string => + target.format === "asar" + ? (target.asarSelectedPath ?? target.sourcePath ?? target.path) + : (target.sourcePath ?? target.path); diff --git a/tests/acceptance/asarSelectedCompanionCli.test.ts b/tests/acceptance/asarSelectedCompanionCli.test.ts new file mode 100644 index 0000000000..0026292553 --- /dev/null +++ b/tests/acceptance/asarSelectedCompanionCli.test.ts @@ -0,0 +1,78 @@ +import { + mkdir, + readFile, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { createPackageWithOptions, extractFile } from "@electron/asar"; +import { expect, onTestFinished } from "vitest"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform === "win32")( + "public ASAR inspection and extraction use the selected alias companion", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-asar-selected-cli-"); + const source = join(root, "source"); + const selected = join(root, "app.asar"); + const canonical = join(root, "content-addressed-archive"); + const bytes = Buffer.from("selected native companion"); + await mkdir(source); + await writeFile(join(source, "native.node"), bytes); + await createPackageWithOptions(source, selected, { unpack: "*.node" }); + await rename(selected, canonical); + await symlink(canonical, selected); + await mkdir(`${canonical}.unpacked`); + await writeFile( + join(`${canonical}.unpacked`, "native.node"), + "different canonical bytes", + ); + expect(extractFile(selected, "native.node").equals(bytes)).toBe(true); + + const inspected = await cli.run({ + arguments: ["inspect-artifact", selected, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + expect(parseEvidence(inspected.json).subject?.local_path).toBe(selected); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + const inventoryEvidence = inspection.substeps.find( + ({ operation }) => operation === "inventory_artifact", + )?.evidence; + if (inventoryEvidence === undefined) + throw new Error("Missing inventory Evidence"); + const inventory = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + expect( + inventory.occurrences.find( + ({ logical_path }) => logical_path === "native.node", + )?.hash_status, + ).toBe("verified"); + + const extracted = await cli.run({ + arguments: ["extract-artifact", selected, "--json"], + }); + expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0); + expect(parseEvidence(extracted.json).subject?.local_path).toBe(selected); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.json).normalized_result, + ); + onTestFinished(() => + rm(result.output_root, { recursive: true, force: true }), + ); + expect( + (await readFile(join(result.output_root, "native.node"))).equals(bytes), + ).toBe(true); + }, +); diff --git a/tests/boundary/filesystem/asarSelectedCompanion.test.ts b/tests/boundary/filesystem/asarSelectedCompanion.test.ts new file mode 100644 index 0000000000..f7e9d4d1eb --- /dev/null +++ b/tests/boundary/filesystem/asarSelectedCompanion.test.ts @@ -0,0 +1,256 @@ +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; +import { withArtifactClient } from "../../support/artifactClientScope.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { createHash } from "node:crypto"; +import { + mkdir, + readFile, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join, relative } from "node:path"; +import { createPackageWithOptions, extractFile } from "@electron/asar"; +import { expect, it } from "vitest"; +import { + inventoryArtifact, + withArtifactResourceScope, +} from "../../fixtures/artifactInventory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { extractArtifact } from "../../../src/artifacts/extraction/ArtifactExtraction.js"; + +const bytes = Buffer.from("selected companion bytes"); +const fixture = async () => { + const root = await createTestTempDirectory("rea-asar-selected-companion-"); + const source = join(root, "source"); + const selected = join(root, "app.asar"); + const canonical = join(root, "stored.asar"); + await mkdir(source); + await writeFile(join(source, "native.node"), bytes); + await writeFile(join(source, "packed.txt"), "packed bytes"); + await createPackageWithOptions(source, selected, { unpack: "*.node" }); + await rename(selected, canonical); + await symlink(canonical, selected); + return { root, selected, canonical }; +}; + +it.skipIf(process.platform === "win32")( + "inventories and extracts the same selected ASAR companion as Electron tooling", + async () => { + const { root, selected } = await fixture(); + expect(extractFile(selected, "native.node").equals(bytes)).toBe(true); + const inventory = await inventoryArtifact(selected); + const occurrence = inventory.occurrences.find( + ({ logical_path }) => logical_path === "native.node", + ); + expect(occurrence?.hash_status).toBe("verified"); + expect( + inventory.nodes.find( + ({ artifact_id }) => artifact_id === occurrence?.artifact_id, + )?.sha256, + ).toBe(createHash("sha256").update(bytes).digest("hex")); + const result = await withArtifactResourceScope((resourceScope) => + extractArtifact({ + inputPath: selected, + inputFormat: "asar", + outputRoot: join(root, "output"), + environment: {}, + integrityPolicy: "fail", + resourceScope, + }), + ); + expect( + (await readFile(join(result.output_root, "native.node"))).equals(bytes), + ).toBe(true); + expect(await readFile(join(result.output_root, "packed.txt"), "utf8")).toBe( + "packed bytes", + ); + }, +); + +it.skipIf(process.platform === "win32")( + "does not substitute the canonical archive companion for an absent selected companion", + async () => { + const { selected, canonical } = await fixture(); + await rename(`${selected}.unpacked`, `${canonical}.unpacked`); + expect(() => extractFile(selected, "native.node")).toThrow(); + const inventory = await inventoryArtifact(selected); + expect( + inventory.occurrences.find( + ({ logical_path }) => logical_path === "native.node", + )?.hash_status, + ).toBe("unavailable"); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps differing selected companions distinct despite identical packed archive bytes", + async () => { + const { root, selected, canonical } = await fixture(); + await mkdir(`${canonical}.unpacked`); + await writeFile( + join(`${canonical}.unpacked`, "native.node"), + "different canonical companion", + ); + expect(extractFile(selected, "native.node").equals(bytes)).toBe(true); + const selectedInventory = await inventoryArtifact(selected); + const canonicalInventory = await inventoryArtifact(canonical, { + integrity: { mode: "record-and-continue" }, + }); + expect(selectedInventory.manifest.root_sha256).toBe( + canonicalInventory.manifest.root_sha256, + ); + expect(selectedInventory.manifest.manifest_id).not.toBe( + canonicalInventory.manifest.manifest_id, + ); + const result = await withArtifactResourceScope((resourceScope) => + extractArtifact({ + inputPath: selected, + inputFormat: "asar", + outputRoot: join(root, "output"), + environment: {}, + integrityPolicy: "fail", + resourceScope, + }), + ); + expect( + (await readFile(join(result.output_root, "native.node"))).equals(bytes), + ).toBe(true); + await rm(join(`${selected}.unpacked`, "native.node")); + await writeFile( + join(`${selected}.unpacked`, "native.node"), + "changed selected companion", + ); + await expect(inventoryArtifact(selected)).rejects.toThrow(); + }, +); + +it + .skipIf(process.platform === "win32") + .each(["canonical", "parent-symlink", "relative-alias"] as const)( + "preserves ordinary ASAR companion lookup through %s selections", + async (selection) => { + const { root, selected, canonical } = await fixture(); + let inputPath = selected; + if (selection === "relative-alias") + inputPath = relative(process.cwd(), selected); + else { + await rename(`${selected}.unpacked`, `${canonical}.unpacked`); + if (selection === "canonical") inputPath = canonical; + else { + const parentAlias = join(root, "parent-alias"); + await symlink(root, parentAlias); + inputPath = join(parentAlias, "stored.asar"); + } + } + expect(extractFile(inputPath, "native.node").equals(bytes)).toBe(true); + const inventory = await inventoryArtifact(inputPath); + expect( + inventory.occurrences.find( + ({ logical_path }) => logical_path === "native.node", + )?.hash_status, + ).toBe("verified"); + const result = await withArtifactResourceScope((resourceScope) => + extractArtifact({ + inputPath, + inputFormat: "asar", + outputRoot: join(root, "output"), + environment: {}, + integrityPolicy: "fail", + resourceScope, + }), + ); + expect( + (await readFile(join(result.output_root, "native.node"))).equals(bytes), + ).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "public artifact admission retains an ASAR alias to an unsuffixed packed file", + async () => { + const { root, selected, canonical } = await fixture(); + const stored = join(root, "content-addressed-archive"); + await rename(canonical, stored); + await rm(selected); + await symlink(stored, selected); + const { runProviderAnalysis } = createDirectAnalysis({}); + const observed = parseEvidence( + await runProviderAnalysis(selected, "inventory_artifact", {}), + ); + expect(observed.subject?.local_path).toBe(selected); + const inventory = artifactInventoryResultSchema.parse( + observed.normalized_result, + ); + expect( + inventory.occurrences.find( + ({ logical_path }) => logical_path === "native.node", + )?.hash_status, + ).toBe("verified"); + }, +); + +it.skipIf(process.platform === "win32")( + "refuses an ASAR alias retargeted after target admission", + async () => { + const { root, selected, canonical } = await fixture(); + const admitted = await parseBinaryTarget(selected); + if (!admitted.ok) throw admitted.error; + expect(admitted.value.path).toBe(canonical); + expect(admitted.value.asarSelectedPath).toBe(selected); + const replacement = join(root, "replacement.asar"); + await writeFile(replacement, await readFile(canonical)); + await rm(selected); + await symlink(replacement, selected); + await withArtifactClient(admitted.value, async (client) => { + const result = await client.execute("inventory_artifact", {}); + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Retargeted archive was unexpectedly admitted"); + expect(result.error).toMatchObject({ + reason: "integrity", + detail: expect.stringContaining( + "no longer resolves to its admitted source", + ), + }); + }); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves symlink-parent traversal when opening the selected ASAR companion", + async () => { + const root = await createTestTempDirectory("rea-asar-link-parent-"); + const actual = join(root, "actual"); + await mkdir(join(actual, "sub"), { recursive: true }); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "native.node"), bytes); + const archive = join(actual, "app.asar"); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); + await symlink(join(actual, "sub"), join(root, "link")); + const selected = `${root}/link/../app.asar`; + const inventory = await inventoryArtifact(selected); + expect( + inventory.occurrences.find( + ({ logical_path }) => logical_path === "native.node", + )?.hash_status, + ).toBe("verified"); + const result = await withArtifactResourceScope((resourceScope) => + extractArtifact({ + inputPath: selected, + inputFormat: "asar", + outputRoot: join(root, "output"), + environment: {}, + integrityPolicy: "fail", + resourceScope, + }), + ); + expect( + (await readFile(join(result.output_root, "native.node"))).equals(bytes), + ).toBe(true); + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationAsarSelectedPath.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationAsarSelectedPath.test.ts new file mode 100644 index 0000000000..50bdab7bfd --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationAsarSelectedPath.test.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { mkdir, rename, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackageWithOptions, extractFile } from "@electron/asar"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { reconstructJavaScriptArtifact } from "../../../support/javascriptApplicationScope.js"; + +it.skipIf(process.platform === "win32").each([ + { + archiveName: "canonical.asar", + aliasName: "selected.asar", + format: "asar" as const, + }, + { + archiveName: "canonical", + aliasName: "selected.asar", + format: "asar" as const, + }, + { + archiveName: "canonical", + aliasName: "selected.asar", + format: "auto" as const, + }, + { + archiveName: "canonical.asar", + aliasName: "selected", + format: "auto" as const, + }, +])( + "analyzes unpacked JavaScript through an alias to $archiveName with format $format", + async ({ archiveName, aliasName, format }) => { + const root = await createTestTempDirectory("rea-js-asar-selected-"); + const source = join(root, "source"); + await mkdir(source); + const main = 'module.exports = require("./dependency.js");'; + await Promise.all([ + writeFile( + join(source, "package.json"), + JSON.stringify({ main: "main.js" }), + ), + writeFile(join(source, "main.js"), main), + writeFile(join(source, "dependency.js"), "module.exports = 42;"), + ]); + const archive = join(root, archiveName); + const selected = join(root, aliasName); + await createPackageWithOptions(source, archive, { unpack: "*.js" }); + await symlink(archive, selected); + await rename(`${archive}.unpacked`, `${selected}.unpacked`); + expect(extractFile(selected, "main.js").toString()).toBe(main); + const result = await reconstructJavaScriptArtifact({ + input_path: selected, + format, + }); + expect(result.statistics.parsed_javascript_files).toBe(2); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: "./dependency.js", + resolved_path: "dependency.js", + }), + }), + ); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + identity: expect.objectContaining({ path: "main.js" }), + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + entry_sha256: createHash("sha256").update(main).digest("hex"), + }), + }), + ]), + }), + ); + }, +); diff --git a/tests/composition/analysis-sessions/binarySessionAsarSelectedPath.test.ts b/tests/composition/analysis-sessions/binarySessionAsarSelectedPath.test.ts new file mode 100644 index 0000000000..ec7f1b199c --- /dev/null +++ b/tests/composition/analysis-sessions/binarySessionAsarSelectedPath.test.ts @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; +import { cp, mkdir, symlink, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackageWithOptions } from "@electron/asar"; +import { expect, it, vi } from "vitest"; + +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "reopens ASAR clients for another selected companion but reuses the same selection", + async () => { + const root = await createTestTempDirectory("rea-session-asar-alias-"); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "payload.bin"), "first"); + const archive = join(root, "canonical.asar"); + const first = join(root, "first.asar"); + const second = join(root, "second.asar"); + await createPackageWithOptions(source, archive, { unpack: "*.bin" }); + await Promise.all([symlink(archive, first), symlink(archive, second)]); + await cp(`${archive}.unpacked`, `${first}.unpacked`, { recursive: true }); + await cp(`${archive}.unpacked`, `${second}.unpacked`, { recursive: true }); + await writeFile(join(`${second}.unpacked`, "payload.bin"), "other"); + const provider = new ArtifactProvider({}); + const created = vi.spyOn(provider, "createClient"); + const session = createTestBinarySession(provider); + const inventory = async (selectedPath: string) => { + const result = await session.execute("inventory_artifact", { + integrity_policy: "record-and-continue", + }); + if (!result.ok) throw result.error; + expect(result.value.subject?.path).toBe(selectedPath); + return artifactInventoryResultSchema.parse(result.value.result); + }; + try { + expect((await session.open(first)).ok).toBe(true); + const firstInventory = await inventory(first); + expect((await session.open(first)).ok).toBe(true); + expect(created).toHaveBeenCalledTimes(1); + expect((await session.open(second)).ok).toBe(true); + expect(created).toHaveBeenCalledTimes(2); + const secondInventory = await inventory(second); + const payloadDigest = (observed: typeof firstInventory) => { + const occurrence = observed.occurrences.find( + ({ logical_path }) => logical_path === "payload.bin", + ); + return observed.nodes.find( + ({ artifact_id }) => artifact_id === occurrence?.artifact_id, + )?.sha256; + }; + expect(payloadDigest(firstInventory)).toBe( + createHash("sha256").update("first").digest("hex"), + ); + expect(payloadDigest(secondInventory)).toBe( + createHash("sha256").update("other").digest("hex"), + ); + expect(secondInventory.integrity_contradictions).toHaveLength(1); + const replacement = join(root, "replacement.asar"); + await writeFile(join(source, "packed.txt"), "different archive"); + await createPackageWithOptions(source, replacement, { unpack: "*.bin" }); + await unlink(second); + await symlink(replacement, second); + const retargeted = await session.execute("inventory_artifact", {}); + expect(retargeted.ok).toBe(false); + if (!retargeted.ok) + expect(retargeted.error).toMatchObject({ + _tag: "ArtifactOperationError", + reason: "integrity", + detail: expect.stringContaining( + "no longer resolves to its admitted source", + ), + }); + } finally { + await session.close(); + created.mockRestore(); + } + }, +);