import { createHash } from "node:crypto"; import { isAbsolute } from "node:path"; import type { Readable } from "node:stream"; import { compareCodePoints } from "../domain/canonicalOrdering.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 { streamChunkToBuffer } from "../artifacts/StreamBytes.js"; import { hashReadable } from "./ArtifactInventory/hash.js"; import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js"; /** Relevant file categories projected from the complete artifact inventory. */ export type JavaScriptArtifactFileKind = | "package-json" | "json" | "javascript" | "html" | "source-map" | "native-addon"; /** One content-addressed local artifact file and its text availability. */ export interface JavaScriptArtifactFile { readonly path: string; readonly container_sha256: string; readonly sha256: string; readonly bytes: number; readonly inventory_artifact_id: string; readonly kind: JavaScriptArtifactFileKind; readonly unpacked: boolean; readonly text: | { readonly included: true; readonly value: string } | { readonly included: false; readonly reason: "not-applicable" | "invalid-utf8"; }; } /** One filesystem-backed ASAR nested beneath a directory input. */ export interface JavaScriptArtifactContainer { readonly path: string; readonly sha256: string; readonly bytes: number; readonly inventory_artifact_id: string; } /** Deterministic relevant-file projection plus explicit omissions. */ export interface JavaScriptArtifactFileSet { readonly files: readonly JavaScriptArtifactFile[]; readonly containers: readonly JavaScriptArtifactContainer[]; readonly text_bytes_read: number; readonly invalid_utf8_files: number; } interface ExpectedFile { readonly path: string; readonly sha256: string; readonly bytes: number; readonly inventoryArtifactId: string; readonly kind: JavaScriptArtifactFileKind; } interface ReadContext { readonly expected: ReadonlyMap; readonly expectedContainers: ReadonlyMap; readonly registry: ArtifactPathRegistry; readonly files: JavaScriptArtifactFile[]; readonly containers: JavaScriptArtifactContainer[]; readonly signal: AbortSignal | undefined; textBytes: number; invalidUtf8: number; } interface ReadTextInput { readonly reader: ArtifactReader; readonly entry: ArtifactEntry; readonly expected: ExpectedFile; } /** Read all relevant textual entries through an already-inventoried reader. */ export const readJavaScriptArtifactFiles = async ( reader: ArtifactReader, snapshot: ArtifactInventorySnapshot, signal?: AbortSignal, ): Promise => { 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( reader.openContainer(signal), { path: ".", sha256: root.sha256, bytes: root.size }, signal, ); } const inventory = expectedInventory(snapshot); const expected = inventory.files; const context: ReadContext = { expected, expectedContainers: inventory.containers, registry: new ArtifactPathRegistry(), files: [], containers: [], signal, textBytes: 0, invalidUtf8: 0, }; await visitReader(reader, "", snapshot.manifest.root_sha256, context); const files = context.files.sort((left, right) => compareCodePoints(left.path, right.path), ); assertExpectedFilesWereVisited(expected, files); return { files, containers: context.containers.sort((left, right) => compareCodePoints(left.path, right.path), ), text_bytes_read: context.textBytes, invalid_utf8_files: context.invalidUtf8, }; }; const visitReader = async ( reader: ArtifactReader, prefix: string, containerSha256: string, context: ReadContext, ): Promise => { const stack: Array<{ readonly reader: ArtifactReader; readonly prefix: string; readonly containerSha256: string; readonly iterator: AsyncIterator; readonly owned: boolean; }> = [ { reader, prefix, containerSha256, iterator: reader.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; 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) { const inventory = expectedContainer(path, context); await verifyEntryBytes( await frame.reader.open(entry, context.signal), inventory, context.signal, ); context.containers.push(inventory); const nested = new AsarArtifactReader(entry.adapterKey); stack.push({ reader: nested, prefix: path, containerSha256: inventory.sha256, iterator: nested.entries(context.signal)[Symbol.asyncIterator](), owned: true, }); continue; } const expected = context.expected.get(path); if (expected === undefined) continue; const text = await readText(context, { 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, }); } } finally { await Promise.allSettled( stack .filter(({ owned }) => owned) .map(async ({ reader, iterator }) => { await iterator.return?.(); await reader.close(); }), ); } }; const expectedContainer = ( path: string, context: ReadContext, ): JavaScriptArtifactContainer => { const container = context.expectedContainers.get(path); if (container === undefined) throw new ArtifactReaderFailure( "integrity", `Nested ASAR disappeared from inventory: ${path}`, ); return container; }; const readText = async ( context: ReadContext, input: ReadTextInput, ): Promise => { if (input.expected.kind === "native-addon") { await verifyEntryBytes( await input.reader.open(input.entry, context.signal), input.expected, context.signal, ); return { included: false, reason: "not-applicable" }; } const bytes = await readAll( 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, value: new TextDecoder("utf-8", { fatal: true }).decode(bytes), }; } catch (cause: unknown) { // Non-UTF8 bytes are counted; the fixed reason preserves the schema. void cause; context.invalidUtf8 += 1; return { included: false, reason: "invalid-utf8" }; } }; const verifyEntryBytes = async ( stream: Readable, expected: Pick, signal?: AbortSignal, ): Promise => { 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; } }; const expectedInventory = ( snapshot: ArtifactInventorySnapshot, ): { readonly files: ReadonlyMap; readonly containers: ReadonlyMap; } => { const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node])); const files = new Map(); const containers = new Map(); for (const occurrence of snapshot.occurrences) { if (occurrence.artifact_id === null || occurrence.logical_path === ".") continue; const node = nodes.get(occurrence.artifact_id); if (node === undefined) throw new ArtifactReaderFailure( "integrity", `Artifact inventory node is missing: ${occurrence.logical_path}`, ); // 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; if (occurrence.logical_path.toLowerCase().endsWith(".asar")) containers.set(occurrence.logical_path, { path: occurrence.logical_path, sha256: node.sha256, bytes: node.size, inventory_artifact_id: node.artifact_id, }); 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, }); } return { files, containers }; }; const readAll = async ( stream: Readable, signal?: AbortSignal, ): Promise => { 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, 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"; if (lower.endsWith(".json")) return "json"; if (/\.(?:cjs|mjs|js|jsx|ts|tsx)$/u.test(lower)) return "javascript"; 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", ); };