mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(providers): preserve launch selections and refresh obsolete Kuna exports
This commit is contained in:
1 parent
eb06e263cb
commit
77468d5043
12 files changed
+411
-29
No files matched your search
@@ -125,6 +125,10 @@ REA writes Kuna's exports only inside `REA_KUNA_CACHE_DIR`:
|
||||
- `trash/` holds retired builds and evicted exports awaiting removal.
|
||||
- `home/` is Kuna's HOME.
|
||||
|
||||
The cache key includes the selected launcher and resolved executable paths.
|
||||
Older exports without that identity are rebuilt automatically and retired through
|
||||
normal cache cleanup; no manual migration is needed.
|
||||
|
||||
Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. REA retains the file state verified with each digest, checks it before warm-cache reuse, and checks open descriptors before and after reading text. Assembly indexes are checked against the same state; a changed file makes the whole report retry rather than mixing facets from different exports. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it.
|
||||
|
||||
## Verify with a real Kuna
|
||||
|
||||
@@ -3,6 +3,8 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const SELECTED_REGISTRATION_SETTINGS = [
|
||||
"GHIDRA_INSTALL_DIR",
|
||||
"GHIDRA_HEADLESS_MAXMEM",
|
||||
"GHIDRA_MAXMEM",
|
||||
"JAVA_HOME",
|
||||
"HOPPER_LAUNCHER_PATH",
|
||||
"REA_ILSPY_CMD_PATH",
|
||||
|
||||
+24
-14
@@ -20,6 +20,7 @@ import {
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
@@ -166,9 +167,15 @@ const verifiedExecutables = new Map<
|
||||
const verifyExecutable = async (
|
||||
installation: KunaInstallation,
|
||||
signal: AbortSignal,
|
||||
operation: string,
|
||||
retainFailedFileClose: KunaCommandRequest["retainFailedFileClose"],
|
||||
): Promise<string | undefined> => {
|
||||
const status = await stat(installation.executable, { bigint: true });
|
||||
const invocationPath = installation.configuration.command;
|
||||
await assertArtifactCommandPath(
|
||||
{ invocationPath, resolvedPath: installation.executable },
|
||||
operation,
|
||||
);
|
||||
const status = await stat(invocationPath, { bigint: true });
|
||||
const identity = [
|
||||
status.dev,
|
||||
status.ino,
|
||||
@@ -176,17 +183,17 @@ const verifyExecutable = async (
|
||||
status.mtimeNs,
|
||||
status.ctimeNs,
|
||||
].join(":");
|
||||
const known = verifiedExecutables.get(installation.executable);
|
||||
const known = verifiedExecutables.get(invocationPath);
|
||||
let sha256 = known?.identity === identity ? known.sha256 : undefined;
|
||||
if (sha256 === undefined) {
|
||||
sha256 = (
|
||||
await hashStableFile(installation.executable, {
|
||||
await hashStableFile(invocationPath, {
|
||||
signal,
|
||||
retainFailedClose: (owner) =>
|
||||
retainFailedFileClose(owner, installation.executable),
|
||||
retainFailedFileClose(owner, invocationPath),
|
||||
})
|
||||
).sha256;
|
||||
verifiedExecutables.set(installation.executable, { identity, sha256 });
|
||||
verifiedExecutables.set(invocationPath, { identity, sha256 });
|
||||
}
|
||||
return sha256 === installation.sha256 ? undefined : sha256;
|
||||
};
|
||||
@@ -200,6 +207,7 @@ export const runKunaCommand = async (
|
||||
const changed = await verifyExecutable(
|
||||
installation,
|
||||
request.signal,
|
||||
request.operation,
|
||||
request.retainFailedFileClose,
|
||||
);
|
||||
if (changed !== undefined)
|
||||
@@ -225,20 +233,22 @@ export const runKunaCommand = async (
|
||||
return {
|
||||
kind: "failed",
|
||||
error:
|
||||
cancellation !== undefined
|
||||
? new AnalysisCancelledError(request.operation, {
|
||||
cause: cancellation.reason,
|
||||
})
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
|
||||
cause,
|
||||
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: cancellation !== undefined
|
||||
? new AnalysisCancelledError(request.operation, {
|
||||
cause: cancellation.reason,
|
||||
})
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
|
||||
cause,
|
||||
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const snapshot = await runOwnedCommand(
|
||||
{
|
||||
command: installation.executable,
|
||||
command: installation.configuration.command,
|
||||
arguments: request.arguments,
|
||||
runId: `rea-kuna-${randomUUID()}`,
|
||||
// A script's process reports its interpreter, not the configured path.
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
|
||||
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
ProviderProcessSnapshot,
|
||||
@@ -426,11 +427,15 @@ export const inspectKunaInstallation = async (
|
||||
phase: "fingerprint",
|
||||
};
|
||||
try {
|
||||
await assertArtifactCommandPath(
|
||||
{ invocationPath: command, resolvedPath: executable },
|
||||
"kuna:installation",
|
||||
);
|
||||
beforeProbeFingerprint = await options.resourceScope.run(() =>
|
||||
hashStableFile(executable, {
|
||||
hashStableFile(command, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
retainFailedClose: (owner) =>
|
||||
options.resourceScope.retain(owner, executable),
|
||||
options.resourceScope.retain(owner, command),
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -456,7 +461,7 @@ export const inspectKunaInstallation = async (
|
||||
snapshot = await options.resourceScope.run(() =>
|
||||
runOwnedCommand(
|
||||
{
|
||||
command: executable,
|
||||
command,
|
||||
arguments: ["--version"],
|
||||
runId: `rea-kuna-version-${randomUUID()}`,
|
||||
// The configured command may be an interpreter-driven shim.
|
||||
@@ -572,10 +577,14 @@ export const inspectKunaInstallation = async (
|
||||
try {
|
||||
({ fingerprint, script } = await options.resourceScope.run(async () => {
|
||||
observed.phase = "identity";
|
||||
const observation = await observeStableFile(executable, {
|
||||
await assertArtifactCommandPath(
|
||||
{ invocationPath: command, resolvedPath: executable },
|
||||
"kuna:installation",
|
||||
);
|
||||
const observation = await observeStableFile(command, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
retainFailedClose: (owner) =>
|
||||
options.resourceScope.retain(owner, executable),
|
||||
options.resourceScope.retain(owner, command),
|
||||
});
|
||||
const fingerprint = {
|
||||
sha256: observation.sha256,
|
||||
|
||||
@@ -59,7 +59,7 @@ import type {
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
|
||||
/** Bumped whenever the entry layout or manifest meaning changes. */
|
||||
const CACHE_LAYOUT = 1;
|
||||
const CACHE_LAYOUT = 2;
|
||||
|
||||
/** A manifest names five files and their digests; anything larger is not ours. */
|
||||
const MANIFEST_MAX_BYTES = 64 * 1024;
|
||||
@@ -72,6 +72,8 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
export interface KunaProjectKeyInputs {
|
||||
readonly targetSha256: string;
|
||||
readonly kunaSha256: string;
|
||||
readonly kunaCommand: string;
|
||||
readonly kunaExecutable: string;
|
||||
readonly kunaVersion: string;
|
||||
/**
|
||||
* `--jobs 1` is Kuna's reference answer; more jobs decompile without
|
||||
@@ -88,6 +90,8 @@ export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string =>
|
||||
layout: CACHE_LAYOUT,
|
||||
target_sha256: inputs.targetSha256,
|
||||
kuna_sha256: inputs.kunaSha256,
|
||||
kuna_command: inputs.kunaCommand,
|
||||
kuna_executable: inputs.kunaExecutable,
|
||||
kuna_version: inputs.kunaVersion,
|
||||
export: { command: "decompile-project", jobs: inputs.jobs },
|
||||
}) ?? "",
|
||||
@@ -106,6 +110,8 @@ const manifestSchema = z.strictObject({
|
||||
inputs: z.strictObject({
|
||||
target_sha256: z.string().regex(SHA256_PATTERN),
|
||||
kuna_sha256: z.string().regex(SHA256_PATTERN),
|
||||
kuna_command: z.string(),
|
||||
kuna_executable: z.string(),
|
||||
kuna_version: z.string().min(1),
|
||||
jobs: z.int().positive(),
|
||||
}),
|
||||
@@ -475,6 +481,8 @@ export class KunaProjectCache {
|
||||
const key = kunaProjectKey({
|
||||
targetSha256: request.targetSha256,
|
||||
kunaSha256: request.installation.sha256,
|
||||
kunaCommand: request.installation.configuration.command,
|
||||
kunaExecutable: request.installation.executable,
|
||||
kunaVersion: request.installation.version,
|
||||
jobs: request.installation.configuration.jobs,
|
||||
});
|
||||
@@ -945,6 +953,8 @@ export class KunaProjectCache {
|
||||
inputs: {
|
||||
target_sha256: request.targetSha256,
|
||||
kuna_sha256: request.installation.sha256,
|
||||
kuna_command: request.installation.configuration.command,
|
||||
kuna_executable: request.installation.executable,
|
||||
kuna_version: request.installation.version,
|
||||
jobs: request.installation.configuration.jobs,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
|
||||
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import {
|
||||
copyAndHash,
|
||||
@@ -170,10 +171,14 @@ const verifyKunaExecutable = async (
|
||||
signal: AbortSignal,
|
||||
retainFailedFileClose: KunaExportRequest["retainFailedFileClose"],
|
||||
): Promise<Result<null, ProviderAdapterError>> => {
|
||||
const executable = await hashStableFile(installation.executable, {
|
||||
const invocationPath = installation.configuration.command;
|
||||
await assertArtifactCommandPath(
|
||||
{ invocationPath, resolvedPath: installation.executable },
|
||||
KUNA_EXPORT_OPERATION,
|
||||
);
|
||||
const executable = await hashStableFile(invocationPath, {
|
||||
signal,
|
||||
retainFailedClose: (owner) =>
|
||||
retainFailedFileClose(owner, installation.executable),
|
||||
retainFailedClose: (owner) => retainFailedFileClose(owner, invocationPath),
|
||||
});
|
||||
if (executable.sha256 === installation.sha256) return ok(null);
|
||||
return err(
|
||||
|
||||
@@ -268,6 +268,8 @@ export class KunaProvider implements AnalysisProviderCandidate {
|
||||
{
|
||||
kuna_version: value.version,
|
||||
kuna_sha256: value.sha256,
|
||||
kuna_command: value.configuration.command,
|
||||
kuna_executable: value.executable,
|
||||
verified_release: value.verified,
|
||||
jobs: value.configuration.jobs,
|
||||
export: "decompile-project",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
regularFileCancellation,
|
||||
RegularFileCleanupFailure,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
@@ -85,7 +88,6 @@ import {
|
||||
type KunaProjectFileSpan,
|
||||
} from "./KunaProjectReader.js";
|
||||
import { kunaUnavailable } from "./KunaUnavailable.js";
|
||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
ArtifactResourceRetryFailure,
|
||||
ArtifactResourceScope,
|
||||
@@ -1495,14 +1497,34 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
return err(error);
|
||||
}
|
||||
const committed = this.#profile?.parameters["kuna_sha256"];
|
||||
if (committed !== undefined && committed !== inspected.value.sha256)
|
||||
const committedCommand = this.#profile?.parameters["kuna_command"];
|
||||
const committedExecutable = this.#profile?.parameters["kuna_executable"];
|
||||
const identityChanged = [
|
||||
[committed, inspected.value.sha256],
|
||||
[committedCommand, inspected.value.configuration.command],
|
||||
[committedExecutable, inspected.value.executable],
|
||||
].some(
|
||||
([expected, observed]) =>
|
||||
expected !== undefined && expected !== observed,
|
||||
);
|
||||
if (identityChanged)
|
||||
return err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, "health", {
|
||||
userMessage:
|
||||
"The Kuna executable changed after this target was opened; reopen the target to analyze it with the current executable.",
|
||||
diagnostics: {
|
||||
committed_sha256: committed,
|
||||
...(committed === undefined
|
||||
? {}
|
||||
: { committed_sha256: committed }),
|
||||
observed_sha256: inspected.value.sha256,
|
||||
...(committedCommand === undefined
|
||||
? {}
|
||||
: { committed_command: committedCommand }),
|
||||
observed_command: inspected.value.configuration.command,
|
||||
...(committedExecutable === undefined
|
||||
? {}
|
||||
: { committed_executable: committedExecutable }),
|
||||
observed_executable: inspected.value.executable,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1540,6 +1562,8 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
const key = kunaProjectKey({
|
||||
targetSha256: this.#target.sha256,
|
||||
kunaSha256: installation.sha256,
|
||||
kunaCommand: installation.configuration.command,
|
||||
kunaExecutable: installation.executable,
|
||||
kunaVersion: installation.version,
|
||||
jobs: installation.configuration.jobs,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ cliTest(
|
||||
const selected = {
|
||||
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
||||
GHIDRA_INSTALL_DIR: join(home, "offline-ghidra"),
|
||||
GHIDRA_HEADLESS_MAXMEM: "512M",
|
||||
GHIDRA_MAXMEM: "3G",
|
||||
JAVA_HOME: join(home, "offline-jdk"),
|
||||
REA_IDA_MCP_CONFIG: join(home, "offline-ida.json"),
|
||||
REA_JADX_MCP_JAR: join(home, "offline-jadx.jar"),
|
||||
@@ -106,6 +108,11 @@ cliTest(
|
||||
// A later setup must update a selection rather than retaining the first value.
|
||||
selected.REA_OBJDUMP_COMMAND = join(home, "replacement-objdump");
|
||||
environment.REA_OBJDUMP_COMMAND = selected.REA_OBJDUMP_COMMAND;
|
||||
// Empty headless heap explicitly selects the lower-priority caller value.
|
||||
selected.GHIDRA_HEADLESS_MAXMEM = "";
|
||||
selected.GHIDRA_MAXMEM = "768M";
|
||||
environment.GHIDRA_HEADLESS_MAXMEM = selected.GHIDRA_HEADLESS_MAXMEM;
|
||||
environment.GHIDRA_MAXMEM = selected.GHIDRA_MAXMEM;
|
||||
const updated = await run();
|
||||
expect(updated.exitCode, updated.stdout).toBe(0);
|
||||
const updatedConfiguration = await readFile(config, "utf8");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
appendFile,
|
||||
chmod,
|
||||
mkdir,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import { expect, it as test, vi } from "vitest";
|
||||
|
||||
@@ -85,6 +87,8 @@ const keyOf = (installation: KunaInstallation, target: Target) =>
|
||||
kunaProjectKey({
|
||||
targetSha256: target.targetSha256,
|
||||
kunaSha256: installation.sha256,
|
||||
kunaCommand: installation.configuration.command,
|
||||
kunaExecutable: installation.executable,
|
||||
kunaVersion: installation.version,
|
||||
jobs: installation.configuration.jobs,
|
||||
});
|
||||
@@ -114,6 +118,85 @@ const deferred = <T>() => {
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
it("regenerates an obsolete export once and retires it without touching unrelated cache resources", async () => {
|
||||
const { kuna, installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-cache-format-transition-",
|
||||
);
|
||||
const input = await target("program");
|
||||
const initial = await cache.build({ installation, ...input });
|
||||
if (!initial.ok) throw initial.error;
|
||||
expect((await cache.close()).ok).toBe(true);
|
||||
// Seed the previously shipped manifest and content-key format using a real
|
||||
// authored export; omitted launcher identity must never be invented or reused.
|
||||
const legacyKey = createHash("sha256")
|
||||
.update(
|
||||
canonicalize({
|
||||
layout: 1,
|
||||
target_sha256: input.targetSha256,
|
||||
kuna_sha256: installation.sha256,
|
||||
kuna_version: installation.version,
|
||||
export: {
|
||||
command: "decompile-project",
|
||||
jobs: installation.configuration.jobs,
|
||||
},
|
||||
}) ?? "",
|
||||
)
|
||||
.digest("hex");
|
||||
const legacyEntry = join(cacheRoot, "projects", legacyKey);
|
||||
await rename(join(cacheRoot, "projects", initial.value.key), legacyEntry);
|
||||
await writeFile(
|
||||
join(legacyEntry, "manifest.json"),
|
||||
JSON.stringify({
|
||||
...initial.value.manifest,
|
||||
layout: 1,
|
||||
key: legacyKey,
|
||||
inputs: {
|
||||
target_sha256: input.targetSha256,
|
||||
kuna_sha256: installation.sha256,
|
||||
kuna_version: installation.version,
|
||||
jobs: installation.configuration.jobs,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const unknown = join(cacheRoot, "projects", "unrelated-directory");
|
||||
const unresolved = join(cacheRoot, "staging", "build-unresolved");
|
||||
await mkdir(unknown);
|
||||
await mkdir(unresolved);
|
||||
await writeFile(join(unknown, "sentinel"), "preserve");
|
||||
await writeFile(join(unresolved, "sentinel"), "preserve");
|
||||
const migrated = newCache(installation);
|
||||
try {
|
||||
const built = await migrated.build({ installation, ...input });
|
||||
if (!built.ok) throw built.error;
|
||||
await expect(access(legacyEntry)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(built.value.key).not.toBe(legacyKey);
|
||||
expect(built.value.manifest).toMatchObject({
|
||||
layout: 2,
|
||||
inputs: {
|
||||
kuna_command: installation.configuration.command,
|
||||
kuna_executable: installation.executable,
|
||||
},
|
||||
});
|
||||
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
|
||||
const fresh = newCache(installation);
|
||||
try {
|
||||
const reused = await fresh.build({ installation, ...input });
|
||||
if (!reused.ok) throw reused.error;
|
||||
expect(reused.value.manifest).toEqual(built.value.manifest);
|
||||
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
|
||||
} finally {
|
||||
expect((await fresh.close()).ok).toBe(true);
|
||||
}
|
||||
expect(await readFile(join(unknown, "sentinel"), "utf8")).toBe("preserve");
|
||||
expect(await readFile(join(unresolved, "sentinel"), "utf8")).toBe(
|
||||
"preserve",
|
||||
);
|
||||
expect(await entries(cacheRoot, "trash")).toEqual([]);
|
||||
} finally {
|
||||
expect((await migrated.close()).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
const causesOf = (cause: unknown): unknown[] => {
|
||||
const pending = [cause];
|
||||
const seen = new Set<unknown>();
|
||||
@@ -152,7 +235,7 @@ it("publishes a verified export and serves later requests from the cache", async
|
||||
value: {
|
||||
key: keyOf(installation, input),
|
||||
manifest: {
|
||||
layout: 1,
|
||||
layout: 2,
|
||||
inputs: {
|
||||
target_sha256: input.targetSha256,
|
||||
kuna_sha256: installation.sha256,
|
||||
|
||||
@@ -181,13 +181,14 @@ it("waits for an aborted pre-session hash before retrying its late close owner",
|
||||
});
|
||||
|
||||
it("commits the observed Kuna release and digest to the analysis profile", async () => {
|
||||
const { profile } = await session("rea-kuna-profile-");
|
||||
const { profile, kuna } = await session("rea-kuna-profile-");
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
provider: { id: "kuna", name: "Kuna", version: "1.833" },
|
||||
parameters: {
|
||||
kuna_version: "1.833",
|
||||
kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
kuna_command: kuna,
|
||||
verified_release: true,
|
||||
jobs: 1,
|
||||
cache_policy: "live",
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import type { RetainedResourceScope } from "../../../../src/application/AnalysisProvider.js";
|
||||
import { runKunaCommand } from "../../../../src/kuna/KunaCommand.js";
|
||||
import type { KunaInstallation } from "../../../../src/kuna/KunaInstallation.js";
|
||||
import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js";
|
||||
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { writeFakeKuna } from "./fakeKuna.js";
|
||||
import {
|
||||
inspectKunaInstallationOwned as inspectKunaInstallation,
|
||||
withKunaResourceScope,
|
||||
} from "./kunaResourceScope.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
const shellArgument = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
const wrapperSource = `#!/bin/sh\nexec ${shellArgument(process.execPath)} "$(/usr/bin/dirname "$0")/producer.cjs" "$@"\n`;
|
||||
|
||||
const selectWrapper = async (
|
||||
root: string,
|
||||
name: string,
|
||||
implementation: string,
|
||||
) => {
|
||||
const directory = join(root, name);
|
||||
await mkdir(directory);
|
||||
const command = join(directory, "kuna");
|
||||
await symlink(implementation, command);
|
||||
return { directory, command };
|
||||
};
|
||||
|
||||
const runInstallation = (
|
||||
installation: KunaInstallation,
|
||||
workspace: string,
|
||||
resources: RetainedResourceScope,
|
||||
) =>
|
||||
runKunaCommand({
|
||||
installation,
|
||||
arguments: ["authored-observation"],
|
||||
workspace,
|
||||
operation: "kuna:selection-fixture",
|
||||
timeoutMs: 5_000,
|
||||
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 5_000 },
|
||||
outputBytes: 4096,
|
||||
signal: new AbortController().signal,
|
||||
retainFailedFileClose: (owner, path) => resources.retain(owner, path),
|
||||
});
|
||||
|
||||
it("uses the selected Kuna launcher and its sibling producer for readiness and commands", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-command-selection-");
|
||||
const implementation = join(root, "implementation");
|
||||
await writeFile(implementation, wrapperSource, { mode: 0o755 });
|
||||
const selected = await selectWrapper(root, "selected", implementation);
|
||||
await writeFile(
|
||||
join(selected.directory, "producer.cjs"),
|
||||
`if (process.argv[2] === '--version') console.log('kuna 1.833');
|
||||
else console.log(JSON.stringify({producer: __filename, arguments: process.argv.slice(2)}));\n`,
|
||||
);
|
||||
const installed = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: selected.command,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
});
|
||||
if (!installed.ok) throw new Error(installed.error.reason);
|
||||
expect(installed.value).toMatchObject({
|
||||
executable: implementation,
|
||||
configuration: { command: selected.command },
|
||||
sha256: createHash("sha256").update(wrapperSource).digest("hex"),
|
||||
});
|
||||
const outcome = await withKunaResourceScope((resources) =>
|
||||
runInstallation(installed.value, root, resources),
|
||||
);
|
||||
if (outcome.kind !== "ok") throw outcome.error;
|
||||
expect(JSON.parse(outcome.snapshot.stdout.text)).toEqual({
|
||||
producer: join(selected.directory, "producer.cjs"),
|
||||
arguments: ["authored-observation"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["different bytes", "identical bytes"] as const)(
|
||||
"refuses a selected launcher retargeted after discovery to %s before executing it",
|
||||
async (mode) => {
|
||||
const root = await createTestTempDirectory("rea-kuna-command-retarget-");
|
||||
const implementation = join(root, "implementation");
|
||||
await writeFile(implementation, wrapperSource, { mode: 0o755 });
|
||||
const selected = await selectWrapper(root, "selected", implementation);
|
||||
const marker = join(root, "ran-after-retarget");
|
||||
await writeFile(
|
||||
join(selected.directory, "producer.cjs"),
|
||||
`if (process.argv[2] === '--version') console.log('kuna 1.833');
|
||||
else require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');\n`,
|
||||
);
|
||||
const installed = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: selected.command,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
});
|
||||
if (!installed.ok) throw new Error(installed.error.reason);
|
||||
const replacement = join(root, "replacement");
|
||||
await writeFile(
|
||||
replacement,
|
||||
mode === "identical bytes"
|
||||
? wrapperSource
|
||||
: `${wrapperSource}# replacement\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await unlink(selected.command);
|
||||
await symlink(replacement, selected.command);
|
||||
const outcome = await withKunaResourceScope((resources) =>
|
||||
runInstallation(installed.value, root, resources),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "failed",
|
||||
error: { _tag: "AnalysisArtifactChangedError", path: selected.command },
|
||||
});
|
||||
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses an identical-byte launcher retargeted between profile creation and session acquisition", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-profile-retarget-");
|
||||
const implementation = join(root, "implementation");
|
||||
await writeFile(implementation, wrapperSource, { mode: 0o755 });
|
||||
const selected = await selectWrapper(root, "selected", implementation);
|
||||
await writeFile(
|
||||
join(selected.directory, "producer.cjs"),
|
||||
"console.log('kuna 1.833');\n",
|
||||
);
|
||||
const provider = new KunaProvider({
|
||||
REA_KUNA_COMMAND: selected.command,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
});
|
||||
const target = {
|
||||
path: join(root, "target"),
|
||||
sha256: createHash("sha256").update("inert input").digest("hex"),
|
||||
kind: "executable" as const,
|
||||
format: "elf" as const,
|
||||
architecture: "x86_64" as const,
|
||||
availableArchitectures: ["x86_64" as const],
|
||||
};
|
||||
await writeFile(target.path, "inert input");
|
||||
const resolved = await withKunaResourceScope((resourceScope) =>
|
||||
provider.resolveAnalysisProfile(target, { resourceScope }),
|
||||
);
|
||||
if (!resolved.ok || resolved.value.profile === null)
|
||||
throw new Error("Expected an admitted Kuna profile");
|
||||
const replacement = join(root, "replacement");
|
||||
await writeFile(replacement, wrapperSource, { mode: 0o755 });
|
||||
await unlink(selected.command);
|
||||
await symlink(replacement, selected.command);
|
||||
const client = provider.createClient(target, resolved.value.profile);
|
||||
try {
|
||||
expect(await client.execute("health", {})).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: {
|
||||
committed_executable: implementation,
|
||||
observed_executable: replacement,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps shared-cache exports separate for equal wrapper bytes selected beside different producers", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-command-cache-");
|
||||
const implementation = join(root, "implementation");
|
||||
await writeFile(implementation, wrapperSource, { mode: 0o755 });
|
||||
const cacheRoot = join(root, "cache");
|
||||
const targetPath = join(root, "target");
|
||||
await writeFile(targetPath, "inert fixture input");
|
||||
const targetSha256 = createHash("sha256")
|
||||
.update(await readFile(targetPath))
|
||||
.digest("hex");
|
||||
const installations: KunaInstallation[] = [];
|
||||
for (const name of ["alpha", "beta"]) {
|
||||
const selected = await selectWrapper(root, name, implementation);
|
||||
const producerRoot = join(root, `producer-${name}`);
|
||||
await mkdir(producerRoot);
|
||||
const producer = await writeFakeKuna(producerRoot, {
|
||||
functions: [{ name, addr: "0x1000" }],
|
||||
});
|
||||
await symlink(producer, join(selected.directory, "producer.cjs"));
|
||||
const installed = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: selected.command,
|
||||
REA_KUNA_CACHE_DIR: cacheRoot,
|
||||
});
|
||||
if (!installed.ok) throw new Error(installed.error.reason);
|
||||
installations.push(installed.value);
|
||||
}
|
||||
expect(installations[0]?.sha256).toBe(installations[1]?.sha256);
|
||||
const cache = new KunaProjectCache(cacheRoot, { maxBytes: 64 * 1024 ** 2 });
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (const [index, installation] of installations.entries()) {
|
||||
const built = await cache.build({
|
||||
installation,
|
||||
targetPath,
|
||||
targetSha256,
|
||||
});
|
||||
if (!built.ok) throw built.error;
|
||||
keys.push(built.value.key);
|
||||
expect(built.value.functions[0]?.name).toBe(
|
||||
index === 0 ? "alpha" : "beta",
|
||||
);
|
||||
expect(built.value.manifest.inputs).toMatchObject({
|
||||
kuna_command: installation.configuration.command,
|
||||
kuna_executable: installation.executable,
|
||||
});
|
||||
}
|
||||
expect(keys[0]).not.toBe(keys[1]);
|
||||
} finally {
|
||||
expect((await cache.close()).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user