From c27560e434980615ecbc66fca518398f45002360 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 27 Oct 2026 06:28:00 +0800
Subject: [PATCH] fix(artifacts): classify selected package names
---
src/application/BinaryTargetResolver.ts | 33 ++---
src/application/binary/BinarySessionOpen.ts | 2 +-
.../JavaScriptArtifactReconstruction.ts | 6 +-
src/artifacts/ArtifactProvider.ts | 4 +-
src/artifacts/inventory/classify.ts | 9 +-
src/domain/artifactNameSelection.ts | 26 ++++
src/domain/binaryTargetTypes.ts | 8 +-
.../artifactSelectedNameCli.test.ts | 76 +++++++++++
.../cli/artifactCallerSelection.test.ts | 27 +++-
.../filesystem/artifactSelectedName.test.ts | 128 ++++++++++++++++++
.../filesystem/asarSelectedCompanion.test.ts | 2 +-
11 files changed, 285 insertions(+), 36 deletions(-)
create mode 100644 src/domain/artifactNameSelection.ts
create mode 100644 tests/acceptance/artifactSelectedNameCli.test.ts
create mode 100644 tests/boundary/filesystem/artifactSelectedName.test.ts
diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts
index 07610a7131..f6cc342ed9 100644
--- a/src/application/BinaryTargetResolver.ts
+++ b/src/application/BinaryTargetResolver.ts
@@ -1,3 +1,7 @@
+import {
+ artifactNameForClassification,
+ namedArtifactFormat,
+} from "../domain/artifactNameSelection.js";
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
import { readPeCoffHeader } from "../domain/peCoffHeader.js";
import {
@@ -372,17 +376,11 @@ const readOpenedBinaryTarget = async (
}
// Probe WASM content before suffix-only database routing; an explicit
// database selection keeps the caller's chosen interpretation.
- let artifactFormat =
+ const classificationPath = artifactNameForClassification(selectedPath, path);
+ const 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";
+ : await detectArtifactFormat(classificationPath, source, signal);
if (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
@@ -398,7 +396,7 @@ const readOpenedBinaryTarget = async (
const identity = {
path,
sourcePath,
- ...(artifactFormat === "asar" ? { asarSelectedPath: selectedPath } : {}),
+ artifactSelectedPath: selectedPath,
sha256: await sha256Handle(handle, path, metadata, signal),
};
return isArchiveFormat(artifactFormat)
@@ -424,7 +422,8 @@ const readOpenedBinaryTarget = async (
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "archive",
- format: zipRootPackageFormat(path),
+ format: zipRootPackageFormat(classificationPath),
+ artifactSelectedPath: selectedPath,
});
const reason =
typeof detected.error === "string"
@@ -669,7 +668,8 @@ const detectArtifactFormat = async (
// Binary plists, including keyed archives, often use other extensions.
if (hasBinaryPlistSignature(magic)) return "plist";
const named = namedArtifactFormat(lower);
- if (named !== undefined) return named;
+ if (named !== undefined && !claimsExecutableHeader(magic, initial.size))
+ return named;
if (
lower.endsWith(".pkg") &&
magic.length >= 4 &&
@@ -717,15 +717,6 @@ const detectArtifactFormat = async (
return undefined;
};
-const namedArtifactFormat = (
- lowerPath: string,
-): "asar" | "plist" | "source-map" | "javascript" | undefined => {
- if (lowerPath.endsWith(".asar")) return "asar";
- if (lowerPath.endsWith(".plist")) return "plist";
- if (lowerPath.endsWith(".map")) return "source-map";
- return /\.(?:m?js|cjs)$/u.test(lowerPath) ? "javascript" : undefined;
-};
-
const isArchiveFormat = (
format: Exclude<
BinaryTarget["format"],
diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts
index 43b7dca677..75af7bcbc6 100644
--- a/src/application/binary/BinarySessionOpen.ts
+++ b/src/application/binary/BinarySessionOpen.ts
@@ -152,7 +152,7 @@ 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.
+ // Artifact name hints and companions depend on the selected spelling.
artifactTargetSelectedPath(current) === artifactTargetSelectedPath(target) &&
snapshotMatchesTarget(snapshotTarget(current), target);
diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts
index 47c61ec1b5..8e3bf7ecc7 100644
--- a/src/application/javascript/JavaScriptArtifactReconstruction.ts
+++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts
@@ -1,3 +1,4 @@
+import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
import type { AnalysisSettingValues } from "../../domain/analysisSettings.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import {
@@ -500,8 +501,9 @@ const resolveFormat = (
const observed = metadata.isDirectory()
? "directory"
: metadata.isFile() &&
- (selectedPath.toLowerCase().endsWith(".asar") ||
- path.toLowerCase().endsWith(".asar"))
+ artifactNameForClassification(selectedPath, path)
+ .toLowerCase()
+ .endsWith(".asar")
? "asar"
: undefined;
if (observed === undefined)
diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts
index d147342676..42712a0b56 100644
--- a/src/artifacts/ArtifactProvider.ts
+++ b/src/artifacts/ArtifactProvider.ts
@@ -310,7 +310,7 @@ class ArtifactClient implements AnalysisClient {
const result = await extractArtifact(
{
inputPath: this.target.sourcePath ?? this.target.path,
- selectedPath: this.target.asarSelectedPath,
+ selectedPath: this.target.artifactSelectedPath,
inputFormat: this.target.format,
outputRoot: parsed.output_root,
environment: this.environment,
@@ -505,7 +505,7 @@ class ArtifactClient implements AnalysisClient {
options?: ExecutionOptions,
) {
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
- selectedPath: this.target.asarSelectedPath,
+ selectedPath: this.target.artifactSelectedPath,
resourceScope: this.#resourceScope,
environment: this.environment,
asarHeaderAdmission: runtimeAsarHeaderAdmission(
diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts
index ef5ad2bdbb..8a31fea340 100644
--- a/src/artifacts/inventory/classify.ts
+++ b/src/artifacts/inventory/classify.ts
@@ -1,3 +1,4 @@
+import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
import {
@@ -185,7 +186,11 @@ const classifyRootFormat = async (
const archiveBytes = (await handle.stat()).size;
const evidence = probe.finish(archiveBytes);
abortIfNeeded(signal);
- const lower = path.toLowerCase();
+ const classificationPath = artifactNameForClassification(
+ selectedPath ?? path,
+ path,
+ );
+ const lower = classificationPath.toLowerCase();
if (hasZipSignature(evidence.prefix)) return zipRootPackageFormat(lower);
if (
lower.endsWith(".pkg") &&
@@ -228,7 +233,7 @@ const classifyRootFormat = async (
? fatEvidence
: { ...fatEvidence, mzWindowsHeaderSignature };
const format = classifyArtifactContent(
- selectedPath?.toLowerCase().endsWith(".asar") ? selectedPath : path,
+ classificationPath,
classificationEvidence,
archiveBytes,
).format;
diff --git a/src/domain/artifactNameSelection.ts b/src/domain/artifactNameSelection.ts
new file mode 100644
index 0000000000..5615eb0058
--- /dev/null
+++ b/src/domain/artifactNameSelection.ts
@@ -0,0 +1,26 @@
+import { zipPackageFormatForPath } from "./zipPackageFormat.js";
+
+/** Name-selected root formats; callers establish byte-signature precedence first. */
+export const namedArtifactFormat = (
+ path: string,
+): "asar" | "plist" | "source-map" | "javascript" | undefined => {
+ const lower = path.toLowerCase();
+ if (lower.endsWith(".asar")) return "asar";
+ if (lower.endsWith(".plist")) return "plist";
+ if (lower.endsWith(".map")) return "source-map";
+ return /\.(?:m?js|cjs)$/u.test(lower) ? "javascript" : undefined;
+};
+
+/**
+ * A recognized selected artifact name supplies format hints. Unknown aliases
+ * retain the canonical name's hints; neither spelling changes packed I/O.
+ */
+export const artifactNameForClassification = (
+ selectedPath: string,
+ canonicalPath: string,
+): string =>
+ namedArtifactFormat(selectedPath) !== undefined ||
+ zipPackageFormatForPath(selectedPath) !== undefined ||
+ /\.(?:app|pkg|dmg)$/iu.test(selectedPath)
+ ? selectedPath
+ : canonicalPath;
diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts
index fb556f092e..d7fb7fb940 100644
--- a/src/domain/binaryTargetTypes.ts
+++ b/src/domain/binaryTargetTypes.ts
@@ -128,8 +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;
+ /** Selected artifact spelling determines name-based format hints and external companions. */
+ readonly artifactSelectedPath?: string;
/** Info.plist of the app bundle the target was resolved from. */
readonly bundleInfoPlist?: string;
readonly sha256: string;
@@ -282,6 +282,6 @@ export const isMachOFormat = (
/** 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.kind === "archive" || target.kind === "artifact"
+ ? (target.artifactSelectedPath ?? target.sourcePath ?? target.path)
: (target.sourcePath ?? target.path);
diff --git a/tests/acceptance/artifactSelectedNameCli.test.ts b/tests/acceptance/artifactSelectedNameCli.test.ts
new file mode 100644
index 0000000000..496fde4dd5
--- /dev/null
+++ b/tests/acceptance/artifactSelectedNameCli.test.ts
@@ -0,0 +1,76 @@
+import { readFile, rm, symlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+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")
+ .for(["content-addressed-archive", "stored.ipa"])(
+ "public APK aliases retain package selection through inspection, projection and extraction from %s",
+ async (storedName, { cli }) => {
+ const root = await createTestTempDirectory("rea-selected-apk-cli-");
+ const stored = join(root, storedName);
+ const selected = join(root, "selected.apk");
+ const writer = new ZipWriter(new Uint8ArrayWriter());
+ await writer.add(
+ "AndroidManifest.xml",
+ new TextReader(
+ '',
+ ),
+ );
+ await writer.add("payload.txt", new TextReader("same packed bytes"));
+ await writeFile(stored, await writer.close());
+ await symlink(stored, selected);
+ const inspected = await cli.run({
+ arguments: ["inspect-artifact", selected, "--json"],
+ });
+ expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0);
+ const inspectedEvidence = parseEvidence(inspected.json);
+ expect(inspectedEvidence.subject?.local_path).toBe(selected);
+ const inspection = artifactInspectionResultSchema.parse(
+ inspectedEvidence.normalized_result,
+ );
+ const inventoryEvidence = inspection.substeps.find(
+ ({ operation }) => operation === "inventory_artifact",
+ )?.evidence;
+ if (inventoryEvidence === undefined)
+ throw new Error("Missing inventory Evidence");
+ expect(
+ artifactInventoryResultSchema.parse(inventoryEvidence.normalized_result)
+ .manifest.root_format,
+ ).toBe("apk");
+ const projection = await cli.run({
+ arguments: [
+ "project-android-application-graph",
+ JSON.stringify({ inventory_evidence: [inventoryEvidence] }),
+ "--json",
+ ],
+ });
+ expect(projection.exitCode, projection.stdout + projection.stderr).toBe(0);
+ expect(parseEvidence(projection.json).subject?.format).toBe("apk");
+ const extracted = await cli.run({
+ arguments: ["extract-artifact", selected, "--json"],
+ });
+ expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0);
+ const extractedEvidence = parseEvidence(extracted.json);
+ expect(extractedEvidence.subject?.local_path).toBe(selected);
+ const result = artifactExtractionResultSchema.parse(
+ extractedEvidence.normalized_result,
+ );
+ onTestFinished(() =>
+ rm(result.output_root, { recursive: true, force: true }),
+ );
+ expect(result.manifest.root_format).toBe("apk");
+ expect(
+ await readFile(join(result.output_root, "payload.txt"), "utf8"),
+ ).toBe("same packed bytes");
+ },
+);
diff --git a/tests/boundary/cli/artifactCallerSelection.test.ts b/tests/boundary/cli/artifactCallerSelection.test.ts
index ae5f76bbe8..73d774fc75 100644
--- a/tests/boundary/cli/artifactCallerSelection.test.ts
+++ b/tests/boundary/cli/artifactCallerSelection.test.ts
@@ -1,3 +1,4 @@
+import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js";
import { readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
@@ -7,7 +8,7 @@ import { createSingleEntryZip } from "../../fixtures/zipNames.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
cliTest.skipIf(process.platform === "win32")(
- "retains the selected archive alias independently of its canonical subject",
+ "retains the selected archive alias without changing inventory identity",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-artifact-selected-alias-");
const target = join(root, "program.zip");
@@ -27,8 +28,28 @@ cliTest.skipIf(process.platform === "win32")(
const observed = parseEvidence(selected.json);
const direct = parseEvidence(control.json);
expect(observed.parameters).toMatchObject({ input_path: alias });
- expect(observed.subject).toEqual(direct.subject);
- expect(observed.normalized_result).toEqual(direct.normalized_result);
+ expect(observed.subject?.local_path).toBe(join(root, alias));
+ expect(direct.subject?.local_path).toBe(target);
+ expect(observed.subject?.digest.sha256).toBe(direct.subject?.digest.sha256);
+ const selectedInspection = artifactInspectionResultSchema.parse(
+ observed.normalized_result,
+ );
+ const directInspection = artifactInspectionResultSchema.parse(
+ direct.normalized_result,
+ );
+ expect(selectedInspection.subject).toEqual(directInspection.subject);
+ const selectedInventory = selectedInspection.substeps.find(
+ ({ operation }) => operation === "inventory_artifact",
+ )?.evidence;
+ const directInventory = directInspection.substeps.find(
+ ({ operation }) => operation === "inventory_artifact",
+ )?.evidence;
+ if (selectedInventory === undefined || directInventory === undefined)
+ throw new Error("Missing nested inventory Evidence");
+ expect(selectedInventory.subject?.local_path).toBe(join(root, alias));
+ expect(selectedInventory.normalized_result).toEqual(
+ directInventory.normalized_result,
+ );
},
);
diff --git a/tests/boundary/filesystem/artifactSelectedName.test.ts b/tests/boundary/filesystem/artifactSelectedName.test.ts
new file mode 100644
index 0000000000..4b8b4dc6fb
--- /dev/null
+++ b/tests/boundary/filesystem/artifactSelectedName.test.ts
@@ -0,0 +1,128 @@
+import { readFile, symlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+import { expect, it, vi } from "vitest";
+import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
+import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
+import {
+ artifactExtractionResultSchema,
+ artifactInventoryResultSchema,
+} from "../../../src/domain/artifactGraph.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { elf } from "../../../src/domain/binaryTarget.fixture.js";
+import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+const fixture = async (storedName = "content-addressed-archive") => {
+ const root = await createTestTempDirectory("rea-artifact-selected-name-");
+ const canonical = join(root, storedName);
+ const writer = new ZipWriter(new Uint8ArrayWriter());
+ await writer.add(
+ "AndroidManifest.xml",
+ new TextReader(
+ '',
+ ),
+ );
+ await writer.add("payload.txt", new TextReader("same packed bytes"));
+ await writeFile(canonical, await writer.close());
+ return { root, canonical };
+};
+
+it.skipIf(process.platform === "win32").each([
+ ["selected.apk", "content", "apk"],
+ ["selected.ipa", "content", "ipa"],
+ ["selected.apk", "canonical.ipa", "apk"],
+ ["selected.ipa", "canonical.apk", "ipa"],
+ ["selected.unknown", "canonical.apk", "apk"],
+ ["selected.asar", "content", "zip"],
+] as const)(
+ "keeps selected %s and stored %s consistent across admission, inventory and extraction",
+ async (name, stored, format) => {
+ const { root, canonical } = await fixture(stored);
+ const selected = join(root, name);
+ await symlink(canonical, selected);
+ const admitted = await parseBinaryTarget(selected);
+ if (!admitted.ok) throw admitted.error;
+ expect(admitted.value.path).toBe(canonical);
+ expect(admitted.value.format).toBe(format);
+ const { runProviderAnalysis } = createDirectAnalysis({});
+ const observed = parseEvidence(
+ await runProviderAnalysis(selected, "inventory_artifact", {}),
+ );
+ expect(observed.subject?.local_path).toBe(selected);
+ expect(
+ artifactInventoryResultSchema.parse(observed.normalized_result).manifest
+ .root_format,
+ ).toBe(format);
+ const provider = new ArtifactProvider({});
+ const client = provider.createClient(admitted.value);
+ try {
+ const execution = await client.execute("extract_artifact", {
+ output_root: join(root, "output"),
+ integrity_policy: "fail",
+ });
+ if (!execution.ok) throw execution.error;
+ expect(execution.value.subject?.path).toBe(selected);
+ const result = artifactExtractionResultSchema.parse(
+ execution.value.result,
+ );
+ expect(result.manifest.root_format).toBe(format);
+ expect(
+ await readFile(join(result.output_root, "payload.txt"), "utf8"),
+ ).toBe("same packed bytes");
+ } finally {
+ await client.close();
+ }
+ },
+);
+
+it.skipIf(process.platform === "win32")(
+ "reopens the same packed file under distinct package selections and reuses unchanged selection",
+ async () => {
+ const { root, canonical } = await fixture();
+ const apk = join(root, "selected.apk");
+ const ipa = join(root, "selected.ipa");
+ await symlink(canonical, apk);
+ await symlink(canonical, ipa);
+ const provider = new ArtifactProvider({});
+ const created = vi.spyOn(provider, "createClient");
+ const session = createTestBinarySession(provider);
+ try {
+ for (const [path, format, calls] of [
+ [apk, "apk", 1],
+ [apk, "apk", 1],
+ [ipa, "ipa", 2],
+ ] as const) {
+ const opened = await session.open(path);
+ if (!opened.ok) throw opened.error;
+ expect(created).toHaveBeenCalledTimes(calls);
+ const execution = await session.execute("inventory_artifact", {});
+ if (!execution.ok) throw execution.error;
+ expect(execution.value.subject?.path).toBe(path);
+ expect(
+ artifactInventoryResultSchema.parse(execution.value.result).manifest
+ .root_format,
+ ).toBe(format);
+ }
+ } finally {
+ await session.close();
+ created.mockRestore();
+ }
+ },
+);
+
+it.skipIf(process.platform === "win32")(
+ "does not reinterpret real executable bytes from an artifact alias suffix",
+ async () => {
+ const root = await createTestTempDirectory("rea-artifact-executable-name-");
+ const canonical = join(root, "program");
+ const selected = join(root, "selected.asar");
+ await writeFile(canonical, elf(2, 1, 62));
+ await symlink(canonical, selected);
+ const admitted = await parseBinaryTarget(selected);
+ if (!admitted.ok) throw admitted.error;
+ expect(admitted.value.format).toBe("elf");
+ expect(admitted.value.kind).toBe("executable");
+ },
+);
diff --git a/tests/boundary/filesystem/asarSelectedCompanion.test.ts b/tests/boundary/filesystem/asarSelectedCompanion.test.ts
index f7e9d4d1eb..cde62c9dd3 100644
--- a/tests/boundary/filesystem/asarSelectedCompanion.test.ts
+++ b/tests/boundary/filesystem/asarSelectedCompanion.test.ts
@@ -200,7 +200,7 @@ it.skipIf(process.platform === "win32")(
const admitted = await parseBinaryTarget(selected);
if (!admitted.ok) throw admitted.error;
expect(admitted.value.path).toBe(canonical);
- expect(admitted.value.asarSelectedPath).toBe(selected);
+ expect(admitted.value.artifactSelectedPath).toBe(selected);
const replacement = join(root, "replacement.asar");
await writeFile(replacement, await readFile(canonical));
await rm(selected);