diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index fb91ac25100c..1e9d75244e4f 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -2306,6 +2306,12 @@ impl<'a> PackageInstall<'a> { crate::PreinstallState::Done => false, _ => 'brk: { if self.patch.is_none() { + if !crate::package_manager_real::directories::cache_entry_is_dir( + self.cache_dir, + self.cache_dir_subpath, + ) { + break 'brk true; + } let exists = match resolution_tag { resolution::Tag::Npm => 'package_json_exists: { // SAFETY: `buf` and `self.cache_dir_subpath` both derive from the @@ -2343,8 +2349,7 @@ impl<'a> PackageInstall<'a> { ZStr::from_buf(&buf[..], subpath_len + 1 + b"package.json".len()); break 'package_json_exists sys::exists_at(self.cache_dir, subpath); } - _ => sys::directory_exists_at(self.cache_dir, self.cache_dir_subpath) - .unwrap_or(false), + _ => true, }; if exists { manager.set_preinstall_state(package_id, crate::PreinstallState::Done); @@ -2365,7 +2370,10 @@ impl<'a> PackageInstall<'a> { // SAFETY: NUL written above. let subpath = ZStr::from_buf(&join_buf[..], cache_dir_subpath_without_patch_hash.len()); - let exists = sys::directory_exists_at(self.cache_dir, subpath).unwrap_or(false); + let exists = crate::package_manager_real::directories::cache_entry_is_dir( + self.cache_dir, + subpath, + ); if exists { manager.set_preinstall_state(package_id, crate::PreinstallState::Done); } @@ -2379,8 +2387,10 @@ impl<'a> PackageInstall<'a> { manager: &mut PackageManager, package_id: PackageID, ) -> bool { - let exists = - sys::directory_exists_at(self.cache_dir, self.cache_dir_subpath).unwrap_or(false); + let exists = crate::package_manager_real::directories::cache_entry_is_dir( + self.cache_dir, + self.cache_dir_subpath, + ); if exists { manager.set_preinstall_state(package_id, crate::PreinstallState::Done); } diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index a2b8cbf6ea1f..54040863f7df 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1492,15 +1492,28 @@ impl<'a> PackageInstaller<'a> { self.summary.skipped += (!needs_install) as u32; if needs_install { + // `--force` re-fetches on the first pass only; the post-download + // re-entry has NEEDS_VERIFY=false and links the fresh entry. + let force_cache_refetch = NEEDS_VERIFY + && self.force_install + && self.manager().get_preinstall_state(package_id) != crate::PreinstallState::Done; if resolution.tag.can_enqueue_install_task() - && installer.package_missing_from_cache( - self.manager_mut(), - package_id, - resolution.tag, - ) + && (force_cache_refetch + || installer.package_missing_from_cache( + self.manager_mut(), + package_id, + resolution.tag, + )) { debug_assert!(resolution.can_enqueue_install_task()); + // Drop the derived `_patch_hash=` entry so the re-entry + // enqueues `ApplyPatch` against the fresh base. + if force_cache_refetch && installer.patch.is_some() { + let _ = bun_sys::Dir::borrow(&installer.cache_dir) + .delete_tree(installer.cache_dir_subpath.as_bytes()); + } + let context = TaskCallbackContext::DependencyInstallContext(DependencyInstallContext { tree_id: self.current_tree_id, diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index e787e9fac145..61ab23180b62 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -327,7 +327,22 @@ unsafe fn ensure_cache_directory(this: *mut PackageManager) -> Dir { unsafe { (*this).cache_directory_path = ZBox::from_bytes(&cache_dir.path) }; match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { - Ok(d) => return d, + Ok(d) => { + if is_trusted_cache_root(d.fd()) { + return d; + } + bun_core::pretty_errorln!( + "warn: ignoring install cache at {} because it is not a directory owned by the current user or is writable by other users. Set $BUN_INSTALL_CACHE_DIR to a directory only you can write to, or remove it.", + bun_fmt::s(&cache_dir.path) + ); + drop(d); + // SAFETY: narrow `&mut enable` projection; disjoint from + // any `&options.{registries,scope}` the caller may hold. + unsafe { (*this).options.enable.set(Enable::CACHE, false) }; + // SAFETY: see fn safety contract. + unsafe { (*this).cache_directory_path = ZBox::from_bytes(b"") }; + continue; + } Err(_) => { // SAFETY: narrow `&mut enable` projection; disjoint from // any `&options.{registries,scope}` the caller may hold. @@ -361,6 +376,22 @@ unsafe fn ensure_cache_directory(this: *mut PackageManager) -> Dir { } } +/// Cache hits never re-verify integrity, so refuse a shared cache root that +/// another user can write to; the caller falls back to `node_modules/.cache`. +#[cfg(unix)] +fn is_trusted_cache_root(dir: Fd) -> bool { + match sys::fstat(dir) { + Ok(st) => sys::stat_is_owner_only_writable_dir(&st, bun_sys::c::getuid()), + Err(_) => false, + } +} + +#[cfg(not(unix))] +#[inline(always)] +fn is_trusted_cache_root(_dir: Fd) -> bool { + true +} + pub struct CacheDir { pub path: Vec, pub is_node_modules: bool, @@ -747,8 +778,29 @@ pub fn cached_tarball_folder_name( ) } +/// `true` iff `subpath` under `dir` is a real directory (not a symlink or +/// junction), so a link planted at the predictable cache-entry name is treated +/// as absent and re-fetched. Windows `lstatat` maps junctions to `S_IFDIR`, +/// hence the explicit `FILE_ATTRIBUTE_REPARSE_POINT` query there. +pub fn cache_entry_is_dir(dir: Fd, subpath: &ZStr) -> bool { + #[cfg(windows)] + { + match sys::get_file_attributes_at(dir, subpath) { + Some(a) => a.is_directory && !a.is_reparse_point, + None => false, + } + } + #[cfg(not(windows))] + { + match sys::lstatat(dir, subpath) { + Ok(st) => bun_sys::S::ISDIR(st.st_mode as _), + Err(_) => false, + } + } +} + pub fn is_folder_in_cache(this: &mut PackageManager, folder_path: &ZStr) -> bool { - sys::directory_exists_at(get_cache_directory(this), folder_path).unwrap_or(false) + cache_entry_is_dir(get_cache_directory(this), folder_path) } // ─────────────────────────── global directories ─────────────────────────────── diff --git a/src/install/PackageManager/PackageManagerLifecycle.rs b/src/install/PackageManager/PackageManagerLifecycle.rs index 2c63b4e08c6b..dcbb392d61d2 100644 --- a/src/install/PackageManager/PackageManagerLifecycle.rs +++ b/src/install/PackageManager/PackageManagerLifecycle.rs @@ -185,7 +185,11 @@ impl PackageManager { return PreinstallState::Extract; } - if directories::is_folder_in_cache(self, folder_path) { + // The cache is keyed only on name@version; `--force` must + // re-fetch and re-verify instead of trusting a hit. + let trust_cache_hit = !self.options.enable.force_install(); + + if trust_cache_hit && directories::is_folder_in_cache(self, folder_path) { self.set_preinstall_state(pkg.meta.id, PreinstallState::Done); return PreinstallState::Done; } @@ -208,7 +212,7 @@ impl PackageManager { }); // Owned NUL-terminated copy. let non_patched_path = ZBox::from_bytes(&folder_path.as_bytes()[..idx]); - if directories::is_folder_in_cache(self, &non_patched_path) { + if trust_cache_hit && directories::is_folder_in_cache(self, &non_patched_path) { self.set_preinstall_state(pkg.meta.id, PreinstallState::ApplyPatch); // yay step 1 is already done for us return PreinstallState::ApplyPatch; diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index 0746103c5485..cf8874ef9736 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -607,7 +607,9 @@ impl ExtractTarball { sys::Errno::NOTEMPTY | sys::Errno::PERM | sys::Errno::BUSY - | sys::Errno::EXIST => { + | sys::Errno::EXIST + // Junction/symlink at the destination → ENOTDIR. + | sys::Errno::NOTDIR => { // before we attempt to delete the destination, let's close the source dir. let _ = sys::close(dir_to_move); diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index d4000f068d8d..1ca5a0494a63 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2345,27 +2345,38 @@ pub(crate) fn install_isolated_packages( let missing_from_cache = match installer.manager().get_preinstall_state(pkg_id) { install::PreinstallState::Done => false, + _ if installer.manager().options.enable.force_install() => { + // Drop the derived `_patch_hash=` entry so + // `apply_package_patch` re-derives from the fresh base. + if matches!(patch_info, installer::PatchInfo::Patch(_)) { + let _ = bun_sys::Dir::borrow(&cache_dir) + .delete_tree(pkg_cache_dir_subpath.slice_z().as_bytes()); + } + true + } _ => 'missing_from_cache: { if matches!(patch_info, installer::PatchInfo::None) { - let exists = match pkg_res_tag { - ResolutionTag::Npm => { - // Reshaped for borrowck — capture length - // instead of `save()` so the path stays unborrowed. - let cache_dir_path_save = pkg_cache_dir_subpath.len(); - pkg_cache_dir_subpath.append(b"package.json").assume_ok(); - let exists = sys::exists_at( - cache_dir, - pkg_cache_dir_subpath.slice_z(), - ); - pkg_cache_dir_subpath.set_length(cache_dir_path_save); - exists - } - _ => sys::directory_exists_at( + let exists = + crate::package_manager_real::directories::cache_entry_is_dir( cache_dir, pkg_cache_dir_subpath.slice_z(), - ) - .unwrap_or(false), - }; + ) && match pkg_res_tag { + ResolutionTag::Npm => { + // Reshaped for borrowck — capture length + // instead of `save()` so the path stays unborrowed. + let cache_dir_path_save = pkg_cache_dir_subpath.len(); + pkg_cache_dir_subpath + .append(b"package.json") + .assume_ok(); + let exists = sys::exists_at( + cache_dir, + pkg_cache_dir_subpath.slice_z(), + ); + pkg_cache_dir_subpath.set_length(cache_dir_path_save); + exists + } + _ => true, + }; if exists { installer.manager_mut().set_preinstall_state( pkg_id, diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index c5be557bbd49..5f25b1efb820 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -307,9 +307,10 @@ impl<'a> Installer<'a> { // contents hash, not the peer set). Once it exists, reuse it: rebuilding // it replaces the directory under earlier entries' running hardlink tasks. if let crate::patch_install::Callback::Apply(apply) = &patch_task.callback { - if sys::directory_exists_at(apply.cache_dir, apply.cache_dir_subpath.as_zstr()) - .unwrap_or(false) - { + if crate::package_manager_real::directories::cache_entry_is_dir( + apply.cache_dir, + apply.cache_dir_subpath.as_zstr(), + ) { return; } } diff --git a/src/install/lib.rs b/src/install/lib.rs index 024fadbf33fb..c700ab503b44 100644 --- a/src/install/lib.rs +++ b/src/install/lib.rs @@ -606,10 +606,7 @@ impl RunCommand { Ok(()) => {} Err(e) if e.get_errno() == bun_sys::E::EEXIST => match bun_sys::lstat(DIR_Z) { Ok(st) - if bun_sys::kind_from_mode(st.st_mode as bun_sys::Mode) - == bun_sys::FileKind::Directory - && st.st_uid == bun_sys::c::getuid() - && (st.st_mode as bun_sys::Mode) & 0o022 == 0 => {} + if bun_sys::stat_is_owner_only_writable_dir(&st, bun_sys::c::getuid()) => {} _ => return Ok(()), }, Err(_) => return Ok(()), diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 9be94531906b..05a102caa820 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -569,11 +569,7 @@ impl BunxCommand { #[cfg(unix)] fn is_trusted_cache_root(cache_root: &ZStr, uid: libc::uid_t) -> bool { match bun_sys::lstat(cache_root) { - Ok(st) => { - (st.st_mode & libc::S_IFMT) == libc::S_IFDIR - && st.st_uid == uid - && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 - } + Ok(st) => bun_sys::stat_is_owner_only_writable_dir(&st, uid), Err(_) => true, } } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..5ec58234835e 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1247,6 +1247,15 @@ pub type Stat = libc::stat; #[cfg(windows)] pub type Stat = bun_libuv_sys::uv_stat_t; +/// Trust predicate shared by the bunx, install, and `bun --bun` cache roots: +/// real directory, owned by `uid`, no group/other write bits. +#[cfg(unix)] +#[inline] +pub fn stat_is_owner_only_writable_dir(st: &Stat, uid: libc::uid_t) -> bool { + let mode = st.st_mode as Mode; + S::ISDIR(mode) && st.st_uid == uid && (mode & (S::IWGRP | S::IWOTH)) == 0 +} + // ────────────────────────────────────────────────────────────────────────── // Syscall surface — real posix libc FFI. Windows path lives in // `windows_impl` (NT/kernel32/libuv triad) below. @@ -7278,13 +7287,18 @@ pub enum ExistsAtType { File, Directory, } -/// Windows tail — `NtQueryAttributesFile` against an -/// OBJECT_ATTRIBUTES built from an already NT-prefixed wide path. Shared by the -/// UTF-8 (`exists_at_type`) and UTF-16 (`exists_at_type_w`) entry points so the -/// width dispatch does not -/// duplicate the syscall body. +/// `NtQueryAttributesFile` against an OBJECT_ATTRIBUTES built from an already +/// NT-prefixed wide path, relative to `dir`. Shared syscall body for +/// `exists_at_type` / `exists_at_type_w` / `get_file_attributes_at` so the +/// OBJECT_ATTRIBUTES setup lives in one place. #[cfg(windows)] -fn exists_at_type_nt(dir: Fd, mut path: &[u16]) -> Maybe { +fn nt_query_basic_attrs_at( + dir: Fd, + mut path: &[u16], +) -> core::result::Result< + bun_windows_sys::externs::FILE_BASIC_INFORMATION, + bun_windows_sys::externs::NTSTATUS, +> { use bun_windows_sys::externs as w; // Trim leading `.\` — NtQueryAttributesFile expects relative paths // without it. @@ -7314,27 +7328,37 @@ fn exists_at_type_nt(dir: Fd, mut path: &[u16]) -> Maybe { let mut basic_info: w::FILE_BASIC_INFORMATION = bun_core::ffi::zeroed(); // SAFETY: FFI; attr/basic_info valid for the call duration. let rc = unsafe { w::ntdll::NtQueryAttributesFile(&attr, &mut basic_info) }; - if rc != w::NTSTATUS::SUCCESS { + if rc == w::NTSTATUS::SUCCESS { + Ok(basic_info) + } else { + Err(rc) + } +} + +#[cfg(windows)] +fn exists_at_type_nt(dir: Fd, path: &[u16]) -> Maybe { + use bun_windows_sys::externs as w; + match nt_query_basic_attrs_at(dir, path) { + Ok(basic_info) => Ok( + // `FILE_ATTRIBUTE_READONLY` on a directory is a folder-customization + // marker (OneDrive sets it) and does not affect directory-ness; only + // `FILE_ATTRIBUTE_DIRECTORY` decides the type. + if (basic_info.FileAttributes & w::FILE_ATTRIBUTE_DIRECTORY) != 0 { + ExistsAtType::Directory + } else { + ExistsAtType::File + }, + ), // `errnoSys` for `NTSTATUS` routes through the curated // `translateNTStatusToErrno` table first (so `OBJECT_PATH_NOT_FOUND` // deterministically maps to `ENOENT`, which `directory_exists_at()` // branches on), then falls back to `RtlNtStatusToDosError` for // unmapped codes. - return Err(Error::from_code( + Err(rc) => Err(Error::from_code( windows::translate_nt_status_to_errno(rc), Tag::access, - )); + )), } - // `FILE_ATTRIBUTE_READONLY` on a directory is a folder-customization - // marker (OneDrive sets it) and does not affect directory-ness; only - // `FILE_ATTRIBUTE_DIRECTORY` decides the type. - Ok( - if (basic_info.FileAttributes & w::FILE_ATTRIBUTE_DIRECTORY) != 0 { - ExistsAtType::Directory - } else { - ExistsAtType::File - }, - ) } /// `fstatat` then `S_ISDIR`. pub fn exists_at_type(dir: Fd, sub: &ZStr) -> Maybe { @@ -7365,6 +7389,22 @@ pub fn exists_at_type_w(dir: Fd, sub: &[u16]) -> Maybe { let path = bun_paths::string_paths::to_nt_path16(&mut wbuf.0[..], sub).as_slice(); exists_at_type_nt(dir, path) } +/// `NtQueryAttributesFile` relative to `dir`, surfacing the directory and +/// reparse-point bits. Unlike `lstatat` → `fstat` (which maps junctions to +/// `S_IFDIR` and never sets `S_IFLNK`), this exposes +/// `FILE_ATTRIBUTE_REPARSE_POINT` so callers can refuse junctions/symlinks. +#[cfg(windows)] +pub fn get_file_attributes_at(dir: Fd, sub: &ZStr) -> Option { + use bun_windows_sys::externs as w; + let mut wbuf = bun_paths::w_path_buffer_pool::get(); + let path = bun_paths::string_paths::to_nt_path(&mut wbuf.0[..], sub.as_bytes()).as_slice(); + let bi = nt_query_basic_attrs_at(dir, path).ok()?; + Some(WindowsFileAttributes { + is_directory: (bi.FileAttributes & w::FILE_ATTRIBUTE_DIRECTORY) != 0, + is_reparse_point: (bi.FileAttributes & w::FILE_ATTRIBUTE_REPARSE_POINT) != 0, + raw: bi.FileAttributes, + }) +} /// `directoryExistsAt(dir, sub)`. ENOENT → `Ok(false)`. pub fn directory_exists_at(dir: impl AsFd, sub: &ZStr) -> Maybe { let dir = dir.as_fd(); diff --git a/test/cli/install/bun-install-cache-trust.test.ts b/test/cli/install/bun-install-cache-trust.test.ts new file mode 100644 index 000000000000..82558796433e --- /dev/null +++ b/test/cli/install/bun-install-cache-trust.test.ts @@ -0,0 +1,206 @@ +import { file, spawn } from "bun"; +import { describe, expect, test } from "bun:test"; +import { chmod, mkdir, readdir, rm, symlink, writeFile } from "fs/promises"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { createHash } from "node:crypto"; +import { join } from "path"; + +// The extraction cache is keyed by "@@@@" only. +// These tests cover the trust properties of that cache: that a matching +// directory name alone is not treated as proof of content, and that the cache +// root itself is subject to an ownership/permission check before use. + +const TARBALL = join(import.meta.dir, "baz-0.0.3.tgz"); +const CLEAN_INDEX = `#! /usr/bin/env node\n\nconsole.log("run baz");\n`; +const POISON = `module.exports = "POISONED";\n`; + +async function sha512(path: string) { + const bytes = await file(path).arrayBuffer(); + return "sha512-" + createHash("sha512").update(Buffer.from(bytes)).digest("base64"); +} + +type Serve = { + url: string; + tarballHits: number; + stop: () => void; +}; + +async function startRegistry(): Promise { + const integrity = await sha512(TARBALL); + let tarballHits = 0; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith(".tgz")) { + tarballHits++; + return new Response(file(TARBALL)); + } + if (url.pathname === "/baz" || url.pathname === "/baz/") { + return Response.json({ + name: "baz", + "dist-tags": { latest: "0.0.3" }, + versions: { + "0.0.3": { + name: "baz", + version: "0.0.3", + dist: { + tarball: `http://localhost:${server.port}/baz-0.0.3.tgz`, + integrity, + }, + }, + }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { + url: `http://localhost:${server.port}/`, + get tarballHits() { + return tarballHits; + }, + stop: () => server.stop(true), + }; +} + +async function makeProject(registryUrl: string, cacheDir: string, linker: "hoisted" | "isolated" = "hoisted") { + const dir = tempDir("cache-trust", { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { baz: "0.0.3" }, + }), + "bunfig.toml": `[install]\nregistry = "${registryUrl}"\nlinker = "${linker}"\n`, + }); + return { dir: String(dir), env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: cacheDir }, dispose: dir }; +} + +async function runInstall(cwd: string, env: Record, extra: string[] = []) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...extra], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +async function findCacheEntry(cacheDir: string): Promise { + const entries = await readdir(cacheDir); + const hit = entries.find(e => e.startsWith("baz@0.0.3")); + if (!hit) throw new Error(`no baz cache entry in ${cacheDir}: ${entries.join(", ")}`); + return join(cacheDir, hit); +} + +describe.concurrent("install extraction cache trust", () => { + for (const linker of ["hoisted", "isolated"] as const) { + test(`--force re-downloads and re-verifies on a cache hit (${linker})`, async () => { + const registry = await startRegistry(); + try { + using scratch = tempDir("cache-trust-scratch", {}); + const cacheDir = join(String(scratch), "cache"); + const { dir, env, dispose } = await makeProject(registry.url, cacheDir, linker); + using _ = dispose; + + // First install: populate cache + lockfile. + const r1 = await runInstall(dir, env); + expect(r1.stderr).toContain("Saved lockfile"); + expect(r1.exitCode).toBe(0); + expect(registry.tarballHits).toBe(1); + + // Tamper with the cached extraction in place. + const entry = await findCacheEntry(cacheDir); + await writeFile(join(entry, "index.js"), POISON); + + // Drop node_modules so the link step has to re-read from the cache. + await rm(join(dir, "node_modules"), { recursive: true, force: true }); + + // --force must bypass the extraction cache and re-download the tarball. + const r2 = await runInstall(dir, env, ["--force"]); + expect(r2.stderr).not.toContain("error:"); + expect(r2.exitCode).toBe(0); + expect(registry.tarballHits).toBe(2); + + // node_modules must reflect the registry bytes, not the poisoned cache. + const installed = await file(join(dir, "node_modules", "baz", "index.js")).text(); + expect(installed).toBe(CLEAN_INDEX); + } finally { + registry.stop(); + } + }); + } + + test("linked cache entry is not trusted", async () => { + const registry = await startRegistry(); + try { + using scratch = tempDir("cache-trust-scratch", {}); + const cacheDir = join(String(scratch), "cache"); + const { dir, env, dispose } = await makeProject(registry.url, cacheDir); + using _ = dispose; + + const r1 = await runInstall(dir, env); + expect(r1.stderr).toContain("Saved lockfile"); + expect(r1.exitCode).toBe(0); + expect(registry.tarballHits).toBe(1); + + // Replace the cache entry with a link to an attacker-controlled dir. On + // Windows, junctions do not require SeCreateSymbolicLinkPrivilege and + // carry FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT, which is + // exactly what cache_entry_is_dir must reject. + const entry = await findCacheEntry(cacheDir); + const attacker = join(String(scratch), "attacker"); + await mkdir(attacker, { recursive: true }); + await writeFile(join(attacker, "index.js"), POISON); + await writeFile( + join(attacker, "package.json"), + JSON.stringify({ name: "baz", version: "0.0.3", bin: { "baz-run": "index.js" } }), + ); + await rm(entry, { recursive: true, force: true }); + await symlink(attacker, entry, isWindows ? "junction" : "dir"); + + await rm(join(dir, "node_modules"), { recursive: true, force: true }); + + const r2 = await runInstall(dir, env, ["--frozen-lockfile"]); + expect(r2.stderr).not.toContain("error:"); + expect(r2.exitCode).toBe(0); + + // The link must not have been followed as a cache hit: the tarball was + // re-fetched and node_modules carries the registry bytes. + expect(registry.tarballHits).toBe(2); + const installed = await file(join(dir, "node_modules", "baz", "index.js")).text(); + expect(installed).toBe(CLEAN_INDEX); + } finally { + registry.stop(); + } + }); + + test.skipIf(isWindows)("group/other-writable shared cache root is rejected", async () => { + const registry = await startRegistry(); + try { + using scratch = tempDir("cache-trust-scratch", {}); + const cacheDir = join(String(scratch), "cache"); + await mkdir(cacheDir, { recursive: true }); + // Simulate a shared/world-writable cache location. + await chmod(cacheDir, 0o777); + + const { dir, env, dispose } = await makeProject(registry.url, cacheDir); + using _ = dispose; + + const r1 = await runInstall(dir, env); + expect(r1.stderr).toContain("writable by other users"); + expect(r1.exitCode).toBe(0); + + // The shared cache must not have been populated; the fallback + // per-project cache under node_modules/.cache is used instead. + const shared = await readdir(cacheDir).catch(() => []); + expect(shared.filter(e => e.startsWith("baz@"))).toEqual([]); + const local = await readdir(join(dir, "node_modules", ".cache")); + expect(local.some(e => e.startsWith("baz@"))).toBe(true); + } finally { + registry.stop(); + } + }); +});