diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 3e3099ea31..d363f950d6 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -184,10 +184,7 @@ export const systemUninstallHost = ( removeClient: (client) => removeClient(client, fileSystem), removeSkill: () => removeManagedSkills(home, fileSystem, environment, platform), - purgeData: async () => [ - await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem), - await removeManagedPath(join(home, ".rea/state"), "state", fileSystem), - ], + purgeData: () => purgeManagedData(join(home, ".rea"), fileSystem), }; }; @@ -380,6 +377,39 @@ const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => { return isOwnedClientRegistrationCommand([command, ...args]); }; +const purgeManagedData = async ( + root: string, + fileSystem: UninstallFileSystem, +): Promise => { + const names = ["cache", "state"]; + try { + const stats = await fileSystem.stat(root); + const linked = stats.isSymbolicLink(); + if (linked || stats.isDirectory?.() !== true) + return names.map((name) => + item( + name, + "retained", + `REA did not remove this item because its managed data root ${root} is ${linked ? "a symbolic link" : "not a directory"}. Verify the root before removing its data manually.`, + ), + ); + } catch (cause: unknown) { + return names.map((name) => + isMissing(cause) + ? item(name, "skipped", `${join(root, name)} does not exist.`) + : item( + name, + "failed", + `The managed data root ${root} could not be verified. Check its permissions, then rerun uninstall.`, + ), + ); + } + const items: UninstallItem[] = []; + for (const name of names) + items.push(await removeManagedPath(join(root, name), name, fileSystem)); + return items; +}; + const removeManagedPath = async ( path: string, name: string, diff --git a/tests/boundary/cli/clientProfileLifecycle.test.ts b/tests/boundary/cli/clientProfileLifecycle.test.ts index 286d043577..531e6378a7 100644 --- a/tests/boundary/cli/clientProfileLifecycle.test.ts +++ b/tests/boundary/cli/clientProfileLifecycle.test.ts @@ -1,4 +1,12 @@ -import { access, readFile, writeFile } from "node:fs/promises"; +import { + access, + lstat, + mkdir, + readFile, + realpath, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -78,6 +86,54 @@ const groups = [ ], }, ]; + +cliTest( + "uninstall purge retains unrelated data behind a managed-root symlink", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-uninstall-root-link-"); + const home = join(root, "home"); + const unrelated = join(root, "unrelated-application"); + await mkdir(home); + for (const name of ["cache", "state"]) { + await mkdir(join(unrelated, name), { recursive: true }); + await writeFile(join(unrelated, name, "keep.txt"), `unrelated ${name}`); + } + const managedRoot = join(home, ".rea"); + await symlink( + unrelated, + managedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + const result = await cli.run({ + arguments: ["uninstall", "--purge-data", "--json"], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(root, "xdg-config"), + XDG_CACHE_HOME: join(root, "xdg-cache"), + }, + timeoutMs: 20_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect.soft(result.json).toMatchObject({ + status: "complete", + items: expect.arrayContaining([ + expect.objectContaining({ name: "cache", status: "retained" }), + expect.objectContaining({ name: "state", status: "retained" }), + ]), + }); + expect((await lstat(managedRoot)).isSymbolicLink()).toBe(true); + expect(await realpath(managedRoot)).toBe(await realpath(unrelated)); + for (const name of ["cache", "state"]) + await expect + .soft(readFile(join(unrelated, name, "keep.txt"), "utf8")) + .resolves.toBe(`unrelated ${name}`); + }, +); + for (const group of groups) { cliTest( `${group.name} lifecycle follows the selected roots`, diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index ae814c8855..9b3e8f5b93 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -1341,6 +1341,146 @@ describe("Codex configuration comments", () => { }); }); +describe("managed data purge root validation", () => { + it.each(["existing", "dangling"] as const)( + "retains cache and state when the managed-root symlink is %s", + async (targetState) => { + const root = await createTestTempDirectory("rea-uninstall-data-root-"); + roots.push(root); + const home = join(root, "home"); + const outside = join(root, "unrelated-application"); + await mkdir(home); + if (targetState === "existing") + for (const name of ["cache", "state"]) { + await mkdir(join(outside, name), { recursive: true }); + await writeFile(join(outside, name, "keep.txt"), "unrelated data"); + } + const managedRoot = join(home, ".rea"); + await symlink( + outside, + managedRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + const result = await runUninstall( + true, + systemUninstallHost(home, undefined, {}), + ); + + expect(result.status).toBe("complete"); + expect((await lstat(managedRoot)).isSymbolicLink()).toBe(true); + for (const name of ["cache", "state"]) + expect(result.items).toContainEqual( + expect.objectContaining({ name, status: "retained" }), + ); + if (targetState === "existing") + for (const name of ["cache", "state"]) + expect(await readFile(join(outside, name, "keep.txt"), "utf8")).toBe( + "unrelated data", + ); + else + await expect(lstat(outside)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + + it("retains a managed data root that is not a directory", async () => { + const home = await createTestTempDirectory("rea-uninstall-data-file-"); + roots.push(home); + const managedRoot = join(home, ".rea"); + await writeFile(managedRoot, "unrelated file"); + + const result = await runUninstall( + true, + systemUninstallHost(home, undefined, {}), + ); + + expect(result.status).toBe("complete"); + for (const name of ["cache", "state"]) + expect(result.items).toContainEqual( + expect.objectContaining({ name, status: "retained" }), + ); + expect(await readFile(managedRoot, "utf8")).toBe("unrelated file"); + }); + + it("purges ordinary managed data through a legitimate home directory link", async () => { + const root = await createTestTempDirectory("rea-uninstall-home-link-"); + roots.push(root); + const home = join(root, "actual-home"); + const linkedHome = join(root, "linked-home"); + const managedRoot = join(home, ".rea"); + for (const name of ["cache", "state"]) { + await mkdir(join(managedRoot, name), { recursive: true }); + await writeFile(join(managedRoot, name, "data.txt"), "managed data"); + } + await writeFile(join(managedRoot, "keep.txt"), "other data"); + await symlink( + home, + linkedHome, + process.platform === "win32" ? "junction" : "dir", + ); + + const result = await runUninstall( + true, + systemUninstallHost(linkedHome, undefined, {}), + ); + + expect(result.status).toBe("complete"); + for (const name of ["cache", "state"]) { + expect(result.items).toContainEqual( + expect.objectContaining({ name, status: "removed" }), + ); + await expect(lstat(join(managedRoot, name))).rejects.toMatchObject({ + code: "ENOENT", + }); + } + expect((await lstat(linkedHome)).isSymbolicLink()).toBe(true); + expect(await readFile(join(managedRoot, "keep.txt"), "utf8")).toBe( + "other data", + ); + }); + + it("reports root inspection failure without removing managed data", async () => { + const home = await createTestTempDirectory("rea-uninstall-data-denied-"); + roots.push(home); + const managedRoot = join(home, ".rea"); + for (const name of ["cache", "state"]) { + await mkdir(join(managedRoot, name), { recursive: true }); + await writeFile(join(managedRoot, name, "keep.txt"), "managed data"); + } + const remove = vi.fn(testFileSystem.remove); + const result = await runUninstall( + true, + systemUninstallHost( + home, + { + ...testFileSystem, + stat: (path) => + path === managedRoot + ? Promise.reject( + Object.assign(new Error("root inspection denied"), { + code: "EACCES", + }), + ) + : lstat(path), + remove, + }, + {}, + ), + ); + + expect(result.status).toBe("failed"); + expect(remove).not.toHaveBeenCalled(); + for (const name of ["cache", "state"]) { + expect(result.items).toContainEqual( + expect.objectContaining({ name, status: "failed" }), + ); + expect(await readFile(join(managedRoot, name, "keep.txt"), "utf8")).toBe( + "managed data", + ); + } + }); +}); + describe("client configuration filesystem removal", () => { it("uninstalls only owned entries and refuses purge symlinks", async () => { const home = await createTestTempDirectory("rea-uninstall-");