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?.(); + } + }, +);