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.
|
- `trash/` holds retired builds and evicted exports awaiting removal.
|
||||||
- `home/` is Kuna's HOME.
|
- `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.
|
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
|
## Verify with a real Kuna
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|||||||
|
|
||||||
const SELECTED_REGISTRATION_SETTINGS = [
|
const SELECTED_REGISTRATION_SETTINGS = [
|
||||||
"GHIDRA_INSTALL_DIR",
|
"GHIDRA_INSTALL_DIR",
|
||||||
|
"GHIDRA_HEADLESS_MAXMEM",
|
||||||
|
"GHIDRA_MAXMEM",
|
||||||
"JAVA_HOME",
|
"JAVA_HOME",
|
||||||
"HOPPER_LAUNCHER_PATH",
|
"HOPPER_LAUNCHER_PATH",
|
||||||
"REA_ILSPY_CMD_PATH",
|
"REA_ILSPY_CMD_PATH",
|
||||||
|
|||||||
+24
-14
@@ -20,6 +20,7 @@ import {
|
|||||||
runOwnedCommand,
|
runOwnedCommand,
|
||||||
} from "../process/OwnedCommand.js";
|
} from "../process/OwnedCommand.js";
|
||||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||||
|
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||||
import {
|
import {
|
||||||
RegularFileCleanupFailure,
|
RegularFileCleanupFailure,
|
||||||
@@ -166,9 +167,15 @@ const verifiedExecutables = new Map<
|
|||||||
const verifyExecutable = async (
|
const verifyExecutable = async (
|
||||||
installation: KunaInstallation,
|
installation: KunaInstallation,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
operation: string,
|
||||||
retainFailedFileClose: KunaCommandRequest["retainFailedFileClose"],
|
retainFailedFileClose: KunaCommandRequest["retainFailedFileClose"],
|
||||||
): Promise<string | undefined> => {
|
): 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 = [
|
const identity = [
|
||||||
status.dev,
|
status.dev,
|
||||||
status.ino,
|
status.ino,
|
||||||
@@ -176,17 +183,17 @@ const verifyExecutable = async (
|
|||||||
status.mtimeNs,
|
status.mtimeNs,
|
||||||
status.ctimeNs,
|
status.ctimeNs,
|
||||||
].join(":");
|
].join(":");
|
||||||
const known = verifiedExecutables.get(installation.executable);
|
const known = verifiedExecutables.get(invocationPath);
|
||||||
let sha256 = known?.identity === identity ? known.sha256 : undefined;
|
let sha256 = known?.identity === identity ? known.sha256 : undefined;
|
||||||
if (sha256 === undefined) {
|
if (sha256 === undefined) {
|
||||||
sha256 = (
|
sha256 = (
|
||||||
await hashStableFile(installation.executable, {
|
await hashStableFile(invocationPath, {
|
||||||
signal,
|
signal,
|
||||||
retainFailedClose: (owner) =>
|
retainFailedClose: (owner) =>
|
||||||
retainFailedFileClose(owner, installation.executable),
|
retainFailedFileClose(owner, invocationPath),
|
||||||
})
|
})
|
||||||
).sha256;
|
).sha256;
|
||||||
verifiedExecutables.set(installation.executable, { identity, sha256 });
|
verifiedExecutables.set(invocationPath, { identity, sha256 });
|
||||||
}
|
}
|
||||||
return sha256 === installation.sha256 ? undefined : sha256;
|
return sha256 === installation.sha256 ? undefined : sha256;
|
||||||
};
|
};
|
||||||
@@ -200,6 +207,7 @@ export const runKunaCommand = async (
|
|||||||
const changed = await verifyExecutable(
|
const changed = await verifyExecutable(
|
||||||
installation,
|
installation,
|
||||||
request.signal,
|
request.signal,
|
||||||
|
request.operation,
|
||||||
request.retainFailedFileClose,
|
request.retainFailedFileClose,
|
||||||
);
|
);
|
||||||
if (changed !== undefined)
|
if (changed !== undefined)
|
||||||
@@ -225,20 +233,22 @@ export const runKunaCommand = async (
|
|||||||
return {
|
return {
|
||||||
kind: "failed",
|
kind: "failed",
|
||||||
error:
|
error:
|
||||||
cancellation !== undefined
|
cause instanceof AnalysisError
|
||||||
? new AnalysisCancelledError(request.operation, {
|
? cause
|
||||||
cause: cancellation.reason,
|
: cancellation !== undefined
|
||||||
})
|
? new AnalysisCancelledError(request.operation, {
|
||||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
|
cause: cancellation.reason,
|
||||||
cause,
|
})
|
||||||
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
|
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
|
||||||
}),
|
cause,
|
||||||
|
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const snapshot = await runOwnedCommand(
|
const snapshot = await runOwnedCommand(
|
||||||
{
|
{
|
||||||
command: installation.executable,
|
command: installation.configuration.command,
|
||||||
arguments: request.arguments,
|
arguments: request.arguments,
|
||||||
runId: `rea-kuna-${randomUUID()}`,
|
runId: `rea-kuna-${randomUUID()}`,
|
||||||
// A script's process reports its interpreter, not the configured path.
|
// A script's process reports its interpreter, not the configured path.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
runOwnedCommand,
|
runOwnedCommand,
|
||||||
} from "../process/OwnedCommand.js";
|
} from "../process/OwnedCommand.js";
|
||||||
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
|
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
|
||||||
|
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||||
import type {
|
import type {
|
||||||
OwnedProviderProcessSpawnOptions,
|
OwnedProviderProcessSpawnOptions,
|
||||||
ProviderProcessSnapshot,
|
ProviderProcessSnapshot,
|
||||||
@@ -426,11 +427,15 @@ export const inspectKunaInstallation = async (
|
|||||||
phase: "fingerprint",
|
phase: "fingerprint",
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
await assertArtifactCommandPath(
|
||||||
|
{ invocationPath: command, resolvedPath: executable },
|
||||||
|
"kuna:installation",
|
||||||
|
);
|
||||||
beforeProbeFingerprint = await options.resourceScope.run(() =>
|
beforeProbeFingerprint = await options.resourceScope.run(() =>
|
||||||
hashStableFile(executable, {
|
hashStableFile(command, {
|
||||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
retainFailedClose: (owner) =>
|
retainFailedClose: (owner) =>
|
||||||
options.resourceScope.retain(owner, executable),
|
options.resourceScope.retain(owner, command),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
@@ -456,7 +461,7 @@ export const inspectKunaInstallation = async (
|
|||||||
snapshot = await options.resourceScope.run(() =>
|
snapshot = await options.resourceScope.run(() =>
|
||||||
runOwnedCommand(
|
runOwnedCommand(
|
||||||
{
|
{
|
||||||
command: executable,
|
command,
|
||||||
arguments: ["--version"],
|
arguments: ["--version"],
|
||||||
runId: `rea-kuna-version-${randomUUID()}`,
|
runId: `rea-kuna-version-${randomUUID()}`,
|
||||||
// The configured command may be an interpreter-driven shim.
|
// The configured command may be an interpreter-driven shim.
|
||||||
@@ -572,10 +577,14 @@ export const inspectKunaInstallation = async (
|
|||||||
try {
|
try {
|
||||||
({ fingerprint, script } = await options.resourceScope.run(async () => {
|
({ fingerprint, script } = await options.resourceScope.run(async () => {
|
||||||
observed.phase = "identity";
|
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 }),
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
retainFailedClose: (owner) =>
|
retainFailedClose: (owner) =>
|
||||||
options.resourceScope.retain(owner, executable),
|
options.resourceScope.retain(owner, command),
|
||||||
});
|
});
|
||||||
const fingerprint = {
|
const fingerprint = {
|
||||||
sha256: observation.sha256,
|
sha256: observation.sha256,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import type {
|
|||||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||||
|
|
||||||
/** Bumped whenever the entry layout or manifest meaning changes. */
|
/** 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. */
|
/** A manifest names five files and their digests; anything larger is not ours. */
|
||||||
const MANIFEST_MAX_BYTES = 64 * 1024;
|
const MANIFEST_MAX_BYTES = 64 * 1024;
|
||||||
@@ -72,6 +72,8 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|||||||
export interface KunaProjectKeyInputs {
|
export interface KunaProjectKeyInputs {
|
||||||
readonly targetSha256: string;
|
readonly targetSha256: string;
|
||||||
readonly kunaSha256: string;
|
readonly kunaSha256: string;
|
||||||
|
readonly kunaCommand: string;
|
||||||
|
readonly kunaExecutable: string;
|
||||||
readonly kunaVersion: string;
|
readonly kunaVersion: string;
|
||||||
/**
|
/**
|
||||||
* `--jobs 1` is Kuna's reference answer; more jobs decompile without
|
* `--jobs 1` is Kuna's reference answer; more jobs decompile without
|
||||||
@@ -88,6 +90,8 @@ export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string =>
|
|||||||
layout: CACHE_LAYOUT,
|
layout: CACHE_LAYOUT,
|
||||||
target_sha256: inputs.targetSha256,
|
target_sha256: inputs.targetSha256,
|
||||||
kuna_sha256: inputs.kunaSha256,
|
kuna_sha256: inputs.kunaSha256,
|
||||||
|
kuna_command: inputs.kunaCommand,
|
||||||
|
kuna_executable: inputs.kunaExecutable,
|
||||||
kuna_version: inputs.kunaVersion,
|
kuna_version: inputs.kunaVersion,
|
||||||
export: { command: "decompile-project", jobs: inputs.jobs },
|
export: { command: "decompile-project", jobs: inputs.jobs },
|
||||||
}) ?? "",
|
}) ?? "",
|
||||||
@@ -106,6 +110,8 @@ const manifestSchema = z.strictObject({
|
|||||||
inputs: z.strictObject({
|
inputs: z.strictObject({
|
||||||
target_sha256: z.string().regex(SHA256_PATTERN),
|
target_sha256: z.string().regex(SHA256_PATTERN),
|
||||||
kuna_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),
|
kuna_version: z.string().min(1),
|
||||||
jobs: z.int().positive(),
|
jobs: z.int().positive(),
|
||||||
}),
|
}),
|
||||||
@@ -475,6 +481,8 @@ export class KunaProjectCache {
|
|||||||
const key = kunaProjectKey({
|
const key = kunaProjectKey({
|
||||||
targetSha256: request.targetSha256,
|
targetSha256: request.targetSha256,
|
||||||
kunaSha256: request.installation.sha256,
|
kunaSha256: request.installation.sha256,
|
||||||
|
kunaCommand: request.installation.configuration.command,
|
||||||
|
kunaExecutable: request.installation.executable,
|
||||||
kunaVersion: request.installation.version,
|
kunaVersion: request.installation.version,
|
||||||
jobs: request.installation.configuration.jobs,
|
jobs: request.installation.configuration.jobs,
|
||||||
});
|
});
|
||||||
@@ -945,6 +953,8 @@ export class KunaProjectCache {
|
|||||||
inputs: {
|
inputs: {
|
||||||
target_sha256: request.targetSha256,
|
target_sha256: request.targetSha256,
|
||||||
kuna_sha256: request.installation.sha256,
|
kuna_sha256: request.installation.sha256,
|
||||||
|
kuna_command: request.installation.configuration.command,
|
||||||
|
kuna_executable: request.installation.executable,
|
||||||
kuna_version: request.installation.version,
|
kuna_version: request.installation.version,
|
||||||
jobs: request.installation.configuration.jobs,
|
jobs: request.installation.configuration.jobs,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "../filesystem/StableFileHash.js";
|
} from "../filesystem/StableFileHash.js";
|
||||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||||
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
|
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
|
||||||
|
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||||
import {
|
import {
|
||||||
copyAndHash,
|
copyAndHash,
|
||||||
@@ -170,10 +171,14 @@ const verifyKunaExecutable = async (
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
retainFailedFileClose: KunaExportRequest["retainFailedFileClose"],
|
retainFailedFileClose: KunaExportRequest["retainFailedFileClose"],
|
||||||
): Promise<Result<null, ProviderAdapterError>> => {
|
): 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,
|
signal,
|
||||||
retainFailedClose: (owner) =>
|
retainFailedClose: (owner) => retainFailedFileClose(owner, invocationPath),
|
||||||
retainFailedFileClose(owner, installation.executable),
|
|
||||||
});
|
});
|
||||||
if (executable.sha256 === installation.sha256) return ok(null);
|
if (executable.sha256 === installation.sha256) return ok(null);
|
||||||
return err(
|
return err(
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ export class KunaProvider implements AnalysisProviderCandidate {
|
|||||||
{
|
{
|
||||||
kuna_version: value.version,
|
kuna_version: value.version,
|
||||||
kuna_sha256: value.sha256,
|
kuna_sha256: value.sha256,
|
||||||
|
kuna_command: value.configuration.command,
|
||||||
|
kuna_executable: value.executable,
|
||||||
verified_release: value.verified,
|
verified_release: value.verified,
|
||||||
jobs: value.configuration.jobs,
|
jobs: value.configuration.jobs,
|
||||||
export: "decompile-project",
|
export: "decompile-project",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
import {
|
||||||
|
regularFileCancellation,
|
||||||
|
RegularFileCleanupFailure,
|
||||||
|
} from "../filesystem/OwnedRegularFile.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCleanupLease,
|
AnalysisCleanupLease,
|
||||||
AnalysisCleanupLeaseGroup,
|
AnalysisCleanupLeaseGroup,
|
||||||
@@ -85,7 +88,6 @@ import {
|
|||||||
type KunaProjectFileSpan,
|
type KunaProjectFileSpan,
|
||||||
} from "./KunaProjectReader.js";
|
} from "./KunaProjectReader.js";
|
||||||
import { kunaUnavailable } from "./KunaUnavailable.js";
|
import { kunaUnavailable } from "./KunaUnavailable.js";
|
||||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
|
||||||
import {
|
import {
|
||||||
ArtifactResourceRetryFailure,
|
ArtifactResourceRetryFailure,
|
||||||
ArtifactResourceScope,
|
ArtifactResourceScope,
|
||||||
@@ -1495,14 +1497,34 @@ export class KunaSessionClient implements AnalysisClient {
|
|||||||
return err(error);
|
return err(error);
|
||||||
}
|
}
|
||||||
const committed = this.#profile?.parameters["kuna_sha256"];
|
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(
|
return err(
|
||||||
new ProviderAdapterError(KUNA_PROVIDER_ID, "health", {
|
new ProviderAdapterError(KUNA_PROVIDER_ID, "health", {
|
||||||
userMessage:
|
userMessage:
|
||||||
"The Kuna executable changed after this target was opened; reopen the target to analyze it with the current executable.",
|
"The Kuna executable changed after this target was opened; reopen the target to analyze it with the current executable.",
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
committed_sha256: committed,
|
...(committed === undefined
|
||||||
|
? {}
|
||||||
|
: { committed_sha256: committed }),
|
||||||
observed_sha256: inspected.value.sha256,
|
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({
|
const key = kunaProjectKey({
|
||||||
targetSha256: this.#target.sha256,
|
targetSha256: this.#target.sha256,
|
||||||
kunaSha256: installation.sha256,
|
kunaSha256: installation.sha256,
|
||||||
|
kunaCommand: installation.configuration.command,
|
||||||
|
kunaExecutable: installation.executable,
|
||||||
kunaVersion: installation.version,
|
kunaVersion: installation.version,
|
||||||
jobs: installation.configuration.jobs,
|
jobs: installation.configuration.jobs,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ cliTest(
|
|||||||
const selected = {
|
const selected = {
|
||||||
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
||||||
GHIDRA_INSTALL_DIR: join(home, "offline-ghidra"),
|
GHIDRA_INSTALL_DIR: join(home, "offline-ghidra"),
|
||||||
|
GHIDRA_HEADLESS_MAXMEM: "512M",
|
||||||
|
GHIDRA_MAXMEM: "3G",
|
||||||
JAVA_HOME: join(home, "offline-jdk"),
|
JAVA_HOME: join(home, "offline-jdk"),
|
||||||
REA_IDA_MCP_CONFIG: join(home, "offline-ida.json"),
|
REA_IDA_MCP_CONFIG: join(home, "offline-ida.json"),
|
||||||
REA_JADX_MCP_JAR: join(home, "offline-jadx.jar"),
|
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.
|
// A later setup must update a selection rather than retaining the first value.
|
||||||
selected.REA_OBJDUMP_COMMAND = join(home, "replacement-objdump");
|
selected.REA_OBJDUMP_COMMAND = join(home, "replacement-objdump");
|
||||||
environment.REA_OBJDUMP_COMMAND = selected.REA_OBJDUMP_COMMAND;
|
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();
|
const updated = await run();
|
||||||
expect(updated.exitCode, updated.stdout).toBe(0);
|
expect(updated.exitCode, updated.stdout).toBe(0);
|
||||||
const updatedConfiguration = await readFile(config, "utf8");
|
const updatedConfiguration = await readFile(config, "utf8");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
|
access,
|
||||||
appendFile,
|
appendFile,
|
||||||
chmod,
|
chmod,
|
||||||
mkdir,
|
mkdir,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
import { expect, it as test, vi } from "vitest";
|
import { expect, it as test, vi } from "vitest";
|
||||||
|
|
||||||
@@ -85,6 +87,8 @@ const keyOf = (installation: KunaInstallation, target: Target) =>
|
|||||||
kunaProjectKey({
|
kunaProjectKey({
|
||||||
targetSha256: target.targetSha256,
|
targetSha256: target.targetSha256,
|
||||||
kunaSha256: installation.sha256,
|
kunaSha256: installation.sha256,
|
||||||
|
kunaCommand: installation.configuration.command,
|
||||||
|
kunaExecutable: installation.executable,
|
||||||
kunaVersion: installation.version,
|
kunaVersion: installation.version,
|
||||||
jobs: installation.configuration.jobs,
|
jobs: installation.configuration.jobs,
|
||||||
});
|
});
|
||||||
@@ -114,6 +118,85 @@ const deferred = <T>() => {
|
|||||||
return { promise, resolve, reject };
|
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 causesOf = (cause: unknown): unknown[] => {
|
||||||
const pending = [cause];
|
const pending = [cause];
|
||||||
const seen = new Set<unknown>();
|
const seen = new Set<unknown>();
|
||||||
@@ -152,7 +235,7 @@ it("publishes a verified export and serves later requests from the cache", async
|
|||||||
value: {
|
value: {
|
||||||
key: keyOf(installation, input),
|
key: keyOf(installation, input),
|
||||||
manifest: {
|
manifest: {
|
||||||
layout: 1,
|
layout: 2,
|
||||||
inputs: {
|
inputs: {
|
||||||
target_sha256: input.targetSha256,
|
target_sha256: input.targetSha256,
|
||||||
kuna_sha256: installation.sha256,
|
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 () => {
|
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({
|
expect(profile).toMatchObject({
|
||||||
provider: { id: "kuna", name: "Kuna", version: "1.833" },
|
provider: { id: "kuna", name: "Kuna", version: "1.833" },
|
||||||
parameters: {
|
parameters: {
|
||||||
kuna_version: "1.833",
|
kuna_version: "1.833",
|
||||||
kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||||
|
kuna_command: kuna,
|
||||||
verified_release: true,
|
verified_release: true,
|
||||||
jobs: 1,
|
jobs: 1,
|
||||||
cache_policy: "live",
|
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