diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index ee1cc11e8e..29b740d4d0 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -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 diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index ccab5059e3..4df523ddd8 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -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", diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index b9ff153601..b2c501b7a4 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -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 => { - 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. diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index b238862d76..8da9bc60cd 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -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, diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index 4f46320330..8501fef9e6 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -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, }, diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index e2266d7177..781a428f22 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -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> => { - 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( diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 9889c021d6..f9f137f5b1 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -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", diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 106c589805..39c87836ab 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -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, }); diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts index 7e8a310e95..db412f8b04 100644 --- a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -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"); diff --git a/tests/boundary/providers/kuna/kunaProjectCache.test.ts b/tests/boundary/providers/kuna/kunaProjectCache.test.ts index a4813b7ff8..59256815fb 100644 --- a/tests/boundary/providers/kuna/kunaProjectCache.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectCache.test.ts @@ -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 = () => { 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(); @@ -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, diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index a58ce8b811..89d719669c 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -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", diff --git a/tests/boundary/providers/kuna/kunaSelectedCommand.test.ts b/tests/boundary/providers/kuna/kunaSelectedCommand.test.ts new file mode 100644 index 0000000000..382b4db53b --- /dev/null +++ b/tests/boundary/providers/kuna/kunaSelectedCommand.test.ts @@ -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); + } +});