From c1df51c7cbc23f29f917c2196b3941ea0a3c4bfc Mon Sep 17 00:00:00 2001 From: Hasan Zayni <36798269+dark13b@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:56:12 +0300 Subject: [PATCH] fix(uninstall): preserve caller-selected Kuna caches during purge (#7921) * fix(uninstall): keep a managed cache namespace whole when it holds a caller-selected Kuna cache purge --purge-data reported a REA_KUNA_CACHE_DIR selection below the default Kuna cache as retained, then removed it together with its parent namespace. A namespace that contains the selection is now retained and reported, so the retained report is true. Fixes #7344 * fix(uninstall): also keep a namespace equal to, or reached by symlink traversal from, the selected Kuna cache Compare the namespace and the selection with the same normalization, treat an equal path as contained, and resolve existing paths before planning so a symbolic link followed by .. is judged by where it leads. * fix(uninstall): judge a selection with parent traversal where the filesystem resolves it Collapsing .. lexically contradicts the existing rule that parent traversal after a symbolic link leads elsewhere, and broke the retained-through-symlink-traversal test. Compare spellings without .. directly and resolve existing paths for the rest, so the symlink-parent case still purges the default cache while a selection that resolves into a managed namespace is retained. * fix(uninstall): keep a namespace whole when the selection's location is unknown A realpath failure other than ENOENT/ENOTDIR (for example EACCES under an inaccessible parent) no longer counts as "absent". Each namespace that may contain the caller-selected Kuna cache is retained instead of purged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/application/Uninstall.ts | 93 ++++++++-- src/config/kunaCacheDirectory.ts | 23 +++ .../filesystem/managedDataPurge.test.ts | 166 +++++++++++++++++- 3 files changed, 268 insertions(+), 14 deletions(-) diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 43c2196fd4..c506ca1faa 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -23,7 +23,9 @@ import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { basename, dirname } from "node:path"; import { defaultKunaCacheDirectory, + hasParentTraversal, sameLexicalDirectory, + withinLexicalDirectory, } from "../config/kunaCacheDirectory.js"; import { defaultDarwinHelperCacheDirectory } from "../process/DarwinHelperCache.js"; import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js"; @@ -1144,10 +1146,11 @@ const purgeSelectedData = async ( ): Promise => { const items: UninstallItem[] = []; const directories: string[] = []; + let custom: string | undefined; if (platform === "darwin" || platform === "linux") { const defaultCache = defaultKunaCacheDirectory(home, environment, platform); directories.push(defaultCache); - const custom = environment.REA_KUNA_CACHE_DIR; + custom = environment.REA_KUNA_CACHE_DIR; // 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. @@ -1163,6 +1166,7 @@ const purgeSelectedData = async ( `Caller-selected Kuna cache ${custom} was retained because purge does not establish ownership of that entire directory.`, ), ); + else custom = undefined; } // The ownership helper cache has no caller-selected location to retain. if (platform === "darwin") @@ -1173,22 +1177,85 @@ const purgeSelectedData = async ( 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, [ - basename(directory), - ])), + // A namespace that contains the caller-selected cache is kept whole, so + // the retained report is true. + const reaRoot = appendCallerPath(home, ".rea"); + // A spelling with `..` is judged only where the filesystem resolves it, + // because collapsing it lexically is wrong after a symbolic link. + // `undefined` means the location could not be established. + const resolve = async (path: string): Promise => { + const resolved = await existingRealpath(fileSystem, path); + return resolved === undefined + ? undefined + : [...(hasParentTraversal(path) ? [] : [path]), ...resolved]; + }; + const selections = custom === undefined ? [] : await resolve(custom); + const purge = async (root: string, names: readonly string[]) => { + const kept: { readonly name: string; readonly unknown: boolean }[] = []; + for (const name of names) { + const targets = await resolve(appendCallerPath(root, name)); + // An unestablished selection or namespace may contain the selection, + // so it is kept rather than treated as absent. + const contains = + custom !== undefined && + (selections === undefined || + targets === undefined || + targets.some((target) => + selections.some((selection) => + withinLexicalDirectory(selection, target, platform), + ), + )); + if (contains) + kept.push({ + name, + unknown: selections === undefined || targets === undefined, + }); + } + const keptItems = kept.map(({ name, unknown }) => + item( + name, + "retained", + unknown + ? `${appendCallerPath(root, name)} was retained because purge could not establish whether it contains the caller-selected Kuna cache ${custom}.` + : `${appendCallerPath(root, name)} was retained because it contains the caller-selected Kuna cache ${custom}, which purge does not remove.`, + ), ); + const owned = names.filter( + (name) => !kept.some((entry) => entry.name === name), + ); + return async () => [ + ...keptItems, + ...(await purgeManagedData(root, fileSystem, owned)), + ]; + }; + const reaPlan = await purge(reaRoot, ["cache", "state"]); + const plans: (() => Promise)[] = []; + for (const directory of directories) + plans.push(await purge(dirname(directory), [basename(directory)])); + items.unshift(...(await reaPlan())); + for (const plan of plans) items.push(...(await plan())); return items; }; +/** + * Canonical spelling of an existing path, nothing when the path is absent, + * or `undefined` when its location cannot be established (for example an + * inaccessible parent, or `..` without a realpath to judge it). + */ +const existingRealpath = async ( + fileSystem: UninstallFileSystem, + path: string, +): Promise => { + if (fileSystem.realpath === undefined) + return hasParentTraversal(path) ? undefined : []; + try { + return [await fileSystem.realpath(path)]; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR" ? [] : undefined; + } +}; + const purgeManagedData = async ( root: string, fileSystem: UninstallFileSystem, diff --git a/src/config/kunaCacheDirectory.ts b/src/config/kunaCacheDirectory.ts index 3418fb5fa1..f40a9faac1 100644 --- a/src/config/kunaCacheDirectory.ts +++ b/src/config/kunaCacheDirectory.ts @@ -32,6 +32,29 @@ export const sameLexicalDirectory = ( ): boolean => lexicalDirectory(left, platform) === lexicalDirectory(right, platform); +/** + * True when `child` is `parent` or is spelled below it. Parent traversal is + * never collapsed, because after a symbolic link it leads somewhere else: + * callers resolve such spellings through the filesystem instead. + */ +export const withinLexicalDirectory = ( + child: string, + parent: string, + platform: NodeJS.Platform, +): boolean => { + const base = lexicalDirectory(parent, platform); + const inner = lexicalDirectory(child, platform); + const separator = (platform === "win32" ? win32 : posix).sep; + return ( + inner === base || + inner.startsWith(base.endsWith(separator) ? base : base + separator) + ); +}; + +/** True when a POSIX spelling contains a `..` segment, whose meaning only the filesystem knows. */ +export const hasParentTraversal = (value: string): boolean => + value.split("/").includes(".."); + const lexicalDirectory = (value: string, platform: NodeJS.Platform): string => { const pathApi = platform === "win32" ? win32 : posix; const normalized = absoluteCallerPath(value, { platform }); diff --git a/tests/boundary/filesystem/managedDataPurge.test.ts b/tests/boundary/filesystem/managedDataPurge.test.ts index e5dd717930..705fccbe7f 100644 --- a/tests/boundary/filesystem/managedDataPurge.test.ts +++ b/tests/boundary/filesystem/managedDataPurge.test.ts @@ -1,4 +1,11 @@ -import { lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + lstat, + mkdir, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -284,3 +291,160 @@ it("reports a retained Kuna cache reached through symlink parent traversal", asy await host.close?.(); } }); + +/** One caller selection that purge must not delete, with the namespace that holds it. */ +interface NestedSelection { + readonly label: string; + readonly namespace: "kuna" | "cache"; + readonly skip?: boolean; + /** Create the selection's files and return the spellings purge receives. */ + readonly prepare: (home: string) => Promise<{ + readonly cacheHome: string; + readonly custom: string; + readonly evidence: string; + }>; +} + +const withEvidence = async (directory: string): Promise => { + await mkdir(directory, { recursive: true }); + const evidence = join(directory, "evidence"); + await writeFile(evidence, "caller evidence"); + return evidence; +}; + +const nestedSelections: readonly NestedSelection[] = [ + { + label: "inside the default Kuna cache", + namespace: "kuna", + prepare: async (home) => { + const cacheHome = join(home, "xdg-cache"); + const custom = join(cacheHome, "rea", "kuna", "selected"); + return { cacheHome, custom, evidence: await withEvidence(custom) }; + }, + }, + { + label: "inside ~/.rea/cache", + namespace: "cache", + prepare: async (home) => { + const custom = join(home, ".rea", "cache", "selected"); + return { + cacheHome: join(home, "xdg-cache"), + custom, + evidence: await withEvidence(custom), + }; + }, + }, + { + label: "equal to ~/.rea/cache", + namespace: "cache", + prepare: async (home) => { + const custom = join(home, ".rea", "cache"); + return { + cacheHome: join(home, "xdg-cache"), + custom, + evidence: await withEvidence(custom), + }; + }, + }, + { + label: "with parent traversal in the cache home", + namespace: "kuna", + prepare: async (home) => { + const cacheHome = join(home, "xdg-cache"); + const custom = join(cacheHome, "rea", "kuna", "selected"); + return { + cacheHome: `${cacheHome}/../xdg-cache`, + custom, + evidence: await withEvidence(custom), + }; + }, + }, + { + label: "reached through a symbolic link and parent traversal", + namespace: "cache", + skip: process.platform === "win32", + prepare: async (home) => { + const inner = join(home, ".rea", "cache", "inner"); + await mkdir(inner, { recursive: true }); + await mkdir(join(home, "outside"), { recursive: true }); + await symlink(inner, join(home, "outside", "alias"), "dir"); + const evidence = await withEvidence( + join(home, ".rea", "cache", "selected"), + ); + return { + cacheHome: join(home, "xdg-cache"), + custom: `${home}/outside/alias/../selected`, + evidence, + }; + }, + }, +]; + +it.for(nestedSelections.filter(({ skip }) => skip !== true))( + "keeps the namespace whole when a caller-selected Kuna cache is $label", + async ({ namespace, prepare }) => { + const home = await createTestTempDirectory("rea-managed-cache-nested-"); + const { cacheHome, custom, evidence } = await prepare(home); + const host = systemUninstallHost( + home, + undefined, + { XDG_CACHE_HOME: cacheHome, REA_KUNA_CACHE_DIR: custom }, + "linux", + ); + try { + expect(await host.purgeData()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "kuna-custom-cache", + status: "retained", + }), + expect.objectContaining({ name: namespace, status: "retained" }), + ]), + ); + expect(await readFile(evidence, "utf8")).toBe("caller evidence"); + } finally { + await host.close?.(); + } + }, +); + +it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "keeps the namespaces whole when the selection's location cannot be established", + async () => { + const home = await createTestTempDirectory("rea-managed-cache-unknown-"); + const outside = join(home, "outside"); + await mkdir(join(home, ".rea", "state"), { recursive: true }); + const evidence = await withEvidence( + join(home, ".rea", "cache", "selected"), + ); + await mkdir(outside); + await symlink(join(home, ".rea", "cache"), join(outside, "alias"), "dir"); + const unrelated = await withEvidence(join(home, "unrelated")); + const host = systemUninstallHost( + home, + undefined, + { + XDG_CACHE_HOME: join(home, "xdg-cache"), + REA_KUNA_CACHE_DIR: `${outside}/alias/../cache/selected`, + }, + "linux", + ); + await chmod(outside, 0o000); + try { + expect(await host.purgeData()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "kuna-custom-cache", + status: "retained", + }), + expect.objectContaining({ name: "cache", status: "retained" }), + ]), + ); + expect(await readFile(evidence, "utf8")).toBe("caller evidence"); + expect(await readFile(unrelated, "utf8")).toBe("caller evidence"); + } finally { + await chmod(outside, 0o755); + await host.close?.(); + } + }, +);