From 8b7ee48a46ba4176f4bf19225dc6c80efb020335 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:23:39 +0800 Subject: [PATCH] fix(kuna): bind cache exports to their producing inputs --- docs/kuna-provider.md | 14 +- src/kuna/KunaProjectCache.ts | 246 ++++++++--- src/kuna/KunaProjectExport.ts | 21 +- src/kuna/KunaSessionClient.ts | 2 + .../providers/kuna/kunaProjectCache.test.ts | 417 ++++++++++++++---- 5 files changed, 557 insertions(+), 143 deletions(-) diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index 29b740d4d0..1a6ea5204b 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -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. diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index 8501fef9e6..2070c2ff5a 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -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; @@ -287,7 +340,13 @@ export class KunaProjectCache { readonly #inflight = new Map(); /** Every build not yet settled, including abandoned ones a retry replaced. */ readonly #unfinished = new Set(); - readonly #verified = new Map(); + readonly #verified = new Map< + string, + { + readonly project: KunaProject; + readonly manifestState: RegularFileState; + } + >(); readonly #pins = new Map(); /** One eviction decision at a time; exports themselves remain concurrent. */ #eviction: Promise = Promise.resolve(); @@ -364,6 +423,11 @@ export class KunaProjectCache { key: string, signal?: AbortSignal, ): Promise { + 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 { 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 { 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 { - 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 { + 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( diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 781a428f22..ea2376d281 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -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> = {}; const fileStates: Partial< Record diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 39c87836ab..ca9ee6f86f 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -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 { const key = kunaProjectKey({ targetSha256: this.#target.sha256, + targetBasename: basename(this.#target.path), kunaSha256: installation.sha256, kunaCommand: installation.configuration.command, kunaExecutable: installation.executable, diff --git a/tests/boundary/providers/kuna/kunaProjectCache.test.ts b/tests/boundary/providers/kuna/kunaProjectCache.test.ts index 59256815fb..0d1b9c92f3 100644 --- a/tests/boundary/providers/kuna/kunaProjectCache.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectCache.test.ts @@ -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 = () => { 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); + } +});