mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(kuna): add Kuna readiness, doctor and owned project cache (#3832)
Add caller-supplied Kuna readiness, scoped doctor support and a verified whole-program export cache. Index function byte ranges without introducing analysis-tool responses, and retain unresolved process ownership through cleanup failures. Separate retryable file cleanup from unproven process cleanup. Verified real Kuna 1.833 on Debian 12 x86-64 with coreutils, SQLite and zlib; 5,207 exported function spans, cold/warm performance, raw-export parity and lifecycle failures. Refs #1957. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
16 files changed
+3530
-1
No files matched your search
@@ -1,2 +1,8 @@
|
||||
/** Deep-analysis providers whose readiness can be selected explicitly by doctor. */
|
||||
export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra", "ida", "jadx"] as const;
|
||||
export const DOCTOR_PROVIDER_IDS = [
|
||||
"hopper",
|
||||
"ghidra",
|
||||
"ida",
|
||||
"jadx",
|
||||
"kuna",
|
||||
] as const;
|
||||
@@ -42,6 +42,27 @@ it("omits REA_JADX_MCP_JAR from the provider registration environment when it is
|
||||
expect(environment).not.toHaveProperty("REA_JADX_MCP_JAR");
|
||||
});
|
||||
|
||||
it("carries a validated REA_KUNA_COMMAND into the provider registration environment", async () => {
|
||||
expect(DOCTOR_PROVIDER_IDS).toContain("kuna");
|
||||
const root = await createTestTempDirectory("rea-kuna-registration-");
|
||||
const kuna = join(root, "kuna");
|
||||
await writeFile(kuna, "#!/bin/sh\nprintf 'kuna 1.833\\n'\n", { mode: 0o700 });
|
||||
|
||||
const inspections = await createSystemDoctorHost({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
}).providerInspections?.();
|
||||
|
||||
expect(inspections?.find(({ id }) => id === "kuna")).toMatchObject({
|
||||
available: true,
|
||||
providerVersion: "1.833",
|
||||
});
|
||||
expect(providerRegistrationEnvironment(inspections ?? [])).toMatchObject({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
});
|
||||
});
|
||||
|
||||
const createFakeJdk = async (root: string): Promise<string> => {
|
||||
const javaHome = join(root, "fake-jdk");
|
||||
const bin = join(javaHome, "bin");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { inspectJadxProvider } from "./android/JadxDoctor.js";
|
||||
import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js";
|
||||
import { inspectIdaRegistration } from "./ida/IdaDoctor.js";
|
||||
import { inspectKunaProvider } from "./kuna/KunaDoctor.js";
|
||||
import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
|
||||
import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js";
|
||||
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
|
||||
@@ -45,6 +46,7 @@ export const createSystemDoctorHost = (
|
||||
await inspectSystemGhidraProvider(environment),
|
||||
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
|
||||
await inspectJadxProvider(environment),
|
||||
await inspectKunaProvider(environment),
|
||||
],
|
||||
linuxDemoRuntimeCheck: () => inspectLinuxPrivateDisplay(environment),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { isAbsolute, join } from "node:path";
|
||||
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
KUNA_COMMAND_REMEDIATION,
|
||||
KUNA_UNSUPPORTED_HOST_REMEDIATION,
|
||||
} from "./KunaRelease.js";
|
||||
|
||||
/** Kuna caps its own decode lanes and worker pool at 32. */
|
||||
const MAX_KUNA_JOBS = 32;
|
||||
|
||||
/**
|
||||
* A 1.4 MB sqlite3 shell exported serially in 66-108 s on Apple M-series and
|
||||
* Linux hosts; thirty minutes admits images an order of magnitude larger
|
||||
* without an unbounded run. Larger images need a raised deadline or more jobs.
|
||||
*/
|
||||
const DEFAULT_PROJECT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A serial (`--jobs 1`) export is Kuna's reference answer: it decompiles
|
||||
* callees before callers, which recovers prototypes that parallel workers
|
||||
* cannot. Measured on coreutils sort, 40-60% of functions differ otherwise.
|
||||
*/
|
||||
const DEFAULT_JOBS = 1;
|
||||
|
||||
/** Measured sqlite3 exports occupy ~25 MiB; four GiB retains many projects. */
|
||||
const DEFAULT_CACHE_MAX_BYTES = 4 * 1024 ** 3;
|
||||
|
||||
/** One project needs its .c, .h, .asm, inventory and manifest; refuse smaller caps. */
|
||||
const MIN_CACHE_MAX_BYTES = 1024 ** 2;
|
||||
|
||||
/** Caller-selected Kuna installation and REA-owned project cache policy. */
|
||||
export interface KunaConfiguration {
|
||||
/** Caller-selected executable path, preserved exactly as configured. */
|
||||
readonly command: string;
|
||||
/** REA-owned cache root; Kuna's own spec cache lives beneath it too. */
|
||||
readonly cacheRoot: string;
|
||||
readonly cacheMaxBytes: number;
|
||||
readonly projectTimeoutMs: number;
|
||||
readonly jobs: number;
|
||||
}
|
||||
|
||||
/** Why the configured environment cannot select a Kuna installation. */
|
||||
export interface KunaConfigurationFailure {
|
||||
readonly code: "not_configured" | "open_options_invalid" | "unsupported_host";
|
||||
readonly reason: string;
|
||||
readonly setting: string | null;
|
||||
}
|
||||
|
||||
const failure = (
|
||||
code: KunaConfigurationFailure["code"],
|
||||
reason: string,
|
||||
setting: string | null = null,
|
||||
): Result<never, KunaConfigurationFailure> => err({ code, reason, setting });
|
||||
|
||||
const positiveInteger = (value: string): number | undefined =>
|
||||
/^[1-9]\d*$/u.test(value) && Number.isSafeInteger(Number(value))
|
||||
? Number(value)
|
||||
: undefined;
|
||||
|
||||
/** Per-user cache directory that REA owns for Kuna on this host. */
|
||||
const defaultCacheRoot = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): string => {
|
||||
const home = homeDirectoryFromEnvironment(environment, platform);
|
||||
if (platform === "darwin")
|
||||
return join(home, "Library", "Caches", "rea", "kuna");
|
||||
const xdg = environment.XDG_CACHE_HOME;
|
||||
return join(
|
||||
xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".cache"),
|
||||
"rea",
|
||||
"kuna",
|
||||
);
|
||||
};
|
||||
|
||||
/** Parse the Kuna settings without touching the filesystem or the executable. */
|
||||
export const readKunaConfiguration = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Result<KunaConfiguration, KunaConfigurationFailure> => {
|
||||
const command = environment.REA_KUNA_COMMAND;
|
||||
if (command === undefined || command.length === 0)
|
||||
return failure("not_configured", KUNA_COMMAND_REMEDIATION);
|
||||
if (!isAbsolute(command))
|
||||
return failure(
|
||||
"open_options_invalid",
|
||||
`REA_KUNA_COMMAND must be an absolute path; received ${command}. ${KUNA_COMMAND_REMEDIATION}`,
|
||||
"REA_KUNA_COMMAND",
|
||||
);
|
||||
if (platform !== "darwin" && platform !== "linux")
|
||||
return failure("unsupported_host", KUNA_UNSUPPORTED_HOST_REMEDIATION);
|
||||
|
||||
const cacheRoot = environment.REA_KUNA_CACHE_DIR;
|
||||
if (cacheRoot !== undefined && !isAbsolute(cacheRoot))
|
||||
return failure(
|
||||
"open_options_invalid",
|
||||
`REA_KUNA_CACHE_DIR must be an absolute directory; received ${cacheRoot}.`,
|
||||
"REA_KUNA_CACHE_DIR",
|
||||
);
|
||||
|
||||
const rawMaxBytes = environment.REA_KUNA_CACHE_MAX_BYTES;
|
||||
const cacheMaxBytes =
|
||||
rawMaxBytes === undefined
|
||||
? DEFAULT_CACHE_MAX_BYTES
|
||||
: positiveInteger(rawMaxBytes);
|
||||
if (cacheMaxBytes === undefined || cacheMaxBytes < MIN_CACHE_MAX_BYTES)
|
||||
return failure(
|
||||
"open_options_invalid",
|
||||
`REA_KUNA_CACHE_MAX_BYTES must be a decimal byte count of at least ${String(MIN_CACHE_MAX_BYTES)}; received ${String(rawMaxBytes)}.`,
|
||||
"REA_KUNA_CACHE_MAX_BYTES",
|
||||
);
|
||||
|
||||
const rawTimeout = environment.REA_KUNA_PROJECT_TIMEOUT_MS;
|
||||
const timeout =
|
||||
rawTimeout === undefined
|
||||
? undefined
|
||||
: providerStartupTimeoutSchema.safeParse(rawTimeout);
|
||||
if (timeout !== undefined && !timeout.success)
|
||||
return failure(
|
||||
"open_options_invalid",
|
||||
`REA_KUNA_PROJECT_TIMEOUT_MS must be a decimal millisecond count from 1 to 2147483647; received ${String(rawTimeout)}.`,
|
||||
"REA_KUNA_PROJECT_TIMEOUT_MS",
|
||||
);
|
||||
|
||||
const rawJobs = environment.REA_KUNA_JOBS;
|
||||
const jobs = rawJobs === undefined ? DEFAULT_JOBS : positiveInteger(rawJobs);
|
||||
if (jobs === undefined || jobs > MAX_KUNA_JOBS)
|
||||
return failure(
|
||||
"open_options_invalid",
|
||||
`REA_KUNA_JOBS must be an integer from 1 to ${String(MAX_KUNA_JOBS)}; received ${String(rawJobs)}.`,
|
||||
"REA_KUNA_JOBS",
|
||||
);
|
||||
|
||||
return ok({
|
||||
command,
|
||||
cacheRoot: cacheRoot ?? defaultCacheRoot(environment, platform),
|
||||
cacheMaxBytes,
|
||||
projectTimeoutMs: timeout?.data ?? DEFAULT_PROJECT_TIMEOUT_MS,
|
||||
jobs,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type {
|
||||
DoctorProviderCheck,
|
||||
DoctorProviderInspection,
|
||||
} from "../application/Doctor.js";
|
||||
import { readKunaConfiguration } from "./KunaConfiguration.js";
|
||||
import {
|
||||
inspectKunaInstallation,
|
||||
type KunaReadinessFailure,
|
||||
} from "./KunaInstallation.js";
|
||||
import {
|
||||
KUNA_COMMAND_REMEDIATION,
|
||||
KUNA_PROVIDER_ID,
|
||||
KUNA_UNSUPPORTED_HOST_REMEDIATION,
|
||||
KUNA_VERIFIED_VERSION,
|
||||
KUNA_VERSION_PROBE_REMEDIATION,
|
||||
} from "./KunaRelease.js";
|
||||
|
||||
/** Settings a registration may carry; only those the caller set explicitly. */
|
||||
const REGISTRATION_SETTINGS = [
|
||||
"REA_KUNA_COMMAND",
|
||||
"REA_KUNA_CACHE_DIR",
|
||||
"REA_KUNA_CACHE_MAX_BYTES",
|
||||
"REA_KUNA_PROJECT_TIMEOUT_MS",
|
||||
"REA_KUNA_JOBS",
|
||||
] as const;
|
||||
|
||||
/** Project Kuna readiness into the doctor contract without writing anything. */
|
||||
export const inspectKunaProvider = async (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DoctorProviderInspection> => {
|
||||
const installation = await inspectKunaInstallation(environment, {
|
||||
platform,
|
||||
});
|
||||
const configured =
|
||||
environment.REA_KUNA_COMMAND !== undefined &&
|
||||
environment.REA_KUNA_COMMAND.length > 0;
|
||||
if (!installation.ok)
|
||||
return {
|
||||
id: KUNA_PROVIDER_ID,
|
||||
configured,
|
||||
available: false,
|
||||
providerVersion: null,
|
||||
registrationEnvironment: {},
|
||||
checks: [installationFailure(installation.error, configured)],
|
||||
};
|
||||
const { value } = installation;
|
||||
return {
|
||||
id: KUNA_PROVIDER_ID,
|
||||
configured,
|
||||
available: true,
|
||||
providerVersion: value.version,
|
||||
registrationEnvironment: Object.fromEntries(
|
||||
REGISTRATION_SETTINGS.flatMap((name) => {
|
||||
const setting = environment[name];
|
||||
return setting === undefined ? [] : [[name, setting]];
|
||||
}),
|
||||
),
|
||||
checks: [
|
||||
{
|
||||
name: "installation",
|
||||
ok: true,
|
||||
code: null,
|
||||
detail: `${value.executable} reports Kuna ${value.version} (sha256 ${value.sha256})${
|
||||
value.verified
|
||||
? "."
|
||||
: `; REA verified ${KUNA_VERIFIED_VERSION}, so this release's project layout is unverified.`
|
||||
}`,
|
||||
remediation: null,
|
||||
classification: "config_drift",
|
||||
details: {
|
||||
executable: value.executable,
|
||||
version: value.version,
|
||||
verified_version: KUNA_VERIFIED_VERSION,
|
||||
sha256: value.sha256,
|
||||
},
|
||||
},
|
||||
await cacheCheck(environment, platform),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const installationFailure = (
|
||||
failure: KunaReadinessFailure,
|
||||
configured: boolean,
|
||||
): DoctorProviderCheck => ({
|
||||
name: "installation",
|
||||
ok: false,
|
||||
code: failure.code,
|
||||
detail: failure.reason,
|
||||
remediation:
|
||||
failure.code === "unsupported_host"
|
||||
? KUNA_UNSUPPORTED_HOST_REMEDIATION
|
||||
: failure.code === "version_unresolved"
|
||||
? KUNA_VERSION_PROBE_REMEDIATION
|
||||
: KUNA_COMMAND_REMEDIATION,
|
||||
classification:
|
||||
failure.code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: configured
|
||||
? "config_drift"
|
||||
: "missing_analysis_engine",
|
||||
details: failure.diagnostics,
|
||||
});
|
||||
|
||||
/** The cache root may not exist yet; doctor reports it without creating it. */
|
||||
const cacheCheck = async (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<DoctorProviderCheck> => {
|
||||
const configuration = readKunaConfiguration(environment, platform);
|
||||
if (!configuration.ok)
|
||||
return {
|
||||
name: "cache",
|
||||
ok: false,
|
||||
code: configuration.error.code,
|
||||
detail: configuration.error.reason,
|
||||
remediation: KUNA_COMMAND_REMEDIATION,
|
||||
classification: "config_drift",
|
||||
};
|
||||
const { cacheRoot, cacheMaxBytes } = configuration.value;
|
||||
const details = { cache_root: cacheRoot, cache_max_bytes: cacheMaxBytes };
|
||||
try {
|
||||
if (!(await stat(cacheRoot)).isDirectory())
|
||||
return {
|
||||
name: "cache",
|
||||
ok: false,
|
||||
code: "open_options_invalid",
|
||||
detail: `The Kuna cache root ${cacheRoot} exists but is not a directory.`,
|
||||
remediation:
|
||||
"Set REA_KUNA_CACHE_DIR to a directory REA may own, or remove the conflicting file.",
|
||||
classification: "config_drift",
|
||||
details,
|
||||
};
|
||||
await access(cacheRoot, constants.W_OK | constants.X_OK);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
|
||||
const creatable = await nearestDirectoryWritable(cacheRoot);
|
||||
return creatable.ok
|
||||
? {
|
||||
name: "cache",
|
||||
ok: true,
|
||||
code: null,
|
||||
detail: `REA creates the Kuna project cache at ${cacheRoot} on first use.`,
|
||||
remediation: null,
|
||||
classification: "config_drift",
|
||||
details,
|
||||
}
|
||||
: {
|
||||
name: "cache",
|
||||
ok: false,
|
||||
code: "open_options_invalid",
|
||||
detail: `REA cannot create the Kuna cache root ${cacheRoot}: ${creatable.reason}`,
|
||||
remediation:
|
||||
"Set REA_KUNA_CACHE_DIR to a directory the REA process user can create or write.",
|
||||
classification: "config_drift",
|
||||
details,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "cache",
|
||||
ok: false,
|
||||
code: "open_options_invalid",
|
||||
detail: `REA cannot write the Kuna cache root ${cacheRoot}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
remediation:
|
||||
"Set REA_KUNA_CACHE_DIR to a directory the REA process user can write.",
|
||||
classification: "config_drift",
|
||||
details,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "cache",
|
||||
ok: true,
|
||||
code: null,
|
||||
detail: `Kuna project exports are cached in ${cacheRoot} (at most ${String(cacheMaxBytes)} bytes).`,
|
||||
remediation: null,
|
||||
classification: "config_drift",
|
||||
details,
|
||||
};
|
||||
};
|
||||
|
||||
/** Whether the nearest existing ancestor is a directory REA may create entries in. */
|
||||
const nearestDirectoryWritable = async (
|
||||
path: string,
|
||||
): Promise<
|
||||
{ readonly ok: true } | { readonly ok: false; readonly reason: string }
|
||||
> => {
|
||||
let candidate = dirname(path);
|
||||
for (;;) {
|
||||
try {
|
||||
if (!(await stat(candidate)).isDirectory())
|
||||
return { ok: false, reason: `${candidate} is not a directory.` };
|
||||
await access(candidate, constants.W_OK | constants.X_OK);
|
||||
return { ok: true };
|
||||
} catch (cause: unknown) {
|
||||
const missing =
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
const parent = dirname(candidate);
|
||||
if (!missing || parent === candidate)
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${candidate} is not writable: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
candidate = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, open, realpath, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
readKunaConfiguration,
|
||||
type KunaConfiguration,
|
||||
type KunaConfigurationFailure,
|
||||
} from "./KunaConfiguration.js";
|
||||
import {
|
||||
KUNA_COMMAND_REMEDIATION,
|
||||
KUNA_VERIFIED_VERSION,
|
||||
KUNA_VERSION_PROBE_REMEDIATION,
|
||||
} from "./KunaRelease.js";
|
||||
|
||||
/** `kuna --version` exits before loading specs or an input; it needs no budget beyond this. */
|
||||
const VERSION_PROBE_TIMEOUT_MS = 10_000;
|
||||
const VERSION_PROBE_MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
/** Release CI prints exactly `kuna <MAJOR.MINOR>`; source builds print the Cargo version. */
|
||||
const VERSION_LINE = /^kuna (\d+\.\d+(?:\.\d+)?)\r?\n?$/u;
|
||||
|
||||
/** One observed Kuna executable, identified by its bytes as well as its version. */
|
||||
export interface KunaInstallation {
|
||||
readonly configuration: KunaConfiguration;
|
||||
/** Canonical path of the probed executable. */
|
||||
readonly executable: string;
|
||||
readonly version: string;
|
||||
/** Whether this release is the one REA verified. */
|
||||
readonly verified: boolean;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
/**
|
||||
* Whether the executable is a `#!` script, such as a package-manager shim.
|
||||
* Its process then reports the interpreter as its command, so ownership
|
||||
* checks cannot match the configured path by argv prefix.
|
||||
*/
|
||||
readonly script: boolean;
|
||||
}
|
||||
|
||||
/** Why the selected Kuna installation cannot be used. */
|
||||
export interface KunaReadinessFailure {
|
||||
readonly code:
|
||||
| KunaConfigurationFailure["code"]
|
||||
| "executable_missing"
|
||||
| "version_unresolved";
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment for every Kuna child. Kuna unpacks its built-in SLEIGH tree into
|
||||
* the per-user cache under HOME (macOS) or XDG_CACHE_HOME (Linux), so both
|
||||
* point inside REA's own cache root instead of the user's.
|
||||
*/
|
||||
export const kunaChildEnvironment = (
|
||||
configuration: KunaConfiguration,
|
||||
): NodeJS.ProcessEnv => {
|
||||
const home = join(configuration.cacheRoot, "home");
|
||||
return {
|
||||
HOME: home,
|
||||
XDG_CACHE_HOME: join(home, ".cache"),
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
};
|
||||
|
||||
const failure = (
|
||||
code: KunaReadinessFailure["code"],
|
||||
reason: string,
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
): Result<never, KunaReadinessFailure> => err({ code, reason, diagnostics });
|
||||
|
||||
const message = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Resolve, version-probe and fingerprint the configured executable. */
|
||||
export const inspectKunaInstallation = async (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
options: {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly signal?: AbortSignal;
|
||||
/** Probe deadline; tests shorten it. */
|
||||
readonly probeTimeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<Result<KunaInstallation, KunaReadinessFailure>> => {
|
||||
const configuration = readKunaConfiguration(environment, options.platform);
|
||||
if (!configuration.ok)
|
||||
return failure(configuration.error.code, configuration.error.reason, {
|
||||
configured: configuration.error.code !== "not_configured",
|
||||
...(configuration.error.setting === null
|
||||
? {}
|
||||
: { setting: configuration.error.setting }),
|
||||
});
|
||||
const { command } = configuration.value;
|
||||
|
||||
let executable: string;
|
||||
try {
|
||||
executable = await realpath(command);
|
||||
await access(executable, constants.X_OK);
|
||||
if (!(await stat(executable)).isFile())
|
||||
return failure(
|
||||
"executable_missing",
|
||||
`REA_KUNA_COMMAND is not a regular file: ${command}. ${KUNA_COMMAND_REMEDIATION}`,
|
||||
{ command },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
options.signal?.throwIfAborted();
|
||||
return failure(
|
||||
"executable_missing",
|
||||
`Cannot execute REA_KUNA_COMMAND ${command}: ${message(cause)}. ${KUNA_COMMAND_REMEDIATION}`,
|
||||
{ command },
|
||||
);
|
||||
}
|
||||
|
||||
const deadline = options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS;
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileOutput(executable, ["--version"], {
|
||||
env: kunaChildEnvironment(configuration.value),
|
||||
timeout: deadline,
|
||||
maxBuffer: VERSION_PROBE_MAX_OUTPUT_BYTES,
|
||||
// Escalate to SIGKILL so an executable that ignores SIGTERM cannot hang doctor.
|
||||
stopSignal: "SIGTERM",
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}));
|
||||
} catch (cause: unknown) {
|
||||
options.signal?.throwIfAborted();
|
||||
const output = execFileOutputFailure(cause);
|
||||
return failure(
|
||||
"version_unresolved",
|
||||
`${executable} --version failed: ${output?.stderr.trim() || message(cause)}. ${KUNA_VERSION_PROBE_REMEDIATION}`,
|
||||
{
|
||||
command,
|
||||
executable,
|
||||
exit_code: typeof output?.code === "number" ? output.code : null,
|
||||
signal: output?.signal ?? null,
|
||||
killed: output?.killed ?? false,
|
||||
output_truncated: output?.outputTruncated ?? false,
|
||||
deadline_ms: deadline,
|
||||
},
|
||||
);
|
||||
}
|
||||
const version = VERSION_LINE.exec(stdout)?.[1];
|
||||
if (version === undefined)
|
||||
return failure(
|
||||
"version_unresolved",
|
||||
`${executable} --version printed no recognizable Kuna version. ${KUNA_VERSION_PROBE_REMEDIATION}`,
|
||||
{ command, executable, stdout: stdout.slice(0, 256) },
|
||||
);
|
||||
|
||||
let fingerprint: { readonly sha256: string; readonly bytes: number };
|
||||
let script: boolean;
|
||||
try {
|
||||
fingerprint = await hashStableFile(executable, options.signal);
|
||||
script = await startsWithShebang(executable);
|
||||
} catch (cause: unknown) {
|
||||
options.signal?.throwIfAborted();
|
||||
return failure(
|
||||
"executable_missing",
|
||||
`Cannot fingerprint ${executable}: ${message(cause)}`,
|
||||
{ command, executable },
|
||||
);
|
||||
}
|
||||
|
||||
return ok({
|
||||
configuration: configuration.value,
|
||||
executable,
|
||||
version,
|
||||
verified: version === KUNA_VERIFIED_VERSION,
|
||||
...fingerprint,
|
||||
script,
|
||||
});
|
||||
};
|
||||
|
||||
const startsWithShebang = async (path: string): Promise<boolean> => {
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
const prefix = Buffer.alloc(2);
|
||||
const { bytesRead } = await handle.read(prefix, 0, 2, 0);
|
||||
return bytesRead === 2 && prefix.toString("latin1") === "#!";
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,690 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
utimes,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import type { KunaInstallation } from "./KunaInstallation.js";
|
||||
import {
|
||||
exportKunaProject,
|
||||
KUNA_EXPORT_OPERATION,
|
||||
kunaExportFailure,
|
||||
readKunaExport,
|
||||
type KunaExport,
|
||||
type KunaExportStatus,
|
||||
type KunaProcessLauncher,
|
||||
} from "./KunaProjectExport.js";
|
||||
import type {
|
||||
KunaInventoryFunction,
|
||||
KunaProjectFunction,
|
||||
} from "./KunaProjectFiles.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
|
||||
/** Bumped whenever the entry layout or manifest meaning changes. */
|
||||
const CACHE_LAYOUT = 1;
|
||||
|
||||
/** A manifest names five files and their digests; anything larger is not ours. */
|
||||
const MANIFEST_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const KEY_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
|
||||
/** Inputs that determine an export's content. Each one is part of the cache key. */
|
||||
export interface KunaProjectKeyInputs {
|
||||
readonly targetSha256: string;
|
||||
readonly kunaSha256: string;
|
||||
readonly kunaVersion: string;
|
||||
/**
|
||||
* `--jobs 1` is Kuna's reference answer; more jobs decompile without
|
||||
* callee-first prototypes and change some bodies.
|
||||
*/
|
||||
readonly jobs: number;
|
||||
}
|
||||
|
||||
/** Content address of one export: every input that can change its bytes. */
|
||||
export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string =>
|
||||
createHash("sha256")
|
||||
.update(
|
||||
canonicalize({
|
||||
layout: CACHE_LAYOUT,
|
||||
target_sha256: inputs.targetSha256,
|
||||
kuna_sha256: inputs.kunaSha256,
|
||||
kuna_version: inputs.kunaVersion,
|
||||
export: { command: "decompile-project", jobs: inputs.jobs },
|
||||
}) ?? "",
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
const fileSchema = z.strictObject({
|
||||
path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(SHA256_PATTERN),
|
||||
});
|
||||
|
||||
const manifestSchema = z.strictObject({
|
||||
layout: z.literal(CACHE_LAYOUT),
|
||||
key: z.string().regex(KEY_PATTERN),
|
||||
inputs: z.strictObject({
|
||||
target_sha256: z.string().regex(SHA256_PATTERN),
|
||||
kuna_sha256: z.string().regex(SHA256_PATTERN),
|
||||
kuna_version: z.string().min(1),
|
||||
jobs: z.number().int().positive(),
|
||||
}),
|
||||
files: z.strictObject({
|
||||
c: fileSchema,
|
||||
h: fileSchema,
|
||||
asm: fileSchema,
|
||||
readme: fileSchema,
|
||||
inventory: fileSchema,
|
||||
}),
|
||||
summary: z.strictObject({
|
||||
decompiled: z.number().int().nonnegative(),
|
||||
failed: z.number().int().nonnegative(),
|
||||
}),
|
||||
created_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
/** Immutable description of one published export, written once at publish. */
|
||||
export type KunaProjectManifest = z.infer<typeof manifestSchema>;
|
||||
|
||||
/** One verified, published export. */
|
||||
export interface KunaProject {
|
||||
readonly key: string;
|
||||
/** Directory holding the export's `.c`, `.h`, `.asm`, README and inventory. */
|
||||
readonly directory: string;
|
||||
readonly manifest: KunaProjectManifest;
|
||||
/** Decompiled function blocks in `.c` order. */
|
||||
readonly functions: readonly KunaProjectFunction[];
|
||||
/** Every callable entry Kuna found, including imports it does not decompile. */
|
||||
readonly inventory: readonly KunaInventoryFunction[];
|
||||
}
|
||||
|
||||
/** One request to produce or reuse the export for a target. */
|
||||
export interface KunaProjectRequest {
|
||||
readonly installation: KunaInstallation;
|
||||
/** Caller-selected target path; REA decompiles a verified copy of it. */
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly onStatus?: (status: KunaExportStatus) => void;
|
||||
}
|
||||
|
||||
interface StagedResult {
|
||||
readonly result: Result<KunaProject, AnalysisError>;
|
||||
/** Set when an owned process may still write into staging. */
|
||||
readonly retainStaging: boolean;
|
||||
}
|
||||
|
||||
interface InflightBuild {
|
||||
readonly key: string;
|
||||
readonly promise: Promise<Result<KunaProject, AnalysisError>>;
|
||||
readonly controller: AbortController;
|
||||
readonly listeners: Set<(status: KunaExportStatus) => void>;
|
||||
waiters: number;
|
||||
}
|
||||
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
||||
|
||||
/** A file an entry's manifest names is gone, replaced or changed under it. */
|
||||
const isDamaged = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
["ENOENT", "ENOTDIR", "EISDIR", "ENOTFILE", "ESTALE"].includes(
|
||||
String(cause.code),
|
||||
);
|
||||
|
||||
const isPublishedByAnother = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "EEXIST" || cause.code === "ENOTEMPTY");
|
||||
|
||||
const parseManifest = (text: string): KunaProjectManifest | undefined => {
|
||||
try {
|
||||
const parsed = manifestSchema.safeParse(JSON.parse(text));
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const manifestBytes = (manifest: KunaProjectManifest): number =>
|
||||
Object.values(manifest.files).reduce((sum, file) => sum + file.bytes, 0);
|
||||
|
||||
/**
|
||||
* REA-owned on-disk cache of `kuna decompile-project` exports.
|
||||
*
|
||||
* Layout under the root: `projects/<key>/` holds published entries
|
||||
* (`manifest.json` plus the export and inventory in `project/`), `staging/`
|
||||
* holds builds whose processes may still be live, `trash/` holds retired
|
||||
* workspaces and evicted entries awaiting removal,
|
||||
* and `home/` is Kuna's own HOME so its spec cache stays inside REA's
|
||||
* ownership.
|
||||
*
|
||||
* Entries are content-addressed and immutable. A build runs in private
|
||||
* staging and is published by one directory rename, so a reader sees a
|
||||
* complete entry or none, and concurrent publishers of one key keep the first.
|
||||
* File digests are recorded once at publish and checked on every first read.
|
||||
* Recency is the entry directory's mtime; publishing evicts the least recently
|
||||
* used entries above the size cap, never an entry pinned by this process.
|
||||
*/
|
||||
export class KunaProjectCache {
|
||||
readonly #root: string;
|
||||
readonly #maxBytes: number;
|
||||
readonly #launcher: KunaProcessLauncher | undefined;
|
||||
/** Builds new requests may join, at most one per key. */
|
||||
readonly #inflight = new Map<string, InflightBuild>();
|
||||
/** Every build not yet settled, including abandoned ones a retry replaced. */
|
||||
readonly #unfinished = new Set<InflightBuild>();
|
||||
readonly #verified = new Map<string, KunaProject>();
|
||||
readonly #pins = new Map<string, number>();
|
||||
/** One eviction decision at a time; exports themselves remain concurrent. */
|
||||
#eviction: Promise<void> = Promise.resolve();
|
||||
#capacity: "within_limit" | "pinned" | "building" = "within_limit";
|
||||
#maintenance: Promise<void> | undefined;
|
||||
#maintenanceRequested = false;
|
||||
readonly #retainedProcesses: ProviderCleanupError[] = [];
|
||||
/** Only resources whose process cleanup is proven may be retried as files. */
|
||||
readonly #retainedFiles = new Map<string, ProviderCleanupError>();
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
options: {
|
||||
readonly maxBytes: number;
|
||||
readonly launcher?: KunaProcessLauncher;
|
||||
},
|
||||
) {
|
||||
this.#root = root;
|
||||
this.#maxBytes = options.maxBytes;
|
||||
this.#launcher = options.launcher;
|
||||
}
|
||||
|
||||
/** Keep an entry from eviction while this process serves it. */
|
||||
pin(key: string): () => void {
|
||||
this.#pins.set(key, (this.#pins.get(key) ?? 0) + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const count = (this.#pins.get(key) ?? 1) - 1;
|
||||
if (count === 0) {
|
||||
this.#pins.delete(key);
|
||||
if (this.#capacity !== "within_limit") this.#scheduleEviction();
|
||||
} else this.#pins.set(key, count);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the verified published entry for a key, or undefined when none is
|
||||
* usable. A corrupt or tampered entry is removed and reported as absent.
|
||||
*/
|
||||
async lookup(
|
||||
key: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KunaProject | undefined> {
|
||||
const verified = this.#verified.get(key);
|
||||
if (verified !== undefined) {
|
||||
// Another process may have evicted it since this one verified it.
|
||||
if (await this.#touch(key)) return verified;
|
||||
this.#verified.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
const entry = join(this.#root, "projects", key);
|
||||
let text: string;
|
||||
try {
|
||||
text = await this.#readBounded(join(entry, "manifest.json"));
|
||||
} catch (cause: unknown) {
|
||||
if (isMissing(cause)) return undefined;
|
||||
if (!(cause instanceof RangeError)) throw cause;
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
const manifest = parseManifest(text);
|
||||
if (manifest?.key !== key) {
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
const directory = join(entry, "project");
|
||||
for (const file of Object.values(manifest.files)) {
|
||||
let observed: { readonly sha256: string; readonly bytes: number };
|
||||
try {
|
||||
observed = await hashStableFile(join(directory, file.path), signal);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
if (!isDamaged(cause)) throw cause;
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
if (observed.sha256 !== file.sha256 || observed.bytes !== file.bytes) {
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const parsed = await readKunaExport(
|
||||
directory,
|
||||
manifest.files,
|
||||
manifest.summary,
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
const project = { key, directory, manifest, ...parsed.value };
|
||||
if (!(await this.#touch(key))) return undefined;
|
||||
this.#verified.set(key, project);
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the entry for a request, building and publishing it when absent.
|
||||
* Concurrent requests for one key share one Kuna run; the run is cancelled
|
||||
* only when every waiting request has been cancelled.
|
||||
*/
|
||||
async build(
|
||||
request: KunaProjectRequest,
|
||||
): Promise<Result<KunaProject, AnalysisError>> {
|
||||
if (request.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION));
|
||||
const key = kunaProjectKey({
|
||||
targetSha256: request.targetSha256,
|
||||
kunaSha256: request.installation.sha256,
|
||||
kunaVersion: request.installation.version,
|
||||
jobs: request.installation.configuration.jobs,
|
||||
});
|
||||
const running = this.#inflight.get(key);
|
||||
// A build whose waiters all left is winding down; a new request starts afresh.
|
||||
if (running !== undefined && !running.controller.signal.aborted)
|
||||
return this.#await(running, request);
|
||||
const controller = new AbortController();
|
||||
const listeners = new Set<(status: KunaExportStatus) => void>();
|
||||
const promise = this.#produce(key, request, controller.signal, (status) => {
|
||||
for (const listener of listeners) listener(status);
|
||||
});
|
||||
const created: InflightBuild = {
|
||||
key,
|
||||
promise,
|
||||
controller,
|
||||
listeners,
|
||||
waiters: 0,
|
||||
};
|
||||
this.#inflight.set(key, created);
|
||||
this.#unfinished.add(created);
|
||||
void promise.then(() => {
|
||||
this.#unfinished.delete(created);
|
||||
if (this.#inflight.get(key) === created) this.#inflight.delete(key);
|
||||
if (this.#unfinished.size === 0 && this.#capacity === "building")
|
||||
this.#scheduleEviction();
|
||||
});
|
||||
return this.#await(created, request);
|
||||
}
|
||||
|
||||
/** Wait for builds, retry safe file removal, and report unresolved cleanup. */
|
||||
async close(): Promise<Result<null, ProviderCleanupError>> {
|
||||
await Promise.allSettled(
|
||||
[...this.#unfinished].map((entry) => entry.promise),
|
||||
);
|
||||
while (this.#maintenance !== undefined) await this.#maintenance;
|
||||
for (const path of [...this.#retainedFiles.keys()])
|
||||
await this.#removeRetired(path).catch(() => undefined);
|
||||
const retained = [
|
||||
...this.#retainedProcesses,
|
||||
...this.#retainedFiles.values(),
|
||||
];
|
||||
if (retained.length === 0) return ok(null);
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
retained.flatMap((entry) => entry.cleanupResources),
|
||||
{
|
||||
reason: retained
|
||||
.map((entry) =>
|
||||
String(entry.diagnostics?.["reason"] ?? entry.message),
|
||||
)
|
||||
.join("; "),
|
||||
},
|
||||
{ operation: KUNA_EXPORT_OPERATION },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#await(
|
||||
entry: InflightBuild,
|
||||
request: KunaProjectRequest,
|
||||
): Promise<Result<KunaProject, AnalysisError>> {
|
||||
entry.waiters += 1;
|
||||
const onStatus = request.onStatus;
|
||||
// Each waiter owns its registration, even when callers reuse a callback.
|
||||
const listener =
|
||||
onStatus === undefined
|
||||
? undefined
|
||||
: (status: KunaExportStatus) => onStatus(status);
|
||||
if (listener !== undefined) entry.listeners.add(listener);
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const leave = () => {
|
||||
if (listener !== undefined) entry.listeners.delete(listener);
|
||||
entry.waiters -= 1;
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
leave();
|
||||
if (entry.waiters === 0) entry.controller.abort();
|
||||
resolve(err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION)));
|
||||
};
|
||||
request.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
void entry.promise.then((result) => {
|
||||
request.signal?.removeEventListener("abort", onAbort);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
leave();
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #produce(
|
||||
key: string,
|
||||
request: KunaProjectRequest,
|
||||
signal: AbortSignal,
|
||||
onStatus: (status: KunaExportStatus) => void,
|
||||
): Promise<Result<KunaProject, AnalysisError>> {
|
||||
let staging: string;
|
||||
try {
|
||||
const existing = await this.lookup(key, signal);
|
||||
if (existing !== undefined) return ok(existing);
|
||||
await Promise.all(
|
||||
["projects", "staging", "trash", "home"].map((name) =>
|
||||
mkdir(join(this.#root, name), { recursive: true, mode: 0o700 }),
|
||||
),
|
||||
);
|
||||
await this.#sweep(signal);
|
||||
staging = await mkdtemp(join(this.#root, "staging", "build-"));
|
||||
} catch (cause: unknown) {
|
||||
return err(kunaExportFailure(cause, signal));
|
||||
}
|
||||
const built = await this.#exportAndPublish(
|
||||
key,
|
||||
staging,
|
||||
request,
|
||||
signal,
|
||||
onStatus,
|
||||
).catch((cause: unknown): StagedResult => ({
|
||||
result: err(kunaExportFailure(cause, signal)),
|
||||
retainStaging: false,
|
||||
}));
|
||||
if (built.retainStaging) return built.result;
|
||||
let cleanupPath = staging;
|
||||
try {
|
||||
// Only a proven-stopped build may enter trash. A failed write, crash or
|
||||
// lost cleanup identity therefore never makes live staging reclaimable.
|
||||
const retired = join(this.#root, "trash", `build-${randomUUID()}`);
|
||||
await rename(staging, retired);
|
||||
cleanupPath = retired;
|
||||
await this.#removeRetired(retired);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ProviderCleanupError) return err(cause);
|
||||
return err(this.#retainFileCleanup(cleanupPath, cause));
|
||||
}
|
||||
if (built.result.ok)
|
||||
// The entry is published either way; a failed eviction retries on the next publish.
|
||||
await this.#evict(key).catch(() => undefined);
|
||||
return built.result;
|
||||
}
|
||||
|
||||
async #exportAndPublish(
|
||||
key: string,
|
||||
staging: string,
|
||||
request: KunaProjectRequest,
|
||||
signal: AbortSignal,
|
||||
onStatus: (status: KunaExportStatus) => void,
|
||||
): Promise<StagedResult> {
|
||||
const outcome = await exportKunaProject({
|
||||
installation: request.installation,
|
||||
targetPath: request.targetPath,
|
||||
targetSha256: request.targetSha256,
|
||||
workspace: staging,
|
||||
output: join(staging, "entry", "project"),
|
||||
signal,
|
||||
onStatus,
|
||||
launcher: this.#launcher,
|
||||
});
|
||||
if (outcome.kind === "retained") {
|
||||
this.#retainedProcesses.push(outcome.error);
|
||||
return { result: err(outcome.error), retainStaging: true };
|
||||
}
|
||||
return {
|
||||
result: outcome.result.ok
|
||||
? await this.#publish(
|
||||
key,
|
||||
staging,
|
||||
request,
|
||||
outcome.result.value,
|
||||
signal,
|
||||
)
|
||||
: outcome.result,
|
||||
retainStaging: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Record the manifest and publish the staged export under its key. */
|
||||
async #publish(
|
||||
key: string,
|
||||
staging: string,
|
||||
request: KunaProjectRequest,
|
||||
exported: KunaExport,
|
||||
signal: AbortSignal,
|
||||
): Promise<Result<KunaProject, AnalysisError>> {
|
||||
const manifest: KunaProjectManifest = {
|
||||
layout: CACHE_LAYOUT,
|
||||
key,
|
||||
inputs: {
|
||||
target_sha256: request.targetSha256,
|
||||
kuna_sha256: request.installation.sha256,
|
||||
kuna_version: request.installation.version,
|
||||
jobs: request.installation.configuration.jobs,
|
||||
},
|
||||
files: exported.files,
|
||||
summary: exported.summary,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const total = manifestBytes(manifest);
|
||||
if (total > this.#maxBytes)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
KUNA_EXPORT_OPERATION,
|
||||
"capacity",
|
||||
`This export occupies ${String(total)} bytes, more than the whole Kuna cache may hold.`,
|
||||
{ cache_max_bytes: this.#maxBytes, export_bytes: total },
|
||||
{
|
||||
remediationAction:
|
||||
"Raise REA_KUNA_CACHE_MAX_BYTES above the export size, or analyze this target with another provider.",
|
||||
},
|
||||
),
|
||||
);
|
||||
await writeFileAtomic(
|
||||
join(staging, "entry", "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const destination = join(this.#root, "projects", key);
|
||||
try {
|
||||
await rename(join(staging, "entry"), destination);
|
||||
} catch (cause: unknown) {
|
||||
if (!isPublishedByAnother(cause)) throw cause;
|
||||
const winner = await this.lookup(key, signal);
|
||||
if (winner !== undefined) return ok(winner);
|
||||
throw cause;
|
||||
}
|
||||
const project = {
|
||||
key,
|
||||
directory: join(destination, "project"),
|
||||
manifest,
|
||||
functions: exported.functions,
|
||||
inventory: exported.inventory,
|
||||
};
|
||||
this.#verified.set(key, project);
|
||||
return ok(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove retired resources only. Age and a missing marker cannot establish
|
||||
* that a process stopped, so unresolved staging is never swept.
|
||||
*/
|
||||
async #sweep(signal: AbortSignal): Promise<void> {
|
||||
for (const name of await readdir(join(this.#root, "trash"))) {
|
||||
signal.throwIfAborted();
|
||||
await this.#removeRetired(join(this.#root, "trash", name)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Evict least recently used entries above the cap, keeping pinned ones. */
|
||||
#evict(keep: string | null): Promise<void> {
|
||||
const eviction = this.#eviction.then(() => this.#evictUnpinned(keep));
|
||||
this.#eviction = eviction.catch(() => undefined);
|
||||
return eviction;
|
||||
}
|
||||
|
||||
/** Coalesce deferred reclamation after callers have had a turn to pin results. */
|
||||
#scheduleEviction(): void {
|
||||
this.#maintenanceRequested = true;
|
||||
if (this.#maintenance !== undefined) return;
|
||||
const maintenance = (async () => {
|
||||
while (this.#maintenanceRequested) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
this.#maintenanceRequested = false;
|
||||
await this.#evict(null).catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
this.#maintenance = maintenance;
|
||||
void maintenance.then(() => {
|
||||
if (this.#maintenance === maintenance) this.#maintenance = undefined;
|
||||
if (this.#maintenanceRequested) this.#scheduleEviction();
|
||||
});
|
||||
}
|
||||
|
||||
async #evictUnpinned(keep: string | null): Promise<void> {
|
||||
// A successful build must reach its callers before they can pin its entry.
|
||||
// Protect every unfinished build while reading this eviction snapshot.
|
||||
const building = new Set([...this.#unfinished].map(({ key }) => key));
|
||||
const entries: { key: string; bytes: number; mtimeMs: number }[] = [];
|
||||
for (const name of await readdir(join(this.#root, "projects"))) {
|
||||
if (!KEY_PATTERN.test(name)) continue;
|
||||
const path = join(this.#root, "projects", name);
|
||||
const status = await stat(path).catch(() => undefined);
|
||||
if (status === undefined) continue;
|
||||
const manifest = await this.#readBounded(join(path, "manifest.json"))
|
||||
.then(parseManifest)
|
||||
.catch(() => undefined);
|
||||
// An owned entry without a valid manifest is unusable; evict it first.
|
||||
entries.push(
|
||||
manifest === undefined
|
||||
? { key: name, bytes: 0, mtimeMs: -1 }
|
||||
: {
|
||||
key: name,
|
||||
bytes: manifestBytes(manifest),
|
||||
mtimeMs: status.mtimeMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
let deferredByBuild = false;
|
||||
for (const entry of entries.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
|
||||
if (total <= this.#maxBytes && entry.mtimeMs >= 0) break;
|
||||
if (building.has(entry.key) || this.#inflight.has(entry.key)) {
|
||||
deferredByBuild = true;
|
||||
continue;
|
||||
}
|
||||
if (entry.key === keep || this.#pins.has(entry.key)) continue;
|
||||
await this.#discard(entry.key);
|
||||
total -= entry.bytes;
|
||||
}
|
||||
this.#capacity =
|
||||
total <= this.#maxBytes
|
||||
? "within_limit"
|
||||
: deferredByBuild
|
||||
? "building"
|
||||
: "pinned";
|
||||
}
|
||||
|
||||
/** Atomically retire one owned entry, then remove it. */
|
||||
async #discard(key: string): Promise<void> {
|
||||
this.#verified.delete(key);
|
||||
const trash = join(this.#root, "trash", `${key}-${randomUUID()}`);
|
||||
try {
|
||||
await mkdir(join(this.#root, "trash"), { recursive: true, mode: 0o700 });
|
||||
await rename(join(this.#root, "projects", key), trash);
|
||||
} catch (cause: unknown) {
|
||||
if (isMissing(cause)) return;
|
||||
throw cause;
|
||||
}
|
||||
await this.#removeRetired(trash);
|
||||
}
|
||||
|
||||
/** Keep each failed removal owned until that path is successfully removed. */
|
||||
async #removeRetired(path: string): Promise<void> {
|
||||
try {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
this.#retainedFiles.delete(path);
|
||||
} catch (cause: unknown) {
|
||||
throw this.#retainFileCleanup(path, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark an entry recently used; false when it is no longer published. */
|
||||
async #touch(key: string): Promise<boolean> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await utimes(join(this.#root, "projects", key), now, now);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// Recency is advisory; only a missing entry changes the answer.
|
||||
return !isMissing(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async #readBounded(path: string): Promise<string> {
|
||||
const status = await stat(path);
|
||||
if (status.size > MANIFEST_MAX_BYTES)
|
||||
throw new RangeError(
|
||||
`${path} exceeds ${String(MANIFEST_MAX_BYTES)} bytes`,
|
||||
);
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
|
||||
#retainFileCleanup(path: string, cause: unknown): ProviderCleanupError {
|
||||
const error = new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[path],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation: KUNA_EXPORT_OPERATION, cause },
|
||||
);
|
||||
this.#retainedFiles.set(path, error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
ProviderProcessSnapshot,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import {
|
||||
kunaChildEnvironment,
|
||||
type KunaInstallation,
|
||||
} from "./KunaInstallation.js";
|
||||
import {
|
||||
parseKunaExportSummary,
|
||||
parseKunaInventory,
|
||||
scanKunaProjectSource,
|
||||
type KunaExportSummary,
|
||||
type KunaInventoryFunction,
|
||||
type KunaProjectFunction,
|
||||
} from "./KunaProjectFiles.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
|
||||
/** Operation name every export and cache failure reports. */
|
||||
export const KUNA_EXPORT_OPERATION = "kuna:decompile-project";
|
||||
|
||||
/** Kuna prints one summary line per export; notes on a large image stay far below this. */
|
||||
const DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* `kuna functions --json` costs ~210 bytes per function; this admits about
|
||||
* 600,000 functions, above the 392,814 of the largest image Kuna documents.
|
||||
*/
|
||||
const INVENTORY_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
const INVENTORY_FILE = "functions.json";
|
||||
|
||||
/** Identity of one file an export consists of. */
|
||||
export interface KunaExportFile {
|
||||
readonly path: string;
|
||||
readonly bytes: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
/** The files of one export, by role. */
|
||||
export interface KunaExportFiles {
|
||||
readonly c: KunaExportFile;
|
||||
readonly h: KunaExportFile;
|
||||
readonly asm: KunaExportFile;
|
||||
readonly readme: KunaExportFile;
|
||||
readonly inventory: KunaExportFile;
|
||||
}
|
||||
|
||||
/** One validated export: its files, Kuna's counts and the facts parsed from them. */
|
||||
export interface KunaExport {
|
||||
readonly files: KunaExportFiles;
|
||||
readonly summary: KunaExportSummary;
|
||||
/** Decompiled function blocks in `.c` order. */
|
||||
readonly functions: readonly KunaProjectFunction[];
|
||||
/** Every callable entry Kuna found, including imports it does not decompile. */
|
||||
readonly inventory: readonly KunaInventoryFunction[];
|
||||
}
|
||||
|
||||
/** The step an export is in; Kuna itself reports no progress until it finishes. */
|
||||
export interface KunaExportStatus {
|
||||
readonly phase: "inventory" | "decompiling" | "sealing";
|
||||
/** Callable entries found by the inventory, once known. */
|
||||
readonly inventoryFunctions: number | null;
|
||||
}
|
||||
|
||||
/** Test seam for the owned process launcher; production uses the default. */
|
||||
export type KunaProcessLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/**
|
||||
* How an export ended. `retained` means an owned Kuna process could not be
|
||||
* proven stopped, so it may still write into the workspace, which therefore
|
||||
* stays with the caller's cleanup owner.
|
||||
*/
|
||||
export type KunaExportOutcome =
|
||||
| {
|
||||
readonly kind: "done";
|
||||
readonly result: Result<KunaExport, AnalysisError>;
|
||||
}
|
||||
| { readonly kind: "retained"; readonly error: ProviderCleanupError };
|
||||
|
||||
/** One export of a target into a private workspace. */
|
||||
export interface KunaExportRequest {
|
||||
readonly installation: KunaInstallation;
|
||||
/** Caller-selected target path; Kuna decompiles a verified copy of it. */
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
/** Private directory for the copy, Kuna's temporary files and its output. */
|
||||
readonly workspace: string;
|
||||
/** Directory, inside the workspace, that receives the export's files. */
|
||||
readonly output: string;
|
||||
readonly signal: AbortSignal;
|
||||
readonly onStatus: (status: KunaExportStatus) => void;
|
||||
readonly launcher?: KunaProcessLauncher | undefined;
|
||||
}
|
||||
|
||||
const message = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
||||
|
||||
/** Map an unexpected failure to a typed one, preferring cancellation. */
|
||||
export const kunaExportFailure = (
|
||||
cause: unknown,
|
||||
signal: AbortSignal,
|
||||
): AnalysisError => {
|
||||
if (signal.aborted)
|
||||
return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause });
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
return new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
cause,
|
||||
userMessage: `Kuna project cache failed: ${message(cause)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const unusable = (reason: string): KunaExportOutcome => ({
|
||||
kind: "done",
|
||||
result: err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
userMessage: `Kuna's project export is not usable: ${reason}`,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Verify the selected installation at each execution and publication boundary. */
|
||||
const verifyKunaExecutable = async (
|
||||
installation: KunaInstallation,
|
||||
signal: AbortSignal,
|
||||
): Promise<Result<null, ProviderAdapterError>> => {
|
||||
const executable = await hashStableFile(installation.executable, signal);
|
||||
if (executable.sha256 === installation.sha256) return ok(null);
|
||||
return err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
userMessage: `${installation.executable} changed after readiness inspection; reopen the target so REA probes the current Kuna executable.`,
|
||||
diagnostics: {
|
||||
expected_sha256: installation.sha256,
|
||||
observed_sha256: executable.sha256,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Inventory and export one target with Kuna, then validate the result.
|
||||
*
|
||||
* Kuna runs on a copy hashed while copying, so the export always describes
|
||||
* the digest the caller selected. Both Kuna runs share one deadline.
|
||||
*/
|
||||
export const exportKunaProject = async (
|
||||
request: KunaExportRequest,
|
||||
): Promise<KunaExportOutcome> => {
|
||||
const { installation, signal } = request;
|
||||
const { configuration } = installation;
|
||||
const deadline = Date.now() + configuration.projectTimeoutMs;
|
||||
const input = join(request.workspace, "input", basename(request.targetPath));
|
||||
await mkdir(join(request.workspace, "input"), { mode: 0o700 });
|
||||
await mkdir(request.output, { recursive: true, mode: 0o700 });
|
||||
|
||||
const copied = await copyAndHash(request.targetPath, input, signal);
|
||||
if (copied !== request.targetSha256)
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new AnalysisArtifactChangedError(
|
||||
KUNA_EXPORT_OPERATION,
|
||||
request.targetPath,
|
||||
"the target no longer matches the digest selected for analysis",
|
||||
),
|
||||
),
|
||||
};
|
||||
await prepareProcessOwnershipInspection(signal);
|
||||
const run = (arguments_: readonly string[], diagnosticBytes: number) =>
|
||||
runKuna(request, arguments_, { deadline, diagnosticBytes });
|
||||
|
||||
request.onStatus({ phase: "inventory", inventoryFunctions: null });
|
||||
const listed = await run(["functions", input, "--json"], INVENTORY_BYTES);
|
||||
if (!listed.ok) return listed.outcome;
|
||||
const inventory = parseKunaInventory(listed.snapshot.stdout.text);
|
||||
if (!inventory.ok) return unusable(inventory.error);
|
||||
if (listed.snapshot.stdout.invalidUtf8 === true)
|
||||
return unusable("the function inventory is not valid UTF-8");
|
||||
await writeFile(
|
||||
join(request.output, INVENTORY_FILE),
|
||||
listed.snapshot.stdout.text,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
request.onStatus({
|
||||
phase: "decompiling",
|
||||
inventoryFunctions: inventory.value.length,
|
||||
});
|
||||
const exported = await run(
|
||||
[
|
||||
"decompile-project",
|
||||
input,
|
||||
"-o",
|
||||
request.output,
|
||||
"--jobs",
|
||||
String(configuration.jobs),
|
||||
],
|
||||
DIAGNOSTIC_BYTES,
|
||||
);
|
||||
if (!exported.ok) return exported.outcome;
|
||||
|
||||
request.onStatus({
|
||||
phase: "sealing",
|
||||
inventoryFunctions: inventory.value.length,
|
||||
});
|
||||
const summary = parseKunaExportSummary(exported.snapshot.stdout.text);
|
||||
if (summary === undefined)
|
||||
return unusable(
|
||||
"Kuna did not print its `functions: N ok, M failed` summary.",
|
||||
);
|
||||
const stem = basename(request.targetPath);
|
||||
const names = {
|
||||
c: `${stem}.c`,
|
||||
h: `${stem}.h`,
|
||||
asm: `${stem}.asm`,
|
||||
readme: "README.md",
|
||||
inventory: INVENTORY_FILE,
|
||||
} as const;
|
||||
const files: Partial<Record<keyof KunaExportFiles, KunaExportFile>> = {};
|
||||
for (const [role, name] of Object.entries(names) as [
|
||||
keyof KunaExportFiles,
|
||||
string,
|
||||
][]) {
|
||||
const path = join(request.output, name);
|
||||
const status = await lstat(path).catch((cause: unknown) => {
|
||||
if (isMissing(cause)) return undefined;
|
||||
throw cause;
|
||||
});
|
||||
if (status === undefined || !status.isFile())
|
||||
return unusable(`Kuna's export has no regular ${name}.`);
|
||||
files[role] = { path: name, ...(await hashStableFile(path, signal)) };
|
||||
}
|
||||
const complete = files as KunaExportFiles;
|
||||
const parsed = await readKunaExport(
|
||||
request.output,
|
||||
complete,
|
||||
summary,
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) return unusable(parsed.error);
|
||||
const identity = await verifyKunaExecutable(installation, signal);
|
||||
if (!identity.ok) return { kind: "done", result: identity };
|
||||
return {
|
||||
kind: "done",
|
||||
result: ok({ files: complete, summary, ...parsed.value }),
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse an export's function blocks and inventory from its own files. */
|
||||
export const readKunaExport = async (
|
||||
directory: string,
|
||||
files: KunaExportFiles,
|
||||
summary: KunaExportSummary,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
readonly functions: readonly KunaProjectFunction[];
|
||||
readonly inventory: readonly KunaInventoryFunction[];
|
||||
},
|
||||
string
|
||||
>
|
||||
> => {
|
||||
const functions = await scanKunaProjectSource(
|
||||
createReadStream(
|
||||
join(directory, files.c.path),
|
||||
signal === undefined ? {} : { signal },
|
||||
),
|
||||
summary,
|
||||
);
|
||||
if (!functions.ok) return functions;
|
||||
const inventory = parseKunaInventory(
|
||||
await readFile(join(directory, files.inventory.path), {
|
||||
encoding: "utf8",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
}),
|
||||
);
|
||||
if (!inventory.ok) return inventory;
|
||||
return ok({ functions: functions.value, inventory: inventory.value });
|
||||
};
|
||||
|
||||
const runKuna = async (
|
||||
request: KunaExportRequest,
|
||||
arguments_: readonly string[],
|
||||
limits: { readonly deadline: number; readonly diagnosticBytes: number },
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly snapshot: ProviderProcessSnapshot }
|
||||
| { readonly ok: false; readonly outcome: KunaExportOutcome }
|
||||
> => {
|
||||
const { installation, signal } = request;
|
||||
try {
|
||||
const identity = await verifyKunaExecutable(installation, signal);
|
||||
if (!identity.ok)
|
||||
return { ok: false, outcome: { kind: "done", result: identity } };
|
||||
const snapshot = await runOwnedCommand(
|
||||
{
|
||||
command: installation.executable,
|
||||
arguments: arguments_,
|
||||
runId: `rea-kuna-${randomUUID()}`,
|
||||
// A script's process reports its interpreter, not the configured path.
|
||||
...(installation.script ? { expectedCommand: null } : {}),
|
||||
cwd: request.workspace,
|
||||
hostEnvironment: kunaChildEnvironment(installation.configuration),
|
||||
env: { TMPDIR: request.workspace },
|
||||
},
|
||||
{
|
||||
timeoutMs: Math.max(1, limits.deadline - Date.now()),
|
||||
diagnosticBytes: limits.diagnosticBytes,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
...(request.launcher === undefined
|
||||
? {}
|
||||
: { launcher: request.launcher }),
|
||||
},
|
||||
);
|
||||
return { ok: true, snapshot };
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
outcome: commandFailure(cause, request, arguments_[0] ?? "", limits),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Copy the target into the workspace and return the SHA-256 of exactly the bytes copied. */
|
||||
const copyAndHash = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
await pipeline(
|
||||
createReadStream(source),
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
createWriteStream(destination, { flags: "wx", mode: 0o600 }),
|
||||
{ signal },
|
||||
);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */
|
||||
const commandFailure = (
|
||||
cause: unknown,
|
||||
request: KunaExportRequest,
|
||||
subcommand: string,
|
||||
limits: { readonly diagnosticBytes: number },
|
||||
): KunaExportOutcome => {
|
||||
const { installation, signal } = request;
|
||||
if (!(cause instanceof OwnedCommandFailure))
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
signal.aborted
|
||||
? new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause })
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
cause,
|
||||
userMessage: `Cannot run ${installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
const snapshot = cause.snapshot;
|
||||
const capturedOutput =
|
||||
snapshot === null
|
||||
? undefined
|
||||
: {
|
||||
// An inventory is evidence-sized, not diagnostic-sized; keep only its tail.
|
||||
stdout: snapshot.stdout.text.slice(-4096),
|
||||
stderr: snapshot.stderr.text,
|
||||
truncated:
|
||||
snapshot.diagnosticTruncated === true ||
|
||||
snapshot.outputIncomplete === true ||
|
||||
snapshot.stdout.text.length > 4096,
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
};
|
||||
const details = {
|
||||
cause,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
};
|
||||
if (cause.cleanupFailure !== null)
|
||||
return {
|
||||
kind: "retained",
|
||||
error: new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[...cause.resources, request.workspace],
|
||||
{ reason: cause.cleanupFailure },
|
||||
{ operation: KUNA_EXPORT_OPERATION, cause },
|
||||
),
|
||||
};
|
||||
if (cause.reason === "cancelled")
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION, details)),
|
||||
};
|
||||
if (cause.reason === "timeout")
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new AnalysisTimeoutError(
|
||||
KUNA_EXPORT_OPERATION,
|
||||
installation.configuration.projectTimeoutMs,
|
||||
details,
|
||||
),
|
||||
),
|
||||
};
|
||||
if (cause.reason === "output-limit")
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new AnalysisResourceConstraintError(
|
||||
KUNA_EXPORT_OPERATION,
|
||||
"capacity",
|
||||
`kuna ${subcommand} wrote more than ${String(limits.diagnosticBytes)} bytes of output; the export was not retained.`,
|
||||
{ output_limit_bytes: limits.diagnosticBytes },
|
||||
details,
|
||||
),
|
||||
),
|
||||
};
|
||||
const exitCode = capturedOutput?.exit_code;
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
|
||||
...details,
|
||||
userMessage: `kuna ${subcommand} failed${exitCode === undefined || exitCode === null ? "" : ` with exit code ${String(exitCode)}`}: ${capturedOutput?.stderr.trim().split("\n").at(-1) ?? cause.message}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/**
|
||||
* One function block in a `kuna decompile-project` `.c` file. The span covers
|
||||
* its `// Function:` header through the blank line that ends the block, which
|
||||
* is how REA serves one function without reading the whole file.
|
||||
*/
|
||||
export interface KunaProjectFunction {
|
||||
readonly name: string;
|
||||
/** Kuna's display address, a lowercase `0x…` virtual address. */
|
||||
readonly address: string;
|
||||
readonly cOffset: number;
|
||||
readonly cLength: number;
|
||||
/** Kuna's own failure for this function; its block is then the header alone. */
|
||||
readonly error: string | null;
|
||||
}
|
||||
|
||||
// Rendered by kuna-console's `render_c`: `// Function: {name} @ 0x{addr:x}`,
|
||||
// or `... @ 0x{addr:x} (error: {message})` for a failed function. A name
|
||||
// can itself contain " @ ", so the address is the last such field.
|
||||
const HEADER = /^\/\/ Function: (.+) @ (0x[0-9a-f]+)(?: {2}\(error: (.*)\))?$/u;
|
||||
const HEADER_PREFIX = "// Function: ";
|
||||
|
||||
/** Kuna's completed-export summary on stdout: `functions: N ok, M failed`. */
|
||||
const SUMMARY = /functions: (\d+) ok, (\d+) failed/u;
|
||||
|
||||
/** Function counts Kuna reported for a finished export. */
|
||||
export interface KunaExportSummary {
|
||||
readonly decompiled: number;
|
||||
readonly failed: number;
|
||||
}
|
||||
|
||||
/** Read the completed-file summary Kuna prints after finalizing an export. */
|
||||
export const parseKunaExportSummary = (
|
||||
stdout: string,
|
||||
): KunaExportSummary | undefined => {
|
||||
const match = SUMMARY.exec(stdout);
|
||||
if (match?.[1] === undefined || match[2] === undefined) return undefined;
|
||||
return { decompiled: Number(match[1]), failed: Number(match[2]) };
|
||||
};
|
||||
|
||||
/** A header line longer than this is not one Kuna wrote. */
|
||||
const MAX_HEADER_BYTES = 64 * 1024;
|
||||
const PREFIX_BYTES = Buffer.from(HEADER_PREFIX);
|
||||
|
||||
interface Header {
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
readonly error: string | null;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a finished export's `.c` into its function blocks, reading it as a
|
||||
* stream so a very large export is never held in memory.
|
||||
*
|
||||
* The `#include` preamble precedes the first header. The header count must
|
||||
* equal the functions Kuna reported, and every address must be distinct; an
|
||||
* export that fails either check is not a usable function inventory.
|
||||
*/
|
||||
export const scanKunaProjectSource = async (
|
||||
chunks: AsyncIterable<Buffer>,
|
||||
summary: KunaExportSummary,
|
||||
): Promise<Result<readonly KunaProjectFunction[], string>> => {
|
||||
const headers: Header[] = [];
|
||||
let offset = 0;
|
||||
let lineStart = 0;
|
||||
// Bytes of the current line, kept only while it may still be a header.
|
||||
let line: Buffer[] | undefined = [];
|
||||
let lineBytes = 0;
|
||||
const endLine = (): string | undefined => {
|
||||
if (line === undefined) return undefined;
|
||||
const text = Buffer.concat(line).toString("utf8");
|
||||
if (!text.startsWith(HEADER_PREFIX)) return undefined;
|
||||
const match = HEADER.exec(text);
|
||||
if (match?.[1] === undefined || match[2] === undefined)
|
||||
return `unrecognized function header at byte ${String(lineStart)}`;
|
||||
headers.push({
|
||||
name: match[1],
|
||||
address: match[2],
|
||||
error: match[3] ?? null,
|
||||
offset: lineStart,
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
for await (const chunk of chunks) {
|
||||
let position = 0;
|
||||
while (position < chunk.length) {
|
||||
const newline = chunk.indexOf(0x0a, position);
|
||||
const end = newline === -1 ? chunk.length : newline;
|
||||
if (line !== undefined) {
|
||||
const segment = chunk.subarray(position, end);
|
||||
line.push(segment);
|
||||
lineBytes += segment.length;
|
||||
const seen = Buffer.concat(line).subarray(0, PREFIX_BYTES.length);
|
||||
if (!PREFIX_BYTES.subarray(0, seen.length).equals(seen))
|
||||
line = undefined;
|
||||
else if (lineBytes > MAX_HEADER_BYTES)
|
||||
return err(
|
||||
`function header at byte ${String(lineStart)} is too long`,
|
||||
);
|
||||
}
|
||||
if (newline === -1) break;
|
||||
const failure = endLine();
|
||||
if (failure !== undefined) return err(failure);
|
||||
position = newline + 1;
|
||||
lineStart = offset + position;
|
||||
line = [];
|
||||
lineBytes = 0;
|
||||
}
|
||||
offset += chunk.length;
|
||||
}
|
||||
const failure = endLine();
|
||||
if (failure !== undefined) return err(failure);
|
||||
const total = offset;
|
||||
const expected = summary.decompiled + summary.failed;
|
||||
if (headers.length !== expected)
|
||||
return err(
|
||||
`the .c holds ${String(headers.length)} function headers but Kuna reported ${String(expected)} functions`,
|
||||
);
|
||||
const failed = headers.filter(({ error }) => error !== null).length;
|
||||
if (failed !== summary.failed)
|
||||
return err(
|
||||
`the .c marks ${String(failed)} failed functions but Kuna reported ${String(summary.failed)}`,
|
||||
);
|
||||
const addresses = new Set<string>();
|
||||
const functions: KunaProjectFunction[] = [];
|
||||
for (const [index, header] of headers.entries()) {
|
||||
if (addresses.has(header.address))
|
||||
return err(`the .c repeats address ${header.address}`);
|
||||
addresses.add(header.address);
|
||||
const end = headers[index + 1]?.offset ?? total;
|
||||
functions.push({
|
||||
name: header.name,
|
||||
address: header.address,
|
||||
cOffset: header.offset,
|
||||
cLength: end - header.offset,
|
||||
error: header.error,
|
||||
});
|
||||
}
|
||||
return ok(functions);
|
||||
};
|
||||
|
||||
const inventorySchema = z.object({
|
||||
functions: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
address_hex: z.string().regex(/^0x[0-9a-f]+$/u),
|
||||
aliases: z.array(z.string()),
|
||||
size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** One callable entry from `kuna functions --json`, including imports Kuna does not decompile. */
|
||||
export interface KunaInventoryFunction {
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
readonly aliases: readonly string[];
|
||||
/** Inventory extent: an upper bound clipped at the next entry, or 0 when unmeasured. */
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Parse the callable-symbol inventory `kuna functions --json` writes. */
|
||||
export const parseKunaInventory = (
|
||||
text: string,
|
||||
): Result<readonly KunaInventoryFunction[], string> => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
return err("the function inventory is not JSON");
|
||||
}
|
||||
const parsed = inventorySchema.safeParse(value);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
`the function inventory does not match Kuna's document: ${parsed.error.issues[0]?.message ?? "invalid"}`,
|
||||
);
|
||||
return ok(
|
||||
parsed.data.functions.map((entry) => ({
|
||||
name: entry.name,
|
||||
address: entry.address_hex,
|
||||
aliases: entry.aliases,
|
||||
size: entry.size,
|
||||
})),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Kuna release whose CLI and project layout REA verified; others report as unverified. */
|
||||
export const KUNA_VERIFIED_VERSION = "1.833";
|
||||
|
||||
/** Stable provider ID shared by readiness, doctor and Evidence. */
|
||||
export const KUNA_PROVIDER_ID = "kuna";
|
||||
|
||||
/** Remediation when `REA_KUNA_COMMAND` is missing or not an absolute path. */
|
||||
export const KUNA_COMMAND_REMEDIATION = `Set REA_KUNA_COMMAND to the absolute path of a caller-supplied kuna executable (verified with ${KUNA_VERIFIED_VERSION}). REA does not download, install or update Kuna.`;
|
||||
|
||||
/** Remediation when the host cannot supervise a Kuna process tree. */
|
||||
export const KUNA_UNSUPPORTED_HOST_REMEDIATION =
|
||||
"Use Linux or macOS for the Kuna provider. Windows process-tree ownership for Kuna is not verified.";
|
||||
|
||||
/** Remediation when `kuna --version` cannot establish the installed release. */
|
||||
export const KUNA_VERSION_PROBE_REMEDIATION =
|
||||
"Run the selected executable with --version: it must exit promptly and print `kuna <major>.<minor>`.";
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** One function the fake exports; `error` renders Kuna's failed-function header. */
|
||||
export interface FakeKunaFunction {
|
||||
readonly name: string;
|
||||
readonly addr: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** Scripted behavior of the fake `kuna` executable for one test. */
|
||||
export interface FakeKunaBehavior {
|
||||
/** Text printed by `--version`; `exit` fails the probe instead. */
|
||||
readonly version?:
|
||||
| { readonly stdout: string }
|
||||
| { readonly exit: number }
|
||||
| { readonly ignoreTerm: true };
|
||||
readonly inventory?: "ok" | "not-json" | "hang";
|
||||
readonly project?:
|
||||
| "ok"
|
||||
| "exit-failure"
|
||||
| "hang"
|
||||
| "miscounted"
|
||||
| "silent"
|
||||
| "block-manifest";
|
||||
readonly functions?: readonly FakeKunaFunction[];
|
||||
/** Extra `.asm` bytes, for capacity tests. */
|
||||
readonly asmPadding?: number;
|
||||
/** Milliseconds the export takes before writing its files. */
|
||||
readonly delayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fake reads its behavior from `<script>.json` and appends one line per
|
||||
* invocation to `<script>.calls`. REA gives Kuna children no PATH, so the
|
||||
* shebang names this Node binary directly. Output formats follow Kuna 1.833:
|
||||
* `functions --json` and the `// Function:` headers of `render_c`.
|
||||
*/
|
||||
const SCRIPT = String.raw`
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const behavior = JSON.parse(fs.readFileSync(__filename + ".json", "utf8"));
|
||||
fs.appendFileSync(__filename + ".calls", JSON.stringify({ pid: process.pid, argv: process.argv.slice(2), home: process.env.HOME, xdg: process.env.XDG_CACHE_HOME, path: process.env.PATH ?? null }) + "\n");
|
||||
const args = process.argv.slice(2);
|
||||
const functions = behavior.functions ?? [{ name: "main", addr: "0x1000" }, { name: "helper", addr: "0x1040" }];
|
||||
const hang = () => setInterval(() => {}, 1000);
|
||||
if (args[0] === "--version") {
|
||||
const version = behavior.version ?? { stdout: "kuna 1.833\n" };
|
||||
if ("exit" in version) { process.stderr.write("version probe failed\n"); process.exit(version.exit); }
|
||||
if ("ignoreTerm" in version) { process.on("SIGTERM", () => {}); hang(); }
|
||||
else process.stdout.write(version.stdout);
|
||||
} else if (args[0] === "functions") {
|
||||
const mode = behavior.inventory ?? "ok";
|
||||
if (mode === "hang") hang();
|
||||
else if (mode === "not-json") process.stdout.write("functions: 2\n");
|
||||
else {
|
||||
const listed = [...functions, { name: "puts", addr: "0x2000" }].map((fn) => ({ name: fn.name, address: parseInt(fn.addr, 16), address_hex: fn.addr, aliases: [], object_location: null, size: fn.name === "puts" ? 0 : 16 }));
|
||||
process.stdout.write(JSON.stringify({ binary: args[1], count: listed.length, total: listed.length, error: null, functions: listed }, null, 2) + "\n");
|
||||
}
|
||||
} else if (args[0] === "decompile-project") {
|
||||
const input = args[1];
|
||||
const output = args[args.indexOf("-o") + 1];
|
||||
const stem = path.basename(input);
|
||||
const mode = behavior.project ?? "ok";
|
||||
if (mode === "hang") hang();
|
||||
else if (mode === "exit-failure") { process.stderr.write("error: not in recognized object file format\n"); process.exit(1); }
|
||||
else setTimeout(() => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
let c = '#include "' + stem + '.h"\n\n';
|
||||
functions.forEach((fn, index) => {
|
||||
c += fn.error === undefined
|
||||
? "// Function: " + fn.name + " @ " + fn.addr + "\nint " + fn.name + "(void)\n{\n return " + index + ";\n}\n\n"
|
||||
: "// Function: " + fn.name + " @ " + fn.addr + " (error: " + fn.error + ")\n\n";
|
||||
});
|
||||
fs.writeFileSync(path.join(output, stem + ".c"), c);
|
||||
fs.writeFileSync(path.join(output, stem + ".h"), "#pragma once\n");
|
||||
fs.writeFileSync(path.join(output, stem + ".asm"), "; asm\n" + "x".repeat(behavior.asmPadding ?? 0));
|
||||
fs.writeFileSync(path.join(output, "README.md"), "# " + stem + "\n");
|
||||
// Occupy the manifest path so REA's publication write fails.
|
||||
if (mode === "block-manifest") fs.mkdirSync(path.join(output, "..", "manifest.json"));
|
||||
const failed = functions.filter((fn) => fn.error !== undefined).length;
|
||||
const reported = mode === "miscounted" ? functions.length + 1 : functions.length - failed;
|
||||
if (mode !== "silent") process.stdout.write("wrote " + output + ": " + stem + ".c (" + c.length + " bytes); functions: " + reported + " ok, " + failed + " failed\n");
|
||||
}, behavior.delayMs ?? 0);
|
||||
} else {
|
||||
process.stderr.write("unexpected command\n");
|
||||
process.exit(2);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Write a fake `kuna` into a directory and return its path. */
|
||||
export const writeFakeKuna = async (
|
||||
directory: string,
|
||||
behavior: FakeKunaBehavior = {},
|
||||
): Promise<string> => {
|
||||
const path = join(directory, "kuna");
|
||||
await writeFile(path, `#!${process.execPath}\n${SCRIPT}`, { mode: 0o755 });
|
||||
await writeFile(`${path}.json`, JSON.stringify(behavior));
|
||||
return path;
|
||||
};
|
||||
|
||||
/** Replace the scripted behavior of an existing fake. */
|
||||
export const setFakeKunaBehavior = (
|
||||
path: string,
|
||||
behavior: FakeKunaBehavior,
|
||||
): Promise<void> => writeFile(`${path}.json`, JSON.stringify(behavior));
|
||||
|
||||
/** Invocations the fake recorded, oldest first. */
|
||||
export const fakeKunaCalls = async (
|
||||
path: string,
|
||||
): Promise<
|
||||
readonly {
|
||||
readonly pid: number;
|
||||
readonly argv: readonly string[];
|
||||
readonly home: string;
|
||||
readonly xdg: string;
|
||||
readonly path: string | null;
|
||||
}[]
|
||||
> => {
|
||||
const text = await readFile(`${path}.calls`, "utf8").catch(() => "");
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as never);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { inspectKunaProvider } from "../../../../src/kuna/KunaDoctor.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { writeFakeKuna } from "./fakeKuna.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("reports an unconfigured Kuna as a missing optional engine", async () => {
|
||||
expect(await inspectKunaProvider({})).toMatchObject({
|
||||
id: "kuna",
|
||||
configured: false,
|
||||
available: false,
|
||||
providerVersion: null,
|
||||
registrationEnvironment: {},
|
||||
checks: [
|
||||
{
|
||||
name: "installation",
|
||||
ok: false,
|
||||
code: "not_configured",
|
||||
classification: "missing_analysis_engine",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the caller's explicit settings once the installation validates", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-doctor-");
|
||||
const kuna = await writeFakeKuna(root);
|
||||
const cache = join(root, "cache");
|
||||
|
||||
const inspection = await inspectKunaProvider({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: cache,
|
||||
REA_KUNA_JOBS: "4",
|
||||
UNRELATED: "not published",
|
||||
});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
configured: true,
|
||||
available: true,
|
||||
providerVersion: "1.833",
|
||||
registrationEnvironment: {
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: cache,
|
||||
REA_KUNA_JOBS: "4",
|
||||
},
|
||||
checks: [
|
||||
{ name: "installation", ok: true, details: { version: "1.833" } },
|
||||
{
|
||||
name: "cache",
|
||||
ok: true,
|
||||
detail: `REA creates the Kuna project cache at ${cache} on first use.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(Object.keys(inspection.registrationEnvironment)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("flags an unverified release without failing the installation", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-doctor-version-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
version: { stdout: "kuna 1.950\n" },
|
||||
});
|
||||
|
||||
const inspection = await inspectKunaProvider({ REA_KUNA_COMMAND: kuna });
|
||||
|
||||
expect(inspection.checks[0]).toMatchObject({
|
||||
ok: true,
|
||||
detail: expect.stringContaining(
|
||||
"this release's project layout is unverified",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a cache root REA cannot use without creating anything", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-doctor-cache-");
|
||||
const kuna = await writeFakeKuna(root);
|
||||
const occupied = join(root, "occupied");
|
||||
await writeFile(occupied, "not a directory");
|
||||
const existing = join(root, "existing");
|
||||
await mkdir(existing);
|
||||
|
||||
const blocked = await inspectKunaProvider({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: occupied,
|
||||
});
|
||||
const ready = await inspectKunaProvider({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: existing,
|
||||
});
|
||||
|
||||
expect(blocked.checks[1]).toMatchObject({
|
||||
name: "cache",
|
||||
ok: false,
|
||||
code: "open_options_invalid",
|
||||
});
|
||||
expect(ready.checks[1]).toMatchObject({
|
||||
name: "cache",
|
||||
ok: true,
|
||||
detail: expect.stringContaining(`cached in ${existing}`),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a cache root REA cannot create under its nearest existing parent", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-doctor-create-");
|
||||
const kuna = await writeFakeKuna(root);
|
||||
const readOnly = join(root, "read-only");
|
||||
await mkdir(readOnly, { mode: 0o500 });
|
||||
const occupied = join(root, "occupied");
|
||||
await writeFile(occupied, "not a directory");
|
||||
|
||||
const underReadOnly = await inspectKunaProvider({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(readOnly, "nested", "cache"),
|
||||
});
|
||||
const underFile = await inspectKunaProvider({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(occupied, "cache"),
|
||||
});
|
||||
|
||||
expect(underReadOnly.checks[1]).toMatchObject({
|
||||
name: "cache",
|
||||
ok: false,
|
||||
detail: expect.stringContaining(`${readOnly} is not writable`),
|
||||
});
|
||||
expect(underFile.checks[1]).toMatchObject({
|
||||
name: "cache",
|
||||
ok: false,
|
||||
// stat reports ENOTDIR for a path beneath a regular file.
|
||||
detail: expect.stringContaining("not a directory"),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, readFile, realpath, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { readKunaConfiguration } from "../../../../src/kuna/KunaConfiguration.js";
|
||||
import { inspectKunaInstallation } from "../../../../src/kuna/KunaInstallation.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("identifies a caller-selected kuna by version and digest", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-install-");
|
||||
const kuna = await writeFakeKuna(root);
|
||||
const link = join(root, "current-kuna");
|
||||
await symlink(kuna, link);
|
||||
|
||||
const result = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: link,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
configuration: { command: link, cacheRoot: join(root, "cache") },
|
||||
executable: await realpath(kuna),
|
||||
version: "1.833",
|
||||
verified: true,
|
||||
// A `#!` launcher reports its interpreter; ownership checks must not expect the path.
|
||||
script: true,
|
||||
sha256: createHash("sha256")
|
||||
.update(await readFile(kuna))
|
||||
.digest("hex"),
|
||||
},
|
||||
});
|
||||
// The probe runs inside REA's cache home with no inherited PATH.
|
||||
expect(await fakeKunaCalls(kuna)).toEqual([
|
||||
{
|
||||
pid: expect.any(Number),
|
||||
argv: ["--version"],
|
||||
home: join(root, "cache", "home"),
|
||||
xdg: join(root, "cache", "home", ".cache"),
|
||||
path: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts another release but reports it as unverified", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-unverified-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
version: { stdout: "kuna 1.900\n" },
|
||||
});
|
||||
|
||||
const result = await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { version: "1.900", verified: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a version line it cannot recognize", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-garbled-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
version: { stdout: "Kuna decompiler, built today\n" },
|
||||
});
|
||||
|
||||
expect(
|
||||
await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "version_unresolved",
|
||||
diagnostics: { stdout: "Kuna decompiler, built today\n" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failed version probe with its exit status", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-probe-exit-");
|
||||
const kuna = await writeFakeKuna(root, { version: { exit: 3 } });
|
||||
|
||||
expect(
|
||||
await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "version_unresolved",
|
||||
reason: expect.stringContaining("version probe failed"),
|
||||
diagnostics: { exit_code: 3, killed: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing or non-executable command as executable_missing", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-missing-");
|
||||
const kuna = await writeFakeKuna(root);
|
||||
await chmod(kuna, 0o644);
|
||||
|
||||
for (const command of [kuna, join(root, "absent"), root])
|
||||
expect(
|
||||
await inspectKunaInstallation({ REA_KUNA_COMMAND: command }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "executable_missing", diagnostics: { command } },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes an unset command from an invalid one", async () => {
|
||||
expect(await inspectKunaInstallation({})).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "not_configured", diagnostics: { configured: false } },
|
||||
});
|
||||
expect(
|
||||
await inspectKunaInstallation({ REA_KUNA_COMMAND: "bin/kuna" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "open_options_invalid",
|
||||
diagnostics: { configured: true, setting: "REA_KUNA_COMMAND" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("declares Windows hosts unsupported before touching the command", async () => {
|
||||
expect(
|
||||
await inspectKunaInstallation(
|
||||
{ REA_KUNA_COMMAND: "/opt/kuna/kuna" },
|
||||
{ platform: "win32" },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { code: "unsupported_host" } });
|
||||
});
|
||||
|
||||
it("parses cache, timeout and job settings at the configuration boundary", () => {
|
||||
const environment = {
|
||||
REA_KUNA_COMMAND: "/opt/kuna/kuna",
|
||||
HOME: "/home/analyst",
|
||||
};
|
||||
expect(readKunaConfiguration(environment, "linux")).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
cacheRoot: "/home/analyst/.cache/rea/kuna",
|
||||
cacheMaxBytes: 4 * 1024 ** 3,
|
||||
projectTimeoutMs: 1_800_000,
|
||||
// Kuna's reference answer is a serial export; more jobs change results.
|
||||
jobs: 1,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
readKunaConfiguration(
|
||||
{ ...environment, XDG_CACHE_HOME: "/var/cache/analyst" },
|
||||
"linux",
|
||||
),
|
||||
).toMatchObject({ value: { cacheRoot: "/var/cache/analyst/rea/kuna" } });
|
||||
expect(readKunaConfiguration(environment, "darwin")).toMatchObject({
|
||||
value: { cacheRoot: "/home/analyst/Library/Caches/rea/kuna" },
|
||||
});
|
||||
expect(
|
||||
readKunaConfiguration(
|
||||
{
|
||||
...environment,
|
||||
REA_KUNA_CACHE_MAX_BYTES: "2097152",
|
||||
REA_KUNA_PROJECT_TIMEOUT_MS: "60000",
|
||||
REA_KUNA_JOBS: "3",
|
||||
},
|
||||
"linux",
|
||||
),
|
||||
).toMatchObject({
|
||||
value: { cacheMaxBytes: 2097152, projectTimeoutMs: 60000, jobs: 3 },
|
||||
});
|
||||
for (const [setting, value] of [
|
||||
["REA_KUNA_CACHE_DIR", "relative/cache"],
|
||||
["REA_KUNA_CACHE_MAX_BYTES", "1024"],
|
||||
["REA_KUNA_CACHE_MAX_BYTES", "4GiB"],
|
||||
["REA_KUNA_PROJECT_TIMEOUT_MS", "0"],
|
||||
["REA_KUNA_JOBS", "33"],
|
||||
["REA_KUNA_JOBS", "0"],
|
||||
] as const)
|
||||
expect(
|
||||
readKunaConfiguration({ ...environment, [setting]: value }, "linux"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "open_options_invalid", setting },
|
||||
});
|
||||
});
|
||||
|
||||
it("stops a version probe that ignores SIGTERM instead of waiting on it", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-probe-term-");
|
||||
const kuna = await writeFakeKuna(root, { version: { ignoreTerm: true } });
|
||||
const started = Date.now();
|
||||
|
||||
const result = await inspectKunaInstallation(
|
||||
{ REA_KUNA_COMMAND: kuna },
|
||||
// Long enough for Node to start the fake and install its SIGTERM handler.
|
||||
{ probeTimeoutMs: 1500 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "version_unresolved",
|
||||
diagnostics: { killed: true, deadline_ms: 1500 },
|
||||
},
|
||||
});
|
||||
// The deadline plus ExecFileOutput's bounded kill grace, never an open-ended wait.
|
||||
expect(Date.now() - started).toBeLessThan(15_000);
|
||||
const pid = (await fakeKunaCalls(kuna))[0]?.pid ?? 0;
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
}, 20_000);
|
||||
@@ -0,0 +1,704 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { inspectKunaInstallation } from "../../../../src/kuna/KunaInstallation.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js";
|
||||
import type { KunaInstallation } from "../../../../src/kuna/KunaInstallation.js";
|
||||
import {
|
||||
KunaProjectCache,
|
||||
kunaProjectKey,
|
||||
} from "../../../../src/kuna/KunaProjectCache.js";
|
||||
import type { KunaExportStatus } from "../../../../src/kuna/KunaProjectExport.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
fakeKunaCalls,
|
||||
setFakeKunaBehavior,
|
||||
writeFakeKuna,
|
||||
type FakeKunaBehavior,
|
||||
} from "./fakeKuna.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
interface Target {
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
}
|
||||
|
||||
const fixture = async (
|
||||
prefix: string,
|
||||
behavior: FakeKunaBehavior = {},
|
||||
options: {
|
||||
readonly maxBytes?: number;
|
||||
readonly environment?: Readonly<Record<string, string>>;
|
||||
} = {},
|
||||
) => {
|
||||
const root = await createTestTempDirectory(prefix);
|
||||
const kuna = await writeFakeKuna(root, behavior);
|
||||
const installed = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
...options.environment,
|
||||
});
|
||||
if (!installed.ok) throw new Error(installed.error.reason);
|
||||
return {
|
||||
kuna,
|
||||
installation: installed.value,
|
||||
cacheRoot: installed.value.configuration.cacheRoot,
|
||||
cache: newCache(installed.value, options.maxBytes),
|
||||
target: async (name: string, content = name): Promise<Target> => {
|
||||
const targetPath = join(root, name);
|
||||
await writeFile(targetPath, content);
|
||||
return {
|
||||
targetPath,
|
||||
targetSha256: createHash("sha256").update(content).digest("hex"),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const newCache = (installation: KunaInstallation, maxBytes = 64 * 1024 ** 2) =>
|
||||
new KunaProjectCache(installation.configuration.cacheRoot, {
|
||||
maxBytes,
|
||||
});
|
||||
|
||||
const keyOf = (installation: KunaInstallation, target: Target) =>
|
||||
kunaProjectKey({
|
||||
targetSha256: target.targetSha256,
|
||||
kunaSha256: installation.sha256,
|
||||
kunaVersion: installation.version,
|
||||
jobs: installation.configuration.jobs,
|
||||
});
|
||||
|
||||
const callsOf = async (kuna: string, subcommand: string) =>
|
||||
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === subcommand);
|
||||
|
||||
const entries = (cacheRoot: string, area: string) =>
|
||||
readdir(join(cacheRoot, area)).catch(() => []);
|
||||
|
||||
const processGone = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
it("publishes a verified export and serves later requests from the cache", async () => {
|
||||
const { kuna, installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-cache-",
|
||||
{
|
||||
functions: [
|
||||
{ name: "main", addr: "0x1000" },
|
||||
{ name: "parse @ table", addr: "0x1040" },
|
||||
{
|
||||
name: "broken",
|
||||
addr: "0x1080",
|
||||
error: "Exceeded the instruction budget",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ environment: { REA_KUNA_JOBS: "1" } },
|
||||
);
|
||||
const input = await target("program");
|
||||
|
||||
const built = await cache.build({ installation, ...input });
|
||||
|
||||
expect(built).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
key: keyOf(installation, input),
|
||||
manifest: {
|
||||
layout: 1,
|
||||
inputs: {
|
||||
target_sha256: input.targetSha256,
|
||||
kuna_sha256: installation.sha256,
|
||||
kuna_version: "1.833",
|
||||
jobs: 1,
|
||||
},
|
||||
summary: { decompiled: 2, failed: 1 },
|
||||
},
|
||||
functions: [
|
||||
{ name: "main", address: "0x1000", error: null },
|
||||
// A name may itself contain " @ "; the address is the last field.
|
||||
{ name: "parse @ table", address: "0x1040", error: null },
|
||||
{
|
||||
name: "broken",
|
||||
address: "0x1080",
|
||||
error: "Exceeded the instruction budget",
|
||||
},
|
||||
],
|
||||
// The inventory also names imports Kuna does not decompile.
|
||||
inventory: [
|
||||
{ name: "main", address: "0x1000", size: 16 },
|
||||
{ name: "parse @ table", address: "0x1040", size: 16 },
|
||||
{ name: "broken", address: "0x1080", size: 16 },
|
||||
{ name: "puts", address: "0x2000", size: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!built.ok) throw built.error;
|
||||
const [main, , broken] = built.value.functions;
|
||||
const c = await readFile(join(built.value.directory, "program.c"));
|
||||
const slice = (span: typeof main) =>
|
||||
c
|
||||
.subarray(span?.cOffset, (span?.cOffset ?? 0) + (span?.cLength ?? 0))
|
||||
.toString("utf8");
|
||||
expect(slice(main)).toBe(
|
||||
"// Function: main @ 0x1000\nint main(void)\n{\n return 0;\n}\n\n",
|
||||
);
|
||||
expect(slice(broken)).toBe(
|
||||
"// Function: broken @ 0x1080 (error: Exceeded the instruction budget)\n\n",
|
||||
);
|
||||
|
||||
// Kuna listed and decompiled a staged copy from REA's own HOME, in reference mode.
|
||||
const [listing] = await callsOf(kuna, "functions");
|
||||
const [exported] = await callsOf(kuna, "decompile-project");
|
||||
expect(listing?.argv).toEqual(["functions", exported?.argv[1], "--json"]);
|
||||
expect(exported?.argv.slice(2)).toEqual([
|
||||
"-o",
|
||||
expect.stringContaining(join("cache", "staging")),
|
||||
"--jobs",
|
||||
"1",
|
||||
]);
|
||||
expect(exported?.argv[1]).not.toBe(input.targetPath);
|
||||
expect(exported?.home).toBe(join(cacheRoot, "home"));
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
|
||||
// The same process and a fresh one both reuse the published entry.
|
||||
await cache.build({ installation, ...input });
|
||||
expect(await newCache(installation).lookup(built.value.key)).toMatchObject({
|
||||
functions: built.value.functions,
|
||||
inventory: built.value.inventory,
|
||||
});
|
||||
expect(await callsOf(kuna, "decompile-project")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shares one Kuna run between concurrent requests for one export", async () => {
|
||||
const { kuna, installation, cache, target } = await fixture(
|
||||
"rea-kuna-singleflight-",
|
||||
{ delayMs: 300 },
|
||||
);
|
||||
const input = await target("program");
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
cache.build({ installation, ...input }),
|
||||
cache.build({ installation, ...input }),
|
||||
]);
|
||||
|
||||
expect(first.ok && second.ok).toBe(true);
|
||||
if (first.ok && second.ok)
|
||||
expect(first.value.directory).toBe(second.value.directory);
|
||||
expect(await callsOf(kuna, "decompile-project")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops the run only when every waiting request is cancelled", async () => {
|
||||
const { kuna, installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-cancel-",
|
||||
{ project: "hang" },
|
||||
);
|
||||
const input = await target("program");
|
||||
const firstCaller = new AbortController();
|
||||
const secondCaller = new AbortController();
|
||||
|
||||
const first = cache.build({
|
||||
installation,
|
||||
...input,
|
||||
signal: firstCaller.signal,
|
||||
});
|
||||
const second = cache.build({
|
||||
installation,
|
||||
...input,
|
||||
signal: secondCaller.signal,
|
||||
});
|
||||
await expect.poll(() => callsOf(kuna, "decompile-project")).toHaveLength(1);
|
||||
const pid = (await callsOf(kuna, "decompile-project"))[0]?.pid ?? 0;
|
||||
|
||||
firstCaller.abort();
|
||||
expect(await first).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
// The other request still owns the run.
|
||||
expect(processGone(pid)).toBe(false);
|
||||
|
||||
secondCaller.abort();
|
||||
expect(await second).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(await cache.close()).toEqual({ ok: true, value: null });
|
||||
expect(processGone(pid)).toBe(true);
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies one deadline to the inventory and the export together", async () => {
|
||||
const { kuna, installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-timeout-",
|
||||
{ project: "hang" },
|
||||
{ environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "800" } },
|
||||
);
|
||||
|
||||
const result = await cache.build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisTimeoutError", timeoutMs: 800 },
|
||||
});
|
||||
const [exported] = await callsOf(kuna, "decompile-project");
|
||||
expect(processGone(exported?.pid ?? 0)).toBe(true);
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
|
||||
await setFakeKunaBehavior(kuna, { inventory: "hang" });
|
||||
expect(
|
||||
await cache.build({ installation, ...(await target("other")) }),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisTimeoutError" } });
|
||||
});
|
||||
|
||||
it("reports a failed export with Kuna's diagnostics and retains nothing", async () => {
|
||||
const { installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-failure-",
|
||||
{ project: "exit-failure" },
|
||||
);
|
||||
|
||||
const result = await cache.build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
userMessage:
|
||||
"kuna decompile-project failed with exit code 1: error: not in recognized object file format",
|
||||
capturedOutput: { exit_code: 1 },
|
||||
},
|
||||
});
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([]);
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an inventory that is not Kuna's JSON document",
|
||||
{ inventory: "not-json" },
|
||||
"inventory is not JSON",
|
||||
],
|
||||
[
|
||||
"an export without Kuna's completed-file summary",
|
||||
{ project: "silent" },
|
||||
"summary",
|
||||
],
|
||||
[
|
||||
"an export whose headers disagree with Kuna's summary",
|
||||
{ project: "miscounted" },
|
||||
"2 function headers but Kuna reported 3 functions",
|
||||
],
|
||||
] as const)("refuses %s", async (_case, behavior, reason) => {
|
||||
const { installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-refused-",
|
||||
behavior,
|
||||
);
|
||||
|
||||
expect(
|
||||
await cache.build({ installation, ...(await target("program")) }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
userMessage: expect.stringContaining(reason),
|
||||
},
|
||||
});
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([]);
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to decompile a target that no longer matches its digest", async () => {
|
||||
const { kuna, installation, cache, target } =
|
||||
await fixture("rea-kuna-changed-");
|
||||
const input = await target("program");
|
||||
await appendFile(input.targetPath, "patched");
|
||||
|
||||
expect(await cache.build({ installation, ...input })).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisArtifactChangedError", path: input.targetPath },
|
||||
});
|
||||
expect(await fakeKunaCalls(kuna)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes its staging when the target disappears before the copy", async () => {
|
||||
const { installation, cacheRoot, cache, target } =
|
||||
await fixture("rea-kuna-vanished-");
|
||||
const input = await target("program");
|
||||
|
||||
const result = await cache.build({
|
||||
installation,
|
||||
...input,
|
||||
targetPath: `${input.targetPath}.gone`,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
userMessage: expect.stringContaining("ENOENT"),
|
||||
},
|
||||
});
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards an entry whose files no longer match their manifest", async () => {
|
||||
const { installation, cacheRoot, cache, target } =
|
||||
await fixture("rea-kuna-tamper-");
|
||||
const input = await target("program");
|
||||
const built = await cache.build({ installation, ...input });
|
||||
if (!built.ok) throw built.error;
|
||||
await appendFile(join(built.value.directory, "program.c"), "// edited\n");
|
||||
|
||||
expect(await newCache(installation).lookup(built.value.key)).toBeUndefined();
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([]);
|
||||
});
|
||||
|
||||
it("evicts least recently used entries above the cap, never a pinned one", async () => {
|
||||
const { installation, cacheRoot, target } = await fixture("rea-kuna-evict-", {
|
||||
asmPadding: 400 * 1024,
|
||||
});
|
||||
const cache = newCache(installation, 1024 * 1024);
|
||||
const build = async (name: string, ageSeconds: number) => {
|
||||
const result = await cache.build({ installation, ...(await target(name)) });
|
||||
if (!result.ok) throw result.error;
|
||||
const when = new Date(Date.now() - ageSeconds * 1000);
|
||||
await utimes(join(cacheRoot, "projects", result.value.key), when, when);
|
||||
return result.value.key;
|
||||
};
|
||||
|
||||
const oldest = await build("oldest", 30);
|
||||
const pinned = await build("pinned", 40);
|
||||
const release = cache.pin(pinned);
|
||||
const newest = await build("newest", 0);
|
||||
|
||||
// Three ~400 KiB entries exceed 1 MiB: the oldest unpinned entry goes.
|
||||
expect((await entries(cacheRoot, "projects")).sort()).toEqual(
|
||||
[pinned, newest].sort(),
|
||||
);
|
||||
expect(await cache.lookup(oldest)).toBeUndefined();
|
||||
|
||||
release();
|
||||
await build("latest", 0);
|
||||
expect(await entries(cacheRoot, "projects")).not.toContain(pinned);
|
||||
});
|
||||
|
||||
it("refuses an export larger than the whole cache may hold", async () => {
|
||||
const { installation, cacheRoot, target } = await fixture(
|
||||
"rea-kuna-capacity-",
|
||||
{ asmPadding: 8 * 1024 },
|
||||
);
|
||||
|
||||
const result = await newCache(installation, 4096).build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "capacity",
|
||||
reportedLimits: { cache_max_bytes: 4096 },
|
||||
},
|
||||
});
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the first publication when another process exports the same key", async () => {
|
||||
const { kuna, installation, cacheRoot, target } = await fixture(
|
||||
"rea-kuna-race-",
|
||||
{ delayMs: 300 },
|
||||
);
|
||||
const input = await target("program");
|
||||
|
||||
// Two cache instances stand in for two REA processes sharing one root.
|
||||
const [first, second] = await Promise.all([
|
||||
newCache(installation).build({ installation, ...input }),
|
||||
newCache(installation).build({ installation, ...input }),
|
||||
]);
|
||||
|
||||
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
|
||||
expect(first.ok && second.ok).toBe(true);
|
||||
if (first.ok && second.ok)
|
||||
expect(first.value.manifest).toEqual(second.value.manifest);
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([
|
||||
keyOf(installation, input),
|
||||
]);
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports each build phase to every waiting request", async () => {
|
||||
const { installation, cache, target } = await fixture("rea-kuna-phases-");
|
||||
const statuses: KunaExportStatus[] = [];
|
||||
|
||||
const result = await cache.build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(statuses).toEqual([
|
||||
{ phase: "inventory", inventoryFunctions: null },
|
||||
{ phase: "decompiling", inventoryFunctions: 3 },
|
||||
{ phase: "sealing", inventoryFunctions: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sweeps retired entries but preserves staging with unresolved process ownership", async () => {
|
||||
const { installation, cacheRoot, cache, target } =
|
||||
await fixture("rea-kuna-sweep-");
|
||||
const abandoned = join(cacheRoot, "staging", "build-abandoned");
|
||||
const recent = join(cacheRoot, "staging", "build-recent");
|
||||
const evicted = join(cacheRoot, "trash", "evicted");
|
||||
for (const path of [abandoned, recent, evicted])
|
||||
await mkdir(path, { recursive: true });
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
await utimes(abandoned, twoDaysAgo, twoDaysAgo);
|
||||
|
||||
expect(
|
||||
(await cache.build({ installation, ...(await target("program")) })).ok,
|
||||
).toBe(true);
|
||||
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([
|
||||
"build-abandoned",
|
||||
"build-recent",
|
||||
]);
|
||||
expect(await entries(cacheRoot, "trash")).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["sweep", "close"] as const)(
|
||||
"keeps failed file cleanup owned until recovery through %s",
|
||||
async (recovery) => {
|
||||
const { installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-trash-cleanup-",
|
||||
);
|
||||
const retired = join(cacheRoot, "trash", "retired");
|
||||
await mkdir(retired, { recursive: true });
|
||||
await writeFile(join(retired, "evidence"), "retained export");
|
||||
await chmod(retired, 0o500);
|
||||
let built;
|
||||
let closed;
|
||||
try {
|
||||
built = await cache.build({ installation, ...(await target("program")) });
|
||||
closed = await cache.close();
|
||||
} finally {
|
||||
await chmod(retired, 0o700);
|
||||
}
|
||||
|
||||
if (!built.ok) throw built.error;
|
||||
expect(closed).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true, cleanupResources: [retired] },
|
||||
});
|
||||
expect(await readFile(join(retired, "evidence"), "utf8")).toBe(
|
||||
"retained export",
|
||||
);
|
||||
if (recovery === "sweep") {
|
||||
const next = await cache.build({
|
||||
installation,
|
||||
...(await target("other")),
|
||||
});
|
||||
if (!next.ok) throw next.error;
|
||||
}
|
||||
expect(await cache.close()).toEqual({ ok: true, value: null });
|
||||
expect(await entries(cacheRoot, "trash")).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps staging owned when a cancelled Kuna process cannot be proven stopped", async () => {
|
||||
const { kuna, installation, cacheRoot, target } = await fixture(
|
||||
"rea-kuna-retained-",
|
||||
{ project: "hang" },
|
||||
);
|
||||
// Stop the process for real, but report that ownership could not be revalidated.
|
||||
const cache = new KunaProjectCache(cacheRoot, {
|
||||
maxBytes: 64 * 1024 ** 2,
|
||||
launcher: async (options) => {
|
||||
const spawned = await spawnOwnedProviderProcess(options);
|
||||
if (options.arguments[0] !== "decompile-project") return spawned;
|
||||
const workspace = options.cwd;
|
||||
if (workspace === undefined) throw new Error("Kuna workspace is missing");
|
||||
return {
|
||||
...spawned,
|
||||
cleanup: async () => {
|
||||
await cleanupOwnedProcessGroup(spawned.ownership);
|
||||
// A full disk or lost directory write access can prevent a final
|
||||
// lifecycle marker. Cleanup must remain fail-closed across instances.
|
||||
await chmod(workspace, 0o500);
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "ownership could not be revalidated",
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const caller = new AbortController();
|
||||
const build = cache.build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
signal: caller.signal,
|
||||
});
|
||||
await expect.poll(() => callsOf(kuna, "decompile-project")).toHaveLength(1);
|
||||
|
||||
caller.abort();
|
||||
expect(await build).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
const closed = await cache.close();
|
||||
const staged = await entries(cacheRoot, "staging");
|
||||
const retained = join(cacheRoot, "staging", staged[0] ?? "missing");
|
||||
// Restore access before assertions so the fixture can always remove it.
|
||||
await chmod(retained, 0o700);
|
||||
|
||||
expect(closed).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
diagnostics: { reason: "ownership could not be revalidated" },
|
||||
},
|
||||
});
|
||||
expect(staged).toHaveLength(1);
|
||||
if (!closed.ok) expect(closed.error.cleanupResources).toContain(retained);
|
||||
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
await utimes(retained, twoDaysAgo, twoDaysAgo);
|
||||
await setFakeKunaBehavior(kuna, {});
|
||||
const other = newCache(installation);
|
||||
const built = await other.build({
|
||||
installation,
|
||||
...(await target("other")),
|
||||
});
|
||||
if (!built.ok) throw built.error;
|
||||
expect(await entries(cacheRoot, "staging")).toEqual(staged);
|
||||
expect(await readFile(join(retained, "input", "program"), "utf8")).toBe(
|
||||
"program",
|
||||
);
|
||||
expect(await other.close()).toEqual({ ok: true, value: null });
|
||||
// The fake process really stopped, so the test can remove its files. File
|
||||
// absence still does not resolve the deliberately unproven process record.
|
||||
await rm(retained, { recursive: true });
|
||||
expect(await cache.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: { diagnostics: { reason: "ownership could not be revalidated" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds an entry another cache instance evicted after this one verified it", async () => {
|
||||
const { kuna, installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-evicted-elsewhere-",
|
||||
);
|
||||
const input = await target("program");
|
||||
const built = await cache.build({ installation, ...input });
|
||||
if (!built.ok) throw built.error;
|
||||
// Another process retires the entry the way #discard does.
|
||||
await mkdir(join(cacheRoot, "trash"), { recursive: true });
|
||||
await rename(
|
||||
join(cacheRoot, "projects", built.value.key),
|
||||
join(cacheRoot, "trash", "evicted"),
|
||||
);
|
||||
|
||||
expect(await cache.lookup(built.value.key)).toBeUndefined();
|
||||
const rebuilt = await cache.build({ installation, ...input });
|
||||
|
||||
expect(rebuilt.ok).toBe(true);
|
||||
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([built.value.key]);
|
||||
});
|
||||
|
||||
it("reports an abandoned build's unproven cleanup even after a retry replaced it", async () => {
|
||||
const { kuna, installation, cacheRoot, target } = await fixture(
|
||||
"rea-kuna-replaced-",
|
||||
{ project: "hang" },
|
||||
);
|
||||
let exports = 0;
|
||||
const cache = new KunaProjectCache(cacheRoot, {
|
||||
maxBytes: 64 * 1024 ** 2,
|
||||
launcher: async (options) => {
|
||||
const spawned = await spawnOwnedProviderProcess(options);
|
||||
if (options.arguments[0] !== "decompile-project" || ++exports > 1)
|
||||
return spawned;
|
||||
// The first export stops slowly and cannot prove its group gone.
|
||||
return {
|
||||
...spawned,
|
||||
cleanup: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await cleanupOwnedProcessGroup(spawned.ownership);
|
||||
return { cleaned: false, reason: "first export unverified" };
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const input = await target("program");
|
||||
const first = new AbortController();
|
||||
const firstBuild = cache.build({
|
||||
installation,
|
||||
...input,
|
||||
signal: first.signal,
|
||||
});
|
||||
await expect.poll(() => callsOf(kuna, "decompile-project")).toHaveLength(1);
|
||||
first.abort();
|
||||
await firstBuild;
|
||||
|
||||
// A retry for the same key starts while the first is still stopping.
|
||||
const second = new AbortController();
|
||||
const secondBuild = cache.build({
|
||||
installation,
|
||||
...input,
|
||||
signal: second.signal,
|
||||
});
|
||||
await expect.poll(() => callsOf(kuna, "decompile-project")).toHaveLength(2);
|
||||
second.abort();
|
||||
await secondBuild;
|
||||
|
||||
expect(await cache.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: { diagnostics: { reason: "first export unverified" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes its staging when publication itself fails", async () => {
|
||||
const { installation, cacheRoot, cache, target } = await fixture(
|
||||
"rea-kuna-publish-failure-",
|
||||
{ project: "block-manifest" },
|
||||
);
|
||||
|
||||
const result = await cache.build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ProviderAdapterError" },
|
||||
});
|
||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||
expect(await entries(cacheRoot, "projects")).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, expect, it as test, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { inspectKunaInstallation } from "../../../../src/kuna/KunaInstallation.js";
|
||||
import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js";
|
||||
import type { KunaExportStatus } from "../../../../src/kuna/KunaProjectExport.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js";
|
||||
|
||||
// Reproduce concurrent eviction's shared directory snapshot while retaining
|
||||
// real exports, publication, hashing and file removal underneath it.
|
||||
const listing = vi.hoisted(() => ({
|
||||
directory: "",
|
||||
names: [] as string[],
|
||||
gate: undefined as Promise<void> | undefined,
|
||||
arrived: 0,
|
||||
touchPath: "",
|
||||
touchGate: undefined as Promise<void> | undefined,
|
||||
touched: false,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
readdir: async (...args: Parameters<typeof actual.readdir>) => {
|
||||
if (String(args[0]) === listing.directory && listing.gate !== undefined) {
|
||||
listing.arrived += 1;
|
||||
await listing.gate;
|
||||
return listing.names;
|
||||
}
|
||||
return actual.readdir(...args);
|
||||
},
|
||||
utimes: async (...args: Parameters<typeof actual.utimes>) => {
|
||||
await actual.utimes(...args);
|
||||
if (
|
||||
String(args[0]) === listing.touchPath &&
|
||||
listing.touchGate !== undefined
|
||||
) {
|
||||
listing.touched = true;
|
||||
await listing.touchGate;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listing.directory = "";
|
||||
listing.gate = undefined;
|
||||
listing.names = [];
|
||||
listing.arrived = 0;
|
||||
listing.touchPath = "";
|
||||
listing.touchGate = undefined;
|
||||
listing.touched = false;
|
||||
});
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
const barrier = () => {
|
||||
let release = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
};
|
||||
|
||||
const fixture = async (maxBytes = 64 * 1024 ** 2) => {
|
||||
const root = await createTestTempDirectory("rea-kuna-concurrency-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
delayMs: 300,
|
||||
asmPadding: 400 * 1024,
|
||||
});
|
||||
const inspected = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
});
|
||||
if (!inspected.ok) throw new Error(inspected.error.reason);
|
||||
const installation = inspected.value;
|
||||
const cache = new KunaProjectCache(installation.configuration.cacheRoot, {
|
||||
maxBytes,
|
||||
});
|
||||
const request = async (name: string) => {
|
||||
const targetPath = join(root, name);
|
||||
await writeFile(targetPath, name);
|
||||
return {
|
||||
installation,
|
||||
targetPath,
|
||||
targetSha256: createHash("sha256").update(name).digest("hex"),
|
||||
};
|
||||
};
|
||||
return {
|
||||
kuna,
|
||||
cache,
|
||||
request,
|
||||
cacheRoot: installation.configuration.cacheRoot,
|
||||
};
|
||||
};
|
||||
|
||||
it.each([true, false])(
|
||||
"preserves concurrent results and reclaims capacity (pin: %s)",
|
||||
async (pin) => {
|
||||
const { cache, request, cacheRoot } = await fixture(600 * 1024);
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
let releaseListing = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseListing = resolve;
|
||||
});
|
||||
listing.directory = join(cacheRoot, "projects");
|
||||
listing.gate = gate;
|
||||
const releases: (() => void)[] = [];
|
||||
const inputs = await Promise.all([request("first"), request("second")]);
|
||||
const builds = inputs.map(async (input) => {
|
||||
const result = await cache.build(input);
|
||||
if (!result.ok) throw result.error;
|
||||
if (pin) releases.push(cache.pin(result.value.key));
|
||||
return result.value;
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
releaseListing();
|
||||
await Promise.allSettled(builds);
|
||||
for (const release of releases) release();
|
||||
const closed = await cache.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
await expect
|
||||
.poll(() => actual.readdir(listing.directory).catch(() => []))
|
||||
.toHaveLength(2);
|
||||
listing.names = await actual.readdir(listing.directory);
|
||||
releaseListing();
|
||||
const projects = await Promise.all(builds);
|
||||
if (!pin) {
|
||||
expect(await cache.close()).toEqual({ ok: true, value: null });
|
||||
expect(await actual.readdir(listing.directory)).toHaveLength(1);
|
||||
return;
|
||||
}
|
||||
for (const project of projects) {
|
||||
expect(
|
||||
await readFile(
|
||||
join(project.directory, project.manifest.files.c.path),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("// Function: main @ 0x1000");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["decompiling", "sealing"] as const)(
|
||||
"rejects an executable changed during %s",
|
||||
async (phase) => {
|
||||
const { kuna, cache, request, cacheRoot } = await fixture();
|
||||
const input = await request("program");
|
||||
const result = await cache.build({
|
||||
...input,
|
||||
onStatus: (status) => {
|
||||
if (status.phase === phase)
|
||||
appendFileSync(kuna, "\n// installation replaced\n");
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
diagnostics: {
|
||||
expected_sha256: input.installation.sha256,
|
||||
observed_sha256: expect.any(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await readdir(join(cacheRoot, "projects"))).toEqual([]);
|
||||
expect(await cache.close()).toEqual({ ok: true, value: null });
|
||||
},
|
||||
);
|
||||
|
||||
it("protects a cache request that arrives after an eviction snapshot starts", async () => {
|
||||
const { cache, cacheRoot, request } = await fixture(600 * 1024);
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const firstInput = await request("first");
|
||||
const first = await cache.build(firstInput);
|
||||
if (!first.ok) throw first.error;
|
||||
const snapshot = barrier();
|
||||
const touch = barrier();
|
||||
listing.directory = join(cacheRoot, "projects");
|
||||
listing.gate = snapshot.promise;
|
||||
listing.touchPath = join(listing.directory, first.value.key);
|
||||
listing.touchGate = touch.promise;
|
||||
const releases: (() => void)[] = [];
|
||||
const pinResult = async (pending: ReturnType<KunaProjectCache["build"]>) => {
|
||||
const result = await pending;
|
||||
if (!result.ok) throw result.error;
|
||||
releases.push(cache.pin(result.value.key));
|
||||
return result.value;
|
||||
};
|
||||
const second = pinResult(cache.build(await request("second")));
|
||||
let warm: ReturnType<typeof pinResult> | undefined;
|
||||
onTestFinished(async () => {
|
||||
snapshot.release();
|
||||
touch.release();
|
||||
await Promise.allSettled(warm === undefined ? [second] : [second, warm]);
|
||||
for (const release of releases) release();
|
||||
const closed = await cache.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
await expect.poll(() => listing.arrived).toBe(1);
|
||||
warm = pinResult(cache.build(firstInput));
|
||||
await expect.poll(() => listing.touched).toBe(true);
|
||||
listing.names = await actual.readdir(listing.directory);
|
||||
snapshot.release();
|
||||
await second;
|
||||
touch.release();
|
||||
const reused = await warm;
|
||||
expect(
|
||||
await readFile(
|
||||
join(reused.directory, reused.manifest.files.c.path),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("// Function: main @ 0x1000");
|
||||
});
|
||||
|
||||
it("keeps a shared progress callback registered for the remaining waiter", async () => {
|
||||
const { kuna, cache, request } = await fixture();
|
||||
const input = await request("program");
|
||||
const controller = new AbortController();
|
||||
const statuses: KunaExportStatus[] = [];
|
||||
const onStatus = (status: KunaExportStatus) => {
|
||||
statuses.push(status);
|
||||
if (status.phase === "decompiling") controller.abort();
|
||||
};
|
||||
const first = cache.build({ ...input, signal: controller.signal, onStatus });
|
||||
const second = cache.build({ ...input, onStatus });
|
||||
expect(await first).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
const result = await second;
|
||||
if (!result.ok) throw result.error;
|
||||
expect(statuses.map(({ phase }) => phase)).toContain("sealing");
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile-project",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(await cache.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseKunaExportSummary,
|
||||
parseKunaInventory,
|
||||
scanKunaProjectSource,
|
||||
} from "../../../../src/kuna/KunaProjectFiles.js";
|
||||
|
||||
const SOURCE = [
|
||||
'#include "a.out.h"',
|
||||
"",
|
||||
"// Function: main @ 0x1000",
|
||||
"int main(void)",
|
||||
"{",
|
||||
' return puts("// Function: not a header");',
|
||||
"}",
|
||||
"",
|
||||
"// Function: operator new(unsigned long) @ 0x1040",
|
||||
"void *operator new(unsigned long a0) { return malloc(a0); }",
|
||||
"",
|
||||
"// Function: sub_1080 @ 0x1080 (error: Flow exceeded maximum allowable instructions)",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
/** Feed the text in fixed-size pieces so headers straddle chunk boundaries. */
|
||||
async function* chunked(text: string, size: number): AsyncGenerator<Buffer> {
|
||||
const bytes = Buffer.from(text);
|
||||
for (let offset = 0; offset < bytes.length; offset += size)
|
||||
yield bytes.subarray(offset, offset + size);
|
||||
}
|
||||
|
||||
it("splits Kuna's .c into function blocks at any chunk size", async () => {
|
||||
// SOURCE is ASCII, so string offsets are byte offsets.
|
||||
const starts = [
|
||||
SOURCE.indexOf("// Function: main"),
|
||||
SOURCE.indexOf("// Function: operator new"),
|
||||
SOURCE.indexOf("// Function: sub_1080"),
|
||||
SOURCE.length,
|
||||
];
|
||||
const span = (index: number) => ({
|
||||
cOffset: starts[index] ?? -1,
|
||||
cLength: (starts[index + 1] ?? -1) - (starts[index] ?? -1),
|
||||
});
|
||||
const expected = [
|
||||
{ name: "main", address: "0x1000", ...span(0), error: null },
|
||||
{
|
||||
name: "operator new(unsigned long)",
|
||||
address: "0x1040",
|
||||
...span(1),
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "sub_1080",
|
||||
address: "0x1080",
|
||||
...span(2),
|
||||
error: "Flow exceeded maximum allowable instructions",
|
||||
},
|
||||
];
|
||||
expect(SOURCE.slice(starts[0], starts[1])).toBe(
|
||||
'// Function: main @ 0x1000\nint main(void)\n{\n return puts("// Function: not a header");\n}\n\n',
|
||||
);
|
||||
for (const size of [1, 7, 64, 4096])
|
||||
expect(
|
||||
await scanKunaProjectSource(chunked(SOURCE, size), {
|
||||
decompiled: 2,
|
||||
failed: 1,
|
||||
}),
|
||||
).toEqual({ ok: true, value: expected });
|
||||
const block = Buffer.from(SOURCE)
|
||||
.subarray(expected[2]?.cOffset, SOURCE.length)
|
||||
.toString();
|
||||
expect(block.startsWith("// Function: sub_1080 @ 0x1080 (error:")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a header count that disagrees with Kuna's summary",
|
||||
SOURCE,
|
||||
{ decompiled: 3, failed: 1 },
|
||||
"3 function headers but Kuna reported 4",
|
||||
],
|
||||
[
|
||||
"a failure count that disagrees with Kuna's summary",
|
||||
SOURCE,
|
||||
{ decompiled: 3, failed: 0 },
|
||||
"marks 1 failed functions but Kuna reported 0",
|
||||
],
|
||||
[
|
||||
"a malformed header",
|
||||
"// Function: main at 0x1000\n",
|
||||
{ decompiled: 1, failed: 0 },
|
||||
"unrecognized function header at byte 0",
|
||||
],
|
||||
[
|
||||
"a repeated address",
|
||||
"// Function: a @ 0x10\n\n// Function: b @ 0x10\n\n",
|
||||
{ decompiled: 2, failed: 0 },
|
||||
"repeats address 0x10",
|
||||
],
|
||||
[
|
||||
"an overlong header line",
|
||||
`// Function: ${"x".repeat(70_000)} @ 0x10\n`,
|
||||
{ decompiled: 1, failed: 0 },
|
||||
"too long",
|
||||
],
|
||||
] as const)("refuses %s", async (_case, source, summary, reason) => {
|
||||
expect(await scanKunaProjectSource(chunked(source, 4096), summary)).toEqual({
|
||||
ok: false,
|
||||
error: expect.stringContaining(reason),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads Kuna's completed-export summary from its stdout", () => {
|
||||
expect(
|
||||
parseKunaExportSummary(
|
||||
"wrote ./a.out.kuna: a.out.c (325163 bytes), a.out.h (21366 bytes); functions: 172 ok, 3 failed\n",
|
||||
),
|
||||
).toEqual({ decompiled: 172, failed: 3 });
|
||||
expect(parseKunaExportSummary("wrote ./a.out.kuna\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads the callable inventory, ignoring fields a later release adds", () => {
|
||||
expect(
|
||||
parseKunaInventory(
|
||||
JSON.stringify({
|
||||
binary: "a.out",
|
||||
count: 1,
|
||||
total: 1,
|
||||
error: null,
|
||||
functions: [
|
||||
{
|
||||
name: "_inflate",
|
||||
address: 4294995696,
|
||||
address_hex: "0x100006ef0",
|
||||
aliases: ["inflate"],
|
||||
object_location: null,
|
||||
size: 252,
|
||||
interior: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: [
|
||||
{
|
||||
name: "_inflate",
|
||||
address: "0x100006ef0",
|
||||
aliases: ["inflate"],
|
||||
size: 252,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parseKunaInventory("functions: 1")).toEqual({
|
||||
ok: false,
|
||||
error: "the function inventory is not JSON",
|
||||
});
|
||||
expect(
|
||||
parseKunaInventory(JSON.stringify({ functions: [{ name: "x" }] })),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
Reference in new issue
Block a user