refactor(artifacts): move inventory and extraction into provider owners (#1032)

This commit is contained in:
Ysopwnable
2026-10-08 05:51:14 +08:00
committed by GitHub
parent c6dad93208
commit 6e4535c101
36 changed files with 147 additions and 91 deletions
-271
View File
@@ -1,271 +0,0 @@
import { lstat, realpath } from "node:fs/promises";
import { canonicalDigest } from "../domain/comparisonSemantics.js";
import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js";
import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../artifacts/ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
} from "../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
import { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
import {
artifactExtractionResultSchema,
type ArtifactExtractionResult,
type ArtifactGraphManifest,
type ArtifactNode,
type ArtifactOccurrence,
} from "../domain/artifactGraph.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { scanArtifactInventory } from "./ArtifactInventory.js";
/** Local extraction input with the output root chosen by the adapter. */
export interface ArtifactExtractionInput {
readonly inputPath: string;
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
}
/** Extract every regular inventory occurrence into an exclusively owned absent root. */
export const extractArtifact = async (
input: ArtifactExtractionInput,
signal?: AbortSignal,
): Promise<ArtifactExtractionResult> => {
const sourcePath = await realpath(input.inputPath);
const snapshot = await scanArtifactInventory(sourcePath, {
signal,
});
const selectedOccurrences = snapshot.occurrences.filter(
(occurrence) =>
(occurrence.entry_kind === "file" || occurrence.entry_kind === "slice") &&
occurrence.logical_path !== ".",
);
const selectedIds = new Set(
selectedOccurrences.map(({ occurrence_id: id }) => id),
);
const occurrences = new Map<string, ArtifactOccurrence>();
const neededNodes = new Set<string>();
collectOccurrences(
snapshot.occurrences,
selectedIds,
occurrences,
neededNodes,
);
const nodes = new Map<string, ArtifactNode>();
collectNodes(snapshot.nodes, neededNodes, nodes);
const inventory: LoadedInventory = {
manifest: snapshot.manifest,
occurrences,
nodes,
};
const selected = selectedOccurrences.map((occurrence) => {
if (
(occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
occurrence.artifact_id === null ||
occurrence.encrypted ||
occurrence.logical_path === "."
)
throw new ArtifactReaderFailure(
"format",
`Selected occurrence is not an extractable regular child file: ${occurrence.occurrence_id}`,
);
const node = inventory.nodes.get(occurrence.artifact_id);
if (node === undefined)
throw new ArtifactReaderFailure(
"integrity",
`Selected occurrence has no inventory node: ${occurrence.occurrence_id}`,
);
return { occurrence, node };
});
return materializeSelection({
input,
sourcePath,
inventory,
selected,
signal,
});
};
interface SelectedOccurrence {
readonly occurrence: ArtifactOccurrence;
readonly node: ArtifactNode;
}
interface ExtractedOccurrence {
readonly artifact_id: string;
readonly relative_path: string;
readonly sha256: string;
readonly bytes_written: number;
readonly created: true;
}
const materializeSelection = async ({
input,
sourcePath,
inventory,
selected,
signal,
}: {
readonly input: ArtifactExtractionInput;
readonly sourcePath: string;
readonly inventory: LoadedInventory;
readonly selected: readonly SelectedOccurrence[];
readonly signal: AbortSignal | undefined;
}): Promise<ArtifactExtractionResult> => {
const byPath = new Map(
selected.map((item) => [item.occurrence.logical_path, item]),
);
const reader = await createReader(sourcePath, input.inputFormat);
const output = await SafeOutputTree.create(input.outputRoot);
let readerClosed = false;
const extracted: ExtractedOccurrence[] = [];
try {
const materialized: SelectedOccurrence[] = [];
const registry = new ArtifactPathRegistry();
for await (const entry of reader.entries(signal)) {
const path = normalizeArtifactPath(entry.path);
registry.add(path, entry.kind);
const selectedItem = byPath.get(path);
if (selectedItem === undefined) {
if (entry.kind === "file" || entry.kind === "slice")
throw new ArtifactReaderFailure(
"integrity",
`Regular artifact entry is missing from inventory: ${path}`,
);
continue;
}
preflight(entry);
const stream = await reader.open(entry, signal);
const written = await output.write(
path,
stream,
selectedItem.node.sha256,
signal,
);
extracted.push({
artifact_id: selectedItem.node.artifact_id,
relative_path: written.relativePath,
sha256: written.sha256,
bytes_written: written.bytesWritten,
created: true,
});
materialized.push(selectedItem);
}
await reader.close();
readerClosed = true;
extracted.sort((left, right) =>
left.relative_path.localeCompare(right.relative_path, "en"),
);
const result = createExtractionResult(
input,
inventory,
materialized,
extracted,
);
await output.commit();
return result;
} catch (cause: unknown) {
if (!readerClosed)
await reader.close().catch((cause: unknown) => {
// best-effort cleanup: reader close must not mask the extraction failure.
void cause;
});
await output.rollback();
throw cause;
}
};
const createExtractionResult = (
input: ArtifactExtractionInput,
inventory: LoadedInventory,
selected: readonly SelectedOccurrence[],
extracted: readonly ExtractedOccurrence[],
): ArtifactExtractionResult => {
const extractionSemantic = {
source_manifest_id: inventory.manifest.manifest_id,
selected_occurrence_ids: selected
.map(({ occurrence }) => occurrence.occurrence_id)
.sort((left, right) => left.localeCompare(right)),
files_sha256: canonicalDigest(extracted, "Artifact"),
output_root_alias: "$OUTPUT_ROOT" as const,
};
return artifactExtractionResultSchema.parse({
manifest: inventory.manifest,
extraction_manifest: {
...extractionSemantic,
extraction_id: `aex_${canonicalDigest(extractionSemantic, "Artifact")}`,
},
output_root: input.outputRoot,
artifacts: extracted,
containment_verified: true,
cleanup: { attempted: false, verified: true, residual_paths: [] },
provenance: [],
limitations: [
"All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.",
],
});
};
interface LoadedInventory {
readonly manifest: ArtifactGraphManifest;
readonly occurrences: ReadonlyMap<string, ArtifactOccurrence>;
readonly nodes: ReadonlyMap<string, ArtifactNode>;
}
const collectOccurrences = (
items: readonly ArtifactOccurrence[],
selected: ReadonlySet<string>,
output: Map<string, ArtifactOccurrence>,
neededNodes: Set<string>,
): void => {
for (const item of items) {
if (!selected.has(item.occurrence_id)) continue;
output.set(item.occurrence_id, item);
if (item.artifact_id !== null) neededNodes.add(item.artifact_id);
}
};
const collectNodes = (
items: readonly ArtifactNode[],
selected: ReadonlySet<string>,
output: Map<string, ArtifactNode>,
): void => {
for (const item of items)
if (selected.has(item.artifact_id)) output.set(item.artifact_id, item);
};
const createReader = async (
path: string,
format: BinaryTarget["format"],
): Promise<ArtifactReader> => {
if ((await lstat(path)).isDirectory())
return new DirectoryArtifactReader(path);
if (format === "asar") return new AsarArtifactReader(path);
if (
format === "ipa" ||
format === "apk" ||
format === "msix" ||
format === "appx" ||
format === "zip"
)
return new ZipArtifactReader(path, format);
if (format === "mach-o") return new MachOSliceArtifactReader(path);
throw new ArtifactReaderFailure(
"unavailable",
`Artifact format has no extraction reader: ${format}`,
);
};
const preflight = (entry: ArtifactEntry): void => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
throw new ArtifactReaderFailure(
"format",
`Selected artifact entry cannot be read: ${entry.path}`,
);
};
@@ -1,14 +0,0 @@
import { expect, it } from "vitest";
import { compareDirectoryChildNames } from "./ArtifactGraphConstruction.js";
it("breaks locale-collation ties deterministically by code unit", () => {
const names = ["a\u200d.txt", "a.txt", "a\u200b.txt", "a\u200c.txt"];
expect("a\u200d.txt".localeCompare("a.txt")).toBe(0);
expect(names.sort(compareDirectoryChildNames)).toEqual([
"a.txt",
"a\u200b.txt",
"a\u200c.txt",
"a\u200d.txt",
]);
});
@@ -1,369 +0,0 @@
import { canonicalDigest } from "../domain/comparisonSemantics.js";
import type { ArtifactEntry } from "../artifacts/ArtifactReader.js";
import type {
ArtifactCommand,
ArtifactEdge,
ArtifactNode,
ArtifactOccurrence,
} from "../domain/artifactGraph.js";
import { zipPackageFormatForPath } from "../domain/zipPackageFormat.js";
import { mzWindowsHeaderOffset, parseDosMzHeader } from "../domain/dosMz.js";
/** Mutable internal occurrence used until root-bound IDs are known. */
export interface MutableOccurrence {
occurrence_id: string;
artifact_id: string | null;
parent_occurrence_id: string | null;
logical_path: string;
entry_kind: ArtifactOccurrence["entry_kind"];
declared_size: number | null;
compressed_size: number | null;
executable: boolean;
encrypted: boolean;
hash_status: ArtifactOccurrence["hash_status"];
source_location: ArtifactOccurrence["source_location"];
limitations: string[];
}
/** Index the first occurrence for each canonical logical path. */
export const indexOccurrencesByPath = (
occurrences: readonly MutableOccurrence[],
): ReadonlyMap<string, MutableOccurrence> => {
const byPath = new Map<string, MutableOccurrence>();
for (const occurrence of occurrences)
if (!byPath.has(occurrence.logical_path))
byPath.set(occurrence.logical_path, occurrence);
return byPath;
};
export const createOccurrence = (
entry: ArtifactEntry,
path: string,
parent: string | null,
): MutableOccurrence => ({
occurrence_id: `occ_${canonicalDigest({ path, kind: entry.kind }, "Artifact")}`,
artifact_id: null,
parent_occurrence_id: parent,
logical_path: path,
entry_kind: entry.kind,
declared_size: entry.declaredSize,
compressed_size: entry.compressedSize,
executable: entry.executable,
encrypted: entry.encrypted,
hash_status: entry.encrypted ? "unavailable" : "not-hashed",
source_location:
entry.byteOffset === null || entry.declaredSize === null
? null
: { offset: entry.byteOffset, length: entry.declaredSize },
limitations: [...entry.limitations],
});
/** Preserve collation order and break distinct-name ties by UTF-16 code units. */
export const compareDirectoryChildNames = (
left: string,
right: string,
): number => {
const collated = left.localeCompare(right);
if (collated !== 0) return collated;
if (left < right) return -1;
return left > right ? 1 : 0;
};
export const materializeDirectoryNodes = (
occurrences: MutableOccurrence[],
nodes: Map<string, ArtifactNode>,
): void => {
const directories = occurrences
.filter(({ entry_kind: kind }) => kind === "directory")
.sort(
(left, right) => depth(right.logical_path) - depth(left.logical_path),
);
const childrenByParent = new Map<string, MutableOccurrence[]>();
for (const occurrence of occurrences) {
if (occurrence.parent_occurrence_id === null) continue;
const children = childrenByParent.get(occurrence.parent_occurrence_id);
if (children === undefined)
childrenByParent.set(occurrence.parent_occurrence_id, [occurrence]);
else children.push(occurrence);
}
for (const directory of directories) {
const children = (childrenByParent.get(directory.occurrence_id) ?? [])
.map(({ logical_path, artifact_id, entry_kind }) => ({
// Intermediate directory entries may be absent from an archive.
name: logical_path.slice(directory.logical_path.length + 1),
artifact_id,
entry_kind,
}))
.sort((left, right) => compareDirectoryChildNames(left.name, right.name));
const node = createArtifactNode({
sha256: canonicalDigest({ kind: "directory", children }, "Artifact"),
size: 0,
kind: directory.logical_path.toLowerCase().endsWith(".framework")
? "framework"
: "container",
format: "directory",
executable: false,
contentState: "virtual",
});
const existing = nodes.get(node.artifact_id);
nodes.set(
node.artifact_id,
existing?.kind === "framework" ? existing : node,
);
directory.artifact_id = node.artifact_id;
directory.hash_status = "verified";
}
};
export const createRootNode = (input: {
readonly path: string;
readonly format: ArtifactNode["format"];
readonly directory: boolean;
readonly digest: { readonly sha256: string; readonly bytes: number } | null;
readonly occurrences: readonly MutableOccurrence[];
}): ArtifactNode =>
createArtifactNode({
sha256:
input.digest?.sha256 ??
canonicalDigest(
{
kind: "directory-root",
children: input.occurrences.map(({ logical_path, artifact_id }) => ({
logical_path,
artifact_id,
})),
},
"Artifact",
),
size: input.digest?.bytes ?? 0,
kind:
input.directory ||
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(
input.format,
)
? "container"
: input.format === "dos-mz"
? "executable"
: classifyArtifactPath(input.path).kind,
format: input.format,
executable: false,
contentState: "materialized",
});
export const createArtifactNode = (input: {
readonly sha256: string;
readonly size: number;
readonly kind: ArtifactNode["kind"];
readonly format: ArtifactNode["format"];
readonly executable: boolean;
readonly contentState: ArtifactNode["content_state"];
readonly limitations?: readonly string[];
}): ArtifactNode => ({
artifact_id: `art_${canonicalDigest({ sha256: input.sha256 }, "Artifact")}`,
kind: input.kind,
format: input.format,
sha256: input.sha256,
size: input.size,
media_type: null,
architecture: null,
executable: input.executable,
content_state: input.contentState,
limitations: [...(input.limitations ?? [])],
});
export const rootOccurrenceFor = (
node: ArtifactNode,
declaredSize: number,
): MutableOccurrence => ({
occurrence_id: `occ_${canonicalDigest({ root: node.artifact_id }, "Artifact")}`,
artifact_id: node.artifact_id,
parent_occurrence_id: null,
logical_path: ".",
entry_kind: node.format === "directory" ? "directory" : "file",
declared_size: declaredSize,
compressed_size: null,
executable: node.executable,
encrypted: false,
hash_status: "verified",
source_location: null,
limitations: [],
});
export const rekeyOccurrences = (
rootArtifactId: string,
occurrences: MutableOccurrence[],
): void => {
const replacements = new Map<string, string>();
for (const occurrence of occurrences)
replacements.set(
occurrence.occurrence_id,
`occ_${canonicalDigest(
{
root_artifact_id: rootArtifactId,
logical_path: occurrence.logical_path,
entry_kind: occurrence.entry_kind,
},
"Artifact",
)}`,
);
for (const occurrence of occurrences) {
occurrence.occurrence_id =
replacements.get(occurrence.occurrence_id) ?? occurrence.occurrence_id;
if (occurrence.parent_occurrence_id !== null)
occurrence.parent_occurrence_id =
replacements.get(occurrence.parent_occurrence_id) ??
occurrence.parent_occurrence_id;
}
};
export const createArtifactEdges = (
rootArtifactId: string,
occurrences: MutableOccurrence[],
producer?: ArtifactCommand,
): ArtifactEdge[] => {
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
const byPath = indexOccurrencesByPath(occurrences);
const edges: ArtifactEdge[] = [];
for (const occurrence of occurrences) {
if (
occurrence.parent_occurrence_id === null ||
occurrence.artifact_id === null
)
continue;
const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map")
? byPath.get(occurrence.logical_path.slice(0, -".map".length))
: undefined;
const parentArtifactId =
mappedSource?.artifact_id ??
byId.get(occurrence.parent_occurrence_id)?.artifact_id ??
rootArtifactId;
const semantic = {
parent_artifact_id: parentArtifactId,
child_artifact_id: occurrence.artifact_id,
relation:
occurrence.entry_kind === "slice"
? ("slice-of" as const)
: relationFor(occurrence.logical_path),
occurrence_id: occurrence.occurrence_id,
logical_path: occurrence.logical_path,
};
edges.push({
edge_id: `edge_${canonicalDigest(semantic, "Artifact")}`,
...semantic,
producer: occurrence.entry_kind === "slice" ? (producer ?? null) : null,
ordinal: edges.length,
});
}
return edges;
};
export const nearestParent = (
path: string,
occurrences: ReadonlyMap<string, MutableOccurrence>,
expandedContainerIds: ReadonlySet<string>,
): MutableOccurrence | undefined => {
const parts = path.split("/");
while (parts.length > 1) {
parts.pop();
const candidate = occurrences.get(parts.join("/"));
if (
candidate !== undefined &&
(candidate.entry_kind === "directory" ||
expandedContainerIds.has(candidate.occurrence_id))
)
return candidate;
}
return undefined;
};
export const classifyArtifactPath = (
path: string,
): Pick<ArtifactNode, "kind" | "format"> => {
const lower = path.toLowerCase();
if (lower.endsWith(".map"))
return { kind: "source-map", format: "source-map" };
if (/\.(?:m?js|cjs)$/u.test(lower))
return { kind: "javascript", format: "javascript-bundle" };
if (lower.endsWith(".asar")) return { kind: "container", format: "asar" };
const archiveFormat = zipPackageFormatForPath(lower);
if (archiveFormat !== undefined)
return { kind: "container", format: archiveFormat };
if (/\.framework(?:\/|$)/u.test(lower))
return { kind: "framework", format: "file" };
if (/\.(?:node|dylib|so)$/u.test(lower))
return {
kind: lower.endsWith(".node") ? "native-addon" : "dynamic-library",
format: "file",
};
if (lower.endsWith(".plist")) return { kind: "plist", format: "plist" };
if (lower.endsWith(".entitlements"))
return { kind: "entitlements", format: "entitlements" };
return { kind: "resource", format: "file" };
};
/** Classify executable formats from bytes while retaining path-specific roles. */
export const classifyArtifactContent = (
path: string,
prefix: Buffer,
fileSize = prefix.length,
): Pick<ArtifactNode, "kind" | "format"> => {
const byPath = classifyArtifactPath(path);
if (prefix.length >= 4) {
const magic = prefix.readUInt32BE(0);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return {
kind: byPath.kind === "native-addon" ? "native-addon" : "executable",
format: "mach-o-universal",
};
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return {
kind: ["native-addon", "dynamic-library"].includes(byPath.kind)
? byPath.kind
: "executable",
format: "mach-o",
};
if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
return {
kind: ["native-addon", "dynamic-library"].includes(byPath.kind)
? byPath.kind
: "executable",
format: "elf",
};
}
if (prefix.length >= 2 && prefix[0] === 0x4d && prefix[1] === 0x5a) {
if (
!Number.isSafeInteger(fileSize) ||
fileSize < prefix.length ||
prefix.length < Math.min(fileSize, 64)
)
return { kind: "unknown", format: "unknown" };
const windowsOffset = mzWindowsHeaderOffset(prefix);
if (windowsOffset !== null) {
if (
windowsOffset >= 64 &&
windowsOffset <= prefix.length - 4 &&
prefix
.subarray(windowsOffset, windowsOffset + 4)
.equals(Buffer.from([0x50, 0x45, 0, 0]))
)
return {
kind: byPath.kind === "native-addon" ? "native-addon" : "executable",
format: "pe",
};
} else if (parseDosMzHeader(prefix, fileSize).ok) {
return { kind: "executable", format: "dos-mz" };
}
// Missing bounded evidence and malformed MZ bytes both remain unclassified.
return { kind: "unknown", format: "unknown" };
}
return byPath;
};
const relationFor = (path: string): ArtifactEdge["relation"] => {
const lower = path.toLowerCase();
if (lower.endsWith(".map")) return "maps-source";
if (lower.includes(".framework/")) return "embeds";
return "contains";
};
const depth = (path: string): number => path.split("/").length;
-43
View File
@@ -1,43 +0,0 @@
import { realpath } from "node:fs/promises";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "../domain/artifactGraph.js";
import { abortIfNeeded } from "../artifacts/ArtifactHash.js";
import { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
import type {
ArtifactIntegrityPolicy,
ArtifactInventoryOptions,
ArtifactInventorySnapshot,
} from "./ArtifactInventory/types.js";
export { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
export type {
ArtifactIntegrityPolicy,
ArtifactInventoryOptions,
ArtifactInventorySnapshot,
} from "./ArtifactInventory/types.js";
/** Inventory one local artifact and return every graph collection inline. */
export const inventoryArtifact = async (
inputPath: string,
options: {
readonly signal?: AbortSignal;
readonly integrity?: ArtifactIntegrityPolicy;
} = {},
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(inputPath, options);
return artifactInventoryResultSchema.parse(snapshot);
};
/** Scan an artifact once and retain the complete immutable graph for projection. */
export const scanArtifactInventory = async (
inputPath: string,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
abortIfNeeded(options.signal);
const path = await realpath(inputPath);
return scanCanonicalArtifactInventory(path, options);
};
@@ -1,40 +0,0 @@
import { open } from "node:fs/promises";
import { classifyArtifactContent } from "../ArtifactGraphConstruction.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../artifacts/ArtifactHash.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
hasZipSignature,
zipPackageFormatForPath,
} from "../../domain/zipPackageFormat.js";
const classifyContainerExtension = (
path: string,
): ArtifactNode["format"] | undefined => {
const lower = path.toLowerCase();
const zipPackage = zipPackageFormatForPath(lower);
if (zipPackage !== undefined) return zipPackage;
for (const format of ["asar", "dmg", "pkg"] as const)
if (lower.endsWith(`.${format}`)) return format;
return undefined;
};
export const classifyRoot = async (
path: string,
directory: boolean,
): Promise<ArtifactNode["format"]> => {
if (directory) return "directory";
const extensionFormat = classifyContainerExtension(path);
if (extensionFormat !== undefined) return extensionFormat;
const handle = await open(path, "r");
try {
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
const observed = await handle.read(magic, 0, magic.length, 0);
const prefix = magic.subarray(0, observed.bytesRead);
if (hasZipSignature(prefix)) return "zip";
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
.format;
} finally {
await handle.close();
}
};
@@ -1,11 +0,0 @@
import type { ArtifactIntegrityPolicy } from "./types.js";
/** Parsed caller intent for integrity mismatch handling. */
export type ArtifactIntegrityIntent =
| { readonly mode: "fail" }
| { readonly mode: "record-and-continue" };
/** Preserve the explicit integrity admission choice without changing verification. */
export const resolveArtifactIntegrityPolicy = (
intent: ArtifactIntegrityIntent,
): ArtifactIntegrityPolicy => intent;
@@ -1,49 +0,0 @@
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import type { ArtifactReader } from "../../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js";
import { MachOSliceArtifactReader } from "../../artifacts/MachOSliceArtifactReader.js";
import { NativeDmgArtifactReader } from "../../artifacts/NativeDmgArtifactReader.js";
import { ZipArtifactReader } from "../../artifacts/ZipArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
export const createReader = async (
path: string,
format: ArtifactNode["format"],
signal?: AbortSignal,
): Promise<ArtifactReader | undefined> => {
switch (format) {
case "directory":
return new DirectoryArtifactReader(path);
case "zip":
case "ipa":
case "apk":
case "msix":
case "appx":
return new ZipArtifactReader(path, format);
case "asar":
return new AsarArtifactReader(path);
case "mach-o-universal":
return process.platform === "darwin"
? new MachOSliceArtifactReader(path)
: undefined;
case "dmg":
if (process.platform !== "darwin") return undefined;
return NativeDmgArtifactReader.create(path, signal);
default:
return undefined;
}
};
export const inventoryLimitations = (
format: ArtifactNode["format"],
reader: ArtifactReader | undefined,
): string[] => {
if (reader !== undefined) return [];
if (format === "dmg" || format === "pkg")
return [
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
];
if (format === "mach-o-universal" && process.platform !== "darwin")
return ["Universal Mach-O slices require the native macOS lipo adapter."];
return ["Artifact has no child container entries."];
};
@@ -1,271 +0,0 @@
import { createReadStream } from "node:fs";
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
import {
ArtifactReaderFailure,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
type ArtifactNode,
type IntegrityContradiction,
} from "../../domain/artifactGraph.js";
import {
createArtifactEdges,
createRootNode,
indexOccurrencesByPath,
materializeDirectoryNodes,
rekeyOccurrences,
rootOccurrenceFor,
type MutableOccurrence,
} from "../ArtifactGraphConstruction.js";
import { canonicalDigest } from "../../domain/comparisonSemantics.js";
import { classifyRoot } from "./classify.js";
import { hashReadable, type HashResult } from "../../artifacts/ArtifactHash.js";
import { createReader, inventoryLimitations } from "./reader.js";
import {
scanReader,
type PendingIntegrityContradiction,
} from "./scanReader.js";
import {
STRICT_INTEGRITY_POLICY,
type ArtifactInventoryOptions,
type ArtifactInventorySnapshot,
} from "./types.js";
export const scanCanonicalArtifactInventory = async (
path: string,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
? null
: await hashReadable(createReadStream(path), options.signal);
const reader = await createReader(path, rootFormat, options.signal);
try {
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
options.signal,
integrity,
);
return await buildInventorySnapshot({
path,
metadata,
rootFormat,
rootDigest,
reader,
signal: options.signal,
nodes,
occurrences,
pendingContradictions,
});
} finally {
await reader?.close();
}
};
interface SnapshotBuildInput {
readonly path: string;
readonly metadata: Stats;
readonly rootFormat: ArtifactNode["format"];
readonly rootDigest: HashResult | null;
readonly reader: ArtifactReader | undefined;
readonly signal: AbortSignal | undefined;
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
}
const buildInventorySnapshot = async (
input: SnapshotBuildInput,
): Promise<ArtifactInventorySnapshot> => {
const { path, metadata, rootFormat, rootDigest, reader, signal } = input;
materializeDirectoryNodes(input.occurrences, input.nodes);
const rootNode = createRootNode({
path,
format: rootFormat,
directory: metadata.isDirectory(),
digest: rootDigest,
occurrences: input.occurrences,
});
input.nodes.set(rootNode.artifact_id, rootNode);
rekeyOccurrences(rootNode.artifact_id, input.occurrences);
const rootOccurrence = rootOccurrenceFor(rootNode, metadata.size);
for (const occurrence of input.occurrences)
if (occurrence.parent_occurrence_id === null)
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
input.occurrences.unshift(rootOccurrence);
const occurrenceById = new Map(
input.occurrences.map((occurrence) => [
occurrence.occurrence_id,
occurrence,
]),
);
const occurrenceByPath = indexOccurrencesByPath(input.occurrences);
const integrityContradictions = buildIntegrityContradictions(
input.pendingContradictions,
occurrenceByPath,
occurrenceById,
rootNode,
);
const edges = createArtifactEdges(
rootNode.artifact_id,
input.occurrences,
reader?.provenance()[0],
);
const orderedNodes = sortNodes([...input.nodes.values()]);
const orderedOccurrences = sortOccurrences(input.occurrences);
const orderedEdges = sortEdges(edges);
await verifyRootDigest(path, rootDigest, signal);
const graphSha256 = canonicalDigest(
{
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
integrity_contradictions: integrityContradictions,
},
"Artifact",
);
const manifest = buildManifest({
rootNode,
graphSha256,
orderedNodes,
orderedOccurrences,
orderedEdges,
});
return {
manifest,
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: buildLimitations(rootFormat, reader, integrityContradictions),
};
};
const buildIntegrityContradictions = (
pending: readonly PendingIntegrityContradiction[],
occurrenceByPath: ReadonlyMap<string, MutableOccurrence>,
occurrenceById: ReadonlyMap<string, MutableOccurrence>,
rootNode: ArtifactNode,
): IntegrityContradiction[] =>
pending.map((contradiction): IntegrityContradiction => {
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"integrity",
"Integrity contradiction lost its graph occurrence",
);
const parent =
occurrence.parent_occurrence_id === null
? undefined
: occurrenceById.get(occurrence.parent_occurrence_id);
const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id;
return {
contradiction_id: `ic_${canonicalDigest(
{
root_artifact_id: rootNode.artifact_id,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
},
"Artifact",
)}`,
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: parentArtifactId,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
entry_kind: contradiction.entryKind,
unpacked: contradiction.unpacked,
trust: "observed-untrusted",
provenance: "container-integrity-metadata-versus-observed-bytes",
limitations: [
"Observed bytes contradict declared integrity metadata and cannot support equivalence.",
],
};
});
const verifyRootDigest = async (
path: string,
rootDigest: HashResult | null,
signal: AbortSignal | undefined,
): Promise<void> => {
if (rootDigest === null) return;
const verified = await hashReadable(createReadStream(path), signal);
if (
verified.sha256 !== rootDigest.sha256 ||
verified.bytes !== rootDigest.bytes
)
throw new ArtifactReaderFailure(
"integrity",
"Root artifact changed during inventory",
);
};
const sortNodes = (nodes: ArtifactNode[]): ArtifactNode[] =>
nodes.sort((left, right) =>
left.artifact_id.localeCompare(right.artifact_id),
);
const sortOccurrences = (
occurrences: MutableOccurrence[],
): MutableOccurrence[] =>
occurrences.sort((left, right) =>
left.logical_path.localeCompare(right.logical_path, "en"),
);
const sortEdges = <T extends { edge_id: string }>(edges: T[]): T[] =>
edges.sort((left, right) => left.edge_id.localeCompare(right.edge_id));
const buildManifest = ({
rootNode,
graphSha256,
orderedNodes,
orderedOccurrences,
orderedEdges,
}: {
readonly rootNode: ArtifactNode;
readonly graphSha256: string;
readonly orderedNodes: readonly ArtifactNode[];
readonly orderedOccurrences: readonly unknown[];
readonly orderedEdges: readonly { edge_id: string }[];
}): ArtifactInventoryResult["manifest"] =>
artifactInventoryResultSchema.shape.manifest.parse({
manifest_id: `agm_${canonicalDigest(
{
root_artifact_id: rootNode.artifact_id,
graph_sha256: graphSha256,
},
"Artifact",
)}`,
root_artifact_id: rootNode.artifact_id,
root_sha256: rootNode.sha256,
root_format: rootNode.format,
graph_sha256: graphSha256,
node_count: orderedNodes.length,
occurrence_count: orderedOccurrences.length,
edge_count: orderedEdges.length,
});
const buildLimitations = (
rootFormat: ArtifactNode["format"],
reader: ArtifactReader | undefined,
integrityContradictions: readonly IntegrityContradiction[],
): string[] => [
...inventoryLimitations(rootFormat, reader),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
];
@@ -1,254 +0,0 @@
import { isAbsolute } from "node:path";
import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../../artifacts/ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
classifyArtifactContent,
createArtifactNode,
createOccurrence,
nearestParent,
type MutableOccurrence,
} from "../ArtifactGraphConstruction.js";
import {
STRICT_INTEGRITY_POLICY,
type ArtifactIntegrityPolicy,
} from "./types.js";
import { hashReadable } from "../../artifacts/ArtifactHash.js";
export interface PendingIntegrityContradiction {
readonly logicalPath: string;
readonly declaredSha256: string;
readonly observedSha256: string;
readonly entryKind: "file" | "slice";
readonly unpacked: boolean;
}
const UNAVAILABLE_UNPACKED_LIMITATION =
"ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.";
const emptyScan = (): {
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
} => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] });
interface ScanContext {
readonly reader: ArtifactReader;
readonly signal: AbortSignal | undefined;
readonly integrity: ArtifactIntegrityPolicy;
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly occurrenceByPath: Map<string, MutableOccurrence>;
readonly registry: ArtifactPathRegistry;
readonly expandedContainerIds: Set<string>;
}
export const scanReader = async (
reader: ArtifactReader | undefined,
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<{
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
return { nodes, occurrences, pendingContradictions };
const context: ScanContext = {
reader,
signal,
integrity,
nodes,
occurrences,
pendingContradictions,
occurrenceByPath: new Map<string, MutableOccurrence>(),
registry: new ArtifactPathRegistry(),
expandedContainerIds: new Set<string>(),
};
await visitArtifactEntries(context, reader, "");
// Archive directories may appear after their children. Resolve containment
// against the complete index before directory identities are materialized.
for (const occurrence of occurrences)
occurrence.parent_occurrence_id =
nearestParent(
occurrence.logical_path,
context.occurrenceByPath,
context.expandedContainerIds,
)?.occurrence_id ?? null;
return { nodes, occurrences, pendingContradictions };
};
const visitArtifactEntries = async (
context: ScanContext,
currentReader: ArtifactReader,
prefix: string,
): Promise<void> => {
const stack: Array<{
readonly reader: ArtifactReader;
readonly prefix: string;
readonly iterator: AsyncIterator<ArtifactEntry>;
readonly owned: boolean;
}> = [
{
reader: currentReader,
prefix,
iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](),
owned: false,
},
];
try {
while (stack.length > 0) {
const frame = stack.at(-1);
if (frame === undefined) break;
const next = await frame.iterator.next();
if (next.done) {
stack.pop();
if (frame.owned) await frame.reader.close();
continue;
}
const entry = next.value;
const logicalPath = normalizeArtifactPath(
frame.prefix.length === 0
? entry.path
: `${frame.prefix}/${entry.path}`,
);
const expandableAsar = isExpandableAsar(entry, logicalPath);
context.registry.add(
logicalPath,
expandableAsar ? "directory" : entry.kind,
);
const occurrence = createOccurrence(entry, logicalPath, null);
let digested:
| { readonly node: ArtifactNode; readonly mismatched: boolean }
| undefined;
try {
digested = await digestArtifactEntry(
context,
frame.reader,
entry,
logicalPath,
);
} catch (cause: unknown) {
if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
occurrence.hash_status = "unavailable";
occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION);
}
if (digested !== undefined) {
context.nodes.set(digested.node.artifact_id, digested.node);
occurrence.artifact_id = digested.node.artifact_id;
occurrence.hash_status = digested.mismatched
? "mismatched"
: "verified";
if (digested.mismatched)
occurrence.limitations.push(
"Declared integrity metadata contradicts observed bytes.",
);
}
context.occurrences.push(occurrence);
context.occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true) {
const nested = new AsarArtifactReader(entry.adapterKey);
// Only traversed containers can own members, not opaque ASAR-named files.
context.expandedContainerIds.add(occurrence.occurrence_id);
stack.push({
reader: nested,
prefix: logicalPath,
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
owned: true,
});
}
}
} finally {
await Promise.allSettled(
stack
.filter(({ owned }) => owned)
.map(async ({ reader, iterator }) => {
await iterator.return?.();
await reader.close();
}),
);
}
};
const digestArtifactEntry = async (
context: ScanContext,
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
): Promise<
{ readonly node: ArtifactNode; readonly mismatched: boolean } | undefined
> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const digest = await hashReadable(
await currentReader.open(entry, context.signal),
context.signal,
);
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && entry.declaredSha256 !== null) {
if (context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
context.pendingContradictions.push({
logicalPath,
declaredSha256: entry.declaredSha256,
observedSha256: digest.sha256,
entryKind: entry.kind,
unpacked: entry.unpacked,
});
}
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
: classifyArtifactContent(logicalPath, digest.prefix, digest.bytes);
return {
node: createArtifactNode({
sha256: digest.sha256,
size: digest.bytes,
kind: classified.kind,
format: classified.format,
executable: entry.executable,
contentState: "embedded",
limitations: mismatched
? [
"Observed content contradicts declared integrity metadata and is untrusted.",
]
: [],
}),
mismatched,
};
};
const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean =>
entry.kind === "file" &&
logicalPath.toLowerCase().endsWith(".asar") &&
isAbsolute(entry.adapterKey);
const isUnavailableUnpackedEntry = (
cause: unknown,
entry: ArtifactEntry,
): boolean =>
entry.unpacked &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "unavailable";
@@ -1,16 +0,0 @@
/** Resolved integrity behavior admitted to the artifact scanner. */
export type ArtifactIntegrityPolicy =
| { readonly mode: "fail" }
| { readonly mode: "record-and-continue" };
export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
mode: "fail",
};
/** Options shared by artifact inventory scans. */
export interface ArtifactInventoryOptions {
readonly signal?: AbortSignal | undefined;
readonly integrity?: ArtifactIntegrityPolicy | undefined;
}
export type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
@@ -1,4 +1,4 @@
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
import type { ArtifactInventorySnapshot } from "../../artifacts/inventory/ArtifactInventory.js";
import {
createJavaScriptApplicationGraph,
type JavaScriptApplicationGraph,
@@ -1,4 +1,4 @@
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
import type { ArtifactInventorySnapshot } from "../../artifacts/inventory/ArtifactInventory.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
@@ -1,4 +1,4 @@
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
import type { ArtifactInventorySnapshot } from "../../artifacts/inventory/ArtifactInventory.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import { completeApplicationCoverage } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
@@ -12,7 +12,7 @@ import {
javascriptArtifactReconstructionInputSchema,
type JavaScriptArtifactReconstructionInput,
} from "./JavaScriptArtifactReconstructionInput.js";
import { scanCanonicalArtifactInventory } from "../ArtifactInventory.js";
import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/ArtifactInventory.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";