diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index bd0763f6f3f0..c572f3a5ece1 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -203,11 +203,12 @@ pub use super::package_installer::PackageInstaller; pub use self::package_manager_directories as directories; use directories::attempt_to_create_package_json_and_open; pub use directories::{ - attempt_to_create_package_json, cached_git_folder_name, cached_git_folder_name_print, - cached_git_folder_name_print_auto, cached_github_folder_name, cached_github_folder_name_print, - cached_github_folder_name_print_auto, cached_npm_package_folder_name, - cached_npm_package_folder_name_print, cached_npm_package_folder_print_basename, - cached_tarball_folder_name, cached_tarball_folder_name_print, compute_cache_dir_and_subpath, + ClearCacheDirectoryError, attempt_to_create_package_json, cached_git_folder_name, + cached_git_folder_name_print, cached_git_folder_name_print_auto, cached_github_folder_name, + cached_github_folder_name_print, cached_github_folder_name_print_auto, + cached_npm_package_folder_name, cached_npm_package_folder_name_print, + cached_npm_package_folder_print_basename, cached_tarball_folder_name, + cached_tarball_folder_name_print, clear_cache_directory, compute_cache_dir_and_subpath, fetch_cache_directory_path, get_cache_directory, get_cache_directory_and_abs_path, get_temporary_directory, global_link_dir, global_link_dir_path, is_folder_in_cache, path_for_cached_npm_path, path_for_resolution, save_lockfile, setup_global_dir, diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index 4861484c4b21..d094d856f560 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -375,8 +375,12 @@ pub struct CacheDir { pub path: Vec, } +/// An empty variable counts as unset: `abs(&[b""])` is the project directory. pub fn fetch_cache_directory_path(env: &mut DotEnvLoader, options: Option<&Options>) -> CacheDir { - if let Some(dir) = env.get(b"BUN_INSTALL_CACHE_DIR") { + if let Some(dir) = env + .get(b"BUN_INSTALL_CACHE_DIR") + .filter(|dir| !dir.is_empty()) + { return CacheDir { path: FileSystem::instance().abs(&[dir]).to_vec(), }; @@ -390,21 +394,21 @@ pub fn fetch_cache_directory_path(env: &mut DotEnvLoader, options: Option<&Optio } } - if let Some(dir) = env.get(b"BUN_INSTALL") { + if let Some(dir) = env.get(b"BUN_INSTALL").filter(|dir| !dir.is_empty()) { let parts: [&[u8]; 3] = [dir, b"install/", b"cache/"]; return CacheDir { path: FileSystem::instance().abs(&parts).to_vec(), }; } - if let Some(dir) = env_var::XDG_CACHE_HOME.get() { + if let Some(dir) = env_var::XDG_CACHE_HOME.get_not_empty() { let parts: [&[u8]; 4] = [dir, b".bun/", b"install/", b"cache/"]; return CacheDir { path: FileSystem::instance().abs(&parts).to_vec(), }; } - if let Some(dir) = env_var::HOME.get() { + if let Some(dir) = env_var::HOME.get_not_empty() { let parts: [&[u8]; 4] = [dir, b".bun/", b"install/", b"cache/"]; return CacheDir { path: FileSystem::instance().abs(&parts).to_vec(), @@ -417,6 +421,124 @@ pub fn fetch_cache_directory_path(env: &mut DotEnvLoader, options: Option<&Optio } } +// ───────────────────────────── bun pm cache rm ──────────────────────────────── + +pub enum ClearCacheDirectoryError { + FilesystemRoot { + cache_dir: Box<[u8]>, + }, + /// `cache_dir` is (`is_same_dir`) or contains `protected`, which `what` names. + Protected { + cache_dir: Box<[u8]>, + what: &'static str, + protected: Box<[u8]>, + is_same_dir: bool, + }, + Io { + cache_dir: Box<[u8]>, + err: sys::Error, + }, +} + +/// `bun pm cache rm`. Empties rather than removes: a symlinked or mounted cache must survive. +pub fn clear_cache_directory( + env: &mut DotEnvLoader, + original_cwd: &[u8], +) -> Result<(), ClearCacheDirectoryError> { + let configured = fetch_cache_directory_path(env, None).path; + let dir = match Dir::open(&configured) { + Ok(dir) => dir, + Err(err) if err.get_errno() == sys::E::ENOENT => return Ok(()), + Err(err) => { + return Err(ClearCacheDirectoryError::Io { + cache_dir: configured.into_boxed_slice(), + err, + }); + } + }; + + // The opened directory, not the setting: a symlink to `$HOME` must compare as `$HOME`. + let mut cache_dir_buf = path::path_buffer_pool::get(); + let cache_dir: &[u8] = match dir.get_fd_path(&mut cache_dir_buf) { + Ok(real) => real, + Err(err) => { + return Err(ClearCacheDirectoryError::Io { + cache_dir: configured.into_boxed_slice(), + err, + }); + } + }; + + if path::basename(cache_dir).is_empty() { + return Err(ClearCacheDirectoryError::FilesystemRoot { + cache_dir: cache_dir.into(), + }); + } + + // The setting says where the cache is, not that the directory holds only a cache. + let protected_dirs: [(&'static str, Option<&[u8]>); 5] = [ + ("the home directory", env_var::HOME.get_not_empty()), + ( + "the bun executable", + bun_core::self_exe_path().ok().map(ZStr::as_bytes), + ), + ( + "$BUN_INSTALL", + env.get(b"BUN_INSTALL").filter(|dir| !dir.is_empty()), + ), + ( + "the project directory", + Some(FileSystem::instance().top_level_dir()), + ), + ("the current directory", Some(original_cwd)), + ]; + for (what, protected) in protected_dirs { + let Some(protected) = protected else { + continue; + }; + let mut protected_buf = path::path_buffer_pool::get(); + let protected = canonical_dir_path(protected, &mut protected_buf); + let is_same_dir = match path::resolve_path::is_parent_or_equal(cache_dir, protected) { + path::resolve_path::ParentEqual::Unrelated => continue, + path::resolve_path::ParentEqual::Equal => true, + path::resolve_path::ParentEqual::Parent => false, + }; + return Err(ClearCacheDirectoryError::Protected { + cache_dir: cache_dir.into(), + what, + protected: protected.into(), + is_same_dir, + }); + } + + let io = |err: sys::Error| ClearCacheDirectoryError::Io { + cache_dir: cache_dir.into(), + err, + }; + + // Collect first: readdir may skip entries while the directory is being emptied. + let mut entries: Vec> = Vec::new(); + let mut iter = sys::iterate_dir(dir.fd()); + while let Some(entry) = iter.next().map_err(io)? { + entries.push(Box::from(entry.name.slice_u8())); + } + for entry in &entries { + dir.delete_tree(entry).map_err(io)?; + } + Ok(()) +} + +/// The real path of the directory at `path`, or `path` itself when it cannot be opened. +fn canonical_dir_path<'a>(path: &'a [u8], buf: &'a mut PathBuffer) -> &'a [u8] { + match Dir::open(path) { + Ok(dir) => match dir.get_fd_path(buf) { + Ok(real) => real, + Err(_) => path, + }, + Err(_) => path, + } +} + // ─────────────────────── cached folder name printers ────────────────────────── // // PERF: an earlier version used `core::fmt::write` over a `format_args!` of diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index a3c3c77392ae..db633e459f48 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -9,8 +9,8 @@ use bun_install::dependency::Dependency; use bun_install::lockfile::{LoadResult, LoadStep, Lockfile, package::PackageColumns as _, tree}; use bun_install::npm as Npm; use bun_install::package_manager_real::{ - CommandLineArguments, Subcommand, fetch_cache_directory_path, get_cache_directory, - package_manager_options::LogLevel, setup_global_dir, + ClearCacheDirectoryError, CommandLineArguments, Subcommand, clear_cache_directory, + get_cache_directory, package_manager_options::LogLevel, setup_global_dir, }; use bun_install::{DependencyID, PackageID, PackageManager, migration}; use bun_paths::{self as Path, PathBuffer}; @@ -141,6 +141,12 @@ impl PackageManagerCommand { Global::exit(0); } + fn note_cache_directory_setting() { + bun_core::note!( + "the cache directory comes from $BUN_INSTALL_CACHE_DIR or $BUN_INSTALL. Point it at a directory that holds nothing but the bun install cache." + ); + } + fn get_subcommand(args_ptr: &mut &'static [&'static [u8]]) -> &'static [u8] { // Mutates through `args_ptr` // directly so the reslice persists into `pm.options.positionals`. @@ -437,37 +443,42 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; { let mut had_err = false; + // Not `pm.env`: a committed .env or .npmrc must not pick what gets deleted. let mut process_env = bun_dotenv::Loader::init(); process_env.load_process()?; - let cache_dir = fetch_cache_directory_path(&mut process_env, None); - let mut rm_buf = PathBuffer::uninit(); - let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { - Ok(d) => d, - Err(err) => { - bun_core::pretty_errorln!( - "{} getting cache directory", - crate::Error::from(err).name(), + match clear_cache_directory(&mut process_env, &cwd) { + Ok(()) => bun_core::prettyln!("Cleared 'bun install' cache"), + Err(ClearCacheDirectoryError::FilesystemRoot { cache_dir }) => { + Output::err_generic( + "refusing to clear {}: it is the root of a filesystem", + (bun_fmt::quote(&cache_dir),), ); - Global::crash(); + Self::note_cache_directory_setting(); + had_err = true; } - }; - let rm_path = match rm_dir.get_fd_path(&mut rm_buf) { - Ok(p) => &p[..], - Err(err) => { - bun_core::pretty_errorln!( - "{} getting cache directory", - crate::Error::from(err).name(), + Err(ClearCacheDirectoryError::Protected { + cache_dir, + what, + protected, + is_same_dir, + }) => { + Output::err_generic( + "refusing to clear {}: it {} {} ({})", + ( + bun_fmt::quote(&cache_dir), + if is_same_dir { "is" } else { "contains" }, + what, + bun_fmt::quote(&protected), + ), ); - Global::crash(); + Self::note_cache_directory_setting(); + had_err = true; + } + Err(ClearCacheDirectoryError::Io { cache_dir, err }) => { + Output::err(err, "could not clear {}", (bun_fmt::quote(&cache_dir),)); + had_err = true; } - }; - rm_dir.close(); - - if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { - Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); - had_err = true; } - bun_core::prettyln!("Cleared 'bun install' cache"); 'bunx: { let tmp = Fs::RealFS::platform_temp_dir(); diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 790a7315b2e0..1e50dd486dfe 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1,9 +1,9 @@ import { spawn } from "bun"; -import { afterAll, afterEach, beforeAll, beforeEach, expect, it, test } from "bun:test"; -import { exists, mkdir, writeFile } from "fs/promises"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from "bun:test"; +import { chmod, copyFile, exists, link, lstat, mkdir, symlink, writeFile } from "fs/promises"; import { bunEnv, bunExe, bunEnv as env, normalizeBunSnapshot, readdirSorted, tempDir, tmpdirSync } from "harness"; import { cpSync } from "node:fs"; -import { join } from "path"; +import { basename, join } from "path"; import { dummyAfterAll, dummyAfterEach, @@ -761,7 +761,303 @@ it("should remove all cache", async () => { expect(await new Response(stderr2).text()).toBe(""); expect(await new Response(stdout2).text()).toInclude("Cleared 'bun install' cache\n"); expect(await exited2).toBe(0); - expect(await exists(cache_dir)).toBeFalse(); + // The entries are removed. The directory itself stays, so a cache directory that is a + // symlink or a mount point keeps working. + expect(await readdirSorted(cache_dir)).toEqual([]); +}); + +it("bun install treats an empty BUN_INSTALL_CACHE_DIR as unset instead of caching into the project", async () => { + const urls: string[] = []; + setHandler(dummyRegistry(urls)); + using side = tempDir("pm-cache-empty-var-install", { "home/.keep": "", "tmp/.keep": "" }); + // dummyBeforeEach writes a bunfig.toml that disables the cache. This test needs it on. + await writeFile(join(package_dir, "bunfig.toml"), `[install]\nregistry = "${root_url}/"\n`); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + bar: "0.0.2", + }, + }), + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: package_dir, + stdout: "pipe", + stderr: "pipe", + env: cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: "", BUN_INSTALL: undefined, XDG_CACHE_HOME: undefined }), + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("+ bar@0.0.2"); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect(urls.sort()).toEqual([`${root_url}/bar`, `${root_url}/bar-0.0.2.tgz`]); + const isManifest = (name: string) => name.endsWith(".npm"); + expect((await readdirSorted(package_dir)).filter(name => name.includes("@@") || isManifest(name))).toEqual([]); + const homeCache = await readdirSorted(join(String(side), "home", ".bun", "install", "cache")); + expect(homeCache).toContain("bar@0.0.2@@localhost@@@1"); + // The manifest name hashes the registry URL, which includes the port. + expect(homeCache.filter(isManifest)).toHaveLength(1); +}); + +/** + * Environment for the `bun pm cache` tests. Every directory the cache directory + * resolution can fall back to, and the temp directory that `bun pm cache rm` sweeps for + * bunx entries, is inside `side`, so neither behavior under test can reach anything + * outside the test's own temp directories. `BUN_INSTALL_CACHE_DIR` is unset unless the + * test sets it. An override of `undefined` unsets the variable. + */ +function cacheEnv(side: string, overrides: Record = {}): NodeJS.Dict { + const spawnEnv: NodeJS.Dict = { + ...env, + HOME: join(side, "home"), + USERPROFILE: join(side, "home"), + XDG_CACHE_HOME: join(side, "xdg-cache"), + BUN_INSTALL: join(side, "bun-install"), + TMPDIR: join(side, "tmp"), + TMP: join(side, "tmp"), + TEMP: join(side, "tmp"), + ...overrides, + }; + if (!("BUN_INSTALL_CACHE_DIR" in overrides)) { + delete spawnEnv.BUN_INSTALL_CACHE_DIR; + } + for (const key of Object.keys(spawnEnv)) { + if (spawnEnv[key] === undefined) delete spawnEnv[key]; + } + return spawnEnv; +} + +async function pmCache(args: string[], cwd: string, spawnEnv: NodeJS.Dict, exe: string = bunExe()) { + await using proc = Bun.spawn({ + cmd: [exe, "pm", "cache", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const sideFiles = { + "home/.ssh/id_canary": "keep", + "bun-install/bin/bun-canary": "keep", + "bun-install/install/cache/bar@0.0.2@@@1/package.json": "{}", + "bun-install/install/cache/0123456789abcdef.npm": "manifest", + "xdg-cache/.keep": "", + "tmp/.keep": "", +}; + +const projectFiles = { + "proj/package.json": JSON.stringify({ name: "demo" }), + "proj/src/index.ts": "console.log(1);\n", + "proj/.git/HEAD": "ref: refs/heads/main\n", + "sibling/keep.txt": "keep", +}; + +const projectEntries = [".git", "package.json", "src"]; +const envCacheEntries = ["0123456789abcdef.npm", "bar@0.0.2@@@1"]; + +// The bunx count is always 0: `cacheEnv` points the temp directory at an empty one. +const cleared = { + stdout: "Cleared 'bun install' cache\nCleared 0 cached 'bunx' packages\n", + stderr: "", + exitCode: 0, +}; + +function refused(cacheDir: string, reason: string) { + return { + stdout: "Cleared 0 cached 'bunx' packages\n", + stderr: + `error: refusing to clear "${cacheDir}": ${reason}\n` + + "note: the cache directory comes from $BUN_INSTALL_CACHE_DIR or $BUN_INSTALL. " + + "Point it at a directory that holds nothing but the bun install cache.\n", + exitCode: 1, + }; +} + +describe("bun pm cache with an empty variable", () => { + test("an empty BUN_INSTALL_CACHE_DIR falls through to the next location", async () => { + using side = tempDir("pm-cache-empty-cache-dir", sideFiles); + using root = tempDir("pm-cache-empty-cache-dir-project", projectFiles); + const proj = join(String(root), "proj"); + const spawnEnv = cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: "" }); + const envCache = join(String(side), "bun-install", "install", "cache"); + + expect(await pmCache([], proj, spawnEnv)).toEqual({ stdout: envCache, stderr: "", exitCode: 0 }); + + expect(await pmCache(["rm"], proj, spawnEnv)).toEqual(cleared); + expect(await readdirSorted(proj)).toEqual(projectEntries); + expect(await readdirSorted(envCache)).toEqual([]); + }); + + test("an empty BUN_INSTALL falls through to the next location", async () => { + using side = tempDir("pm-cache-empty-bun-install", sideFiles); + using root = tempDir("pm-cache-empty-bun-install-project", projectFiles); + const proj = join(String(root), "proj"); + const spawnEnv = cacheEnv(String(side), { BUN_INSTALL: "" }); + + expect(await pmCache([], proj, spawnEnv)).toEqual({ + stdout: join(String(side), "xdg-cache", ".bun", "install", "cache"), + stderr: "", + exitCode: 0, + }); + expect(await exists(join(proj, "install"))).toBeFalse(); + }); +}); + +describe("bun pm cache rm refuses a cache directory that holds more than the cache", () => { + test("the project directory", async () => { + using side = tempDir("pm-cache-rm-project", sideFiles); + using root = tempDir("pm-cache-rm-project-project", projectFiles); + const proj = join(String(root), "proj"); + + const result = await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: proj })); + expect(await readdirSorted(proj)).toEqual(projectEntries); + expect(result).toEqual(refused(proj, `it is the project directory ("${proj}")`)); + }); + + test("a parent of the project directory (relative setting)", async () => { + using side = tempDir("pm-cache-rm-parent", sideFiles); + using root = tempDir("pm-cache-rm-parent-project", projectFiles); + const proj = join(String(root), "proj"); + + const result = await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: ".." })); + expect(await readdirSorted(String(root))).toEqual(["proj", "sibling"]); + expect(await readdirSorted(proj)).toEqual(projectEntries); + expect(result).toEqual(refused(String(root), `it contains the project directory ("${proj}")`)); + }); + + test("the directory the command runs from, inside a workspace", async () => { + using side = tempDir("pm-cache-rm-cwd", sideFiles); + using root = tempDir("pm-cache-rm-cwd-workspace", { + "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }), + "packages/a/package.json": JSON.stringify({ name: "a" }), + "packages/b/package.json": JSON.stringify({ name: "b" }), + }); + const packages = join(String(root), "packages"); + const cwd = join(packages, "a"); + + const result = await pmCache(["rm"], cwd, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: packages })); + expect(await readdirSorted(packages)).toEqual(["a", "b"]); + expect(result).toEqual(refused(packages, `it contains the current directory ("${cwd}")`)); + }); + + test("the home directory", async () => { + using side = tempDir("pm-cache-rm-home", sideFiles); + using root = tempDir("pm-cache-rm-home-project", projectFiles); + const proj = join(String(root), "proj"); + const home = join(String(side), "home"); + + const result = await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: home })); + expect(await exists(join(home, ".ssh", "id_canary"))).toBeTrue(); + expect(result).toEqual(refused(home, `it is the home directory ("${home}")`)); + }); + + test("$BUN_INSTALL", async () => { + using side = tempDir("pm-cache-rm-bun-install", sideFiles); + using root = tempDir("pm-cache-rm-bun-install-project", projectFiles); + const proj = join(String(root), "proj"); + const bunInstall = join(String(side), "bun-install"); + + const result = await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: bunInstall })); + expect(await exists(join(bunInstall, "bin", "bun-canary"))).toBeTrue(); + expect(result).toEqual(refused(bunInstall, `it is $BUN_INSTALL ("${bunInstall}")`)); + }); + + test("the directory that holds the running bun executable", async () => { + using side = tempDir("pm-cache-rm-exe", sideFiles); + using root = tempDir("pm-cache-rm-exe-project", projectFiles); + const proj = join(String(root), "proj"); + // Run a second name for the binary from inside the temp tree. Without the refusal, + // this command deletes the directory that holds the binary, which must not be the + // build directory. + const bin = join(String(root), "bin"); + const exe = join(bin, basename(bunExe())); + await mkdir(bin); + await link(bunExe(), exe).catch(async () => { + await copyFile(bunExe(), exe); + await chmod(exe, 0o755); + }); + + const result = await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: bin }), exe); + expect(await exists(exe)).toBeTrue(); + expect(result).toEqual(refused(bin, `it contains the bun executable ("${exe}")`)); + }); + + test("a symlink to the home directory", async () => { + using side = tempDir("pm-cache-rm-symlink-home", sideFiles); + using root = tempDir("pm-cache-rm-symlink-home-project", projectFiles); + const proj = join(String(root), "proj"); + const home = join(String(side), "home"); + const link = join(String(root), "cache-link"); + await symlink(home, link, "junction"); + + const result = await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: link })); + expect(await exists(join(home, ".ssh", "id_canary"))).toBeTrue(); + expect(result).toEqual(refused(home, `it is the home directory ("${home}")`)); + }); +}); + +describe("bun pm cache rm clears the entries and keeps the directory", () => { + test("a cache directory that is a symlink", async () => { + using side = tempDir("pm-cache-rm-symlink", sideFiles); + using root = tempDir("pm-cache-rm-symlink-project", projectFiles); + const proj = join(String(root), "proj"); + const target = join(String(side), "bun-install", "install", "cache"); + const link = join(String(root), "cache-link"); + await symlink(target, link, "junction"); + expect(await readdirSorted(target)).toEqual(envCacheEntries); + + expect(await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: link }))).toEqual(cleared); + expect(await readdirSorted(target)).toEqual([]); + expect((await lstat(link)).isSymbolicLink()).toBeTrue(); + expect(await readdirSorted(link)).toEqual([]); + }); + + test("an entry that is a symlink is unlinked, not followed", async () => { + using side = tempDir("pm-cache-rm-entry-symlink", sideFiles); + using root = tempDir("pm-cache-rm-entry-symlink-project", projectFiles); + const proj = join(String(root), "proj"); + const envCache = join(String(side), "bun-install", "install", "cache"); + const sibling = join(String(root), "sibling"); + await symlink(sibling, join(envCache, "escape@1.0.0@@@1"), "junction"); + + expect(await pmCache(["rm"], proj, cacheEnv(String(side)))).toEqual(cleared); + expect(await readdirSorted(envCache)).toEqual([]); + expect(await readdirSorted(sibling)).toEqual(["keep.txt"]); + }); + + test("a cache directory that does not exist is not created", async () => { + using side = tempDir("pm-cache-rm-missing", sideFiles); + using root = tempDir("pm-cache-rm-missing-project", projectFiles); + const proj = join(String(root), "proj"); + const missing = join(String(side), "does-not-exist"); + + expect(await pmCache(["rm"], proj, cacheEnv(String(side), { BUN_INSTALL_CACHE_DIR: missing }))).toEqual(cleared); + expect(await exists(missing)).toBeFalse(); + }); + + test("a cache directory named by the project's .npmrc is not the one cleared", async () => { + using side = tempDir("pm-cache-rm-npmrc", sideFiles); + using root = tempDir("pm-cache-rm-npmrc-project", { ...projectFiles, "proj/.npmrc": "cache=.\n" }); + const proj = join(String(root), "proj"); + const envCache = join(String(side), "bun-install", "install", "cache"); + const spawnEnv = cacheEnv(String(side)); + + // `bun pm cache` (like `bun install`) reads the directory from the .npmrc... + expect(await pmCache([], proj, spawnEnv)).toEqual({ stdout: proj, stderr: "", exitCode: 0 }); + + // ...but a file committed to the repository cannot choose what `bun pm cache rm` deletes. + expect(await pmCache(["rm"], proj, spawnEnv)).toEqual(cleared); + expect(await readdirSorted(proj)).toEqual([".git", ".npmrc", "package.json", "src"]); + expect(await readdirSorted(envCache)).toEqual([]); + }); }); it("bun pm migrate", async () => {