fix(setup): report Kuna cache aliases consistently (#7207)
fix(setup): report Kuna cache aliases consistently (#7207) Unify lexical default-cache reports without changing removal authority. Plan selections before deleting managed directories; resolve absolute POSIX paths independently of ambient cwd. Preserve parent traversal, literal backslashes, caller data, and result/removal order. Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
1 parent
2817d30ab2
commit
98422997c0
7 files changed
+318
-19
No files matched your search
@@ -444,6 +444,9 @@ and other MCP servers. Purging removes REA's legacy cache and state under
|
||||
archives under `${XDG_CACHE_HOME:-~/.cache}/rea/hopper-releases`. Other cache
|
||||
namespaces remain intact. A caller-selected `REA_KUNA_CACHE_DIR` is retained
|
||||
and reported because REA cannot establish ownership of its entire contents.
|
||||
Lexically equivalent spellings of that default directory, including a trailing
|
||||
separator, a `.` segment, or a repeated separator, share its purge result.
|
||||
The comparison preserves POSIX `..` traversal and literal backslashes.
|
||||
A client configuration that is malformed, unreadable, or at an unsafe
|
||||
path stops the operation before anything is removed, and a client that fails
|
||||
while being updated stops the remaining removals. Uninstall reports each
|
||||
|
||||
@@ -20,7 +20,10 @@ import { copyFile, lstat, realpath, rm, rmdir } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { defaultKunaCacheDirectory } from "../config/kunaCacheDirectory.js";
|
||||
import {
|
||||
defaultKunaCacheDirectory,
|
||||
sameLexicalDirectory,
|
||||
} from "../config/kunaCacheDirectory.js";
|
||||
import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js";
|
||||
|
||||
import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js";
|
||||
@@ -1029,17 +1032,20 @@ const purgeSelectedData = async (
|
||||
platform: NodeJS.Platform,
|
||||
fileSystem: UninstallFileSystem,
|
||||
): Promise<readonly UninstallItem[]> => {
|
||||
const items = [
|
||||
...(await purgeManagedData(appendCallerPath(home, ".rea"), fileSystem, [
|
||||
"cache",
|
||||
"state",
|
||||
])),
|
||||
];
|
||||
const items: UninstallItem[] = [];
|
||||
const directories: string[] = [];
|
||||
if (platform === "darwin" || platform === "linux") {
|
||||
directories.push(defaultKunaCacheDirectory(home, environment, platform));
|
||||
const defaultCache = defaultKunaCacheDirectory(home, environment, platform);
|
||||
directories.push(defaultCache);
|
||||
const custom = environment.REA_KUNA_CACHE_DIR;
|
||||
if (custom !== undefined && custom !== directories[0])
|
||||
// Lexical spellings identify the default namespace for reporting.
|
||||
// Removal still uses the owned-path checks below. Other selections
|
||||
// remain separately reported without resolving symbolic links.
|
||||
if (
|
||||
custom !== undefined &&
|
||||
custom !== defaultCache &&
|
||||
!sameLexicalDirectory(custom, defaultCache, platform)
|
||||
)
|
||||
items.push(
|
||||
item(
|
||||
"kuna-custom-cache",
|
||||
@@ -1053,6 +1059,14 @@ const purgeSelectedData = async (
|
||||
if (hopper.ok) directories.push(hopper.value);
|
||||
else items.push(item("hopper-releases", "failed", hopper.error.message));
|
||||
}
|
||||
// Resolve selections before a managed namespace removal can delete cwd.
|
||||
// Keep the existing result and removal order after planning those facts.
|
||||
items.unshift(
|
||||
...(await purgeManagedData(appendCallerPath(home, ".rea"), fileSystem, [
|
||||
"cache",
|
||||
"state",
|
||||
])),
|
||||
);
|
||||
for (const directory of directories)
|
||||
items.push(
|
||||
...(await purgeManagedData(dirname(directory), fileSystem, [
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||
import { isAbsolute, posix, win32 } from "node:path";
|
||||
import {
|
||||
absoluteCallerPath,
|
||||
appendCallerPath,
|
||||
} from "../filesystem/callerPath.js";
|
||||
|
||||
/** Per-user cache directory that REA owns for Kuna on this host. */
|
||||
export const defaultKunaCacheDirectory = (
|
||||
@@ -16,3 +19,28 @@ export const defaultKunaCacheDirectory = (
|
||||
: appendCallerPath(home, ".cache", platform);
|
||||
return appendCallerPath(root, "rea/kuna", platform);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lexical directory identity. Keeps POSIX parent traversal distinct because
|
||||
* collapsing it can change directory identity across symbolic links.
|
||||
* On Darwin and Linux, `\` is a filename character, so only `/` is removed.
|
||||
*/
|
||||
export const sameLexicalDirectory = (
|
||||
left: string,
|
||||
right: string,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean =>
|
||||
lexicalDirectory(left, platform) === lexicalDirectory(right, platform);
|
||||
|
||||
const lexicalDirectory = (value: string, platform: NodeJS.Platform): string => {
|
||||
const pathApi = platform === "win32" ? win32 : posix;
|
||||
const normalized = absoluteCallerPath(value, { platform });
|
||||
const root = pathApi.parse(normalized).root;
|
||||
let end = normalized.length;
|
||||
while (
|
||||
end > root.length &&
|
||||
normalized.charCodeAt(end - 1) === pathApi.sep.charCodeAt(0)
|
||||
)
|
||||
end -= 1;
|
||||
return normalized.slice(0, end);
|
||||
};
|
||||
@@ -22,20 +22,22 @@ export const appendCallerPath = (
|
||||
* change the named file; every `..` is kept for the filesystem to resolve, and
|
||||
* a trailing `/` or `/.` keeps its directory requirement as one trailing `/`.
|
||||
* A relative `cwd` is made absolute the same way. Win32 resolves `..`
|
||||
* lexically, so normalizing there is exact.
|
||||
* lexically, so normalizing there is exact. Absolute POSIX paths never need
|
||||
* the ambient cwd, which may already have been removed during cleanup.
|
||||
*/
|
||||
export const absoluteCallerPath = (
|
||||
path: string,
|
||||
{
|
||||
cwd = process.cwd(),
|
||||
cwd,
|
||||
platform = process.platform,
|
||||
}: { readonly cwd?: string; readonly platform?: NodeJS.Platform } = {},
|
||||
): string => {
|
||||
if (platform === "win32") return win32.resolve(cwd, path);
|
||||
if (platform === "win32") return win32.resolve(cwd ?? process.cwd(), path);
|
||||
if (!posix.isAbsolute(path)) {
|
||||
const base = posix.isAbsolute(cwd)
|
||||
? cwd
|
||||
: absoluteCallerPath(cwd, { platform });
|
||||
const selectedCwd = cwd ?? process.cwd();
|
||||
const base = posix.isAbsolute(selectedCwd)
|
||||
? selectedCwd
|
||||
: absoluteCallerPath(selectedCwd, { platform });
|
||||
return absoluteCallerPath(`${base}/${path}`, { platform });
|
||||
}
|
||||
const segments = path
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest
|
||||
.runIf(process.platform === "linux" || process.platform === "darwin")
|
||||
.for([
|
||||
{ label: "default directory alias", suffix: "/", retained: false },
|
||||
{ label: "literal POSIX backslash", suffix: "\\", retained: true },
|
||||
])(
|
||||
"reports actual Kuna purge effects for $label",
|
||||
async ({ suffix, retained }, { cli }) => {
|
||||
const home = await createTestTempDirectory("rea-kuna-purge-cli-");
|
||||
const cacheRoot = join(
|
||||
home,
|
||||
process.platform === "darwin" ? "Library/Caches" : "cache",
|
||||
);
|
||||
const cache = join(cacheRoot, "rea", "kuna");
|
||||
const custom = `${cache}${suffix}`;
|
||||
await mkdir(cache, { recursive: true });
|
||||
await mkdir(custom, { recursive: true });
|
||||
await writeFile(join(cache, "owned"), "owned cache");
|
||||
await writeFile(join(custom, "selected"), "caller evidence");
|
||||
const sibling = join(cacheRoot, "rea", "caller-evidence");
|
||||
await writeFile(sibling, "unrelated evidence");
|
||||
|
||||
const result = await cli.run({
|
||||
arguments: ["uninstall", "--purge-data", "--format", "json"],
|
||||
cwd: home,
|
||||
environment: {
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
XDG_CACHE_HOME: cacheRoot,
|
||||
XDG_DATA_HOME: join(home, ".local/share"),
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system/gemini.json"),
|
||||
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(
|
||||
home,
|
||||
"system/gemini-defaults.json",
|
||||
),
|
||||
GEMINI_CLI_TRUSTED_FOLDERS_PATH: join(home, ".gemini/trusted.json"),
|
||||
REA_KUNA_CACHE_DIR: custom,
|
||||
},
|
||||
});
|
||||
expect(result.exitCode, result.stdout + result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
status: "complete",
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "kuna", status: "removed" }),
|
||||
]),
|
||||
});
|
||||
if (retained) {
|
||||
expect(result.json).toMatchObject({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "kuna-custom-cache",
|
||||
status: "retained",
|
||||
detail: expect.stringContaining(custom),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(await readFile(join(custom, "selected"), "utf8")).toBe(
|
||||
"caller evidence",
|
||||
);
|
||||
} else {
|
||||
expect(result.json).not.toMatchObject({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "kuna-custom-cache" }),
|
||||
]),
|
||||
});
|
||||
await expect(readFile(join(custom, "selected"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
await expect(readFile(join(cache, "owned"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(await readFile(sibling, "utf8")).toBe("unrelated evidence");
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,23 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js";
|
||||
|
||||
it("resolves absolute POSIX paths independently of an unavailable cwd", () => {
|
||||
const cwd = vi.spyOn(process, "cwd").mockImplementation(() => {
|
||||
throw new Error("working directory was removed");
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
absoluteCallerPath("/cache//rea/kuna/.", { platform: "linux" }),
|
||||
).toBe("/cache/rea/kuna/");
|
||||
expect(
|
||||
absoluteCallerPath("sample", { cwd: "/explicit", platform: "linux" }),
|
||||
).toBe("/explicit/sample");
|
||||
} finally {
|
||||
cwd.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps POSIX parent components for the filesystem to resolve", () => {
|
||||
const posix = { cwd: "/work", platform: "linux" } as const;
|
||||
expect(absoluteCallerPath("link/../sample", posix)).toBe(
|
||||
|
||||
@@ -1,10 +1,77 @@
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
{ name: "cache", relative: false },
|
||||
{ name: "state", relative: false },
|
||||
{ name: "cache", relative: true },
|
||||
{ name: "state", relative: true },
|
||||
])(
|
||||
"finishes purging after $name removes the working directory (relative selection: $relative)",
|
||||
async ({ name, relative }) => {
|
||||
const home = await createTestTempDirectory("rea-managed-cache-cwd-");
|
||||
const cwd = join(home, ".rea", name);
|
||||
const cacheRoot = join(home, "cache");
|
||||
const cache = join(cacheRoot, "rea", "kuna");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await mkdir(cache, { recursive: true });
|
||||
await writeFile(join(cache, "cached"), "owned cache");
|
||||
const callerCache = join(home, "caller-cache");
|
||||
await mkdir(callerCache);
|
||||
await writeFile(join(callerCache, "evidence"), "caller evidence");
|
||||
const selected = relative ? "../../caller-cache" : `${cache}/`;
|
||||
const moduleUrl = new URL(
|
||||
"../../../dist/application/Uninstall.js",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const result = await execFileOutput(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import { systemUninstallHost } from ${JSON.stringify(moduleUrl)};
|
||||
const [home, cwd, cacheRoot, selected] = process.argv.slice(1);
|
||||
const host = systemUninstallHost(home, undefined, {
|
||||
XDG_CACHE_HOME: cacheRoot, REA_KUNA_CACHE_DIR: selected,
|
||||
}, "linux");
|
||||
// Reset Node's cwd cache after module loading, before the real removal.
|
||||
process.chdir(cwd);
|
||||
try { console.log(JSON.stringify(await host.purgeData())); }
|
||||
finally { await host.close?.(); }`,
|
||||
home,
|
||||
cwd,
|
||||
cacheRoot,
|
||||
selected,
|
||||
],
|
||||
{ timeout: 10_000, maxBuffer: 64 * 1024, stopSignal: "SIGTERM" },
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name, status: "removed" }),
|
||||
expect.objectContaining({ name: "kuna", status: "removed" }),
|
||||
]),
|
||||
);
|
||||
await expect(lstat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(cache)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(join(callerCache, "evidence"), "utf8")).toBe(
|
||||
"caller evidence",
|
||||
);
|
||||
if (relative)
|
||||
expect(JSON.parse(result.stdout)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "kuna-custom-cache",
|
||||
status: "retained",
|
||||
detail: expect.stringContaining(selected),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["darwin", "linux"] as const)(
|
||||
"purges producer-owned default cache namespaces on %s and preserves their siblings",
|
||||
async (platform) => {
|
||||
@@ -90,6 +157,53 @@ it("retains an arbitrary caller-selected Kuna cache and refuses a linked managed
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "trailing separator",
|
||||
spelling: (cache: string) => `${cache}/`,
|
||||
},
|
||||
{
|
||||
label: "dot segment",
|
||||
spelling: (cache: string) => `${cache}/.`,
|
||||
},
|
||||
{
|
||||
label: "duplicate separator",
|
||||
spelling: (cache: string) =>
|
||||
`${cache.slice(0, cache.lastIndexOf("kuna"))}/${"kuna"}`,
|
||||
},
|
||||
])(
|
||||
"purges a default Kuna cache selected with a $label",
|
||||
async ({ spelling }) => {
|
||||
const home = await createTestTempDirectory("rea-managed-cache-spelling-");
|
||||
const root = join(home, "Library/Caches");
|
||||
const cache = join(root, "rea", "kuna");
|
||||
await mkdir(cache, { recursive: true });
|
||||
await writeFile(join(cache, "cached"), "REA cache");
|
||||
const host = systemUninstallHost(
|
||||
home,
|
||||
undefined,
|
||||
{
|
||||
REA_KUNA_CACHE_DIR: spelling(cache),
|
||||
},
|
||||
"darwin",
|
||||
);
|
||||
try {
|
||||
const result = await host.purgeData();
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({ name: "kuna-custom-cache" }),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ name: "kuna", status: "removed" }),
|
||||
);
|
||||
await expect(readFile(join(cache, "cached"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await host.close?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports the Hopper producer's invalid cache selection rather than silently falling back", async () => {
|
||||
const home = await createTestTempDirectory("rea-managed-cache-invalid-");
|
||||
const fallback = join(home, ".cache", "rea", "hopper-releases");
|
||||
@@ -114,3 +228,43 @@ it("reports the Hopper producer's invalid cache selection rather than silently f
|
||||
await host.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a retained Kuna cache reached through symlink parent traversal", async () => {
|
||||
const home = await createTestTempDirectory("rea-managed-cache-parent-");
|
||||
const root = join(home, "cache");
|
||||
const cache = join(root, "rea", "kuna");
|
||||
const external = join(home, "external");
|
||||
await mkdir(cache, { recursive: true });
|
||||
await mkdir(join(external, "subdir"), { recursive: true });
|
||||
await mkdir(join(external, "kuna"));
|
||||
await writeFile(join(cache, "cached"), "owned cache");
|
||||
await writeFile(join(external, "kuna", "evidence"), "caller evidence");
|
||||
await symlink(join(external, "subdir"), join(root, "rea", "alias"), "dir");
|
||||
const custom = `${root}/rea/alias/../kuna`;
|
||||
const host = systemUninstallHost(
|
||||
home,
|
||||
undefined,
|
||||
{ XDG_CACHE_HOME: root, REA_KUNA_CACHE_DIR: custom },
|
||||
"linux",
|
||||
);
|
||||
try {
|
||||
const result = await host.purgeData();
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "kuna-custom-cache",
|
||||
status: "retained",
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ name: "kuna", status: "removed" }),
|
||||
);
|
||||
expect(await readFile(join(external, "kuna", "evidence"), "utf8")).toBe(
|
||||
"caller evidence",
|
||||
);
|
||||
await expect(readFile(join(cache, "cached"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await host.close?.();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user