refactor(artifacts): remove selectors and traversal quotas

This commit is contained in:
morluto
2026-09-29 12:15:49 +08:00
parent fe342faa83
commit 8c24272ee7
31 changed files with 268 additions and 666 deletions
+4 -2
View File
@@ -89,8 +89,10 @@ maximum authority:
- reference-source roots;
- native mount enablement.
Artifact extraction retains its existing explicit per-call approval and maps to
an administrator root ceiling of `/` for compatibility.
Artifact extraction takes no path or occurrence selectors. It materializes all
regular files into a fresh REA-chosen temporary directory. The write still
passes through the active permission evaluator; the tool caller does not choose
an output path.
`rea policy status`, `list`, `explain`, and `revoke` inspect the same evaluator
used by MCP. Optional project grants require both
@@ -27,9 +27,10 @@ Call `inspect_artifact` before extraction for application bundles, archives,
ZIP/APK/IPA/MSIX/AppX, ASAR, or DMG inputs. It returns the complete artifact
graph inline. Cite graph manifest IDs.
`extract_artifact` requires explicit approval, an absent absolute output root,
and selected occurrence IDs. Never extract every entry implicitly. Symlinks and
encrypted entries are inventory facts, not extractable files.
`extract_artifact` takes no arguments and materializes all regular files into a
fresh temporary directory chosen by REA. Symlinks and encrypted entries are
inventory facts, not extractable files. The operation still requires the
filesystem-write permission grant; callers do not choose the destination.
Native DMG traversal is macOS-only, read-only, and requires both operator policy
and `native_mount_approved: true`; without both, retain the root-hash-only result.
+23 -126
View File
@@ -8,7 +8,6 @@ import {
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactLimits,
type ArtifactReader,
} from "../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
@@ -24,35 +23,28 @@ import {
} from "../domain/artifactGraph.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { scanArtifactInventory } from "./ArtifactInventory.js";
import {
digestCanonical,
toOutputLimits,
} from "./ArtifactGraphConstruction.js";
import { digestCanonical } from "./ArtifactGraphConstruction.js";
/** Already-approved extraction request from a caller-owned policy boundary. */
export interface ArtifactExtractionInput {
readonly inputPath: string;
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
readonly selection:
| { readonly paths: readonly string[] }
| { readonly occurrenceIds: readonly string[] };
readonly limits: ArtifactLimits;
}
/** Extract selected inventory occurrences into an exclusively owned absent root. */
/** Extract every regular inventory occurrence into an exclusively owned absent root. */
export const extractArtifact = async (
input: ArtifactExtractionInput,
signal?: AbortSignal,
): Promise<ArtifactExtractionResult> => {
validateSelection(input.selection, input.limits);
const sourcePath = await realpath(input.inputPath);
const snapshot = await scanArtifactInventory(sourcePath, input.limits, {
const snapshot = await scanArtifactInventory(sourcePath, {
signal,
});
const selectedOccurrences = resolveSelection(
snapshot.occurrences,
input.selection,
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),
@@ -130,25 +122,25 @@ const materializeSelection = async ({
selected.map((item) => [item.occurrence.logical_path, item]),
);
const reader = await createReader(sourcePath, input.inputFormat);
const output = await SafeOutputTree.create(input.outputRoot, input.limits);
const output = await SafeOutputTree.create(input.outputRoot);
let readerClosed = false;
const extracted: ExtractedOccurrence[] = [];
try {
const found = new Set<string>();
const materialized: SelectedOccurrence[] = [];
const registry = new ArtifactPathRegistry();
let entryCount = 0;
for await (const entry of reader.entries(signal)) {
entryCount += 1;
if (entryCount > input.limits.maxEntries)
throw new ArtifactReaderFailure(
"limit",
"Artifact entry limit exceeded during extraction",
);
const path = normalizeArtifactPath(entry.path, input.limits);
const path = normalizeArtifactPath(entry.path);
registry.add(path, entry.kind);
const selectedItem = byPath.get(path);
if (selectedItem === undefined) continue;
preflight(entry, input.limits);
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,
@@ -163,13 +155,8 @@ const materializeSelection = async ({
bytes_written: written.bytesWritten,
created: true,
});
found.add(path);
materialized.push(selectedItem);
}
if (found.size !== selected.length)
throw new ArtifactReaderFailure(
"integrity",
"Selected inventory occurrence disappeared before extraction",
);
await reader.close();
readerClosed = true;
extracted.sort((left, right) =>
@@ -178,7 +165,7 @@ const materializeSelection = async ({
const result = createExtractionResult(
input,
inventory,
selected,
materialized,
extracted,
);
await output.commit();
@@ -214,10 +201,9 @@ const createExtractionResult = (
artifacts: extracted,
containment_verified: true,
cleanup: { attempted: false, verified: true, residual_paths: [] },
limits: toOutputLimits(input.limits),
provenance: [],
limitations: [
"Only caller-selected regular file occurrences were materialized.",
"All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.",
],
});
};
@@ -272,99 +258,10 @@ const createReader = async (
);
};
type ArtifactSelection = ArtifactExtractionInput["selection"];
const validateSelection = (
selection: ArtifactSelection,
limits: ArtifactLimits,
): void => {
const values =
"paths" in selection ? selection.paths : selection.occurrenceIds;
if (values.length === 0)
throw new ArtifactReaderFailure(
"limit",
"Extraction requires at least one selected path or occurrence",
);
const normalized =
"paths" in selection
? values.map((path) => normalizeArtifactPath(path, limits))
: values;
if (new Set(normalized).size !== normalized.length)
throw new ArtifactReaderFailure(
"path",
"Extraction selection contains duplicates",
);
};
const resolveSelection = (
occurrences: readonly ArtifactOccurrence[],
selection: ArtifactSelection,
): ArtifactOccurrence[] => {
if ("occurrenceIds" in selection) {
const byId = new Map(
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
);
return selection.occurrenceIds.map((id) => {
const occurrence = byId.get(id);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact occurrence was not found: ${id}`,
);
return occurrence;
});
}
const byPath = new Map<string, ArtifactOccurrence[]>();
for (const occurrence of occurrences) {
const matches = byPath.get(occurrence.logical_path);
if (matches === undefined)
byPath.set(occurrence.logical_path, [occurrence]);
else matches.push(occurrence);
}
return selection.paths.map((path) => {
const normalizedPath = path.normalize("NFC");
const matches = byPath.get(normalizedPath) ?? [];
if (matches.length === 0)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact path was not found: ${path}`,
);
if (matches.length > 1)
throw new ArtifactReaderFailure(
"path",
`Selected artifact path is ambiguous: ${path}`,
);
const match = matches[0];
if (match === undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact path was not found: ${path}`,
);
return match;
});
};
const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => {
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}`,
);
if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
throw new ArtifactReaderFailure(
"limit",
`Selected artifact exceeds byte limit: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
(entry.compressedSize === 0
? entry.declaredSize > 0
: entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio)
)
throw new ArtifactReaderFailure(
"limit",
`Selected artifact exceeds compression ratio limit: ${entry.path}`,
);
};
+2 -14
View File
@@ -2,10 +2,7 @@ import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import type {
ArtifactEntry,
ArtifactLimits,
} from "../artifacts/ArtifactReader.js";
import type { ArtifactEntry } from "../artifacts/ArtifactReader.js";
import type {
ArtifactCommand,
ArtifactEdge,
@@ -55,7 +52,7 @@ export const createOccurrence = (
compressed_size: entry.compressedSize,
executable: entry.executable,
encrypted: entry.encrypted,
hash_status: entry.encrypted ? "unavailable" : "not-hashed-limit",
hash_status: entry.encrypted ? "unavailable" : "not-hashed",
source_location:
entry.byteOffset === null || entry.declaredSize === null
? null
@@ -318,15 +315,6 @@ export const classifyArtifactContent = (
return byPath;
};
export const toOutputLimits = (limits: ArtifactLimits) => ({
max_entries: limits.maxEntries,
max_total_bytes: limits.maxTotalBytes,
max_entry_bytes: limits.maxEntryBytes,
max_compression_ratio: limits.maxCompressionRatio,
max_depth: limits.maxDepth,
max_path_bytes: limits.maxPathBytes,
});
export const digestCanonical = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
+3 -7
View File
@@ -1,6 +1,5 @@
import { realpath } from "node:fs/promises";
import type { ArtifactLimits } from "../artifacts/ArtifactReader.js";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
@@ -26,38 +25,35 @@ export type {
/** Inventory one local artifact without extracting or mounting it. */
export const inventoryArtifact = async (
inputPath: string,
limits: ArtifactLimits,
options: {
readonly signal?: AbortSignal;
readonly nativeMount?: ArtifactNativeMountPolicy;
readonly integrity?: ArtifactIntegrityPolicy;
} = {},
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(inputPath, limits, options);
const snapshot = await scanArtifactInventory(inputPath, options);
return artifactInventoryResultSchema.parse(snapshot);
};
/** Inventory one artifact and return every graph collection in one response. */
export const inventoryArtifactFully = async (
inputPath: string,
limits: ArtifactLimits,
options: {
readonly signal?: AbortSignal;
readonly nativeMount?: ArtifactNativeMountPolicy;
readonly integrity?: ArtifactIntegrityPolicy;
} = {},
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(inputPath, limits, options);
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,
limits: ArtifactLimits,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
abortIfNeeded(options.signal);
const path = await realpath(inputPath);
return scanCanonicalArtifactInventory(path, limits, options);
return scanCanonicalArtifactInventory(path, options);
};
+1 -44
View File
@@ -1,11 +1,7 @@
import { createHash } from "node:crypto";
import type { Readable } from "node:stream";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactLimits,
} from "../../artifacts/ArtifactReader.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
export type HashResult = {
@@ -22,40 +18,8 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
);
};
export const preflightEntry = (
entry: ArtifactEntry,
limits: ArtifactLimits,
): void => {
if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
throw new ArtifactReaderFailure(
"limit",
`Entry exceeds byte limit: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
entry.compressedSize === 0 &&
entry.declaredSize > 0
)
throw new ArtifactReaderFailure(
"limit",
`Invalid compression ratio: ${entry.path}`,
);
if (
entry.declaredSize !== null &&
entry.compressedSize !== null &&
entry.compressedSize > 0 &&
entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio
)
throw new ArtifactReaderFailure(
"limit",
`Compression ratio exceeds limit: ${entry.path}`,
);
};
export const hashReadable = async (
stream: Readable,
maximum: number,
signal?: AbortSignal,
): Promise<HashResult> => {
const hash = createHash("sha256");
@@ -66,13 +30,6 @@ export const hashReadable = async (
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
if (bytes > maximum) {
stream.destroy();
throw new ArtifactReaderFailure(
"limit",
"Observed entry bytes exceed limit",
);
}
hash.update(chunk);
if (prefixBytes < 16) {
const selected = chunk.subarray(0, 16 - prefixBytes);
@@ -4,7 +4,6 @@ import type { Stats } from "node:fs";
import {
ArtifactReaderFailure,
type ArtifactLimits,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import {
@@ -21,7 +20,6 @@ import {
materializeDirectoryNodes,
rekeyOccurrences,
rootOccurrenceFor,
toOutputLimits,
type MutableOccurrence,
} from "../ArtifactGraphConstruction.js";
import { classifyRoot } from "./classify.js";
@@ -40,7 +38,6 @@ import {
export const scanCanonicalArtifactInventory = async (
path: string,
limits: ArtifactLimits,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
@@ -48,11 +45,7 @@ export const scanCanonicalArtifactInventory = async (
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
? null
: await hashReadable(
createReadStream(path),
limits.maxTotalBytes,
options.signal,
);
: await hashReadable(createReadStream(path), options.signal);
const reader = await createReader(
path,
rootFormat,
@@ -63,7 +56,6 @@ export const scanCanonicalArtifactInventory = async (
try {
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
limits,
options.signal,
integrity,
);
@@ -73,7 +65,6 @@ export const scanCanonicalArtifactInventory = async (
rootFormat,
rootDigest,
reader,
limits,
signal: options.signal,
nodes,
occurrences,
@@ -90,7 +81,6 @@ interface SnapshotBuildInput {
readonly rootFormat: ArtifactNode["format"];
readonly rootDigest: HashResult | null;
readonly reader: ArtifactReader | undefined;
readonly limits: ArtifactLimits;
readonly signal: AbortSignal | undefined;
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
@@ -100,8 +90,7 @@ interface SnapshotBuildInput {
const buildInventorySnapshot = async (
input: SnapshotBuildInput,
): Promise<ArtifactInventorySnapshot> => {
const { path, metadata, rootFormat, rootDigest, reader, limits, signal } =
input;
const { path, metadata, rootFormat, rootDigest, reader, signal } = input;
materializeDirectoryNodes(input.occurrences, input.nodes);
const rootNode = createRootNode({
path,
@@ -140,7 +129,7 @@ const buildInventorySnapshot = async (
const orderedOccurrences = sortOccurrences(input.occurrences);
const orderedEdges = sortEdges(edges);
await verifyRootDigest(path, rootDigest, limits.maxTotalBytes, signal);
await verifyRootDigest(path, rootDigest, signal);
const graphSha256 = digestCanonical({
nodes: orderedNodes,
@@ -160,7 +149,6 @@ const buildInventorySnapshot = async (
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
limits: toOutputLimits(limits),
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: buildLimitations(rootFormat, reader, integrityContradictions),
@@ -210,15 +198,10 @@ const buildIntegrityContradictions = (
const verifyRootDigest = async (
path: string,
rootDigest: HashResult | null,
maxTotalBytes: number,
signal: AbortSignal | undefined,
): Promise<void> => {
if (rootDigest === null) return;
const verified = await hashReadable(
createReadStream(path),
maxTotalBytes,
signal,
);
const verified = await hashReadable(createReadStream(path), signal);
if (
verified.sha256 !== rootDigest.sha256 ||
verified.bytes !== rootDigest.bytes
+83 -75
View File
@@ -5,7 +5,6 @@ import {
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactLimits,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
@@ -21,7 +20,7 @@ import {
STRICT_INTEGRITY_POLICY,
type ArtifactIntegrityPolicy,
} from "./types.js";
import { hashReadable, preflightEntry } from "./hash.js";
import { hashReadable } from "./hash.js";
export interface PendingIntegrityContradiction {
readonly logicalPath: string;
@@ -42,7 +41,6 @@ const emptyScan = (): {
interface ScanContext {
readonly reader: ArtifactReader;
readonly limits: ArtifactLimits;
readonly signal: AbortSignal | undefined;
readonly integrity: ArtifactIntegrityPolicy;
readonly nodes: Map<string, ArtifactNode>;
@@ -50,12 +48,10 @@ interface ScanContext {
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly occurrenceByPath: Map<string, MutableOccurrence>;
readonly registry: ArtifactPathRegistry;
totalBytes: number;
}
export const scanReader = async (
reader: ArtifactReader | undefined,
limits: ArtifactLimits,
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<{
@@ -68,7 +64,6 @@ export const scanReader = async (
return { nodes, occurrences, pendingContradictions };
const context: ScanContext = {
reader,
limits,
signal,
integrity,
nodes,
@@ -76,7 +71,6 @@ export const scanReader = async (
pendingContradictions,
occurrenceByPath: new Map<string, MutableOccurrence>(),
registry: new ArtifactPathRegistry(),
totalBytes: 0,
};
await visitArtifactEntries(context, reader, "");
return { nodes, occurrences, pendingContradictions };
@@ -87,53 +81,93 @@ const visitArtifactEntries = async (
currentReader: ArtifactReader,
prefix: string,
): Promise<void> => {
for await (const entry of currentReader.entries(context.signal)) {
if (context.occurrences.length >= context.limits.maxEntries)
throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
const logicalPath = normalizeArtifactPath(
prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`,
context.limits,
);
const expandableAsar = isExpandableAsar(entry, logicalPath);
context.registry.add(
logicalPath,
expandableAsar ? "directory" : entry.kind,
);
preflightEntry(entry, context.limits);
const parent = nearestParent(logicalPath, context.occurrenceByPath);
const occurrence = createOccurrence(
entry,
logicalPath,
parent?.occurrence_id ?? null,
);
let digested:
| { readonly node: ArtifactNode; readonly mismatched: boolean }
| undefined;
try {
digested = await digestArtifactEntry(
context,
currentReader,
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 parent = nearestParent(logicalPath, context.occurrenceByPath);
const occurrence = createOccurrence(
entry,
logicalPath,
parent?.occurrence_id ?? null,
);
} 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.",
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);
stack.push({
reader: nested,
prefix: logicalPath,
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
owned: true,
});
}
}
context.occurrences.push(occurrence);
context.occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true)
await visitNestedAsar(context, entry.adapterKey, logicalPath);
} finally {
await Promise.allSettled(
stack
.filter(({ owned }) => owned)
.map(async ({ reader, iterator }) => {
await iterator.return?.();
await reader.close();
}),
);
}
};
@@ -147,23 +181,10 @@ const digestArtifactEntry = async (
> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const remainingBytes = context.limits.maxTotalBytes - context.totalBytes;
if (remainingBytes <= 0)
throw new ArtifactReaderFailure(
"limit",
"Artifact total byte limit exceeded",
);
if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
throw new ArtifactReaderFailure(
"limit",
"Declared artifact bytes exceed remaining cumulative limit",
);
const digest = await hashReadable(
await currentReader.open(entry, context.signal),
Math.min(context.limits.maxEntryBytes, remainingBytes),
context.signal,
);
context.totalBytes += digest.bytes;
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && entry.declaredSha256 !== null) {
@@ -209,19 +230,6 @@ const digestArtifactEntry = async (
};
};
const visitNestedAsar = async (
context: ScanContext,
adapterKey: string,
logicalPath: string,
): Promise<void> => {
const nested = new AsarArtifactReader(adapterKey);
try {
await visitArtifactEntries(context, nested, logicalPath);
} finally {
await nested.close();
}
};
const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean =>
entry.kind === "file" &&
logicalPath.toLowerCase().endsWith(".asar") &&
@@ -35,7 +35,6 @@ export interface ArtifactInventorySnapshot {
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"];
readonly edges: ArtifactInventoryResult["edges"];
readonly limits: ArtifactInventoryResult["limits"];
readonly provenance: ReadonlyArray<
ArtifactInventoryResult["provenance"][number]
>;
@@ -1,6 +1,5 @@
import { realpath } from "node:fs/promises";
import type { ArtifactLimits } from "../artifacts/ArtifactReader.js";
import {
scanCanonicalArtifactInventory,
type ArtifactInventoryOptions,
@@ -10,9 +9,8 @@ import {
/** Resolve, authorize, and scan one artifact without a second path resolution. */
export const scanAuthorizedArtifactInventory = async (
inputPath: string,
limits: ArtifactLimits,
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
const path = await realpath(inputPath);
return scanCanonicalArtifactInventory(path, limits, options);
return scanCanonicalArtifactInventory(path, options);
};
+73 -49
View File
@@ -148,56 +148,80 @@ const visitReader = async (
containerSha256: string,
context: ReadContext,
): Promise<void> => {
for await (const entry of reader.entries(context.signal)) {
abortIfNeeded(context.signal);
const path = normalizeArtifactPath(
prefix === "" ? entry.path : `${prefix}/${entry.path}`,
{
maxDepth: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxDepth,
maxPathBytes:
JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxPathBytes,
},
);
const nestedAsar = isFilesystemAsar(entry, path);
context.registry.add(path, nestedAsar ? "directory" : entry.kind);
if (nestedAsar) {
await visitNestedAsar(entry, path, context);
continue;
}
const expected = context.expected.get(path);
if (expected === undefined) continue;
const selection = context.selections.get(path);
const text = await readTextIfSelected(context, {
const stack: Array<{
readonly reader: ArtifactReader;
readonly prefix: string;
readonly containerSha256: string;
readonly iterator: AsyncIterator<ArtifactEntry>;
readonly owned: boolean;
}> = [
{
reader,
entry,
expected,
selection,
});
context.files.push({
path,
container_sha256: containerSha256,
sha256: expected.sha256,
bytes: expected.bytes,
inventory_artifact_id: expected.inventoryArtifactId,
kind: expected.kind,
unpacked: entry.unpacked,
text,
});
}
};
const visitNestedAsar = async (
entry: ArtifactEntry,
path: string,
context: ReadContext,
): Promise<void> => {
const inventory = expectedContainer(path, context);
context.containers.push(inventory);
const nested = new AsarArtifactReader(entry.adapterKey);
prefix,
containerSha256,
iterator: reader.entries(context.signal)[Symbol.asyncIterator](),
owned: false,
},
];
try {
await visitReader(nested, path, inventory.sha256, context);
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);
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 selection = context.selections.get(path);
const text = await readTextIfSelected(context, {
reader: frame.reader,
entry,
expected,
selection,
});
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 nested.close();
await Promise.allSettled(
stack
.filter(({ owned }) => owned)
.map(async ({ reader, iterator }) => {
await iterator.return?.();
await reader.close();
}),
);
}
};
@@ -333,8 +357,8 @@ const readBounded = async (
if (bytes > maximum) {
stream.destroy();
throw new ArtifactReaderFailure(
"limit",
"Selected JavaScript artifact text exceeded its byte limit",
"integrity",
"Artifact entry grew after inventory and exceeded the selected text read size",
);
}
chunks.push(chunk);
@@ -204,16 +204,6 @@ const graphLimitations = (
};
const reconstructionLimits = () => [
{
name: "max-entries",
value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxEntries,
unit: "items" as const,
},
{
name: "max-total-artifact-bytes",
value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxTotalBytes,
unit: "bytes" as const,
},
{
name: "max-text-files",
value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTextFiles,
@@ -14,7 +14,6 @@ import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"
import { readJavaScriptArtifactFiles } from "./JavaScriptArtifactFiles.js";
import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js";
import {
artifactLimitsForReconstruction,
javascriptArtifactReconstructionInputSchema,
type JavaScriptArtifactReconstructionInput,
} from "./JavaScriptArtifactReconstructionInput.js";
@@ -59,11 +58,7 @@ export const reconstructJavaScriptArtifact = async (
abortIfNeeded(signal);
const path = await realpath(input.input_path);
const format = await resolveFormat(path, input);
const snapshot = await scanCanonicalArtifactInventory(
path,
artifactLimitsForReconstruction(),
{ signal },
);
const snapshot = await scanCanonicalArtifactInventory(path, { signal });
if (snapshot.manifest.root_format !== format)
throw new ArtifactReaderFailure(
"format",
@@ -1,17 +1,7 @@
import { z } from "zod";
import type { ArtifactLimits } from "../artifacts/ArtifactReader.js";
/** Resource safeguards for local artifact traversal and static parsing. */
export const JAVASCRIPT_APPLICATION_RESOURCE_LIMITS = {
artifact: {
maxEntries: 8_000,
maxTotalBytes: 512 * 1_024 * 1_024,
maxEntryBytes: 128 * 1_024 * 1_024,
maxCompressionRatio: 1_000,
maxDepth: 64,
maxPathBytes: 4_096,
} satisfies ArtifactLimits,
maxTextFiles: 5_000,
maxTotalTextBytes: 128 * 1_024 * 1_024,
maxTextFileBytes: 8 * 1_024 * 1_024,
@@ -29,7 +19,3 @@ export const javascriptArtifactReconstructionInputSchema = z.strictObject({
export type JavaScriptArtifactReconstructionInput = z.infer<
typeof javascriptArtifactReconstructionInputSchema
>;
/** Project private resource safeguards into the artifact traversal policy. */
export const artifactLimitsForReconstruction = (): ArtifactLimits =>
JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact;
+2 -11
View File
@@ -1,18 +1,11 @@
import { Buffer } from "node:buffer";
import { posix } from "node:path";
import {
ArtifactReaderFailure,
type ArtifactLimits,
} from "./ArtifactReader.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
/** Normalize an untrusted logical archive path without touching filesystem. */
export const normalizeArtifactPath = (
input: string,
limits: Pick<ArtifactLimits, "maxDepth" | "maxPathBytes">,
): string => {
export const normalizeArtifactPath = (input: string): string => {
if (
input.includes("\0") ||
input.includes("\\") ||
@@ -28,8 +21,6 @@ export const normalizeArtifactPath = (
if (
normalized.length === 0 ||
parts.some((part) => part === "" || part === "." || part === "..") ||
parts.length > limits.maxDepth ||
Buffer.byteLength(normalized, "utf8") > limits.maxPathBytes ||
posix.normalize(normalized) !== normalized
)
throw new ArtifactReaderFailure("path", "Artifact path is not normalized");
+16 -37
View File
@@ -23,10 +23,7 @@ import {
} from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import {
ArtifactReaderFailure,
type ArtifactLimits,
} from "./ArtifactReader.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
import { createEvidence } from "../domain/evidence.js";
import { createArtifactInspection } from "../domain/artifactInspection.js";
@@ -119,11 +116,6 @@ class ArtifactClient implements AnalysisClient {
inputPath: this.target.sourcePath ?? this.target.path,
inputFormat: this.target.format,
outputRoot: parsed.output_root,
selection:
"paths" in parsed
? { paths: parsed.paths }
: { occurrenceIds: parsed.occurrence_ids },
limits: DEFAULT_ARTIFACT_LIMITS,
},
options?.signal,
);
@@ -225,37 +217,24 @@ class ArtifactClient implements AnalysisClient {
},
options?: ExecutionOptions,
) {
return inventoryArtifactFully(
this.target.sourcePath ?? this.target.path,
DEFAULT_ARTIFACT_LIMITS,
{
...(options?.signal === undefined ? {} : { signal: options.signal }),
nativeMount: resolveNativeMountPolicy(
parsed.native_mount_approved === true,
this.nativeMountEnabled,
),
integrity: resolveArtifactIntegrityPolicy(
parsed.integrity_policy === "fail"
? { mode: "fail" }
: {
mode: parsed.integrity_policy,
},
this.integrityContinueEnabled,
),
},
);
return inventoryArtifactFully(this.target.sourcePath ?? this.target.path, {
...(options?.signal === undefined ? {} : { signal: options.signal }),
nativeMount: resolveNativeMountPolicy(
parsed.native_mount_approved === true,
this.nativeMountEnabled,
),
integrity: resolveArtifactIntegrityPolicy(
parsed.integrity_policy === "fail"
? { mode: "fail" }
: {
mode: parsed.integrity_policy,
},
this.integrityContinueEnabled,
),
});
}
}
const DEFAULT_ARTIFACT_LIMITS: ArtifactLimits = {
maxEntries: 10_000,
maxTotalBytes: 1_073_741_824,
maxEntryBytes: 268_435_456,
maxCompressionRatio: 1_000,
maxDepth: 20,
maxPathBytes: 4_096,
};
const isArtifactOperation = (
operation: AnalysisOperation,
): operation is ArtifactAnalysisOperation =>
-11
View File
@@ -31,16 +31,6 @@ export interface ArtifactReader {
close(): Promise<void>;
}
/** Hard traversal limits shared by every reader and extraction operation. */
export interface ArtifactLimits {
readonly maxEntries: number;
readonly maxTotalBytes: number;
readonly maxEntryBytes: number;
readonly maxCompressionRatio: number;
readonly maxDepth: number;
readonly maxPathBytes: number;
}
/** Typed adapter failure translated at provider boundary. */
export class ArtifactReaderFailure extends Error {
constructor(
@@ -48,7 +38,6 @@ export class ArtifactReaderFailure extends Error {
| "cancelled"
| "format"
| "integrity"
| "limit"
| "path"
| "policy"
| "unavailable",
+6 -26
View File
@@ -16,10 +16,7 @@ import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactLimits,
} from "./ArtifactReader.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
/** One file durably written to an operation-owned output tree. */
export interface SafeOutputFile {
@@ -40,24 +37,18 @@ export type SafeOutputCleanup =
/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */
export class SafeOutputTree {
readonly #registry = new ArtifactPathRegistry();
readonly #limits: ArtifactLimits;
readonly #outputRoot: string;
#totalBytes = 0;
#published = false;
#cleanup: SafeOutputCleanup = {
status: "not-required",
};
private constructor(outputRoot: string, limits: ArtifactLimits) {
private constructor(outputRoot: string) {
this.#outputRoot = outputRoot;
this.#limits = limits;
}
/** Exclusively create the absent destination as this operation's owned tree. */
static async create(
outputRoot: string,
limits: ArtifactLimits,
): Promise<SafeOutputTree> {
static async create(outputRoot: string): Promise<SafeOutputTree> {
if (!isAbsolute(outputRoot))
throw new ArtifactReaderFailure(
"path",
@@ -96,7 +87,7 @@ export class SafeOutputTree {
} finally {
await stagingHandle.close();
}
return new SafeOutputTree(canonicalOutput, limits);
return new SafeOutputTree(canonicalOutput);
} catch (cause: unknown) {
await rm(canonicalOutput, { recursive: true, force: true });
throw cause;
@@ -111,7 +102,7 @@ export class SafeOutputTree {
return structuredClone(this.#cleanup);
}
/** Stream one selected regular file with exact byte and digest verification. */
/** Stream one regular file with exact byte and digest verification. */
async write(
relativePath: string,
source: Readable,
@@ -119,7 +110,7 @@ export class SafeOutputTree {
signal?: AbortSignal,
): Promise<SafeOutputFile> {
this.#assertWritable();
const path = normalizeArtifactPath(relativePath, this.#limits);
const path = normalizeArtifactPath(relativePath);
this.#registry.add(path, "file");
const destination = await this.#prepareParent(path);
const handle = await open(
@@ -143,16 +134,6 @@ export class SafeOutputTree {
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
if (bytes > this.#limits.maxEntryBytes)
throw new ArtifactReaderFailure(
"limit",
`Observed entry bytes exceed limit: ${path}`,
);
if (this.#totalBytes + bytes > this.#limits.maxTotalBytes)
throw new ArtifactReaderFailure(
"limit",
"Observed extraction bytes exceed cumulative limit",
);
hash.update(chunk);
await writeAll(handle, chunk);
}
@@ -170,7 +151,6 @@ export class SafeOutputTree {
"integrity",
`Durable readback verification failed: ${path}`,
);
this.#totalBytes += bytes;
return { relativePath: path, sha256, bytesWritten: bytes };
} catch (cause: unknown) {
await handle.close().catch(() => undefined);
+3 -26
View File
@@ -17,41 +17,18 @@ export const registerArtifactCommands = (
const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.extractArtifact, {
description: "Extract explicitly selected artifact occurrences safely",
description: "Extract all regular artifact contents safely",
args: z.object({
path: z.string().describe("Application or package path"),
}),
options: z
.object({
paths: z
.array(z.string().min(1))
.min(1)
.optional()
.describe("Logical artifact paths selected for extraction"),
occurrenceIds: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.optional()
.describe("Alternate exact occurrence IDs selected for extraction"),
})
.refine(
({ paths, occurrenceIds }) =>
(paths === undefined) !== (occurrenceIds === undefined),
"Provide paths or occurrence IDs",
),
alias: { occurrenceIds: "occurrence-ids" },
run: ({ args, options }) =>
options: z.object({}),
run: ({ args }) =>
logCliCommand(logger, "extract-artifact", () =>
runProviderAnalysis(
args.path,
"extract_artifact",
{
output_root: createArtifactExtractionDestination(),
...(options.paths !== undefined
? { paths: options.paths }
: options.occurrenceIds !== undefined
? { occurrence_ids: options.occurrenceIds }
: {}),
},
logger,
),
@@ -47,14 +47,6 @@ const inventory = (digit: string) => {
},
],
edges: [],
limits: {
max_entries: 100,
max_total_bytes: 1024,
max_entry_bytes: 1024,
max_compression_ratio: 10,
max_depth: 10,
max_path_bytes: 256,
},
provenance: [],
limitations: [],
});
+8 -26
View File
@@ -26,36 +26,18 @@ export const artifactInventoryInputSchema = z.union([
z.object({ ...artifactInventoryFacts, ...integrityInput.continue }),
]);
/** Exact caller boundary for approved artifact extraction. */
const artifactOccurrenceSelectionSchema = z.strictObject({
occurrence_ids: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.describe("Alternate exact occurrence IDs from inspect_artifact"),
});
const artifactPathSelectionSchema = z.strictObject({
paths: z
.array(z.string().min(1))
.min(1)
.describe("Exact logical_path values from inspect_artifact"),
});
export const artifactExtractionInputSchema = z.union([
artifactPathSelectionSchema,
artifactOccurrenceSelectionSchema,
]);
/** Extraction needs no selector: it materializes every regular child file. */
export const artifactExtractionInputSchema = z.strictObject({});
/** Provider input after the local permission boundary chooses its destination. */
export const artifactExtractionExecutionSchema = z.union([
artifactPathSelectionSchema.extend({ output_root: z.string().min(1) }),
artifactOccurrenceSelectionSchema.extend({ output_root: z.string().min(1) }),
]);
export const artifactExtractionExecutionSchema = z.strictObject({
output_root: z.string().min(1),
});
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
inspect_artifact: {},
extract_artifact: {
paths: ["Contents/Info.plist"],
},
extract_artifact: {},
};
const artifact = <
@@ -87,12 +69,12 @@ const artifact = <
export const ARTIFACT_TOOL_CONTRACTS = [
artifact(
"inspect_artifact",
"Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. It does not extract files; use extract_artifact for selected occurrences.",
"Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. It does not extract files; use extract_artifact to materialize its regular contents.",
artifactInventoryInputSchema,
),
artifact(
"extract_artifact",
"Extract selected regular files by logical paths from inspect_artifact (or by occurrence IDs) into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
"Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
artifactExtractionInputSchema,
),
] as const satisfies readonly ToolContract[];
+1 -19
View File
@@ -10,7 +10,6 @@ const contradictionIdSchema = z.string().regex(/^ic_[a-f0-9]{64}$/u);
const boundedRelativePathSchema = z
.string()
.min(1)
.max(4_096)
.refine(
(path) =>
!path.startsWith("/") &&
@@ -61,16 +60,6 @@ const artifactFormatSchema = z.enum([
"unknown",
]);
/** Exact bounded policy applied while discovering or extracting artifacts. */
const artifactTraversalLimitsSchema = z.object({
max_entries: z.number().int().min(1).max(1_000_000),
max_total_bytes: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
max_entry_bytes: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
max_compression_ratio: z.number().min(1).max(100_000),
max_depth: z.number().int().min(0).max(100),
max_path_bytes: z.number().int().min(1).max(65_535),
});
/** File byte interval for an artifact derived without writing it to disk. */
const artifactByteRangeSchema = z.object({
offset: z.number().int().min(0),
@@ -125,12 +114,7 @@ const artifactOccurrenceSchema = z.object({
.nullable(),
executable: z.boolean(),
encrypted: z.boolean(),
hash_status: z.enum([
"verified",
"mismatched",
"not-hashed-limit",
"unavailable",
]),
hash_status: z.enum(["verified", "mismatched", "not-hashed", "unavailable"]),
source_location: artifactByteRangeSchema.nullable(),
limitations: z.array(z.string()),
});
@@ -188,7 +172,6 @@ export const artifactInventoryResultSchema = z.object({
nodes: z.array(artifactNodeSchema),
occurrences: z.array(artifactOccurrenceSchema),
edges: z.array(artifactEdgeSchema),
limits: artifactTraversalLimitsSchema,
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
limitations: z.array(z.string()),
@@ -224,7 +207,6 @@ export const artifactExtractionResultSchema = z.object({
verified: z.boolean(),
residual_paths: z.array(boundedRelativePathSchema),
}),
limits: artifactTraversalLimitsSchema,
provenance: z.array(artifactCommandSchema),
limitations: z.array(z.string()),
});
+1 -1
View File
@@ -55,7 +55,7 @@ import {
} from "./sessionToolPolicies.js";
const TARGET_FREE_INSTRUCTIONS =
"Start with binary_session to see the active target and available providers. For an archive or application package, call open_binary(path), then inspect_artifact for the complete graph and findings; use extract_artifact only for selected files. For standalone JavaScript or ASAR analysis, use analyze_javascript_application. For managed PE/CLI files, use inspect_managed_artifact. For native binaries, open the target and start with binary_overview. For a live page or runtime, discover targets with list_browser_targets, list_electron_targets, or list_javascript_runtime_targets before inspection; scenario capture tools are for controlled interactions.";
"Start with binary_session to see the active target and available providers. For an archive or application package, call open_binary(path), then inspect_artifact for the complete graph and findings; use extract_artifact to materialize its regular contents when needed. For standalone JavaScript or ASAR analysis, use analyze_javascript_application. For managed PE/CLI files, use inspect_managed_artifact. For native binaries, open the target and start with binary_overview. For a live page or runtime, discover targets with list_browser_targets, list_electron_targets, or list_javascript_runtime_targets before inspection; scenario capture tools are for controlled interactions.";
const ACTIVE_TARGET_INSTRUCTIONS =
"REA analyzes the active reverse-engineering target. For native binaries, call binary_overview for target metadata and inventory counts, then search_procedures or list_procedures to choose a function and analyze_function with its returned name or address for a complete dossier. Use inspect_native_api when you need to reconstruct a native API boundary, and focused procedure tools when you need only one facet such as assembly, callers, or references.";
@@ -7,21 +7,12 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
const LIMITS = {
maxEntries: 10,
maxTotalBytes: 1_024,
maxEntryBytes: 512,
maxCompressionRatio: 10,
maxDepth: 8,
maxPathBytes: 256,
} as const;
describe("artifact inventory snapshot", () => {
it("retains the complete scan after the source directory changes", async () => {
const root = await createTestTempDirectory("rea-inventory-snapshot-");
await writeFile(join(root, "a.txt"), "a");
await writeFile(join(root, "b.txt"), "b");
const snapshot = await scanArtifactInventory(root, LIMITS);
const snapshot = await scanArtifactInventory(root);
await rm(join(root, "b.txt"));
expect(snapshot.occurrences.map(({ logical_path: path }) => path)).toEqual([
@@ -7,21 +7,12 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { scanAuthorizedArtifactInventory } from "../../../src/application/AuthorizedArtifactInventory.js";
const LIMITS = {
maxEntries: 100,
maxTotalBytes: 1024 * 1024,
maxEntryBytes: 1024 * 1024,
maxCompressionRatio: 100,
maxDepth: 10,
maxPathBytes: 1024,
} as const;
describe("authorized artifact inventory", () => {
it("scans a local artifact without configured input roots", async () => {
const root = await createTestTempDirectory("rea-artifact-roots-");
const path = join(root, "artifact.js");
await writeFile(path, "export const value = 1;\n");
const inventory = await scanAuthorizedArtifactInventory(path, LIMITS);
const inventory = await scanAuthorizedArtifactInventory(path);
expect(inventory.manifest.root_format).toBe("javascript-bundle");
});
});
@@ -8,10 +8,6 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { readJavaScriptArtifactFiles } from "../../../src/application/JavaScriptArtifactFiles.js";
import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js";
import {
artifactLimitsForReconstruction,
javascriptArtifactReconstructionInputSchema,
} from "../../../src/application/JavaScriptArtifactReconstructionInput.js";
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
import {
type ArtifactEntry,
@@ -163,10 +159,7 @@ it("retains every repeated content observation and containment path", async () =
it("rejects traversal from a production reader seam and malformed ASAR containers", async () => {
const root = await fixtureDirectory();
const snapshot = await scanArtifactInventory(
root,
artifactLimitsForReconstruction(),
);
const snapshot = await scanArtifactInventory(root);
await expect(
readJavaScriptArtifactFiles(new TraversalReader(), snapshot),
).rejects.toMatchObject({ reason: "path" });
@@ -194,12 +187,6 @@ it("preserves cancellation and explicit format diagnostics", async () => {
reason: "format",
message: expect.stringContaining(root),
});
expect(
javascriptArtifactReconstructionInputSchema.safeParse({
input_path: root,
limits: { max_entries: 100_000 },
}).success,
).toBe(false);
});
class TraversalReader implements ArtifactReader {
@@ -9,22 +9,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
const LIMITS = {
maxEntries: 10,
maxTotalBytes: 1_024,
maxEntryBytes: 512,
maxCompressionRatio: 10,
maxDepth: 8,
maxPathBytes: 256,
} as const;
describe("safe artifact output tree", () => {
it.skipIf(process.platform !== "linux")(
"closes the parent descriptor when the output disappears before commit",
async () => {
const parent = await createTestTempDirectory("rea-safe-output-");
const output = join(parent, "published");
const tree = await SafeOutputTree.create(output, LIMITS);
const tree = await SafeOutputTree.create(output);
await rm(output, { recursive: true });
await expect(tree.commit()).rejects.toThrow();
const targets = await Promise.all(
@@ -42,7 +33,7 @@ describe("safe artifact output tree", () => {
it("removes only its owned tree after digest failure and proves absence", async () => {
const parent = await createTestTempDirectory("rea-safe-output-");
const output = join(parent, "published");
const tree = await SafeOutputTree.create(output, LIMITS);
const tree = await SafeOutputTree.create(output);
await expect(
tree.write(
"nested/file.txt",
@@ -61,7 +52,7 @@ describe("safe artifact output tree", () => {
it("publishes files while building and preserves them after sealing", async () => {
const parent = await createTestTempDirectory("rea-safe-output-");
const output = join(parent, "published");
const tree = await SafeOutputTree.create(output, LIMITS);
const tree = await SafeOutputTree.create(output);
const bytes = Buffer.from("visible before seal");
const digest = createHash("sha256").update(bytes).digest("hex");
@@ -79,7 +70,7 @@ describe("safe artifact output tree", () => {
it("returns detached cleanup reports", async () => {
const parent = await createTestTempDirectory("rea-safe-output-");
const tree = await SafeOutputTree.create(join(parent, "published"), LIMITS);
const tree = await SafeOutputTree.create(join(parent, "published"));
const cleanup = await tree.rollback();
if (cleanup.status !== "complete") throw new Error("expected cleanup");
(cleanup.residualPaths as unknown as string[]).push("/forged/path");
@@ -50,11 +50,11 @@ describe("artifact archive safety", () => {
expect(result.nodes.map(({ kind }) => kind)).toEqual(
expect.arrayContaining(["javascript", "native-addon"]),
);
const selected = result.occurrences.find(
({ logical_path: logicalPath }) => logicalPath === "Assets/main.js",
);
expect(selected).toBeDefined();
if (selected === undefined) return;
expect(
result.occurrences.some(
({ logical_path: logicalPath }) => logicalPath === "Assets/main.js",
),
).toBe(true);
const output = join(root, "output");
const extracted = await new ArtifactProvider()
.createClient(parsed.value)
@@ -62,7 +62,6 @@ describe("artifact archive safety", () => {
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: output,
occurrence_ids: [selected.occurrence_id],
}),
);
expect(extracted.ok).toBe(true);
@@ -80,10 +79,17 @@ describe("artifact archive safety", () => {
},
);
it("rejects unsafe paths, collisions, and default compression bombs", async () => {
it("rejects unsafe paths and collisions", async () => {
expect(
normalizeArtifactPath(
Array.from({ length: 24 }, (_, index) => `level${String(index)}`).join(
"/",
) + "/entry.js",
).split("/"),
).toHaveLength(25);
let unsafePathError: unknown;
try {
normalizeArtifactPath("../escape", limits());
normalizeArtifactPath("../escape");
} catch (error: unknown) {
unsafePathError = error;
}
@@ -105,18 +111,6 @@ describe("artifact archive safety", () => {
reason: "path",
message: "Artifact path collision: a.js",
});
const root = await createTestTempDirectory("rea-bomb-");
const zipPath = join(root, "bomb.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("zeros", new TextReader("0".repeat(10_000_000)));
await writeFile(zipPath, await writer.close());
const client = new ArtifactProvider().createClient(target(zipPath, "zip"));
const result = await client.execute("inventory_artifact", {});
expect(result).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "limit" },
});
});
});
const inventory = async (targetValue: BinaryTarget) => {
@@ -126,16 +120,3 @@ const inventory = async (targetValue: BinaryTarget) => {
if (!result.ok) throw result.error;
return artifactInventoryResultSchema.parse(result.value.result);
};
const target = (
path: string,
format: Extract<BinaryTarget, { kind: "archive" }>["format"] | "directory",
): BinaryTarget => ({
path,
sourcePath: path,
sha256: "0".repeat(64),
kind: "archive",
format: format === "directory" ? "asar" : format,
});
const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 });
@@ -14,7 +14,7 @@ import {
import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js";
describe("artifact extraction", () => {
it("extracts only selected occurrences through an exclusively owned output tree", async () => {
it("extracts all regular occurrences through an exclusively owned output tree", async () => {
const root = await createTestTempDirectory("rea-extract-");
const source = join(root, "source");
await mkdir(join(source, "assets"), { recursive: true });
@@ -22,11 +22,11 @@ describe("artifact extraction", () => {
await writeFile(join(source, "assets", "ignored.js"), "ignored();\n");
const targetValue = target(source, "directory");
const graph = await inventory(targetValue);
const selected = graph.occurrences.find(
({ logical_path: path }) => path === "assets/selected.js",
);
expect(selected).toBeDefined();
if (selected === undefined) return;
expect(
graph.occurrences.some(
({ logical_path }) => logical_path === "assets/selected.js",
),
).toBe(true);
const cancelledOutput = join(root, "cancelled-output");
const controller = new AbortController();
controller.abort();
@@ -36,7 +36,6 @@ describe("artifact extraction", () => {
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: cancelledOutput,
occurrence_ids: [selected.occurrence_id],
}),
{ signal: controller.signal },
);
@@ -52,7 +51,6 @@ describe("artifact extraction", () => {
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: output,
paths: ["assets/selected.js"],
}),
);
expect(result.ok).toBe(true);
@@ -71,25 +69,9 @@ describe("artifact extraction", () => {
expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe(
"selected();\n",
);
await expect(
access(join(output, "assets", "ignored.js")),
).rejects.toThrow();
const traversalOutput = join(root, "traversal-output");
const traversal = await new ArtifactProvider()
.createClient(targetValue)
.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: traversalOutput,
paths: ["../source/assets/ignored.js"],
}),
);
expect(traversal).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "path" },
});
await expect(access(traversalOutput)).rejects.toThrow();
expect(await readFile(join(output, "assets", "ignored.js"), "utf8")).toBe(
"ignored();\n",
);
const relocatedOutput = join(root, "relocated-output");
const relocated = await new ArtifactProvider()
@@ -98,7 +80,6 @@ describe("artifact extraction", () => {
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: relocatedOutput,
occurrence_ids: [selected.occurrence_id],
}),
);
expect(relocated.ok).toBe(true);
@@ -114,7 +95,6 @@ describe("artifact extraction", () => {
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: output,
occurrence_ids: [selected.occurrence_id],
}),
);
expect(second).toMatchObject({
@@ -14,14 +14,6 @@ import {
import { createEvidence } from "../../../src/domain/evidence.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
const LIMITS = {
maxEntries: 1_000,
maxTotalBytes: 1024 * 1024,
maxEntryBytes: 1024 * 1024,
maxCompressionRatio: 100,
maxDepth: 20,
maxPathBytes: 1024,
} as const;
const PROVIDER = {
id: "rea-artifact-graph",
name: "REA artifact graph",
@@ -29,7 +21,7 @@ const PROVIDER = {
} as const;
const observe = async (path: string) => {
const inventory = await inventoryArtifact(path, LIMITS);
const inventory = await inventoryArtifact(path);
return createEvidence(
{
path,
@@ -164,14 +164,7 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent",
await createPackageWithOptions(source, archive, { unpack: "**/*.node" });
await rm(join(`${archive}.unpacked`, "native", "addon.node"));
const snapshot = await scanArtifactInventory(archive, {
maxEntries: 8_000,
maxTotalBytes: 512 * 1024 * 1024,
maxEntryBytes: 128 * 1024 * 1024,
maxCompressionRatio: 1_000,
maxDepth: 64,
maxPathBytes: 4_096,
});
const snapshot = await scanArtifactInventory(archive);
const missingNative = snapshot.occurrences.find(
({ logical_path }) => logical_path === "native/addon.node",
);