mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(artifacts): preserve selected ASAR companion paths
This commit is contained in:
1 parent
e891926534
commit
779d548c4e
19 files changed
+643
-38
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
+80
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user