mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
4b985e5021
Inventory gave nested members a ZIP or ASAR family from their path suffix. Byte classification replaced it only for ZIP magic, so text named `.apk` or `.asar` stayed a container, and encrypted entries and symlinks kept the suffix guess. A filesystem `.asar` without an ASAR header failed the whole inventory as a malformed archive. Nested ZIP families now follow ZIP magic and ASAR follows its header pickle, shared with root classification. An unread member named like a container has an unknown format and a limitation naming the suffix. ASAR expansion requires the header. The Android projection adds a limitation when no root AndroidManifest.xml was observed, and its rejection names the observed root format. Fixes #1491 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
8.6 KiB
TypeScript
275 lines
8.6 KiB
TypeScript
import { isAbsolute } from "node:path";
|
|
|
|
import {
|
|
annotateEnglishUnicodeCaseCollisions,
|
|
ArtifactPathRegistry,
|
|
normalizeArtifactPath,
|
|
} from "../ArtifactPaths.js";
|
|
import {
|
|
ArtifactReaderFailure,
|
|
type ArtifactEntry,
|
|
type ArtifactReader,
|
|
} from "../ArtifactReader.js";
|
|
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
|
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
|
import {
|
|
classifyArtifactContent,
|
|
classifyArtifactBytes,
|
|
createArtifactNode,
|
|
createOccurrence,
|
|
noteUnreadContainerSuffix,
|
|
nearestParent,
|
|
type MutableOccurrence,
|
|
} from "./ArtifactGraphConstruction.js";
|
|
import {
|
|
STRICT_INTEGRITY_POLICY,
|
|
type ArtifactIntegrityPolicy,
|
|
} from "./types.js";
|
|
import { hashReadable } from "../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>;
|
|
readonly ownedReaders: ArtifactReader[];
|
|
}
|
|
|
|
export const scanReader = async (
|
|
reader: ArtifactReader | undefined,
|
|
ownedReaders: ArtifactReader[],
|
|
signal?: AbortSignal,
|
|
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
|
|
): Promise<{
|
|
readonly nodes: Map<string, ArtifactNode>;
|
|
readonly occurrences: MutableOccurrence[];
|
|
readonly pendingContradictions: PendingIntegrityContradiction[];
|
|
readonly caseCollisionLimitation: string | undefined;
|
|
}> => {
|
|
const { nodes, occurrences, pendingContradictions } = emptyScan();
|
|
if (reader === undefined)
|
|
return {
|
|
nodes,
|
|
occurrences,
|
|
pendingContradictions,
|
|
caseCollisionLimitation: undefined,
|
|
};
|
|
const context: ScanContext = {
|
|
reader,
|
|
signal,
|
|
integrity,
|
|
nodes,
|
|
occurrences,
|
|
pendingContradictions,
|
|
occurrenceByPath: new Map<string, MutableOccurrence>(),
|
|
registry: new ArtifactPathRegistry(),
|
|
expandedContainerIds: new Set<string>(),
|
|
ownedReaders,
|
|
};
|
|
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,
|
|
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
|
|
};
|
|
};
|
|
|
|
const visitArtifactEntries = async (
|
|
context: ScanContext,
|
|
currentReader: ArtifactReader,
|
|
prefix: string,
|
|
): Promise<void> => {
|
|
const stack: Array<{
|
|
readonly reader: ArtifactReader;
|
|
readonly prefix: string;
|
|
readonly iterator: AsyncIterator<ArtifactEntry>;
|
|
}> = [
|
|
{
|
|
reader: currentReader,
|
|
prefix,
|
|
iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](),
|
|
},
|
|
];
|
|
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);
|
|
context.registry.add(
|
|
logicalPath,
|
|
expandableAsar ? "directory" : entry.kind,
|
|
);
|
|
const occurrence = createOccurrence(entry, logicalPath, null);
|
|
let digested:
|
|
| {
|
|
readonly node: ArtifactNode;
|
|
readonly mismatched: boolean;
|
|
readonly classification: ReturnType<typeof classifyArtifactContent>;
|
|
}
|
|
| 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) 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);
|
|
context.occurrenceByPath.set(logicalPath, occurrence);
|
|
if (
|
|
expandableAsar &&
|
|
digested !== undefined &&
|
|
digested.classification.format === "asar" &&
|
|
!digested.mismatched
|
|
) {
|
|
const nested = new AsarArtifactReader(entry.adapterKey);
|
|
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,
|
|
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
// Readers remain owned by the inventory until its complete observation is built.
|
|
for (const { iterator } of stack) await iterator.return?.();
|
|
}
|
|
};
|
|
|
|
const digestArtifactEntry = async (
|
|
context: ScanContext,
|
|
currentReader: ArtifactReader,
|
|
entry: ArtifactEntry,
|
|
logicalPath: string,
|
|
): 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,
|
|
);
|
|
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,
|
|
format: classifyArtifactBytes(digest.prefix, digest.bytes),
|
|
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";
|