fix(kuna): bind cache exports to their producing inputs

This commit is contained in:
morluto committed 2026-10-27 05:23:53 +08:00
1 parent 906f161dc6
commit 8b7ee48a46
5 files changed
+557 -143

No files matched your search

+11 -3
View File
@@ -125,9 +125,17 @@ REA writes Kuna's exports only inside `REA_KUNA_CACHE_DIR`:
- `trash/` holds retired builds and evicted exports awaiting removal.
- `home/` is Kuna's HOME.
The cache key includes the selected launcher and resolved executable paths.
Older exports without that identity are rebuilt automatically and retired through
normal cache cleanup; no manual migration is needed.
The cache key includes the selected launcher and resolved executable paths, plus
the target's native basename: that name determines the export filenames and
appears in generated source. REA accepts one current manifest layout. Older
exports are rebuilt on demand and retired through normal owned-cache cleanup;
no manual deletion or migration is needed. Manifests must name the producer's
exact flat file layout, with matching basenames and producing inputs. Replaced
entry, project or manifest symlinks are refused before their targets are read;
an unverified `projects/` parent grants no removal authority. Warm reuse checks
the manifest's file identity as well as the export files. These refusal and
migration cases are verified with controlled Kuna-format exports; they do not
establish behavior of an independently installed Kuna release.
Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. REA retains the file state verified with each digest, checks it before warm-cache reuse, and checks open descriptors before and after reading text. Assembly indexes are checked against the same state; a changed file makes the whole report retry rather than mixing facets from different exports. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it.
+191 -55
View File
@@ -1,4 +1,9 @@
import type { RegularFileState } from "../filesystem/RegularFile.js";
import {
regularFileState,
sameRegularFileState,
type RegularFileState,
} from "../filesystem/RegularFile.js";
import type { Stats } from "node:fs";
import {
assertKunaProjectState,
type KunaProjectFileStates,
@@ -6,16 +11,16 @@ import {
import { createHash, randomUUID } from "node:crypto";
import {
mkdir,
lstat,
mkdtemp,
readdir,
realpath,
readFile,
rename,
rm,
stat,
utimes,
} from "node:fs/promises";
import { join } from "node:path";
import { basename, join } from "node:path";
import canonicalize from "canonicalize";
import writeFileAtomic from "write-file-atomic";
@@ -30,6 +35,7 @@ import {
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
observeStableFile,
@@ -43,6 +49,7 @@ import {
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import {
kunaExportFileNames,
exportKunaProject,
KUNA_EXPORT_OPERATION,
kunaExportFailure,
@@ -59,7 +66,7 @@ import type {
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
/** Bumped whenever the entry layout or manifest meaning changes. */
const CACHE_LAYOUT = 2;
const CACHE_LAYOUT = 3;
/** A manifest names five files and their digests; anything larger is not ours. */
const MANIFEST_MAX_BYTES = 64 * 1024;
@@ -71,6 +78,7 @@ 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 targetBasename: string;
readonly kunaSha256: string;
readonly kunaCommand: string;
readonly kunaExecutable: string;
@@ -89,6 +97,7 @@ export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string =>
canonicalize({
layout: CACHE_LAYOUT,
target_sha256: inputs.targetSha256,
target_basename: inputs.targetBasename,
kuna_sha256: inputs.kunaSha256,
kuna_command: inputs.kunaCommand,
kuna_executable: inputs.kunaExecutable,
@@ -104,30 +113,74 @@ const fileSchema = z.strictObject({
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_command: z.string(),
kuna_executable: z.string(),
kuna_version: z.string().min(1),
jobs: z.int().positive(),
}),
files: z.strictObject({
c: fileSchema,
h: fileSchema,
asm: fileSchema,
readme: fileSchema,
inventory: fileSchema,
}),
summary: z.strictObject({
decompiled: z.int().nonnegative(),
failed: z.int().nonnegative(),
}),
created_at: utcDateTimeSchema,
});
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),
target_basename: z.string().min(1),
kuna_sha256: z.string().regex(SHA256_PATTERN),
kuna_command: z.string(),
kuna_executable: z.string(),
kuna_version: z.string().min(1),
jobs: z.int().positive(),
}),
files: z.strictObject({
c: fileSchema,
h: fileSchema,
asm: fileSchema,
readme: fileSchema,
inventory: fileSchema,
}),
summary: z.strictObject({
decompiled: z.int().nonnegative(),
failed: z.int().nonnegative(),
}),
created_at: utcDateTimeSchema,
})
.superRefine(({ files, inputs, key }, context) => {
if (
key !==
kunaProjectKey({
targetSha256: inputs.target_sha256,
targetBasename: inputs.target_basename,
kunaSha256: inputs.kuna_sha256,
kunaCommand: inputs.kuna_command,
kunaExecutable: inputs.kuna_executable,
kunaVersion: inputs.kuna_version,
jobs: inputs.jobs,
})
)
context.addIssue({
code: "custom",
path: ["key"],
message: "Export cache key does not match its producing inputs",
});
const stem = inputs.target_basename;
if (
stem.length === 0 ||
stem === "." ||
stem === ".." ||
stem.includes("\0") ||
basename(stem) !== stem
) {
context.addIssue({
code: "custom",
path: ["files", "c", "path"],
message: "Export source must use the target's native basename",
});
return;
}
const expected = kunaExportFileNames(stem);
for (const role of Object.keys(expected) as (keyof typeof expected)[])
if (files[role].path !== expected[role])
context.addIssue({
code: "custom",
path: ["files", role, "path"],
message: "Export file does not match its producer role and basename",
});
});
/** Immutable description of one published export, written once at publish. */
export type KunaProjectManifest = z.infer<typeof manifestSchema>;
@@ -287,7 +340,13 @@ export class KunaProjectCache {
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 #verified = new Map<
string,
{
readonly project: KunaProject;
readonly manifestState: RegularFileState;
}
>();
readonly #pins = new Map<string, number>();
/** One eviction decision at a time; exports themselves remain concurrent. */
#eviction: Promise<void> = Promise.resolve();
@@ -364,6 +423,11 @@ export class KunaProjectCache {
key: string,
signal?: AbortSignal,
): Promise<KunaProject | undefined> {
if (!KEY_PATTERN.test(key))
throw new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
userMessage:
"Kuna cache lookup requires a content-addressed project key",
});
await this.#retryHashFiles(key);
const cleanup = this.#cleanupResult(this.#hashCleanupFailures(key));
if (!cleanup.ok) throw cleanup.error;
@@ -374,13 +438,27 @@ export class KunaProjectCache {
if (isMissing(cause)) return undefined;
throw cause;
}
const verified = this.#verified.get(key);
const projects = await this.#admitProjects(root);
if (projects === undefined) return undefined;
const admitted = await this.#admitEntry(projects, key);
if (admitted === undefined) {
await this.#discard(key);
return undefined;
}
let verified = this.#verified.get(key);
if (
verified !== undefined &&
!sameRegularFileState(verified.manifestState, admitted.manifest)
) {
this.#verified.delete(key);
verified = undefined;
}
if (verified !== undefined) {
try {
await assertKunaProjectState(
verified.directory,
verified.manifest.files,
verified.fileStates,
verified.project.directory,
verified.project.manifest.files,
verified.project.fileStates,
signal,
);
} catch (cause: unknown) {
@@ -388,19 +466,17 @@ export class KunaProjectCache {
await this.#discard(key);
return undefined;
}
if (await this.#touch(key)) return verified;
if (await this.#touch(key)) return verified.project;
this.#verified.delete(key);
return undefined;
}
const entry = join(root, "projects", key);
let text: string;
try {
text = await this.#readBounded(join(entry, "manifest.json"));
text = await readFile(join(entry, "manifest.json"), "utf8");
} catch (cause: unknown) {
if (isMissing(cause)) return undefined;
if (!(cause instanceof RangeError)) throw cause;
await this.#discard(key);
return undefined;
throw cause;
}
const manifest = parseManifest(text);
if (manifest?.key !== key) {
@@ -464,7 +540,10 @@ export class KunaProjectCache {
...parsed.value,
};
if (!(await this.#touch(key))) return undefined;
this.#verified.set(key, project);
this.#verified.set(key, {
project,
manifestState: regularFileState(admitted.manifest),
});
return project;
}
@@ -480,6 +559,7 @@ export class KunaProjectCache {
return err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION));
const key = kunaProjectKey({
targetSha256: request.targetSha256,
targetBasename: basename(request.targetPath),
kunaSha256: request.installation.sha256,
kunaCommand: request.installation.configuration.command,
kunaExecutable: request.installation.executable,
@@ -952,6 +1032,7 @@ export class KunaProjectCache {
key,
inputs: {
target_sha256: request.targetSha256,
target_basename: basename(request.targetPath),
kuna_sha256: request.installation.sha256,
kuna_command: request.installation.configuration.command,
kuna_executable: request.installation.executable,
@@ -981,6 +1062,9 @@ export class KunaProjectCache {
`${JSON.stringify(manifest, null, 2)}\n`,
{ mode: 0o600 },
);
const manifestState = regularFileState(
await lstat(join(staging, "entry", "manifest.json")),
);
const destination = join(await this.#resolveRoot(), "projects", key);
try {
await rename(join(staging, "entry"), destination);
@@ -998,7 +1082,7 @@ export class KunaProjectCache {
functions: exported.functions,
inventory: exported.inventory,
};
this.#verified.set(key, project);
this.#verified.set(key, { project, manifestState });
return ok(project);
}
@@ -1043,21 +1127,31 @@ export class KunaProjectCache {
async #evictUnpinned(keep: string | null): Promise<void> {
const root = await this.#resolveRoot();
const projects = await this.#admitProjects(root);
if (projects === undefined) {
this.#capacity = "within_limit";
return;
}
// 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(root, "projects"))) {
for (const name of await readdir(projects)) {
if (!KEY_PATTERN.test(name)) continue;
const path = join(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);
const status = await this.#admitEntry(projects, name);
const manifest =
status === undefined
? undefined
: await readFile(join(path, "manifest.json"), "utf8")
.then(parseManifest)
.catch((cause: unknown) => {
if (isMissing(cause)) return undefined;
throw cause;
});
// An owned entry without a valid manifest is unusable; evict it first.
entries.push(
manifest === undefined
manifest === undefined || status === undefined
? { key: name, bytes: 0, mtimeMs: -1 }
: {
key: name,
@@ -1090,10 +1184,12 @@ export class KunaProjectCache {
async #discard(key: string): Promise<void> {
this.#verified.delete(key);
const root = await this.#resolveRoot();
const projects = await this.#admitProjects(root);
if (projects === undefined) return;
const trash = join(root, "trash", `${key}-${randomUUID()}`);
try {
await mkdir(join(root, "trash"), { recursive: true, mode: 0o700 });
await rename(join(root, "projects", key), trash);
await rename(join(projects, key), trash);
} catch (cause: unknown) {
if (isMissing(cause)) return;
throw cause;
@@ -1126,13 +1222,53 @@ export class KunaProjectCache {
}
}
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");
/** A replaced projects parent does not confer ownership of its children. */
async #admitProjects(root: string): Promise<string | undefined> {
const projects = join(root, "projects");
const parent = await lstat(projects).catch((cause: unknown) => {
if (isMissing(cause)) return undefined;
throw cause;
});
if (parent === undefined) return undefined;
if (!parent.isDirectory() || parent.isSymbolicLink())
throw new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
userMessage: `Kuna cache project ownership is unknown: ${projects} is not a regular directory`,
});
return projects;
}
/** Admit the owned entry layout before reading any manifest or project bytes. */
async #admitEntry(
projects: string,
key: string,
): Promise<
| {
readonly mtimeMs: number;
readonly manifest: Stats;
}
| undefined
> {
const entry = join(projects, key);
try {
const status = await lstat(entry);
if (!status.isDirectory() || status.isSymbolicLink()) return undefined;
const project = await lstat(join(entry, "project"));
if (!project.isDirectory() || project.isSymbolicLink()) return undefined;
const manifest = await lstat(join(entry, "manifest.json"));
if (
!manifest.isFile() ||
manifest.isSymbolicLink() ||
manifest.size > MANIFEST_MAX_BYTES
)
return undefined;
return {
mtimeMs: status.mtimeMs,
manifest,
};
} catch (cause: unknown) {
if (isMissing(cause)) return undefined;
throw cause;
}
}
#retainFileCleanup(
+13 -8
View File
@@ -54,6 +54,18 @@ export const KUNA_EXPORT_OPERATION = "kuna:decompile-project";
const INVENTORY_FILE = "functions.json";
/** Exact flat file layout emitted for the selected target's native basename. */
export const kunaExportFileNames = (targetPath: string) => {
const stem = basename(targetPath);
return {
c: `${stem}.c`,
h: `${stem}.h`,
asm: `${stem}.asm`,
readme: "README.md",
inventory: INVENTORY_FILE,
} as const;
};
/** Identity of one file an export consists of. */
export interface KunaExportFile {
readonly path: string;
@@ -292,14 +304,7 @@ export const exportKunaProject = async (
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 names = kunaExportFileNames(request.targetPath);
const files: Partial<Record<keyof KunaExportFiles, KunaExportFile>> = {};
const fileStates: Partial<
Record<keyof KunaProjectFileStates, RegularFileState>
+2
View File
@@ -1,3 +1,4 @@
import { basename } from "node:path";
import {
regularFileCancellation,
RegularFileCleanupFailure,
@@ -1561,6 +1562,7 @@ export class KunaSessionClient implements AnalysisClient {
async #lookupOrBuild(installation: KunaInstallation): Promise<void> {
const key = kunaProjectKey({
targetSha256: this.#target.sha256,
targetBasename: basename(this.#target.path),
kunaSha256: installation.sha256,
kunaCommand: installation.configuration.command,
kunaExecutable: installation.executable,
@@ -13,7 +13,7 @@ import {
utimes,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { basename, dirname, join, relative } from "node:path";
import canonicalize from "canonicalize";
import { expect, it as test, vi } from "vitest";
@@ -86,6 +86,7 @@ const newCache = (installation: KunaInstallation, maxBytes = 64 * 1024 ** 2) =>
const keyOf = (installation: KunaInstallation, target: Target) =>
kunaProjectKey({
targetSha256: target.targetSha256,
targetBasename: basename(target.targetPath),
kunaSha256: installation.sha256,
kunaCommand: installation.configuration.command,
kunaExecutable: installation.executable,
@@ -118,84 +119,103 @@ const deferred = <T>() => {
return { promise, resolve, reject };
};
it("regenerates an obsolete export once and retires it without touching unrelated cache resources", async () => {
const { kuna, installation, cacheRoot, cache, target } = await fixture(
"rea-kuna-cache-format-transition-",
);
const input = await target("program");
const initial = await cache.build({ installation, ...input });
if (!initial.ok) throw initial.error;
expect((await cache.close()).ok).toBe(true);
// Seed the previously shipped manifest and content-key format using a real
// authored export; omitted launcher identity must never be invented or reused.
const legacyKey = createHash("sha256")
.update(
canonicalize({
layout: 1,
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
kuna_version: installation.version,
export: {
command: "decompile-project",
it.each([1, 2])(
"regenerates layout %i once and retires it without touching unrelated cache resources",
async (layout) => {
const { kuna, installation, cacheRoot, cache, target } = await fixture(
"rea-kuna-cache-format-transition-",
);
const input = await target("program");
const initial = await cache.build({ installation, ...input });
if (!initial.ok) throw initial.error;
expect((await cache.close()).ok).toBe(true);
// Seed the previously shipped manifest and content-key format using a real
// authored export; omitted launcher identity must never be invented or reused.
const legacyKey = createHash("sha256")
.update(
canonicalize({
layout,
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
...(layout === 2
? {
kuna_command: installation.configuration.command,
kuna_executable: installation.executable,
}
: {}),
kuna_version: installation.version,
export: {
command: "decompile-project",
jobs: installation.configuration.jobs,
},
}) ?? "",
)
.digest("hex");
const legacyEntry = join(cacheRoot, "projects", legacyKey);
await rename(join(cacheRoot, "projects", initial.value.key), legacyEntry);
await writeFile(
join(legacyEntry, "manifest.json"),
JSON.stringify({
...initial.value.manifest,
layout,
key: legacyKey,
inputs: {
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
...(layout === 2
? {
kuna_command: installation.configuration.command,
kuna_executable: installation.executable,
}
: {}),
kuna_version: installation.version,
jobs: installation.configuration.jobs,
},
}) ?? "",
)
.digest("hex");
const legacyEntry = join(cacheRoot, "projects", legacyKey);
await rename(join(cacheRoot, "projects", initial.value.key), legacyEntry);
await writeFile(
join(legacyEntry, "manifest.json"),
JSON.stringify({
...initial.value.manifest,
layout: 1,
key: legacyKey,
inputs: {
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
kuna_version: installation.version,
jobs: installation.configuration.jobs,
},
}),
);
const unknown = join(cacheRoot, "projects", "unrelated-directory");
const unresolved = join(cacheRoot, "staging", "build-unresolved");
await mkdir(unknown);
await mkdir(unresolved);
await writeFile(join(unknown, "sentinel"), "preserve");
await writeFile(join(unresolved, "sentinel"), "preserve");
const migrated = newCache(installation);
try {
const built = await migrated.build({ installation, ...input });
if (!built.ok) throw built.error;
await expect(access(legacyEntry)).rejects.toMatchObject({ code: "ENOENT" });
expect(built.value.key).not.toBe(legacyKey);
expect(built.value.manifest).toMatchObject({
layout: 2,
inputs: {
kuna_command: installation.configuration.command,
kuna_executable: installation.executable,
},
});
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
const fresh = newCache(installation);
try {
const reused = await fresh.build({ installation, ...input });
if (!reused.ok) throw reused.error;
expect(reused.value.manifest).toEqual(built.value.manifest);
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
} finally {
expect((await fresh.close()).ok).toBe(true);
}
expect(await readFile(join(unknown, "sentinel"), "utf8")).toBe("preserve");
expect(await readFile(join(unresolved, "sentinel"), "utf8")).toBe(
"preserve",
}),
);
expect(await entries(cacheRoot, "trash")).toEqual([]);
} finally {
expect((await migrated.close()).ok).toBe(true);
}
});
const unknown = join(cacheRoot, "projects", "unrelated-directory");
const unresolved = join(cacheRoot, "staging", "build-unresolved");
await mkdir(unknown);
await mkdir(unresolved);
await writeFile(join(unknown, "sentinel"), "preserve");
await writeFile(join(unresolved, "sentinel"), "preserve");
const migrated = newCache(installation);
try {
const built = await migrated.build({ installation, ...input });
if (!built.ok) throw built.error;
await expect(access(legacyEntry)).rejects.toMatchObject({
code: "ENOENT",
});
expect(built.value.key).not.toBe(legacyKey);
expect(built.value.manifest).toMatchObject({
layout: 3,
inputs: {
kuna_command: installation.configuration.command,
kuna_executable: installation.executable,
},
});
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
const fresh = newCache(installation);
try {
const reused = await fresh.build({ installation, ...input });
if (!reused.ok) throw reused.error;
expect(reused.value.manifest).toEqual(built.value.manifest);
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
} finally {
expect((await fresh.close()).ok).toBe(true);
}
expect(await readFile(join(unknown, "sentinel"), "utf8")).toBe(
"preserve",
);
expect(await readFile(join(unresolved, "sentinel"), "utf8")).toBe(
"preserve",
);
expect(await entries(cacheRoot, "trash")).toEqual([]);
} finally {
expect((await migrated.close()).ok).toBe(true);
}
},
);
const causesOf = (cause: unknown): unknown[] => {
const pending = [cause];
@@ -235,7 +255,7 @@ it("publishes a verified export and serves later requests from the cache", async
value: {
key: keyOf(installation, input),
manifest: {
layout: 2,
layout: 3,
inputs: {
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
@@ -1248,3 +1268,246 @@ it("invalidates a warm export when same-length content changes", async () => {
expect(await cache.close()).toMatchObject({ ok: true });
}
});
it("refuses escaped manifest paths before reading export files and preserves the outside project", async () => {
const { installation, cacheRoot, cache, target } = await fixture(
"rea-kuna-escaped-manifest-",
);
const built = await cache.build({
installation,
...(await target("program")),
});
if (!built.ok) throw built.error;
expect((await cache.close()).ok).toBe(true);
const outside = join(dirname(cacheRoot), "saved-project");
await rename(built.value.directory, outside);
await mkdir(built.value.directory);
const manifest = structuredClone(built.value.manifest);
for (const file of Object.values(manifest.files))
file.path = relative(built.value.directory, join(outside, file.path));
await writeFile(
join(dirname(built.value.directory), "manifest.json"),
JSON.stringify(manifest),
);
const hashes = vi.spyOn(stableFileHash, "observeStableFile");
const reopened = newCache(installation);
try {
expect(await reopened.lookup(built.value.key)).toBeUndefined();
expect(hashes).not.toHaveBeenCalled();
await expect(access(dirname(built.value.directory))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(join(outside, "program.c"), "utf8")).toContain(
"// Function: main",
);
} finally {
hashes.mockRestore();
expect((await reopened.close()).ok).toBe(true);
}
});
it.each(["h", "readme", "inventory"] as const)(
"refuses a manifest's mismatched %s role before hashing files",
async (role) => {
const { installation, cache, target } = await fixture(
"rea-kuna-role-manifest-",
);
const built = await cache.build({
installation,
...(await target("program")),
});
if (!built.ok) throw built.error;
expect((await cache.close()).ok).toBe(true);
const manifest = structuredClone(built.value.manifest);
manifest.files[role].path =
role === "h" ? "different.h" : `saved-${manifest.files[role].path}`;
await writeFile(
join(dirname(built.value.directory), "manifest.json"),
JSON.stringify(manifest),
);
const hashes = vi.spyOn(stableFileHash, "observeStableFile");
const reopened = newCache(installation);
try {
expect(await reopened.lookup(built.value.key)).toBeUndefined();
expect(hashes).not.toHaveBeenCalled();
} finally {
hashes.mockRestore();
expect((await reopened.close()).ok).toBe(true);
}
},
);
it.each(["entry", "project", "manifest"] as const)(
"refuses a symlinked %s on cold and verified lookup without touching its target",
async (kind) => {
for (const verified of [false, true]) {
const { installation, cacheRoot, cache, target } = await fixture(
"rea-kuna-linked-entry-",
);
const built = await cache.build({
installation,
...(await target("program")),
});
if (!built.ok) throw built.error;
expect((await cache.close()).ok).toBe(true);
const reopened = newCache(installation);
if (verified)
expect(await reopened.lookup(built.value.key)).toBeDefined();
const entry = dirname(built.value.directory);
const selected =
kind === "entry"
? entry
: kind === "project"
? built.value.directory
: join(entry, "manifest.json");
const outside = join(dirname(cacheRoot), `saved-${kind}`);
await rename(selected, outside);
await symlink(outside, selected);
const preserved =
kind === "entry"
? join(outside, "project", "program.c")
: kind === "project"
? join(outside, "program.c")
: outside;
const before = await readFile(preserved);
const hashes = vi.spyOn(stableFileHash, "observeStableFile");
try {
expect(await reopened.lookup(built.value.key)).toBeUndefined();
expect(hashes).not.toHaveBeenCalled();
expect((await readFile(preserved)).equals(before)).toBe(true);
await expect(access(entry)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
hashes.mockRestore();
expect((await reopened.close()).ok).toBe(true);
}
}
},
);
it("keeps an empty symlinked projects parent outside its removal authority", async () => {
const { installation, cacheRoot, cache, target } = await fixture(
"rea-kuna-linked-projects-",
);
const built = await cache.build({
installation,
...(await target("program")),
});
if (!built.ok) throw built.error;
expect((await cache.close()).ok).toBe(true);
const outside = join(dirname(cacheRoot), "empty-projects");
await mkdir(outside);
await rename(join(cacheRoot, "projects"), join(cacheRoot, "saved-projects"));
await symlink(outside, join(cacheRoot, "projects"));
const reopened = newCache(installation);
try {
await expect(reopened.lookup(built.value.key)).rejects.toMatchObject({
_tag: "ProviderAdapterError",
userMessage: expect.stringContaining("ownership is unknown"),
});
expect(await readdir(outside)).toEqual([]);
await access(
join(cacheRoot, "saved-projects", built.value.key, "manifest.json"),
);
} finally {
expect((await reopened.close()).ok).toBe(true);
}
});
it("preserves a legitimate POSIX backslash in the common export basename", async () => {
const { installation, cache, target } = await fixture(
"rea-kuna-native-basename-",
);
const built = await cache.build({
installation,
...(await target("program\\name")),
});
if (!built.ok) throw built.error;
expect((await cache.close()).ok).toBe(true);
const reopened = newCache(installation);
try {
expect(await reopened.lookup(built.value.key)).toMatchObject({
manifest: {
files: {
c: { path: "program\\name.c" },
h: { path: "program\\name.h" },
asm: { path: "program\\name.asm" },
},
},
});
} finally {
expect((await reopened.close()).ok).toBe(true);
}
});
it.each(["invalid JSON", "changed producing inputs", "valid rewrite"] as const)(
"revalidates a warm cache after manifest %s",
async (change) => {
const { installation, cache, target } = await fixture(
"rea-kuna-warm-manifest-",
);
const built = await cache.build({
installation,
...(await target("program")),
});
if (!built.ok) throw built.error;
expect(await cache.lookup(built.value.key)).toBeDefined();
const manifest = structuredClone(built.value.manifest);
if (change === "changed producing inputs")
manifest.inputs.target_sha256 = "f".repeat(64);
await writeFile(
join(dirname(built.value.directory), "manifest.json"),
change === "invalid JSON"
? "{not JSON}"
: JSON.stringify(manifest, null, 3),
);
const hashes = vi.spyOn(stableFileHash, "observeStableFile");
try {
const found = await cache.lookup(built.value.key);
if (change === "valid rewrite") {
expect(found).toMatchObject({ key: built.value.key });
expect(hashes).toHaveBeenCalledTimes(5);
} else {
expect(found).toBeUndefined();
expect(hashes).not.toHaveBeenCalled();
}
} finally {
hashes.mockRestore();
expect((await cache.close()).ok).toBe(true);
}
},
);
it("separates equal target bytes whose native basenames change the export", async () => {
const { kuna, installation, cache, target } = await fixture(
"rea-kuna-basename-key-",
);
const first = await cache.build({
installation,
...(await target("first", "same bytes")),
});
const secondInput = await target("second", "same bytes");
const second = await cache.build({ installation, ...secondInput });
if (!first.ok) throw first.error;
if (!second.ok) throw second.error;
expect(second.value.key).not.toBe(first.value.key);
expect(first.value.manifest.inputs.target_basename).toBe("first");
expect(second.value.manifest.inputs.target_basename).toBe("second");
expect(first.value.manifest.files.c.path).toBe("first.c");
expect(second.value.manifest.files.c.path).toBe("second.c");
expect(first.value.manifest.files.c.sha256).not.toBe(
second.value.manifest.files.c.sha256,
);
expect(
await readFile(join(second.value.directory, "second.c"), "utf8"),
).toContain('#include "second.h"');
expect((await cache.close()).ok).toBe(true);
const reopened = newCache(installation);
try {
const reused = await reopened.build({ installation, ...secondInput });
if (!reused.ok) throw reused.error;
expect(reused.value.manifest).toEqual(second.value.manifest);
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
} finally {
expect((await reopened.close()).ok).toBe(true);
}
});