2026-07-16 05:29:40 +08:00
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { isAbsolute } from "node:path";
|
|
|
|
|
import type { Readable } from "node:stream";
|
|
|
|
|
|
2026-10-09 23:17:17 +08:00
|
|
|
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
2026-10-07 17:57:33 +08:00
|
|
|
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
2026-10-11 05:33:15 +08:00
|
|
|
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
2026-07-16 05:29:40 +08:00
|
|
|
import {
|
|
|
|
|
ArtifactPathRegistry,
|
|
|
|
|
normalizeArtifactPath,
|
2026-10-07 17:57:33 +08:00
|
|
|
} from "../ArtifactPaths.js";
|
2026-07-16 05:29:40 +08:00
|
|
|
import {
|
|
|
|
|
ArtifactReaderFailure,
|
|
|
|
|
type ArtifactEntry,
|
|
|
|
|
type ArtifactReader,
|
2026-10-07 17:57:33 +08:00
|
|
|
} from "../ArtifactReader.js";
|
|
|
|
|
import { streamChunkToBuffer } from "../StreamBytes.js";
|
|
|
|
|
import { hashReadable } from "../ArtifactHash.js";
|
2026-10-10 02:00:08 +08:00
|
|
|
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
2026-10-07 17:57:33 +08:00
|
|
|
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
2026-10-12 04:07:14 +08:00
|
|
|
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
2026-10-14 08:31:03 +08:00
|
|
|
import { isJavaScriptSourcePath } from "../../domain/javascript/javascriptArtifactFiles.js";
|
2026-10-07 17:57:33 +08:00
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
JavaScriptArtifactFileKind,
|
|
|
|
|
JavaScriptArtifactFile,
|
|
|
|
|
JavaScriptArtifactContainer,
|
2026-10-14 08:31:03 +08:00
|
|
|
JavaScriptUnanalyzedArtifactFile,
|
|
|
|
|
JavaScriptUnanalyzedArtifactLookup,
|
2026-10-07 17:57:33 +08:00
|
|
|
JavaScriptArtifactFileSet,
|
|
|
|
|
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
2026-07-16 05:29:40 +08:00
|
|
|
|
|
|
|
|
interface ExpectedFile {
|
|
|
|
|
readonly path: string;
|
|
|
|
|
readonly sha256: string;
|
|
|
|
|
readonly bytes: number;
|
|
|
|
|
readonly inventoryArtifactId: string;
|
|
|
|
|
readonly kind: JavaScriptArtifactFileKind;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 02:00:08 +08:00
|
|
|
interface ExpectedContainer {
|
|
|
|
|
readonly hash_status: ArtifactOccurrence["hash_status"];
|
2026-10-13 09:23:58 -04:00
|
|
|
readonly format: ArtifactOccurrence["artifact_format"];
|
2026-10-10 02:00:08 +08:00
|
|
|
readonly inventory: JavaScriptArtifactContainer | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 05:29:40 +08:00
|
|
|
interface ReadContext {
|
2026-10-11 05:33:15 +08:00
|
|
|
readonly resources: ArtifactResourceScope;
|
2026-07-16 05:29:40 +08:00
|
|
|
readonly expected: ReadonlyMap<string, ExpectedFile>;
|
2026-10-10 02:00:08 +08:00
|
|
|
readonly expectedContainers: ReadonlyMap<string, ExpectedContainer>;
|
2026-07-16 05:29:40 +08:00
|
|
|
readonly registry: ArtifactPathRegistry;
|
|
|
|
|
readonly files: JavaScriptArtifactFile[];
|
|
|
|
|
readonly containers: JavaScriptArtifactContainer[];
|
|
|
|
|
readonly signal: AbortSignal | undefined;
|
2026-10-12 04:07:14 +08:00
|
|
|
readonly selectedPaths: ReadonlyMap<string, number> | undefined;
|
2026-07-16 05:29:40 +08:00
|
|
|
textBytes: number;
|
|
|
|
|
invalidUtf8: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ReadTextInput {
|
|
|
|
|
readonly reader: ArtifactReader;
|
|
|
|
|
readonly entry: ArtifactEntry;
|
|
|
|
|
readonly expected: ExpectedFile;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 04:07:14 +08:00
|
|
|
/** Verify inventoried entries; acquire text only inside the caller's scope. */
|
2026-07-16 05:29:40 +08:00
|
|
|
export const readJavaScriptArtifactFiles = async (
|
|
|
|
|
reader: ArtifactReader,
|
|
|
|
|
snapshot: ArtifactInventorySnapshot,
|
2026-10-11 05:33:15 +08:00
|
|
|
resources: ArtifactResourceScope,
|
2026-07-16 05:29:40 +08:00
|
|
|
signal?: AbortSignal,
|
2026-10-12 04:07:14 +08:00
|
|
|
sourcePaths?: readonly string[],
|
2026-07-16 05:29:40 +08:00
|
|
|
): Promise<JavaScriptArtifactFileSet> => {
|
2026-10-12 04:07:14 +08:00
|
|
|
const inventory = expectedInventory(snapshot);
|
|
|
|
|
const expected = inventory.files;
|
|
|
|
|
const selectedPaths =
|
|
|
|
|
sourcePaths === undefined
|
|
|
|
|
? undefined
|
|
|
|
|
: new Map([...new Set(sourcePaths)].map((path, index) => [path, index]));
|
|
|
|
|
const issues = (sourcePaths ?? []).flatMap((path, index) =>
|
|
|
|
|
expected.has(path) || inventory.unavailableFiles.has(path)
|
|
|
|
|
? []
|
|
|
|
|
: [
|
|
|
|
|
{
|
|
|
|
|
path: ["source_paths", index],
|
|
|
|
|
reason: "invalid_value" as const,
|
|
|
|
|
message:
|
|
|
|
|
"Select an exact relevant file path from the artifact inventory; no globs or path aliases.",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
if (issues.length > 0)
|
|
|
|
|
throw new AnalysisInputError(
|
|
|
|
|
"analyze_javascript_application",
|
|
|
|
|
undefined,
|
|
|
|
|
issues,
|
|
|
|
|
);
|
|
|
|
|
const unavailablePath = sourcePaths?.find((path) =>
|
|
|
|
|
inventory.unavailableFiles.has(path),
|
|
|
|
|
);
|
|
|
|
|
if (unavailablePath !== undefined)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"unavailable",
|
|
|
|
|
`Selected inventoried source bytes are unavailable: ${unavailablePath}`,
|
|
|
|
|
{
|
|
|
|
|
partialObservation: { kind: "artifact-inventory", inventory: snapshot },
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-10-07 10:33:10 +08:00
|
|
|
if (reader instanceof AsarArtifactReader) {
|
|
|
|
|
const root = snapshot.nodes.find(
|
|
|
|
|
({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id,
|
|
|
|
|
);
|
|
|
|
|
if (root === undefined)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
"Root artifact node is missing",
|
|
|
|
|
);
|
|
|
|
|
await verifyEntryBytes(
|
2026-10-10 06:19:41 +08:00
|
|
|
await reader.openContainer(signal),
|
2026-10-07 10:33:10 +08:00
|
|
|
{ path: ".", sha256: root.sha256, bytes: root.size },
|
|
|
|
|
signal,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-16 05:29:40 +08:00
|
|
|
const context: ReadContext = {
|
2026-10-11 05:33:15 +08:00
|
|
|
resources,
|
2026-07-16 05:29:40 +08:00
|
|
|
expected,
|
|
|
|
|
expectedContainers: inventory.containers,
|
|
|
|
|
registry: new ArtifactPathRegistry(),
|
|
|
|
|
files: [],
|
|
|
|
|
containers: [],
|
|
|
|
|
signal,
|
2026-10-12 04:07:14 +08:00
|
|
|
selectedPaths,
|
2026-07-16 05:29:40 +08:00
|
|
|
textBytes: 0,
|
|
|
|
|
invalidUtf8: 0,
|
|
|
|
|
};
|
|
|
|
|
await visitReader(reader, "", snapshot.manifest.root_sha256, context);
|
2026-10-12 04:07:14 +08:00
|
|
|
const files = context.files.sort((left, right) => {
|
|
|
|
|
const leftPriority = selectedPaths?.get(left.path) ?? Infinity;
|
|
|
|
|
const rightPriority = selectedPaths?.get(right.path) ?? Infinity;
|
|
|
|
|
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
|
|
|
|
return compareUnicodeCodePoints(left.path, right.path);
|
|
|
|
|
});
|
2026-10-14 16:53:16 +08:00
|
|
|
const unanalyzedFiles = createUnanalyzedArtifactLookup(
|
|
|
|
|
snapshot,
|
|
|
|
|
context.containers,
|
|
|
|
|
);
|
|
|
|
|
const unavailableRelevantFiles = [...inventory.unavailableFiles]
|
|
|
|
|
.sort(compareUnicodeCodePoints)
|
|
|
|
|
.flatMap((path) => {
|
|
|
|
|
const unavailable = unanalyzedFiles.get(path);
|
|
|
|
|
return unavailable === undefined ? [] : [unavailable];
|
|
|
|
|
});
|
2026-07-16 05:29:40 +08:00
|
|
|
assertExpectedFilesWereVisited(expected, files);
|
|
|
|
|
return {
|
|
|
|
|
files,
|
2026-10-14 16:53:16 +08:00
|
|
|
unanalyzed_files: unanalyzedFiles,
|
|
|
|
|
unavailable_relevant_files: unavailableRelevantFiles,
|
2026-07-16 05:29:40 +08:00
|
|
|
containers: context.containers.sort((left, right) =>
|
2026-10-09 23:17:17 +08:00
|
|
|
compareUnicodeCodePoints(left.path, right.path),
|
2026-07-16 05:29:40 +08:00
|
|
|
),
|
|
|
|
|
text_bytes_read: context.textBytes,
|
|
|
|
|
invalid_utf8_files: context.invalidUtf8,
|
2026-10-12 04:07:14 +08:00
|
|
|
...(sourcePaths === undefined
|
|
|
|
|
? {}
|
|
|
|
|
: {
|
|
|
|
|
source_selection: {
|
|
|
|
|
requested_paths: [...sourcePaths],
|
|
|
|
|
unselected_files: files.filter(
|
|
|
|
|
({ text }) =>
|
|
|
|
|
!text.included && text.reason === "outside-analysis-scope",
|
|
|
|
|
).length,
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-07-16 05:29:40 +08:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const visitReader = async (
|
|
|
|
|
reader: ArtifactReader,
|
|
|
|
|
prefix: string,
|
|
|
|
|
containerSha256: string,
|
|
|
|
|
context: ReadContext,
|
|
|
|
|
): Promise<void> => {
|
2026-09-29 12:15:49 +08:00
|
|
|
const stack: Array<{
|
|
|
|
|
readonly reader: ArtifactReader;
|
|
|
|
|
readonly prefix: string;
|
|
|
|
|
readonly containerSha256: string;
|
|
|
|
|
readonly iterator: AsyncIterator<ArtifactEntry>;
|
|
|
|
|
}> = [
|
|
|
|
|
{
|
2026-07-16 05:29:40 +08:00
|
|
|
reader,
|
2026-09-29 12:15:49 +08:00
|
|
|
prefix,
|
|
|
|
|
containerSha256,
|
|
|
|
|
iterator: reader.entries(context.signal)[Symbol.asyncIterator](),
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-10-11 05:33:15 +08:00
|
|
|
const ownedReaders: {
|
|
|
|
|
readonly reader: ArtifactReader;
|
|
|
|
|
readonly path: string;
|
|
|
|
|
}[] = [];
|
2026-10-10 06:19:41 +08:00
|
|
|
let failure: { readonly cause: unknown } | undefined;
|
2026-07-16 05:29:40 +08:00
|
|
|
try {
|
2026-09-29 12:15:49 +08:00
|
|
|
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;
|
|
|
|
|
abortIfNeeded(context.signal);
|
|
|
|
|
const path = normalizeArtifactPath(
|
|
|
|
|
frame.prefix === "" ? entry.path : `${frame.prefix}/${entry.path}`,
|
|
|
|
|
);
|
|
|
|
|
const nestedAsar = isFilesystemAsar(entry, path);
|
|
|
|
|
context.registry.add(path, nestedAsar ? "directory" : entry.kind);
|
|
|
|
|
if (nestedAsar) {
|
2026-10-10 02:00:08 +08:00
|
|
|
const container = context.expectedContainers.get(path);
|
|
|
|
|
// Inventory deliberately does not expand unverified or unavailable
|
|
|
|
|
// nested archives. Keep the same boundary here when reconstruction
|
|
|
|
|
// reads the active entries again.
|
|
|
|
|
if (container === undefined)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Nested ASAR was not present in inventory: ${path}`,
|
|
|
|
|
);
|
|
|
|
|
if (container.hash_status === "mismatched") continue;
|
|
|
|
|
if (container.hash_status === "unavailable")
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"unavailable",
|
|
|
|
|
`Nested ASAR bytes were unavailable during inventory: ${path}`,
|
|
|
|
|
);
|
|
|
|
|
if (container.hash_status !== "verified")
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Nested ASAR was not integrity-verified: ${path}`,
|
|
|
|
|
);
|
2026-10-13 09:23:58 -04:00
|
|
|
// Inventory expands an ASAR-named file only when its bytes are an
|
|
|
|
|
// ASAR. One it recorded as ordinary bytes has no members to read.
|
|
|
|
|
if (container.format !== "asar") continue;
|
2026-10-10 02:00:08 +08:00
|
|
|
const inventory = container.inventory;
|
|
|
|
|
if (inventory === null)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Verified nested ASAR has no inventory node: ${path}`,
|
|
|
|
|
);
|
2026-10-07 10:33:10 +08:00
|
|
|
await verifyEntryBytes(
|
|
|
|
|
await frame.reader.open(entry, context.signal),
|
|
|
|
|
inventory,
|
|
|
|
|
context.signal,
|
|
|
|
|
);
|
2026-09-29 12:15:49 +08:00
|
|
|
context.containers.push(inventory);
|
|
|
|
|
const nested = new AsarArtifactReader(entry.adapterKey);
|
2026-10-11 05:33:15 +08:00
|
|
|
ownedReaders.push({ reader: nested, path });
|
2026-09-29 12:15:49 +08:00
|
|
|
stack.push({
|
|
|
|
|
reader: nested,
|
|
|
|
|
prefix: path,
|
|
|
|
|
containerSha256: inventory.sha256,
|
|
|
|
|
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
|
|
|
|
|
});
|
2026-10-10 06:19:41 +08:00
|
|
|
await nested.prepareContainer(inventory.sha256, context.signal);
|
2026-09-29 12:15:49 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const expected = context.expected.get(path);
|
|
|
|
|
if (expected === undefined) continue;
|
2026-09-29 14:53:50 +08:00
|
|
|
const text = await readText(context, {
|
2026-09-29 12:15:49 +08:00
|
|
|
reader: frame.reader,
|
|
|
|
|
entry,
|
|
|
|
|
expected,
|
|
|
|
|
});
|
|
|
|
|
context.files.push({
|
|
|
|
|
path,
|
|
|
|
|
container_sha256: frame.containerSha256,
|
|
|
|
|
sha256: expected.sha256,
|
|
|
|
|
bytes: expected.bytes,
|
|
|
|
|
inventory_artifact_id: expected.inventoryArtifactId,
|
|
|
|
|
kind: expected.kind,
|
|
|
|
|
unpacked: entry.unpacked,
|
|
|
|
|
text,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-10 06:19:41 +08:00
|
|
|
} catch (cause: unknown) {
|
|
|
|
|
failure = { cause };
|
2026-07-16 05:29:40 +08:00
|
|
|
} finally {
|
2026-10-11 05:33:15 +08:00
|
|
|
for (const { iterator, prefix, reader } of stack.reverse()) {
|
2026-10-10 06:19:41 +08:00
|
|
|
try {
|
|
|
|
|
await iterator.return?.();
|
|
|
|
|
} catch (cause: unknown) {
|
|
|
|
|
failure = {
|
|
|
|
|
cause: ArtifactReaderFailure.withCleanup(
|
2026-10-11 05:33:15 +08:00
|
|
|
failure === undefined ? cause : failure.cause,
|
2026-10-10 06:19:41 +08:00
|
|
|
ArtifactReaderFailure.cleanupObservation(
|
|
|
|
|
cause,
|
2026-10-11 05:33:15 +08:00
|
|
|
`JavaScript artifact traversal for ${prefix || reader.format}`,
|
2026-10-10 06:19:41 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-11 05:33:15 +08:00
|
|
|
for (const owned of ownedReaders.reverse()) {
|
|
|
|
|
const resource = `nested ASAR reader for ${owned.path}`;
|
|
|
|
|
const cleanup = await context.resources.release({
|
|
|
|
|
kind: "reader",
|
|
|
|
|
reader: owned.reader,
|
|
|
|
|
resource,
|
|
|
|
|
});
|
|
|
|
|
if (cleanup.kind === "failed") {
|
|
|
|
|
failure = {
|
|
|
|
|
cause: ArtifactReaderFailure.withCleanup(
|
|
|
|
|
failure === undefined ? cleanup.cause : failure.cause,
|
|
|
|
|
ArtifactReaderFailure.cleanupObservation(cleanup.cause, resource),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-16 05:29:40 +08:00
|
|
|
}
|
2026-10-10 06:19:41 +08:00
|
|
|
if (failure !== undefined) throw failure.cause;
|
2026-07-16 05:29:40 +08:00
|
|
|
};
|
|
|
|
|
|
2026-09-29 14:53:50 +08:00
|
|
|
const readText = async (
|
2026-07-16 05:29:40 +08:00
|
|
|
context: ReadContext,
|
|
|
|
|
input: ReadTextInput,
|
|
|
|
|
): Promise<JavaScriptArtifactFile["text"]> => {
|
2026-10-12 04:07:14 +08:00
|
|
|
if (
|
|
|
|
|
context.selectedPaths !== undefined &&
|
|
|
|
|
!context.selectedPaths.has(input.expected.path)
|
|
|
|
|
) {
|
|
|
|
|
// Keep the inventory/acquisition identity check without allocating or
|
|
|
|
|
// decoding an unrelated source's text.
|
|
|
|
|
await verifyEntryBytes(
|
|
|
|
|
await input.reader.open(input.entry, context.signal),
|
|
|
|
|
input.expected,
|
|
|
|
|
context.signal,
|
|
|
|
|
);
|
|
|
|
|
return { included: false, reason: "outside-analysis-scope" };
|
|
|
|
|
}
|
2026-10-07 10:33:10 +08:00
|
|
|
if (input.expected.kind === "native-addon") {
|
|
|
|
|
await verifyEntryBytes(
|
|
|
|
|
await input.reader.open(input.entry, context.signal),
|
|
|
|
|
input.expected,
|
|
|
|
|
context.signal,
|
|
|
|
|
);
|
2026-07-16 05:29:40 +08:00
|
|
|
return { included: false, reason: "not-applicable" };
|
2026-10-07 10:33:10 +08:00
|
|
|
}
|
2026-09-29 14:53:50 +08:00
|
|
|
const bytes = await readAll(
|
2026-07-16 05:29:40 +08:00
|
|
|
await input.reader.open(input.entry, context.signal),
|
|
|
|
|
context.signal,
|
|
|
|
|
);
|
|
|
|
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
|
|
|
if (digest !== input.expected.sha256 || bytes.length !== input.expected.bytes)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Artifact entry changed after inventory: ${input.expected.path}`,
|
|
|
|
|
);
|
|
|
|
|
context.textBytes += bytes.length;
|
|
|
|
|
try {
|
|
|
|
|
return {
|
|
|
|
|
included: true,
|
2026-10-08 12:09:55 -04:00
|
|
|
value: new TextDecoder("utf-8", {
|
|
|
|
|
fatal: true,
|
|
|
|
|
ignoreBOM:
|
|
|
|
|
input.expected.kind === "javascript" ||
|
|
|
|
|
input.expected.kind === "html",
|
|
|
|
|
}).decode(bytes),
|
2026-07-16 05:29:40 +08:00
|
|
|
};
|
2026-10-06 05:58:46 +08:00
|
|
|
} catch (cause: unknown) {
|
|
|
|
|
// Non-UTF8 bytes are counted; the fixed reason preserves the schema.
|
|
|
|
|
void cause;
|
2026-07-16 05:29:40 +08:00
|
|
|
context.invalidUtf8 += 1;
|
|
|
|
|
return { included: false, reason: "invalid-utf8" };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-07 10:33:10 +08:00
|
|
|
const verifyEntryBytes = async (
|
|
|
|
|
stream: Readable,
|
|
|
|
|
expected: Pick<ExpectedFile, "path" | "sha256" | "bytes">,
|
|
|
|
|
signal?: AbortSignal,
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
try {
|
|
|
|
|
const digest = await hashReadable(stream, signal);
|
|
|
|
|
if (digest.sha256 !== expected.sha256 || digest.bytes !== expected.bytes)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Artifact entry changed after inventory: ${expected.path}`,
|
|
|
|
|
);
|
|
|
|
|
} catch (cause: unknown) {
|
|
|
|
|
abortIfNeeded(signal);
|
|
|
|
|
throw cause;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-16 05:29:40 +08:00
|
|
|
const expectedInventory = (
|
|
|
|
|
snapshot: ArtifactInventorySnapshot,
|
|
|
|
|
): {
|
|
|
|
|
readonly files: ReadonlyMap<string, ExpectedFile>;
|
2026-10-10 02:00:08 +08:00
|
|
|
readonly containers: ReadonlyMap<string, ExpectedContainer>;
|
2026-10-12 04:07:14 +08:00
|
|
|
readonly unavailableFiles: ReadonlySet<string>;
|
2026-07-16 05:29:40 +08:00
|
|
|
} => {
|
|
|
|
|
const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node]));
|
|
|
|
|
const files = new Map<string, ExpectedFile>();
|
2026-10-10 02:00:08 +08:00
|
|
|
const containers = new Map<string, ExpectedContainer>();
|
2026-10-12 04:07:14 +08:00
|
|
|
const unavailableFiles = new Set<string>();
|
2026-07-16 05:29:40 +08:00
|
|
|
for (const occurrence of snapshot.occurrences) {
|
2026-10-10 02:00:08 +08:00
|
|
|
if (occurrence.logical_path === ".") continue;
|
|
|
|
|
const isNestedAsar =
|
|
|
|
|
occurrence.entry_kind === "file" &&
|
|
|
|
|
occurrence.logical_path.toLowerCase().endsWith(".asar");
|
|
|
|
|
if (occurrence.artifact_id === null) {
|
2026-10-12 04:07:14 +08:00
|
|
|
if (
|
|
|
|
|
occurrence.entry_kind === "file" &&
|
|
|
|
|
relevantKind(occurrence.logical_path) !== undefined
|
|
|
|
|
)
|
|
|
|
|
unavailableFiles.add(occurrence.logical_path);
|
2026-10-10 02:00:08 +08:00
|
|
|
if (isNestedAsar)
|
|
|
|
|
containers.set(occurrence.logical_path, {
|
|
|
|
|
hash_status: occurrence.hash_status,
|
2026-10-13 09:23:58 -04:00
|
|
|
format: occurrence.artifact_format,
|
2026-10-10 02:00:08 +08:00
|
|
|
inventory: null,
|
|
|
|
|
});
|
2026-07-16 05:29:40 +08:00
|
|
|
continue;
|
2026-10-10 02:00:08 +08:00
|
|
|
}
|
2026-07-16 05:29:40 +08:00
|
|
|
const node = nodes.get(occurrence.artifact_id);
|
|
|
|
|
if (node === undefined)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Artifact inventory node is missing: ${occurrence.logical_path}`,
|
|
|
|
|
);
|
2026-10-05 23:19:04 +08:00
|
|
|
// Inventory occurrences can have content identities for directories too.
|
|
|
|
|
// Suffixes only describe file candidates; a directory such as
|
|
|
|
|
// node_modules/@zip.js remains a graph node and must never be opened.
|
|
|
|
|
if (occurrence.entry_kind !== "file") continue;
|
2026-10-10 02:00:08 +08:00
|
|
|
if (isNestedAsar)
|
2026-07-16 05:29:40 +08:00
|
|
|
containers.set(occurrence.logical_path, {
|
2026-10-10 02:00:08 +08:00
|
|
|
hash_status: occurrence.hash_status,
|
2026-10-13 09:23:58 -04:00
|
|
|
format: occurrence.artifact_format,
|
2026-10-10 02:00:08 +08:00
|
|
|
inventory: {
|
|
|
|
|
path: occurrence.logical_path,
|
|
|
|
|
sha256: node.sha256,
|
|
|
|
|
bytes: node.size,
|
|
|
|
|
inventory_artifact_id: node.artifact_id,
|
|
|
|
|
},
|
2026-07-16 05:29:40 +08:00
|
|
|
});
|
|
|
|
|
const kind = relevantKind(occurrence.logical_path);
|
|
|
|
|
if (kind === undefined) continue;
|
|
|
|
|
files.set(occurrence.logical_path, {
|
|
|
|
|
path: occurrence.logical_path,
|
|
|
|
|
sha256: node.sha256,
|
|
|
|
|
bytes: node.size,
|
|
|
|
|
inventoryArtifactId: node.artifact_id,
|
|
|
|
|
kind,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-12 04:07:14 +08:00
|
|
|
return { files, containers, unavailableFiles };
|
2026-07-16 05:29:40 +08:00
|
|
|
};
|
|
|
|
|
|
2026-10-14 08:31:03 +08:00
|
|
|
/** Canonical snapshot arrays are already ordered by code-point identity. */
|
|
|
|
|
const lowerBound = <T>(
|
|
|
|
|
rows: readonly T[],
|
|
|
|
|
key: string,
|
|
|
|
|
identity: (row: T) => string,
|
|
|
|
|
): number => {
|
|
|
|
|
let low = 0;
|
|
|
|
|
let high = rows.length;
|
|
|
|
|
while (low < high) {
|
|
|
|
|
const middle = low + Math.floor((high - low) / 2);
|
|
|
|
|
const row = rows[middle];
|
|
|
|
|
if (row !== undefined && compareUnicodeCodePoints(identity(row), key) < 0)
|
|
|
|
|
low = middle + 1;
|
|
|
|
|
else high = middle;
|
|
|
|
|
}
|
|
|
|
|
return low;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createUnanalyzedArtifactLookup = (
|
|
|
|
|
snapshot: ArtifactInventorySnapshot,
|
|
|
|
|
containers: readonly JavaScriptArtifactContainer[],
|
|
|
|
|
): JavaScriptUnanalyzedArtifactLookup => {
|
|
|
|
|
const requested = new Map<
|
|
|
|
|
string,
|
|
|
|
|
JavaScriptUnanalyzedArtifactFile | undefined
|
|
|
|
|
>();
|
|
|
|
|
const directories = new Map<string, Map<string | undefined, boolean>>();
|
|
|
|
|
const containerIds = new Map(
|
|
|
|
|
containers.map((container) => [container.path, container.sha256]),
|
|
|
|
|
);
|
|
|
|
|
const containerFor = (path: string): string => {
|
|
|
|
|
let prefix = path;
|
|
|
|
|
for (;;) {
|
|
|
|
|
const slash = prefix.lastIndexOf("/");
|
|
|
|
|
if (slash < 0) return snapshot.manifest.root_sha256;
|
|
|
|
|
prefix = prefix.slice(0, slash);
|
|
|
|
|
const sha256 = containerIds.get(prefix);
|
|
|
|
|
if (sha256 !== undefined) return sha256;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const isOpaque = (occurrence: ArtifactOccurrence): boolean =>
|
|
|
|
|
occurrence.entry_kind === "file" &&
|
|
|
|
|
relevantKind(occurrence.logical_path) === undefined;
|
2026-10-14 16:53:16 +08:00
|
|
|
const isUnavailableRelevant = (occurrence: ArtifactOccurrence): boolean =>
|
|
|
|
|
occurrence.entry_kind === "file" &&
|
|
|
|
|
occurrence.artifact_id === null &&
|
|
|
|
|
relevantKind(occurrence.logical_path) !== undefined;
|
2026-10-14 08:31:03 +08:00
|
|
|
return {
|
|
|
|
|
get(path) {
|
|
|
|
|
if (requested.has(path)) return requested.get(path);
|
|
|
|
|
const occurrence =
|
|
|
|
|
snapshot.occurrences[
|
|
|
|
|
lowerBound(snapshot.occurrences, path, (entry) => entry.logical_path)
|
|
|
|
|
];
|
2026-10-14 16:53:16 +08:00
|
|
|
const kind = relevantKind(path);
|
2026-10-14 08:31:03 +08:00
|
|
|
const id =
|
2026-10-14 16:53:16 +08:00
|
|
|
occurrence?.logical_path === path &&
|
|
|
|
|
(isOpaque(occurrence) || isUnavailableRelevant(occurrence))
|
2026-10-14 08:31:03 +08:00
|
|
|
? occurrence.artifact_id
|
|
|
|
|
: undefined;
|
|
|
|
|
const candidate =
|
|
|
|
|
id == null
|
|
|
|
|
? undefined
|
|
|
|
|
: snapshot.nodes[
|
|
|
|
|
lowerBound(snapshot.nodes, id, (entry) => entry.artifact_id)
|
|
|
|
|
];
|
|
|
|
|
const node = candidate?.artifact_id === id ? candidate : undefined;
|
|
|
|
|
const file =
|
2026-10-14 16:53:16 +08:00
|
|
|
occurrence?.logical_path !== path ||
|
|
|
|
|
(!isOpaque(occurrence) && !isUnavailableRelevant(occurrence))
|
2026-10-14 08:31:03 +08:00
|
|
|
? undefined
|
|
|
|
|
: {
|
|
|
|
|
path,
|
|
|
|
|
container_sha256: containerFor(path),
|
|
|
|
|
sha256: node?.sha256 ?? null,
|
|
|
|
|
bytes: node?.size ?? occurrence.declared_size,
|
|
|
|
|
inventory_artifact_id: occurrence.artifact_id,
|
2026-10-14 16:53:16 +08:00
|
|
|
inventory_occurrence_id: occurrence.occurrence_id,
|
|
|
|
|
...(kind === undefined ? {} : { kind }),
|
|
|
|
|
artifact_kind: occurrence.artifact_kind,
|
|
|
|
|
hash_status: occurrence.hash_status,
|
2026-10-14 08:31:03 +08:00
|
|
|
limitations: occurrence.limitations,
|
|
|
|
|
};
|
|
|
|
|
requested.set(path, file);
|
|
|
|
|
return file;
|
|
|
|
|
},
|
|
|
|
|
hasDirectory(path, containerSha256) {
|
|
|
|
|
let results = directories.get(path);
|
|
|
|
|
const previous = results?.get(containerSha256);
|
|
|
|
|
if (previous !== undefined) return previous;
|
|
|
|
|
const prefix = `${path}/`;
|
|
|
|
|
let present = false;
|
|
|
|
|
for (
|
|
|
|
|
let index = lowerBound(
|
|
|
|
|
snapshot.occurrences,
|
|
|
|
|
prefix,
|
|
|
|
|
(entry) => entry.logical_path,
|
|
|
|
|
);
|
|
|
|
|
index < snapshot.occurrences.length;
|
|
|
|
|
index += 1
|
|
|
|
|
) {
|
|
|
|
|
const entry = snapshot.occurrences[index];
|
|
|
|
|
if (entry === undefined || !entry.logical_path.startsWith(prefix))
|
|
|
|
|
break;
|
|
|
|
|
if (
|
2026-10-14 16:53:16 +08:00
|
|
|
(isOpaque(entry) || isUnavailableRelevant(entry)) &&
|
2026-10-14 08:31:03 +08:00
|
|
|
(containerSha256 === undefined ||
|
|
|
|
|
containerFor(entry.logical_path) === containerSha256)
|
|
|
|
|
) {
|
|
|
|
|
present = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (results === undefined) {
|
|
|
|
|
results = new Map();
|
|
|
|
|
directories.set(path, results);
|
|
|
|
|
}
|
|
|
|
|
results.set(containerSha256, present);
|
|
|
|
|
return present;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-29 14:53:50 +08:00
|
|
|
const readAll = async (
|
2026-07-16 05:29:40 +08:00
|
|
|
stream: Readable,
|
|
|
|
|
signal?: AbortSignal,
|
|
|
|
|
): Promise<Buffer> => {
|
|
|
|
|
const chunks: Buffer[] = [];
|
|
|
|
|
let bytes = 0;
|
|
|
|
|
for await (const raw of stream) {
|
|
|
|
|
abortIfNeeded(signal);
|
|
|
|
|
const chunk = streamChunkToBuffer(raw);
|
|
|
|
|
bytes += chunk.length;
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
}
|
|
|
|
|
return Buffer.concat(chunks, bytes);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const assertExpectedFilesWereVisited = (
|
|
|
|
|
expected: ReadonlyMap<string, ExpectedFile>,
|
|
|
|
|
actual: readonly JavaScriptArtifactFile[],
|
|
|
|
|
): void => {
|
|
|
|
|
const observed = new Set(actual.map(({ path }) => path));
|
|
|
|
|
for (const path of expected.keys())
|
|
|
|
|
if (!observed.has(path))
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"integrity",
|
|
|
|
|
`Relevant artifact entry disappeared after inventory: ${path}`,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const relevantKind = (path: string): JavaScriptArtifactFileKind | undefined => {
|
|
|
|
|
const lower = path.toLowerCase();
|
|
|
|
|
if (lower === "package.json" || lower.endsWith("/package.json"))
|
|
|
|
|
return "package-json";
|
2026-07-17 23:57:23 +08:00
|
|
|
if (lower.endsWith(".json")) return "json";
|
2026-10-14 08:31:03 +08:00
|
|
|
if (isJavaScriptSourcePath(path)) return "javascript";
|
2026-07-16 05:29:40 +08:00
|
|
|
if (/\.html?$/u.test(lower)) return "html";
|
|
|
|
|
if (lower.endsWith(".map")) return "source-map";
|
|
|
|
|
if (lower.endsWith(".node")) return "native-addon";
|
|
|
|
|
return undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isFilesystemAsar = (entry: ArtifactEntry, path: string): boolean =>
|
|
|
|
|
entry.kind === "file" &&
|
|
|
|
|
path.toLowerCase().endsWith(".asar") &&
|
|
|
|
|
isAbsolute(entry.adapterKey);
|
|
|
|
|
|
|
|
|
|
const abortIfNeeded = (signal?: AbortSignal): void => {
|
|
|
|
|
if (signal?.aborted === true)
|
|
|
|
|
throw new ArtifactReaderFailure(
|
|
|
|
|
"cancelled",
|
|
|
|
|
"JavaScript artifact reconstruction cancelled",
|
|
|
|
|
);
|
|
|
|
|
};
|