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);