Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 13cc7a752b Merge origin/main into fix/kuna-doctor-dangling-cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:05:46 -07:00
Kaoru0822andClaude Opus 5.5 bd1d281107 fix(kuna): reject a dangling cache root link in doctor
Doctor followed the cache root with stat and treated ENOENT as an absent,
creatable root, so a dangling symbolic link under a writable parent passed
even though the cache's recursive mkdir fails. The nearest-directory walk now
starts at the root itself and only steps to a parent when lstat confirms no
entry occupies the name; a dangling link, at the root or any ancestor, fails
the cache check without creating anything or touching the link.

Fixes #5554

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:49:41 +00:00
2 changed files with 74 additions and 9 deletions

No files matched your search

+30 -8
View File
@@ -1,5 +1,5 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { access, lstat, stat } from "node:fs/promises";
import { dirname } from "node:path";
import type {
@@ -240,7 +240,7 @@ const cacheCheck = async (
};
await access(cacheRoot, constants.W_OK | constants.X_OK);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
if (isMissing(cause)) {
const creatable = await nearestDirectoryWritable(cacheRoot);
return creatable.ok
? {
@@ -285,13 +285,17 @@ const cacheCheck = async (
};
};
/** Whether the nearest existing ancestor is a directory REA may create entries in. */
/**
* Whether the nearest existing entry on `path` is a directory REA may create
* entries in. A missing target is only absent when no entry occupies its name;
* a dangling symbolic link blocks creation just as a regular file does.
*/
const nearestDirectoryWritable = async (
path: string,
): Promise<
{ readonly ok: true } | { readonly ok: false; readonly reason: string }
> => {
let candidate = dirname(path);
let candidate = path;
for (;;) {
try {
if (!(await stat(candidate)).isDirectory())
@@ -299,15 +303,33 @@ const nearestDirectoryWritable = async (
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)
const absent = isMissing(cause) && (await entryAbsent(candidate));
if (absent !== true || parent === candidate)
return {
ok: false,
reason: `${candidate} is not writable: ${cause instanceof Error ? cause.message : String(cause)}`,
reason:
typeof absent === "string"
? absent
: `${candidate} is not writable: ${cause instanceof Error ? cause.message : String(cause)}`,
};
candidate = parent;
}
}
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
/** True when nothing occupies `path`; otherwise why its name is unusable. */
const entryAbsent = async (path: string): Promise<true | string> => {
try {
return (await lstat(path)).isSymbolicLink()
? `${path} is a symbolic link whose target does not exist.`
: `${path} exists but cannot be resolved.`;
} catch (cause: unknown) {
return isMissing(cause)
? true
: `${path} cannot be inspected: ${cause instanceof Error ? cause.message : String(cause)}`;
}
};
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from "node:fs/promises";
import { lstat, mkdir, readlink, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
@@ -139,6 +139,49 @@ it("reports a cache root REA cannot create under its nearest existing parent", a
});
});
it("reports a dangling cache link as unusable and accepts a resolving link", async () => {
const root = await createTestTempDirectory("rea-kuna-doctor-link-");
const kuna = await writeFakeKuna(root);
const dangling = join(root, "dangling");
const missing = join(root, "missing-target");
await symlink(missing, dangling);
const target = join(root, "target");
await mkdir(target);
const resolving = join(root, "resolving");
await symlink(target, resolving);
const direct = await inspectKunaProvider({
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: dangling,
});
const beneath = await inspectKunaProvider({
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: join(dangling, "nested", "cache"),
});
const ready = await inspectKunaProvider({
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: resolving,
});
for (const blocked of [direct, beneath])
expect(blocked.checks[1]).toMatchObject({
name: "cache",
ok: false,
code: "open_options_invalid",
detail: expect.stringContaining(
`${dangling} is a symbolic link whose target does not exist.`,
),
});
expect(ready.checks[1]).toMatchObject({
name: "cache",
ok: true,
detail: expect.stringContaining(`cached in ${resolving}`),
});
// Diagnosis neither creates the cache nor repairs the link.
expect(await readlink(dangling)).toBe(missing);
await expect(lstat(missing)).rejects.toMatchObject({ code: "ENOENT" });
});
const doctorEnvironment = (root: string, kuna: string): NodeJS.ProcessEnv => ({
HOME: root,
PATH: "",