diff --git a/docs/installation.md b/docs/installation.md index af9a96bff3..987b07e30d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 4b0a4fd64b..0befd7f2dc 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -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 => { - 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, [ diff --git a/src/config/kunaCacheDirectory.ts b/src/config/kunaCacheDirectory.ts index 25a0f7f789..3418fb5fa1 100644 --- a/src/config/kunaCacheDirectory.ts +++ b/src/config/kunaCacheDirectory.ts @@ -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); +}; diff --git a/src/filesystem/callerPath.ts b/src/filesystem/callerPath.ts index fac1988bdc..a2ff309288 100644 --- a/src/filesystem/callerPath.ts +++ b/src/filesystem/callerPath.ts @@ -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 diff --git a/tests/acceptance/setup/kunaCachePurgeCli.test.ts b/tests/acceptance/setup/kunaCachePurgeCli.test.ts new file mode 100644 index 0000000000..e0a6bfa55f --- /dev/null +++ b/tests/acceptance/setup/kunaCachePurgeCli.test.ts @@ -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"); + }, +); diff --git a/tests/boundary/filesystem/callerPath.test.ts b/tests/boundary/filesystem/callerPath.test.ts index 57593c3ac3..25cc5eb35e 100644 --- a/tests/boundary/filesystem/callerPath.test.ts +++ b/tests/boundary/filesystem/callerPath.test.ts @@ -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( diff --git a/tests/boundary/filesystem/managedDataPurge.test.ts b/tests/boundary/filesystem/managedDataPurge.test.ts index 7fad45e752..5465247aeb 100644 --- a/tests/boundary/filesystem/managedDataPurge.test.ts +++ b/tests/boundary/filesystem/managedDataPurge.test.ts @@ -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?.(); + } +});