mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
fix(artifacts): bind operations to admitted sources
This commit is contained in:
@@ -158,6 +158,7 @@ const isArtifactBundleSubject = (
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
target.bundleInfoPlist !== undefined &&
|
||||
(operation === "inventory_artifact" ||
|
||||
operation === "inspect_artifact" ||
|
||||
operation === "extract_artifact") &&
|
||||
|
||||
@@ -311,6 +311,10 @@ class ArtifactClient implements AnalysisClient {
|
||||
const result = await extractArtifact(
|
||||
{
|
||||
inputPath: this.target.sourcePath ?? this.target.path,
|
||||
expectedRootSha256:
|
||||
this.target.bundleInfoPlist === undefined
|
||||
? this.target.sha256
|
||||
: undefined,
|
||||
selectedPath: this.target.artifactSelectedPath,
|
||||
inputFormat: this.target.format,
|
||||
outputRoot: parsed.output_root,
|
||||
@@ -506,6 +510,10 @@ class ArtifactClient implements AnalysisClient {
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
|
||||
expectedRootSha256:
|
||||
this.target.bundleInfoPlist === undefined
|
||||
? this.target.sha256
|
||||
: undefined,
|
||||
selectedPath: this.target.artifactSelectedPath,
|
||||
resourceScope: this.#resourceScope,
|
||||
environment: this.environment,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/** Verify selection spelling independently of canonical descriptor ownership. */
|
||||
export const assertArtifactSelectedSource = async (
|
||||
canonicalPath: string,
|
||||
selectedPath: string | undefined,
|
||||
): Promise<void> => {
|
||||
if (selectedPath === undefined) return;
|
||||
const changed = (cause?: unknown) =>
|
||||
new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Selected artifact no longer resolves to its admitted source: ${selectedPath}`,
|
||||
{
|
||||
userMessage:
|
||||
"The selected artifact path no longer names the opened file. Reopen the selected artifact, then retry.",
|
||||
...(cause === undefined ? {} : { cause }),
|
||||
},
|
||||
);
|
||||
let current: string;
|
||||
try {
|
||||
current = await realpath(selectedPath);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
|
||||
)
|
||||
throw changed(cause);
|
||||
throw cause;
|
||||
}
|
||||
if (current !== canonicalPath) throw changed();
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js";
|
||||
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
||||
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
|
||||
@@ -437,12 +438,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
// The packed pathname is fixed for this reader; member checks only need
|
||||
// to re-observe the caller's independently selected alias.
|
||||
this.#selectedCanonicalSource ??= await realpath(this.path);
|
||||
const current = await realpath(selected);
|
||||
if (this.#selectedCanonicalSource !== current)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Selected ASAR archive no longer resolves to its admitted source: ${selected}`,
|
||||
);
|
||||
await assertArtifactSelectedSource(this.#selectedCanonicalSource, selected);
|
||||
}
|
||||
|
||||
async #openUnpackedStream(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { assertAdmittedRootDigest } from "../inventory/classify.js";
|
||||
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
||||
import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js";
|
||||
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import {
|
||||
@@ -56,7 +59,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
|
||||
export interface ArtifactExtractionInput {
|
||||
readonly asarHeaderAdmission?: AsarHeaderAdmission;
|
||||
readonly inputPath: string;
|
||||
/** Selected ASAR spelling; packed I/O remains on inputPath. */
|
||||
readonly expectedRootSha256?: string | undefined;
|
||||
/** Selected artifact spelling; packed I/O remains on inputPath. */
|
||||
readonly selectedPath?: string | undefined;
|
||||
readonly inputFormat: BinaryTarget["format"];
|
||||
readonly outputRoot: string;
|
||||
@@ -79,6 +83,8 @@ const extractArtifactInScope = async (
|
||||
// Cancellation wins over every later refusal, including unsupported formats.
|
||||
abortIfNeeded(signal);
|
||||
const sourcePath = await realpath(input.inputPath);
|
||||
if (input.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
|
||||
const admission = await requireExtractableFormat(sourcePath, input, signal);
|
||||
const { snapshot, zipSource } =
|
||||
await scanArtifactInventoryForExtractionInScope(
|
||||
@@ -86,6 +92,7 @@ const extractArtifactInScope = async (
|
||||
{
|
||||
resourceScope: input.resourceScope,
|
||||
selectedPath: input.selectedPath,
|
||||
expectedRootSha256: input.expectedRootSha256,
|
||||
signal,
|
||||
environment: input.environment,
|
||||
asarHeaderAdmission:
|
||||
@@ -176,7 +183,7 @@ export const materializeArtifactInventoryInScope = async (
|
||||
)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
input.selectedPath ?? input.inputPath,
|
||||
`Godot PCK directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`,
|
||||
);
|
||||
const selectedOccurrences = snapshot.occurrences.filter(
|
||||
@@ -218,7 +225,7 @@ export const materializeArtifactInventoryInScope = async (
|
||||
if (occurrence.encrypted)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
input.selectedPath ?? input.inputPath,
|
||||
`Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`,
|
||||
);
|
||||
// Inventory retains unavailable source occurrences, but extraction cannot
|
||||
@@ -377,6 +384,8 @@ const materializeSelection = async ({
|
||||
traversal.snapshot(),
|
||||
reader.provenance(),
|
||||
);
|
||||
if (input.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
|
||||
await output.commit(signal);
|
||||
return completed;
|
||||
} catch (cause: unknown) {
|
||||
@@ -550,7 +559,11 @@ const requireExtractableFormat = async (
|
||||
path: string,
|
||||
input: Pick<
|
||||
ArtifactExtractionInput,
|
||||
"inputPath" | "inputFormat" | "resourceScope"
|
||||
| "inputPath"
|
||||
| "selectedPath"
|
||||
| "inputFormat"
|
||||
| "resourceScope"
|
||||
| "expectedRootSha256"
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
previous?: ArtifactEmbeddedFormatProbeFacts,
|
||||
@@ -591,13 +604,26 @@ const requireExtractableFormat = async (
|
||||
((format === "elf" || format === "pe" || format === "mach-o") &&
|
||||
((await probes.nodeSea()) !== undefined ||
|
||||
(await probes.pyInstaller())));
|
||||
if (pack && input.expectedRootSha256 !== undefined) {
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
source.handle,
|
||||
source.initial,
|
||||
signal,
|
||||
);
|
||||
assertAdmittedRootDigest(
|
||||
path,
|
||||
input.expectedRootSha256,
|
||||
digest.sha256,
|
||||
);
|
||||
}
|
||||
return { pack, probeFacts: probes.completedFacts() };
|
||||
},
|
||||
);
|
||||
if (!detected.pack)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
input.selectedPath ?? input.inputPath,
|
||||
`Artifact format has no extraction reader: ${format}`,
|
||||
);
|
||||
return { directory, probeFacts: detected.probeFacts };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
||||
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
|
||||
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
@@ -65,7 +66,11 @@ export const classifyAndHashRootForInventory = async (
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath"
|
||||
| "resourceScope"
|
||||
| "signal"
|
||||
| "asarHeaderAdmission"
|
||||
| "selectedPath"
|
||||
| "expectedRootSha256"
|
||||
>,
|
||||
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
@@ -76,6 +81,15 @@ export const classifyAndHashRootForInventory = async (
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
}
|
||||
if (directory && options.expectedRootSha256 !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact root changed after target admission: expected an admitted file at ${path}, observed a directory`,
|
||||
{
|
||||
userMessage:
|
||||
"The opened file was replaced by a directory. Select the intended artifact and reopen it before retrying.",
|
||||
},
|
||||
);
|
||||
if (directory)
|
||||
return {
|
||||
format: "directory",
|
||||
@@ -83,6 +97,8 @@ export const classifyAndHashRootForInventory = async (
|
||||
rootSource: undefined,
|
||||
readerAdmission: undefined,
|
||||
};
|
||||
if (options.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(path, options.selectedPath);
|
||||
const source = await openRootArtifact(
|
||||
path,
|
||||
options.resourceScope,
|
||||
@@ -101,27 +117,46 @@ export const classifyAndHashRootForInventory = async (
|
||||
"integrity",
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
// Prior probe state can prove replacement without interpreting any bytes.
|
||||
const probes = new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
options.signal,
|
||||
probeFacts,
|
||||
);
|
||||
const admittedDigest =
|
||||
options.expectedRootSha256 === undefined
|
||||
? undefined
|
||||
: await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
);
|
||||
if (admittedDigest !== undefined)
|
||||
assertAdmittedRootDigest(
|
||||
path,
|
||||
options.expectedRootSha256,
|
||||
admittedDigest.sha256,
|
||||
);
|
||||
const classified = await classifyRootFormat(
|
||||
path,
|
||||
source,
|
||||
new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
options.signal,
|
||||
probeFacts,
|
||||
),
|
||||
probes,
|
||||
options.signal,
|
||||
options.asarHeaderAdmission,
|
||||
options.selectedPath,
|
||||
);
|
||||
const format =
|
||||
typeof classified === "string" ? classified : classified.kind;
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
);
|
||||
const digest =
|
||||
admittedDigest ??
|
||||
(await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
));
|
||||
const retainSource =
|
||||
isZipPackageFormat(format) ||
|
||||
format === "mach-o-universal" ||
|
||||
@@ -357,3 +392,20 @@ const readRootMzWindowsHeaderSignature = async (
|
||||
if (available === 0) return undefined;
|
||||
return { offset, bytes: window.subarray(0, available) };
|
||||
};
|
||||
|
||||
/** Compare physically completed bytes before interpreting an admitted file root. */
|
||||
export const assertAdmittedRootDigest = (
|
||||
path: string,
|
||||
expected: string | undefined,
|
||||
observed: string,
|
||||
): void => {
|
||||
if (expected !== undefined && observed !== expected)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact root changed after target admission at ${path}: expected ${expected}, observed ${observed}`,
|
||||
{
|
||||
userMessage:
|
||||
"The artifact changed after it was opened. Reopen the selected artifact, then retry.",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
|
||||
/** Options shared by artifact inventory scans. */
|
||||
export interface ArtifactInventoryOptions {
|
||||
readonly resourceScope: ArtifactResourceScope;
|
||||
/** Identity of a previously admitted file root; directory roots have no file digest. */
|
||||
readonly expectedRootSha256?: string | undefined;
|
||||
/** Independently selected root spelling used only for ASAR companion lookup. */
|
||||
readonly selectedPath?: string | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { artifactExtractionResultSchema } from "../../src/domain/artifactGraph.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("refuses changed admitted archives over stdio MCP before publishing extraction and accepts an explicit reopen", async () => {
|
||||
const root = await createTestTempDirectory("rea-admitted-archive-mcp-");
|
||||
const temporary = join(root, "temporary");
|
||||
await mkdir(temporary);
|
||||
const path = join(root, "selected.zip");
|
||||
const writeArchive = async (value: string) => {
|
||||
const zip = new ZipWriter(new Uint8ArrayWriter());
|
||||
await zip.add("payload.txt", new TextReader(value));
|
||||
await writeFile(path, await zip.close());
|
||||
};
|
||||
await writeArchive("admitted version one");
|
||||
const { call } = await connectLocalToolsMcp({
|
||||
TMPDIR: temporary,
|
||||
TEMP: temporary,
|
||||
TMP: temporary,
|
||||
REA_MCP_TOOL_FAMILIES: "session,artifact",
|
||||
});
|
||||
expect((await call("open_binary", { path })).isError).not.toBe(true);
|
||||
const openedTemporaryBaseline = (await readdir(temporary)).sort();
|
||||
await writeArchive("changed version two");
|
||||
for (const operation of ["inspect_artifact", "extract_artifact"]) {
|
||||
const refused = parseMcpToolError(await call(operation, {}));
|
||||
expect(refused.error).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
details: { reason: "integrity" },
|
||||
message: expect.stringContaining("Reopen the selected artifact"),
|
||||
});
|
||||
expect((await readdir(temporary)).sort()).toEqual(openedTemporaryBaseline);
|
||||
}
|
||||
expect((await call("open_binary", { path })).isError).not.toBe(true);
|
||||
const extracted = await call("extract_artifact", {});
|
||||
expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true);
|
||||
const result = artifactExtractionResultSchema.parse(
|
||||
parseEvidence(extracted.structuredContent).normalized_result,
|
||||
);
|
||||
try {
|
||||
expect(dirname(result.output_root)).toBe(temporary);
|
||||
expect(
|
||||
await readFile(join(result.output_root, "payload.txt"), "utf8"),
|
||||
).toBe("changed version two");
|
||||
} finally {
|
||||
await rm(result.output_root, { recursive: true, force: true });
|
||||
}
|
||||
const reopenedTemporaryBaseline = (await readdir(temporary)).sort();
|
||||
await writeFile(path, "not an archive");
|
||||
for (const operation of ["inspect_artifact", "extract_artifact"]) {
|
||||
const refused = parseMcpToolError(await call(operation, {}));
|
||||
expect(refused.error).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
details: { reason: "integrity" },
|
||||
});
|
||||
expect((await readdir(temporary)).sort()).toEqual(
|
||||
reopenedTemporaryBaseline,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { build } from "plist";
|
||||
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import {
|
||||
machoImage,
|
||||
CPU,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { buildGodotPck, pckU64 } from "../../fixtures/godotPck.js";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
writeFile,
|
||||
unlink,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createPackage } from "@electron/asar";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import {
|
||||
artifactExtractionResultSchema,
|
||||
artifactInventoryResultSchema,
|
||||
} from "../../../src/domain/artifactGraph.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.each([
|
||||
{ format: "zip", replacement: "valid" },
|
||||
{ format: "apk", replacement: "valid" },
|
||||
{ format: "asar", replacement: "valid" },
|
||||
{ format: "zip", replacement: "malformed" },
|
||||
{ format: "asar", replacement: "malformed" },
|
||||
])(
|
||||
"binds $format $replacement archive operations to the admitted source before output creation",
|
||||
async ({ format, replacement }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-archive-admitted-identity-",
|
||||
);
|
||||
const path = join(root, `selected.${format}`);
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
const writeArchive = async (value: string) => {
|
||||
if (format === "asar") {
|
||||
await writeFile(join(source, "payload.txt"), value);
|
||||
await createPackage(source, path);
|
||||
} else {
|
||||
const zip = new ZipWriter(new Uint8ArrayWriter());
|
||||
await zip.add("payload.txt", new TextReader(value));
|
||||
await writeFile(path, await zip.close());
|
||||
}
|
||||
};
|
||||
await writeArchive("admitted version one");
|
||||
const session = createTestBinarySession(new ArtifactProvider({}));
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const initial = await session.execute("inventory_artifact", {});
|
||||
if (!initial.ok) throw initial.error;
|
||||
if (replacement === "malformed") await writeFile(path, "not an archive");
|
||||
else await writeArchive("changed version two");
|
||||
for (const operation of [
|
||||
"inventory_artifact",
|
||||
"inspect_artifact",
|
||||
"extract_artifact",
|
||||
] as const) {
|
||||
const output = join(root, `refused-${operation}`);
|
||||
const result = await session.execute(
|
||||
operation,
|
||||
operation === "extract_artifact"
|
||||
? { output_root: output, integrity_policy: "fail" }
|
||||
: {},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "integrity",
|
||||
detail: expect.stringContaining("changed after target admission"),
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
if (replacement === "valid") {
|
||||
const reopened = await session.open(path);
|
||||
if (!reopened.ok) throw reopened.error;
|
||||
const inventory = await session.execute("inventory_artifact", {});
|
||||
if (!inventory.ok) throw inventory.error;
|
||||
expect(
|
||||
artifactInventoryResultSchema.parse(inventory.value.result).manifest
|
||||
.root_sha256,
|
||||
).not.toBe(
|
||||
artifactInventoryResultSchema.parse(initial.value.result).manifest
|
||||
.root_sha256,
|
||||
);
|
||||
const extracted = await session.execute("extract_artifact", {
|
||||
output_root: join(root, "reopened-output"),
|
||||
integrity_policy: "fail",
|
||||
});
|
||||
if (!extracted.ok) throw extracted.error;
|
||||
const result = artifactExtractionResultSchema.parse(
|
||||
extracted.value.result,
|
||||
);
|
||||
expect(
|
||||
await readFile(join(result.output_root, "payload.txt"), "utf8"),
|
||||
).toBe("changed version two");
|
||||
}
|
||||
} finally {
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects retargeted ZIP selections without attributing or publishing the old packed bytes",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-admitted-zip-alias-");
|
||||
const stored = join(root, "stored.zip");
|
||||
const other = join(root, "other.zip");
|
||||
const selected = join(root, "selected.zip");
|
||||
const zip = new ZipWriter(new Uint8ArrayWriter());
|
||||
await zip.add("payload.txt", new TextReader("same bytes"));
|
||||
const bytes = await zip.close();
|
||||
await writeFile(stored, bytes);
|
||||
await writeFile(other, bytes);
|
||||
await symlink(stored, selected);
|
||||
const session = createTestBinarySession(new ArtifactProvider({}));
|
||||
try {
|
||||
const opened = await session.open(selected);
|
||||
if (!opened.ok) throw opened.error;
|
||||
await unlink(selected);
|
||||
await symlink(other, selected);
|
||||
for (const operation of [
|
||||
"inventory_artifact",
|
||||
"extract_artifact",
|
||||
] as const) {
|
||||
const output = join(root, operation);
|
||||
const result = await session.execute(
|
||||
operation,
|
||||
operation === "extract_artifact" ? { output_root: output } : {},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "integrity",
|
||||
detail: expect.stringContaining(
|
||||
"no longer resolves to its admitted source",
|
||||
),
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an admitted archive replaced by a directory", async () => {
|
||||
const root = await createTestTempDirectory("rea-admitted-file-directory-");
|
||||
const path = join(root, "selected.zip");
|
||||
const zip = new ZipWriter(new Uint8ArrayWriter());
|
||||
await zip.add("payload.txt", new TextReader("original"));
|
||||
await writeFile(path, await zip.close());
|
||||
const session = createTestBinarySession(new ArtifactProvider({}));
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok) throw opened.error;
|
||||
await unlink(path);
|
||||
await mkdir(path);
|
||||
await writeFile(join(path, "payload.txt"), "replacement directory");
|
||||
const output = join(root, "output");
|
||||
expect(
|
||||
await session.execute("extract_artifact", { output_root: output }),
|
||||
).toMatchObject({ ok: false, error: { reason: "integrity" } });
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps app bundle aggregate inventories live when resources change", async () => {
|
||||
const root = await createTestTempDirectory("rea-admitted-bundle-resources-");
|
||||
const app = join(root, "Fixture.app");
|
||||
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
|
||||
await writeFile(
|
||||
join(app, "Contents", "MacOS", "fixture"),
|
||||
machoImage({ cpu: CPU.arm64e }),
|
||||
);
|
||||
await writeFile(
|
||||
join(app, "Contents", "Info.plist"),
|
||||
build({ CFBundlePackageType: "APPL", CFBundleExecutable: "fixture" }),
|
||||
);
|
||||
const payload = join(app, "Contents", "payload.txt");
|
||||
await writeFile(payload, "first resource");
|
||||
const session = createTestBinarySession(new ArtifactProvider({}));
|
||||
try {
|
||||
const opened = await session.open(app);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const initial = await session.execute("inventory_artifact", {});
|
||||
if (!initial.ok) throw initial.error;
|
||||
await writeFile(payload, "second resource");
|
||||
const current = await session.execute("inventory_artifact", {});
|
||||
if (!current.ok) throw current.error;
|
||||
expect(
|
||||
artifactInventoryResultSchema.parse(current.value.result).manifest
|
||||
.root_sha256,
|
||||
).not.toBe(
|
||||
artifactInventoryResultSchema.parse(initial.value.result).manifest
|
||||
.root_sha256,
|
||||
);
|
||||
const extracted = await session.execute("extract_artifact", {
|
||||
output_root: join(root, "output"),
|
||||
});
|
||||
if (!extracted.ok) throw extracted.error;
|
||||
const result = artifactExtractionResultSchema.parse(extracted.value.result);
|
||||
expect(
|
||||
await readFile(
|
||||
join(result.output_root, "Contents", "payload.txt"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("second resource");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("checks embedded native packs before acceptance while preserving unsupported fast refusal", async () => {
|
||||
const root = await createTestTempDirectory("rea-admitted-native-preflight-");
|
||||
const path = join(root, "game");
|
||||
const writePack = async (text: string) => {
|
||||
const pack = buildGodotPck(2, [
|
||||
{ path: "res://payload.txt", data: Buffer.from(text) },
|
||||
]);
|
||||
await writeFile(
|
||||
path,
|
||||
Buffer.concat([
|
||||
elf(2, 1, 62),
|
||||
pack,
|
||||
pckU64(pack.length),
|
||||
Buffer.from("GDPC"),
|
||||
]),
|
||||
);
|
||||
};
|
||||
await writePack("first");
|
||||
const session = createTestBinarySession(new ArtifactProvider({}));
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok) throw opened.error;
|
||||
await writePack("second");
|
||||
const output = join(root, "output");
|
||||
expect(
|
||||
await session.execute("extract_artifact", { output_root: output }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "integrity",
|
||||
detail: expect.stringContaining("changed after target admission"),
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await writeFile(path, "not a native pack");
|
||||
expect(
|
||||
await session.execute("extract_artifact", { output_root: output }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisUnsupportedTargetError" },
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
@@ -98,7 +98,7 @@ describe("artifact archive inventory", () => {
|
||||
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"));
|
||||
const asarResult = await inventory(await target(asarPath));
|
||||
expect(asarResult.occurrences).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ logical_path: "main.js" }),
|
||||
@@ -107,7 +107,7 @@ describe("artifact archive inventory", () => {
|
||||
|
||||
const unpackedPath = join(root, "unpacked.asar");
|
||||
await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" });
|
||||
const unpackedResult = await inventory(target(unpackedPath, "asar"));
|
||||
const unpackedResult = await inventory(await target(unpackedPath));
|
||||
expect(unpackedResult.occurrences).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -121,7 +121,7 @@ describe("artifact archive inventory", () => {
|
||||
"changed();\n",
|
||||
);
|
||||
const corrupted = await new ArtifactProvider(process.env)
|
||||
.createClient(target(unpackedPath, "asar"))
|
||||
.createClient(await target(unpackedPath))
|
||||
.execute("inventory_artifact", {});
|
||||
expect(corrupted).toMatchObject({
|
||||
ok: false,
|
||||
@@ -135,7 +135,7 @@ describe("artifact archive inventory", () => {
|
||||
},
|
||||
});
|
||||
const failedInspection = await new ArtifactProvider(process.env)
|
||||
.createClient(target(unpackedPath, "asar"))
|
||||
.createClient(await target(unpackedPath))
|
||||
.execute("inspect_artifact", {});
|
||||
expect(failedInspection).toMatchObject({
|
||||
ok: false,
|
||||
@@ -151,13 +151,8 @@ const inventory = async (targetValue: BinaryTarget) => {
|
||||
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 target = async (path: string): Promise<BinaryTarget> => {
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
cArchiveExecutable,
|
||||
pyzArchive,
|
||||
@@ -28,17 +29,9 @@ it.each([1, 129])(
|
||||
})),
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
const client = new ArtifactProvider({}).createClient({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "pe",
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
});
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const client = new ArtifactProvider({}).createClient(target.value);
|
||||
try {
|
||||
const result = await client.execute("inventory_artifact", {});
|
||||
if (count === 1) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPackage } from "@electron/asar";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js";
|
||||
@@ -29,19 +30,16 @@ const createArchive = async () => {
|
||||
return { root, archive };
|
||||
};
|
||||
|
||||
const createClient = (path: string) =>
|
||||
new ArtifactProvider(process.env).createClient({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
});
|
||||
const createClient = async (path: string) => {
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
return new ArtifactProvider(process.env).createClient(target.value);
|
||||
};
|
||||
|
||||
it("retains a failed reader close, blocks new work, and retries the same reader", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const originalClose = AsarArtifactReader.prototype.close;
|
||||
const closeOwners: AsarArtifactReader[] = [];
|
||||
let readerCloseBlocked = true;
|
||||
@@ -109,7 +107,7 @@ it("retains a failed reader close, blocks new work, and retries the same reader"
|
||||
it("retains a failed rollback owner and preserves the materialization failure", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const originalWrite = SafeOutputTree.prototype.write;
|
||||
const originalRollback = SafeOutputTree.prototype.rollback;
|
||||
const rollbackOwners: SafeOutputTree[] = [];
|
||||
@@ -173,7 +171,7 @@ it("retains a tree whose setup failed after root identity was captured", async (
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const blocker = join(output, "unowned.txt");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const prototypeHandle = await open(join(root, "prototype-handle"), "w");
|
||||
const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick<
|
||||
typeof prototypeHandle,
|
||||
@@ -213,7 +211,7 @@ it("retains a tree whose setup failed after root identity was captured", async (
|
||||
it("waits for admitted extraction before close and rejects later work", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const client = createClient(archive);
|
||||
const client = await createClient(archive);
|
||||
const originalWrite = SafeOutputTree.prototype.write;
|
||||
const started = deferred();
|
||||
const finish = deferred();
|
||||
@@ -247,7 +245,13 @@ it("waits for admitted extraction before close and rejects later work", async ()
|
||||
const extraction = client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
await started.promise;
|
||||
await Promise.race([
|
||||
started.promise,
|
||||
extraction.then((result) => {
|
||||
if (!result.ok) throw result.error;
|
||||
throw new Error("Extraction completed before reaching its write stage");
|
||||
}),
|
||||
]);
|
||||
const closing = client.close().then((result) => {
|
||||
closeFinished = true;
|
||||
return result;
|
||||
|
||||
@@ -15,53 +15,55 @@ describe("artifact directory diagnostics", () => {
|
||||
"omits inaccessible directory descendants during inventory but refuses incomplete extraction",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-directory-policy-");
|
||||
const source = join(root, "source");
|
||||
const source = join(root, "Fixture.app");
|
||||
const blocked = join(source, "blocked");
|
||||
const output = join(root, "output");
|
||||
await mkdir(blocked, { recursive: true });
|
||||
await mkdir(join(source, "Contents", "MacOS"), { recursive: true });
|
||||
await writeFile(
|
||||
join(source, "Contents", "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "Contents", "MacOS", "Fixture"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
await writeFile(join(source, "readable.txt"), "retained bytes\n");
|
||||
await writeFile(join(blocked, "hidden.txt"), "unexamined bytes\n");
|
||||
await chmod(blocked, 0);
|
||||
try {
|
||||
await withArtifactClient(
|
||||
{
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
},
|
||||
async (client) => {
|
||||
const inventoried = await client.execute("inventory_artifact", {});
|
||||
if (!inventoried.ok) throw inventoried.error;
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
inventoried.value.result,
|
||||
);
|
||||
expect(inventory.coverage.status).toBe("unknown");
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "readable.txt",
|
||||
hash_status: "verified",
|
||||
}),
|
||||
);
|
||||
expect(inventory.limitations).toContainEqual(
|
||||
expect.stringContaining("EACCES"),
|
||||
);
|
||||
const extracted = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extracted).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
detail: expect.stringContaining("EACCES"),
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
const target = await parseBinaryTarget(source);
|
||||
if (!target.ok) throw target.error;
|
||||
await withArtifactClient(target.value, async (client) => {
|
||||
const inventoried = await client.execute("inventory_artifact", {});
|
||||
if (!inventoried.ok) throw inventoried.error;
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
inventoried.value.result,
|
||||
);
|
||||
expect(inventory.coverage.status).toBe("unknown");
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "readable.txt",
|
||||
hash_status: "verified",
|
||||
}),
|
||||
);
|
||||
expect(inventory.limitations).toContainEqual(
|
||||
expect.stringContaining("EACCES"),
|
||||
);
|
||||
const extracted = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extracted).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
detail: expect.stringContaining("EACCES"),
|
||||
},
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await chmod(blocked, 0o700);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
CPU,
|
||||
machoImage,
|
||||
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
@@ -18,7 +23,11 @@ describe("artifact directory inventory", () => {
|
||||
await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), {
|
||||
recursive: true,
|
||||
});
|
||||
const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]);
|
||||
const machO = machoImage({ cpu: CPU.arm64 });
|
||||
await writeFile(
|
||||
join(app, "Contents/Info.plist"),
|
||||
'<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>',
|
||||
);
|
||||
await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, {
|
||||
mode: 0o755,
|
||||
});
|
||||
@@ -30,7 +39,7 @@ describe("artifact directory inventory", () => {
|
||||
await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n");
|
||||
await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside"));
|
||||
const client = new ArtifactProvider(process.env).createClient(
|
||||
target(app, "directory"),
|
||||
await target(app),
|
||||
);
|
||||
const input = {};
|
||||
const first = await client.execute("inventory_artifact", input);
|
||||
@@ -72,13 +81,9 @@ describe("artifact directory inventory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 target = async (path: string): Promise<BinaryTarget> => {
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.bundleInfoPlist).toBe(join(path, "Contents/Info.plist"));
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
CPU,
|
||||
machoImage,
|
||||
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
import {
|
||||
@@ -19,13 +24,13 @@ describe("artifact extraction", () => {
|
||||
"refuses an invalid parent (%s) before acquiring output and permits a corrected request",
|
||||
async (parent) => {
|
||||
const root = await createTestTempDirectory("rea-extract-invalid-parent-");
|
||||
const source = join(root, "source");
|
||||
const source = join(root, "source.app");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "payload.txt"), "selected payload\n");
|
||||
await writeFile(join(root, "file"), "parent is a file");
|
||||
const output = join(root, "out");
|
||||
|
||||
await withArtifactClient(target(source, "directory"), async (client) => {
|
||||
await withArtifactClient(await target(source), async (client) => {
|
||||
const refused = await client.execute("extract_artifact", {
|
||||
output_root: `${root}/${parent}/out`,
|
||||
});
|
||||
@@ -52,12 +57,12 @@ describe("artifact extraction", () => {
|
||||
|
||||
it("keeps committed files after the owning client closes", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-client-close-");
|
||||
const source = join(root, "source");
|
||||
const source = join(root, "source.app");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "payload.txt"), "committed\n");
|
||||
const output = join(root, "output");
|
||||
const client = new ArtifactProvider(process.env).createClient(
|
||||
target(source, "directory"),
|
||||
await target(source),
|
||||
);
|
||||
|
||||
const result = await client.execute("extract_artifact", {
|
||||
@@ -72,11 +77,11 @@ describe("artifact extraction", () => {
|
||||
|
||||
it("extracts all regular occurrences through an exclusively owned output tree", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-");
|
||||
const source = join(root, "source");
|
||||
const source = join(root, "source.app");
|
||||
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 targetValue = await target(source);
|
||||
const graph = await inventory(targetValue);
|
||||
expect(
|
||||
graph.occurrences.some(
|
||||
@@ -160,7 +165,7 @@ describe("artifact extraction", () => {
|
||||
expect((await readdir(root)).sort()).toEqual([
|
||||
"output",
|
||||
"relocated-output",
|
||||
"source",
|
||||
"source.app",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -172,13 +177,15 @@ const inventory = async (targetValue: BinaryTarget) => {
|
||||
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,
|
||||
});
|
||||
/** A real bundle target has an executable identity distinct from its resource tree. */
|
||||
const target = async (path: string): Promise<BinaryTarget> => {
|
||||
await writeFile(
|
||||
join(path, "Info.plist"),
|
||||
'<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Main</string></dict></plist>',
|
||||
);
|
||||
await writeFile(join(path, "Main"), machoImage({ cpu: CPU.arm64 }));
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.bundleInfoPlist).toBe(join(path, "Info.plist"));
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
appendFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
@@ -33,7 +35,9 @@ it("refuses an unsupported source without hashing its bytes", async () => {
|
||||
{
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(source))
|
||||
.digest("hex"),
|
||||
kind: "archive",
|
||||
format: "dmg",
|
||||
},
|
||||
@@ -73,7 +77,9 @@ it("preserves cancellation arriving during the detection descriptor's final clos
|
||||
{
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(source))
|
||||
.digest("hex"),
|
||||
kind: "archive",
|
||||
format: "dmg",
|
||||
},
|
||||
@@ -118,7 +124,9 @@ it("keeps detection I/O failure distinct from unsupported format and closes its
|
||||
{
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(source))
|
||||
.digest("hex"),
|
||||
kind: "archive",
|
||||
format: "dmg",
|
||||
},
|
||||
@@ -150,7 +158,7 @@ it.each([
|
||||
"changed same inode",
|
||||
"directory replacement",
|
||||
] as const)(
|
||||
"refuses to reuse completed detection facts after %s before hashing",
|
||||
"refuses to reuse completed detection facts after %s before inventory output",
|
||||
async (change) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-extraction-admission-identity-",
|
||||
@@ -161,11 +169,13 @@ it.each([
|
||||
buildGodotPck(3, [{ path: "res://main.gd", data: "extends Node\n" }]),
|
||||
);
|
||||
await writeFile(source, bytes);
|
||||
let hashesAtMutation: number | undefined;
|
||||
const close = OwnedFileHandle.prototype.close;
|
||||
const closeSpy = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
||||
await close.call(this);
|
||||
hashesAtMutation = hash.mock.calls.length;
|
||||
if (change === "changed same inode")
|
||||
await appendFile(source, "changed");
|
||||
else if (change === "directory replacement") {
|
||||
@@ -183,7 +193,9 @@ it.each([
|
||||
{
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(source))
|
||||
.digest("hex"),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
@@ -197,7 +209,9 @@ it.each([
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "integrity" },
|
||||
});
|
||||
expect(hash).not.toHaveBeenCalled();
|
||||
expect(hashesAtMutation).toBeDefined();
|
||||
// Initial admission may hash; facts known stale after close must not.
|
||||
expect(hash).toHaveBeenCalledTimes(hashesAtMutation!);
|
||||
await expect(access(output)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
@@ -275,7 +289,9 @@ it.each([
|
||||
{
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(source))
|
||||
.digest("hex"),
|
||||
kind: "executable",
|
||||
format: "pe",
|
||||
executableRole: "application",
|
||||
|
||||
@@ -5,12 +5,12 @@ import { join } from "node:path";
|
||||
import { createPackage, createPackageWithOptions } from "@electron/asar";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import {
|
||||
materializeArtifactInventory,
|
||||
scanArtifactInventory,
|
||||
} from "../../../fixtures/artifactInventory.js";
|
||||
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
@@ -29,9 +29,7 @@ it.for([false, true])(
|
||||
"unrepresented",
|
||||
);
|
||||
const snapshot = await scanArtifactInventory(source);
|
||||
const result = await extract(source, join(root, "output"));
|
||||
if (!result.ok) throw result.error;
|
||||
const extracted = artifactExtractionResultSchema.parse(result.value.result);
|
||||
const extracted = await extractDirectory(source, join(root, "output"));
|
||||
expect(extracted.coverage).toEqual(snapshot.coverage);
|
||||
expect(extracted.coverage.status).toBe(
|
||||
invalidName ? "unknown" : "complete",
|
||||
@@ -317,9 +315,7 @@ it("preserves every equal-content occurrence and copies a nested ASAR as its con
|
||||
await writeFile(join(source, "a.txt"), "same bytes\n");
|
||||
await writeFile(join(source, "b.txt"), "same bytes\n");
|
||||
const output = join(root, "output");
|
||||
const result = await extract(source, output);
|
||||
if (!result.ok) throw result.error;
|
||||
const extracted = artifactExtractionResultSchema.parse(result.value.result);
|
||||
const extracted = await extractDirectory(source, output);
|
||||
expect(extracted.artifacts.map(({ relative_path }) => relative_path)).toEqual(
|
||||
["a.txt", "app.asar", "b.txt"],
|
||||
);
|
||||
@@ -350,11 +346,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr
|
||||
await rm(join(`${archive}.unpacked`, "native", "addon.node"));
|
||||
|
||||
const nestedOutput = join(root, "nested-output");
|
||||
const nestedResult = await extract(source, nestedOutput);
|
||||
if (!nestedResult.ok) throw nestedResult.error;
|
||||
const nestedExtraction = artifactExtractionResultSchema.parse(
|
||||
nestedResult.value.result,
|
||||
);
|
||||
const nestedExtraction = await extractDirectory(source, nestedOutput);
|
||||
expect(
|
||||
nestedExtraction.extraction_manifest.selected_occurrence_ids,
|
||||
).toHaveLength(1);
|
||||
@@ -369,7 +361,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr
|
||||
).toBe(0);
|
||||
|
||||
const activeOutput = join(root, "active-output");
|
||||
const activeResult = await extract(archive, activeOutput);
|
||||
const activeResult = await extractArchive(archive, activeOutput);
|
||||
if (activeResult.ok)
|
||||
throw new Error("Unavailable active ASAR member must fail extraction");
|
||||
expect(projectAnalysisError(activeResult.error)).toMatchObject({
|
||||
@@ -396,13 +388,16 @@ const inventoriedExtraction = async (source: string, output: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const extract = (source: string, output: string) =>
|
||||
new ArtifactProvider(process.env)
|
||||
.createClient({
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
})
|
||||
/** Raw directory completeness belongs to inventory/materialization, not file target admission. */
|
||||
const extractDirectory = async (source: string, output: string) => {
|
||||
const materialize = await inventoriedExtraction(source, output);
|
||||
return materialize();
|
||||
};
|
||||
|
||||
const extractArchive = async (source: string, output: string) => {
|
||||
const target = await parseBinaryTarget(source);
|
||||
if (!target.ok) throw target.error;
|
||||
return new ArtifactProvider(process.env)
|
||||
.createClient(target.value)
|
||||
.execute("extract_artifact", { output_root: output });
|
||||
};
|
||||
|
||||
@@ -3,11 +3,13 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { elfExportImage } from "../../../fixtures/binaryDiagnostics/nativeExports.js";
|
||||
import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { buildGodotPck, embedGodotPck } from "../../../fixtures/godotPck.js";
|
||||
import { buildGodotPck, pckU64 } from "../../../fixtures/godotPck.js";
|
||||
import { withArtifactClient } from "../../../support/artifactClientScope.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import {
|
||||
CPU,
|
||||
@@ -22,13 +24,17 @@ describe("artifact extraction format support", () => {
|
||||
it("refuses unsupported formats before inventory or output", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-format-");
|
||||
const image = join(root, "Image.dmg");
|
||||
await writeFile(image, "not mounted");
|
||||
const udif = Buffer.alloc(512);
|
||||
udif.write("koly");
|
||||
await writeFile(image, udif);
|
||||
const alias = join(root, "Alias.dmg");
|
||||
await symlink(image, alias);
|
||||
const output = join(root, "output");
|
||||
const selected = await admittedTarget(alias);
|
||||
expect(selected.format).toBe("dmg");
|
||||
const extract = (signal?: AbortSignal) =>
|
||||
new ArtifactProvider(process.env)
|
||||
.createClient(archiveTarget(alias, "dmg"))
|
||||
.createClient(selected)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
@@ -59,7 +65,7 @@ describe("artifact extraction format support", () => {
|
||||
const output = join(root, "output");
|
||||
expect(
|
||||
await new ArtifactProvider(process.env)
|
||||
.createClient(thinMachOTarget(binary))
|
||||
.createClient(await admittedTarget(binary))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
@@ -99,7 +105,7 @@ describe("artifact extraction format support", () => {
|
||||
]),
|
||||
);
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(thinMachOTarget(binary))
|
||||
.createClient(await admittedTarget(binary))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
@@ -119,39 +125,40 @@ describe("artifact extraction reader selection", () => {
|
||||
const root = await createTestTempDirectory("rea-extract-embedded-pck-");
|
||||
const binary = join(root, "game");
|
||||
const output = join(root, "output");
|
||||
const pack = buildGodotPck(3, [
|
||||
{ path: "res://main.gd", data: "extends Node\n" },
|
||||
]);
|
||||
await writeFile(
|
||||
binary,
|
||||
embedGodotPck(
|
||||
buildGodotPck(3, [{ path: "res://main.gd", data: "extends Node\n" }]),
|
||||
),
|
||||
);
|
||||
await withArtifactClient(
|
||||
{
|
||||
path: binary,
|
||||
sourcePath: binary,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
},
|
||||
async (client) => {
|
||||
const result = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
const extracted = artifactExtractionResultSchema.parse(
|
||||
result.value.result,
|
||||
);
|
||||
expect(extracted.manifest.root_format).toBe("godot-pck");
|
||||
expect(
|
||||
extracted.artifacts.map(({ relative_path }) => relative_path),
|
||||
).toEqual(["main.gd"]);
|
||||
expect(await readFile(join(output, "main.gd"), "utf8")).toBe(
|
||||
"extends Node\n",
|
||||
);
|
||||
},
|
||||
Buffer.concat([
|
||||
elfExportImage({ symbols: [] }),
|
||||
pack,
|
||||
pckU64(pack.length),
|
||||
Buffer.from("GDPC"),
|
||||
]),
|
||||
);
|
||||
const selected = await admittedTarget(binary);
|
||||
expect(selected).toMatchObject({
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
await withArtifactClient(await admittedTarget(binary), async (client) => {
|
||||
const result = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
const extracted = artifactExtractionResultSchema.parse(
|
||||
result.value.result,
|
||||
);
|
||||
expect(extracted.manifest.root_format).toBe("godot-pck");
|
||||
expect(
|
||||
extracted.artifacts.map(({ relative_path }) => relative_path),
|
||||
).toEqual(["main.gd"]);
|
||||
expect(await readFile(join(output, "main.gd"), "utf8")).toBe(
|
||||
"extends Node\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["zip", "hap", "hsp", "app-pack"] as const)(
|
||||
@@ -164,7 +171,7 @@ describe("artifact extraction reader selection", () => {
|
||||
);
|
||||
await writeOrderedZip(archive, ["payload.txt"]);
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(archiveTarget(archive, format))
|
||||
.createClient(await admittedTarget(archive))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
@@ -182,23 +189,8 @@ describe("artifact extraction reader selection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const archiveTarget = (
|
||||
path: string,
|
||||
format: Extract<BinaryTarget, { kind: "archive" }>["format"],
|
||||
): BinaryTarget => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format,
|
||||
});
|
||||
|
||||
const thinMachOTarget = (path: string): BinaryTarget => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
});
|
||||
const admittedTarget = async (path: string): Promise<BinaryTarget> => {
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
@@ -16,7 +17,7 @@ describe("artifact extraction identity", () => {
|
||||
const source = join(root, "source.zip");
|
||||
await writeOrderedZip(source, ["ä.js", "a.js", "Z.js"]);
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(archiveTarget(source))
|
||||
.createClient(await archiveTarget(source))
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
@@ -34,10 +35,8 @@ describe("artifact extraction identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const archiveTarget = (path: string): BinaryTarget => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format: "zip",
|
||||
});
|
||||
const archiveTarget = async (path: string): Promise<BinaryTarget> => {
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
@@ -4,11 +4,13 @@ import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { CPU_ARM64, universalMacho } from "../../../fixtures/universalMacho.js";
|
||||
import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js";
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
|
||||
const sliceReader = vi.hoisted(() => ({
|
||||
reached: vi.fn(),
|
||||
@@ -41,10 +43,21 @@ it("routes universal Mach-O extraction through slice inventory", async () => {
|
||||
);
|
||||
const root = await createTestTempDirectory("rea-extract-universal-");
|
||||
const image = join(root, "Universal");
|
||||
await writeFile(image, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0]));
|
||||
await writeFile(
|
||||
image,
|
||||
universalMacho([
|
||||
{
|
||||
bytes: thinMach(0xfeedfacf, CPU_ARM64),
|
||||
cpuType: CPU_ARM64,
|
||||
cpuSubtype: 0,
|
||||
},
|
||||
]).bytes,
|
||||
);
|
||||
const target = await parseBinaryTarget(image, { hostArchitecture: "arm64" });
|
||||
if (!target.ok) throw target.error;
|
||||
const output = join(root, "output");
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(universalTarget(image))
|
||||
.createClient(target.value)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
@@ -61,14 +74,3 @@ it("routes universal Mach-O extraction through slice inventory", async () => {
|
||||
expect(sliceReader.reached).toHaveBeenCalledOnce();
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
const universalTarget = (path: string): BinaryTarget => ({
|
||||
path,
|
||||
sourcePath: path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o-universal",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
universalSlice: { offset: 0, size: 32 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user