414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
import {
|
|
access,
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { createPackage, createPackageWithOptions } from "@electron/asar";
|
|
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js";
|
|
import { MachOSliceArtifactReader } from "../src/artifacts/MachOSliceArtifactReader.js";
|
|
import { runProviderAnalysis } from "../src/application/DirectAnalysis.js";
|
|
import {
|
|
ArtifactPathRegistry,
|
|
normalizeArtifactPath,
|
|
} from "../src/artifacts/ArtifactPaths.js";
|
|
import {
|
|
artifactExtractionInputSchema,
|
|
artifactInventoryInputSchema,
|
|
} from "../src/contracts/artifactToolContracts.js";
|
|
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
|
import { parseBinaryTarget } from "../src/domain/binaryTarget.js";
|
|
import { parseEvidence } from "../src/domain/evidence.js";
|
|
import { ok } from "../src/domain/result.js";
|
|
import type { NativeCommandRunner } from "../src/native/CommandRunner.js";
|
|
import {
|
|
artifactExtractionResultSchema,
|
|
artifactInventoryResultSchema,
|
|
} from "../src/domain/artifactGraph.js";
|
|
|
|
describe("artifact graph provider", () => {
|
|
it("inventories app trees deterministically without following symlinks", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-artifacts-"));
|
|
const app = join(root, "Fixture.app");
|
|
await mkdir(join(app, "Contents", "Resources"), { recursive: true });
|
|
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
|
|
await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), {
|
|
recursive: true,
|
|
});
|
|
const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]);
|
|
await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, {
|
|
mode: 0o755,
|
|
});
|
|
await writeFile(
|
|
join(app, "Contents", "Frameworks", "Fixture.framework", "Fixture"),
|
|
machO,
|
|
);
|
|
await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n");
|
|
await writeFile(join(app, "Contents", "Resources", "main.js.map"), "{}\n");
|
|
await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside"));
|
|
const client = new ArtifactProvider().createClient(
|
|
target(app, "directory"),
|
|
);
|
|
const input = artifactInventoryInputSchema.parse({
|
|
node_limit: 500,
|
|
occurrence_limit: 500,
|
|
edge_limit: 500,
|
|
});
|
|
const first = await client.execute("inventory_artifact", input);
|
|
const second = await client.execute("inventory_artifact", input);
|
|
expect(first.ok).toBe(true);
|
|
expect(second.ok).toBe(true);
|
|
if (!first.ok || !second.ok) return;
|
|
const left = artifactInventoryResultSchema.parse(first.value.result);
|
|
const right = artifactInventoryResultSchema.parse(second.value.result);
|
|
expect(left.manifest).toEqual(right.manifest);
|
|
expect(left.nodes.items.map(({ kind }) => kind)).toEqual(
|
|
expect.arrayContaining([
|
|
"executable",
|
|
"framework",
|
|
"javascript",
|
|
"source-map",
|
|
]),
|
|
);
|
|
expect(
|
|
left.occurrences.items.find(({ logical_path: path }) =>
|
|
path.endsWith("outside"),
|
|
),
|
|
).toMatchObject({ artifact_id: null, entry_kind: "symlink" });
|
|
const script = left.occurrences.items.find(({ logical_path: path }) =>
|
|
path.endsWith("main.js"),
|
|
);
|
|
const sourceMap = left.occurrences.items.find(({ logical_path: path }) =>
|
|
path.endsWith("main.js.map"),
|
|
);
|
|
expect(left.edges.items).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
relation: "maps-source",
|
|
parent_artifact_id: script?.artifact_id,
|
|
child_artifact_id: sourceMap?.artifact_id,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("streams ZIP and official ASAR inventories within shared limits", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-containers-"));
|
|
const zipPath = join(root, "fixture.apk");
|
|
const zipWriter = new ZipWriter(new Uint8ArrayWriter());
|
|
await zipWriter.add("assets/index.js", new TextReader("export default 1;"));
|
|
await zipWriter.add("lib/arm64-v8a/addon.so", new TextReader("native"));
|
|
await writeFile(zipPath, await zipWriter.close());
|
|
const parsed = await parseBinaryTarget(zipPath);
|
|
expect(parsed.ok && parsed.value.format).toBe("apk");
|
|
if (!parsed.ok) return;
|
|
const zipResult = await inventory(parsed.value);
|
|
expect(zipResult.occurrences.total).toBe(3);
|
|
expect(zipResult.nodes.items.map(({ kind }) => kind)).toEqual(
|
|
expect.arrayContaining(["javascript", "dynamic-library"]),
|
|
);
|
|
const cliEvidence = parseEvidence(
|
|
await runProviderAnalysis(zipPath, "inventory_artifact", {}),
|
|
);
|
|
expect(cliEvidence).toMatchObject({
|
|
operation: "inventory_artifact",
|
|
provider: { id: "rea-artifact-graph" },
|
|
subject: { format: "apk" },
|
|
});
|
|
const cancellation = new AbortController();
|
|
cancellation.abort();
|
|
await expect(
|
|
runProviderAnalysis(
|
|
zipPath,
|
|
"inventory_artifact",
|
|
{},
|
|
undefined,
|
|
cancellation.signal,
|
|
),
|
|
).resolves.toMatchObject({ code: "cancelled" });
|
|
|
|
const source = join(root, "asar-source");
|
|
await mkdir(source);
|
|
await writeFile(join(source, "main.js"), "console.log('ok');\n");
|
|
const asarPath = join(root, "app.asar");
|
|
await createPackage(source, asarPath);
|
|
const asarResult = await inventory(target(asarPath, "asar"));
|
|
expect(asarResult.occurrences.items).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ logical_path: "main.js" }),
|
|
]),
|
|
);
|
|
|
|
const unpackedPath = join(root, "unpacked.asar");
|
|
await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" });
|
|
const unpackedResult = await inventory(target(unpackedPath, "asar"));
|
|
expect(unpackedResult.occurrences.items).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "main.js",
|
|
hash_status: "verified",
|
|
}),
|
|
]),
|
|
);
|
|
await writeFile(
|
|
join(`${unpackedPath}.unpacked`, "main.js"),
|
|
"changed();\n",
|
|
);
|
|
const corrupted = await new ArtifactProvider()
|
|
.createClient(target(unpackedPath, "asar"))
|
|
.execute("inventory_artifact", artifactInventoryInputSchema.parse({}));
|
|
expect(corrupted).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "ArtifactOperationError",
|
|
reason: "integrity",
|
|
artifactDetails: {
|
|
logicalPath: "main.js",
|
|
unpacked: true,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects unsafe, colliding, and over-ratio entries", async () => {
|
|
expect(() => normalizeArtifactPath("../escape", limits())).toThrow(
|
|
/normalized/u,
|
|
);
|
|
const registry = new ArtifactPathRegistry();
|
|
registry.add("A.js", "file");
|
|
expect(() => registry.add("a.js", "file")).toThrow(/collision/u);
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "rea-bomb-"));
|
|
const zipPath = join(root, "bomb.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("zeros", new TextReader("0".repeat(100_000)));
|
|
await writeFile(zipPath, await writer.close());
|
|
const client = new ArtifactProvider().createClient(target(zipPath, "zip"));
|
|
const result = await client.execute(
|
|
"inventory_artifact",
|
|
artifactInventoryInputSchema.parse({ max_compression_ratio: 2 }),
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "ArtifactOperationError", reason: "limit" },
|
|
});
|
|
});
|
|
|
|
it("extracts only selected occurrences through an exclusively owned output tree", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-extract-"));
|
|
const source = join(root, "source");
|
|
await mkdir(join(source, "assets"), { recursive: true });
|
|
await writeFile(join(source, "assets", "selected.js"), "selected();\n");
|
|
await writeFile(join(source, "assets", "ignored.js"), "ignored();\n");
|
|
const targetValue = target(source, "directory");
|
|
const graph = await inventory(targetValue);
|
|
const selected = graph.occurrences.items.find(
|
|
({ logical_path: path }) => path === "assets/selected.js",
|
|
);
|
|
expect(selected).toBeDefined();
|
|
if (selected === undefined) return;
|
|
const cancelledOutput = join(root, "cancelled-output");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const cancelled = await new ArtifactProvider()
|
|
.createClient(targetValue)
|
|
.execute(
|
|
"extract_artifact",
|
|
artifactExtractionInputSchema.parse({
|
|
approved: true,
|
|
output_root: cancelledOutput,
|
|
occurrence_ids: [selected.occurrence_id],
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(cancelled).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "ArtifactOperationError", reason: "cancelled" },
|
|
});
|
|
await expect(access(cancelledOutput)).rejects.toThrow();
|
|
const output = join(root, "output");
|
|
const result = await new ArtifactProvider()
|
|
.createClient(targetValue)
|
|
.execute(
|
|
"extract_artifact",
|
|
artifactExtractionInputSchema.parse({
|
|
approved: true,
|
|
output_root: output,
|
|
occurrence_ids: [selected.occurrence_id],
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
const firstExtraction = artifactExtractionResultSchema.parse(
|
|
result.value.result,
|
|
);
|
|
expect(firstExtraction).toMatchObject({
|
|
output_root: "$OUTPUT_ROOT",
|
|
containment_verified: true,
|
|
artifacts: { total: 1 },
|
|
extraction_manifest: { output_root_alias: "$OUTPUT_ROOT" },
|
|
});
|
|
expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe(
|
|
"selected();\n",
|
|
);
|
|
await expect(
|
|
access(join(output, "assets", "ignored.js")),
|
|
).rejects.toThrow();
|
|
|
|
const relocatedOutput = join(root, "relocated-output");
|
|
const relocated = await new ArtifactProvider()
|
|
.createClient(targetValue)
|
|
.execute(
|
|
"extract_artifact",
|
|
artifactExtractionInputSchema.parse({
|
|
approved: true,
|
|
output_root: relocatedOutput,
|
|
occurrence_ids: [selected.occurrence_id],
|
|
}),
|
|
);
|
|
expect(relocated.ok).toBe(true);
|
|
if (!relocated.ok) return;
|
|
expect(
|
|
artifactExtractionResultSchema.parse(relocated.value.result)
|
|
.extraction_manifest,
|
|
).toEqual(firstExtraction.extraction_manifest);
|
|
|
|
const second = await new ArtifactProvider()
|
|
.createClient(targetValue)
|
|
.execute(
|
|
"extract_artifact",
|
|
artifactExtractionInputSchema.parse({
|
|
approved: true,
|
|
output_root: output,
|
|
occurrence_ids: [selected.occurrence_id],
|
|
}),
|
|
);
|
|
expect(second).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "ArtifactOperationError", reason: "path" },
|
|
});
|
|
expect(await readdir(root)).not.toEqual(
|
|
expect.arrayContaining([expect.stringMatching(/^\.output\.rea-/u)]),
|
|
);
|
|
});
|
|
|
|
it("paginates wide graphs without changing manifest identity", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-wide-"));
|
|
await Promise.all(
|
|
Array.from({ length: 520 }, async (_, index) =>
|
|
writeFile(
|
|
join(root, `file-${String(index).padStart(4, "0")}.txt`),
|
|
`${index}`,
|
|
),
|
|
),
|
|
);
|
|
const client = new ArtifactProvider().createClient(
|
|
target(root, "directory"),
|
|
);
|
|
const first = await client.execute(
|
|
"inventory_artifact",
|
|
artifactInventoryInputSchema.parse({ occurrence_limit: 500 }),
|
|
);
|
|
const secondPage = await client.execute(
|
|
"inventory_artifact",
|
|
artifactInventoryInputSchema.parse({
|
|
occurrence_offset: 500,
|
|
occurrence_limit: 500,
|
|
}),
|
|
);
|
|
expect(first.ok && secondPage.ok).toBe(true);
|
|
if (!first.ok || !secondPage.ok) return;
|
|
const left = artifactInventoryResultSchema.parse(first.value.result);
|
|
const right = artifactInventoryResultSchema.parse(secondPage.value.result);
|
|
expect(left.manifest).toEqual(right.manifest);
|
|
expect(left.occurrences).toMatchObject({ total: 521, next_offset: 500 });
|
|
expect(right.occurrences).toMatchObject({
|
|
offset: 500,
|
|
next_offset: null,
|
|
});
|
|
}, 15_000);
|
|
|
|
it("uses bounded native lipo metadata for universal slice ranges", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-slices-"));
|
|
const binary = join(root, "fat");
|
|
await writeFile(binary, Buffer.from("0123456789abcdef"));
|
|
const runner: NativeCommandRunner = {
|
|
run: () =>
|
|
Promise.resolve(
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: ["-detailed_info", binary],
|
|
stdout:
|
|
"architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n",
|
|
stderr: "",
|
|
stdoutBytes: 1,
|
|
stderrBytes: 0,
|
|
stdoutTruncated: false,
|
|
stderrTruncated: false,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
),
|
|
};
|
|
const reader = new MachOSliceArtifactReader(binary, runner);
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
expect(entries).toHaveLength(2);
|
|
const secondEntry = entries[1];
|
|
expect(secondEntry).toBeDefined();
|
|
if (secondEntry === undefined) return;
|
|
expect(secondEntry).toMatchObject({
|
|
path: "slices/arm64",
|
|
byteOffset: 8,
|
|
declaredSize: 8,
|
|
});
|
|
const chunks: Buffer[] = [];
|
|
const stream = await reader.open(secondEntry);
|
|
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
expect(Buffer.concat(chunks).toString()).toBe("89abcdef");
|
|
expect(reader.provenance()).toEqual([
|
|
expect.objectContaining({ tool: "lipo", effects: ["read"] }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
const inventory = async (targetValue: BinaryTarget) => {
|
|
const result = await new ArtifactProvider().createClient(targetValue).execute(
|
|
"inventory_artifact",
|
|
artifactInventoryInputSchema.parse({
|
|
node_limit: 500,
|
|
occurrence_limit: 500,
|
|
edge_limit: 500,
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
return artifactInventoryResultSchema.parse(result.value.result);
|
|
};
|
|
|
|
const target = (
|
|
path: string,
|
|
format: BinaryTarget["format"] | "directory",
|
|
): BinaryTarget => ({
|
|
path,
|
|
sourcePath: path,
|
|
sha256: "0".repeat(64),
|
|
kind: format === "directory" ? "executable" : "archive",
|
|
format: format === "directory" ? "mach-o" : format,
|
|
loaderArgs: [],
|
|
});
|
|
|
|
const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 });
|