mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
refactor(artifacts): remove selectors and traversal quotas
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user