mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
refactor(artifacts): move inventory and extraction into provider owners (#1032)
This commit is contained in:
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user