Files
rea/src/artifacts/inventory/scanReader.ts
T

369 lines
12 KiB
TypeScript

import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
import {
artifactIntegrityPair,
artifactIntegrityDetails,
type ArtifactIntegrityPair,
} from "../../domain/artifactIntegrity.js";
import { isAbsolute } from "node:path";
import {
annotateEnglishUnicodeCaseCollisions,
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
} from "../ArtifactReader.js";
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
import { DirectoryArtifactEntryAccessDenied } from "../DirectoryArtifactReader.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
classifyArtifactContent,
classifyArtifactBytes,
createArtifactNode,
createOccurrence,
noteUnreadContainerSuffix,
indexDestinationOccurrencesByPath,
resolveOccurrenceParents,
type MutableOccurrence,
} from "./ArtifactGraphConstruction.js";
import {
STRICT_INTEGRITY_POLICY,
type ArtifactIntegrityPolicy,
} from "./types.js";
import { abortIfNeeded, hashReadable } from "../ArtifactHash.js";
export interface PendingIntegrityContradiction {
readonly occurrence: MutableOccurrence;
readonly integrity: ArtifactIntegrityPair;
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 asarHeaderAdmission: AsarHeaderAdmission | undefined;
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly occurrenceCountByPath: Map<string, number>;
readonly registry: ArtifactPathRegistry;
readonly expandedContainerIds: Set<string>;
readonly ownedReaders: ArtifactReader[];
readonly traversal: ArtifactTraversalObservation;
}
export const scanReader = async (
reader: ArtifactReader | undefined,
ownedReaders: ArtifactReader[],
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
asarHeaderAdmission?: AsarHeaderAdmission,
): Promise<{
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
readonly traversal: ArtifactReaderTraversal;
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
return {
nodes,
occurrences,
pendingContradictions,
caseCollisionLimitation: undefined,
unknownParentOccurrences: new Set(),
traversal: { status: "complete", limitations: [] },
};
const context: ScanContext = {
reader,
signal,
integrity,
asarHeaderAdmission,
nodes,
occurrences,
pendingContradictions,
occurrenceCountByPath: new Map<string, number>(),
registry: new ArtifactPathRegistry(),
expandedContainerIds: new Set<string>(),
ownedReaders,
traversal: new ArtifactTraversalObservation(),
};
await visitArtifactEntries(context, reader, "");
// Archive directories may appear after their children. Resolve containment
// against the complete index before directory identities are materialized.
const byPath = indexDestinationOccurrencesByPath(occurrences);
const parentResolution = resolveOccurrenceParents(
occurrences,
byPath,
context.expandedContainerIds,
);
const structuralLimitations = [...parentResolution.limitations];
context.traversal.onCoverage({
status: structuralLimitations.length === 0 ? "complete" : "unknown",
limitations: structuralLimitations,
});
return {
nodes,
occurrences,
pendingContradictions,
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
unknownParentOccurrences: parentResolution.unknownParentOccurrences,
traversal: context.traversal.snapshot(),
};
};
const inventoryEntries = (
context: ScanContext,
reader: ArtifactReader,
prefix: string,
): AsyncIterator<ArtifactEntry> =>
reader
.entries(context.signal, {
onCoverage: context.traversal.onCoverage,
onOmission: (omission) =>
context.traversal.onOmission({
...omission,
path:
prefix.length === 0 ? omission.path : `${prefix}/${omission.path}`,
}),
})
[Symbol.asyncIterator]();
const visitArtifactEntries = async (
context: ScanContext,
currentReader: ArtifactReader,
prefix: string,
): Promise<void> => {
const stack: Array<{
readonly reader: ArtifactReader;
readonly prefix: string;
readonly containerId: string | null;
readonly iterator: AsyncIterator<ArtifactEntry>;
}> = [
{
reader: currentReader,
prefix,
containerId: null,
iterator: inventoryEntries(context, currentReader, prefix),
},
];
let failure: { readonly cause: unknown } | undefined;
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();
continue;
}
const entry = next.value;
const logicalPath = normalizeArtifactPath(
frame.prefix.length === 0
? entry.path
: `${frame.prefix}/${entry.path}`,
);
const expandableAsar = isExpandableAsar(entry, logicalPath);
// PyInstaller TOC rows are source-distinct even when their logical
// destinations collide. Inventory retains those observations; the
// extraction path registry remains the authority for materialization.
if (frame.reader.format !== "pyinstaller")
context.registry.add(
logicalPath,
expandableAsar ? "directory" : entry.kind,
);
const duplicateOrdinal =
context.occurrenceCountByPath.get(logicalPath) ?? 0;
context.occurrenceCountByPath.set(logicalPath, duplicateOrdinal + 1);
const occurrence = createOccurrence(
entry,
logicalPath,
entry.kind === "metadata" ? frame.containerId : null,
duplicateOrdinal,
);
let digested:
| {
readonly node: ArtifactNode;
readonly mismatched: boolean;
readonly classification: ReturnType<typeof classifyArtifactContent>;
}
| undefined;
try {
digested = await digestArtifactEntry(
context,
frame.reader,
entry,
logicalPath,
occurrence,
);
} catch (cause: unknown) {
let limitation = UNAVAILABLE_UNPACKED_LIMITATION;
if (cause instanceof DirectoryArtifactEntryAccessDenied) {
abortIfNeeded(context.signal);
limitation = cause.message;
context.traversal.onCoverage({
status: "unknown",
limitations: [limitation],
});
} else if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
occurrence.hash_status = "unavailable";
occurrence.limitations.push(limitation);
}
if (digested === undefined) noteUnreadContainerSuffix(occurrence);
else {
context.nodes.set(digested.node.artifact_id, digested.node);
occurrence.artifact_id = digested.node.artifact_id;
occurrence.artifact_kind = digested.classification.kind;
occurrence.artifact_format = digested.classification.format;
occurrence.hash_status = digested.mismatched
? "mismatched"
: "verified";
if (digested.mismatched)
occurrence.limitations.push(
"Declared integrity metadata contradicts observed bytes.",
);
}
context.occurrences.push(occurrence);
if (
expandableAsar &&
digested !== undefined &&
digested.classification.format === "asar" &&
!digested.mismatched
) {
const nested = new AsarArtifactReader(
entry.adapterKey,
context.asarHeaderAdmission === undefined
? {}
: { headerAdmission: context.asarHeaderAdmission },
);
context.ownedReaders.push(nested);
await nested.prepareContainer(digested.node.sha256, context.signal);
// Only traversed containers can own members, not opaque ASAR-named files.
context.expandedContainerIds.add(occurrence.occurrence_id);
stack.push({
reader: nested,
prefix: logicalPath,
containerId: occurrence.occurrence_id,
iterator: inventoryEntries(context, nested, logicalPath),
});
}
}
} catch (cause: unknown) {
failure = { cause };
} finally {
// Readers remain owned by the inventory until its complete observation is built.
for (const { iterator, prefix, reader } of stack.reverse()) {
try {
await iterator.return?.();
} catch (cause: unknown) {
failure = {
cause: ArtifactReaderFailure.withCleanup(
failure === undefined ? cause : failure.cause,
ArtifactReaderFailure.cleanupObservation(
cause,
`artifact traversal for ${prefix || reader.format}`,
),
),
};
}
}
}
if (failure !== undefined) throw failure.cause;
};
const digestArtifactEntry = async (
context: ScanContext,
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
occurrence: MutableOccurrence,
): Promise<
| {
readonly node: ArtifactNode;
readonly mismatched: boolean;
readonly classification: ReturnType<typeof classifyArtifactContent>;
}
| undefined
> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const digest = await hashReadable(
await currentReader.open(entry, context.signal),
context.signal,
entry.declaredIntegrity?.algorithm,
context.asarHeaderAdmission,
);
const claim = entry.declaredIntegrity;
const observed = claim?.algorithm === "md5" ? digest.md5 : digest.sha256;
if (claim !== null && observed === undefined)
throw new ArtifactReaderFailure(
"integrity",
"The selected integrity digest was not computed",
);
const mismatched = claim !== null && claim.digest !== observed;
if (mismatched && claim !== null && observed !== undefined) {
if (context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
artifactIntegrityDetails(logicalPath, claim, observed, entry.unpacked),
);
context.pendingContradictions.push({
occurrence,
integrity: artifactIntegrityPair(claim, observed),
entryKind: entry.kind,
unpacked: entry.unpacked,
});
}
const classified =
entry.kind === "slice"
? {
kind: "universal-slice" as const,
format: "mach-o" as const,
contentFormat: classifyArtifactBytes(digest, digest.bytes),
}
: classifyArtifactContent(logicalPath, digest, digest.bytes);
return {
node: createArtifactNode({
sha256: digest.sha256,
size: digest.bytes,
format: classified.contentFormat,
contentState: "embedded",
}),
mismatched,
classification: classified,
};
};
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";