fix(artifacts): preserve selected ASAR companion paths

This commit is contained in:
morluto committed 2026-10-27 06:12:41 +08:00
1 parent e891926534
commit 779d548c4e
19 files changed
+643 -38

No files matched your search

+12 -3
View File
@@ -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<TargetResolution> => {
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)
@@ -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);
@@ -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 {
@@ -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)
+9 -4
View File
@@ -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(
+23 -2
View File
@@ -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<string, AsarEntryState>();
readonly #ownedHandles = new Map<SharedAsarFileHandle, OwnedAsarHandle>();
@@ -278,6 +280,7 @@ export class AsarArtifactReader implements ArtifactReader {
): Promise<void> {
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<void> {
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<Readable> {
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<void>;
+9 -5
View File
@@ -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":
@@ -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<NodeJS.ProcessEnv>;
@@ -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"
+8 -1
View File
@@ -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<ArtifactInventorySnapshot> => {
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),
);
};
+4 -2
View File
@@ -64,7 +64,7 @@ export const classifyAndHashRootForInventory = async (
expectedMetadata: Stats,
options: Pick<
ArtifactInventoryOptions,
"resourceScope" | "signal" | "asarHeaderAdmission"
"resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath"
>,
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
): Promise<RootInventoryClassification> => {
@@ -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<ArtifactOccurrence["artifact_format"] | NodeSeaAdmission> => {
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;
+2
View File
@@ -21,6 +21,7 @@ export const createReader = (
environment: Readonly<NodeJS.ProcessEnv>,
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,
});
};
+24 -3
View File
@@ -61,18 +61,37 @@ export const scanCanonicalArtifactInventoryInScope = async (
path: string,
options: ArtifactInventoryOptions,
readerFactory: typeof createReader = createReader,
selectedPath: string = path,
): Promise<ArtifactInventorySnapshot> =>
(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 };
+2
View File
@@ -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<NodeJS.ProcessEnv> | undefined;
@@ -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");
+8
View File
@@ -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);
@@ -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);
},
);
@@ -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);
},
);
@@ -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"),
}),
}),
]),
}),
);
},
);
@@ -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();
}
},
);