diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 24775418293f..066587ef1e02 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -731,6 +731,9 @@ impl<'a> PackageInstaller<'a> { let pkg_names = pkgs.items_name(); let completed_trees = &self.completed_trees; + let copied_tree = self + .copy_trees + .is_set_allow_out_of_bound(tree_id as usize, false); let tree = &mut self.trees[tree_id as usize]; let mut deferred: Vec = Vec::new(); @@ -769,6 +772,7 @@ impl<'a> PackageInstaller<'a> { .slice(string_buf); let package_name_ = strings::StringOrTinyString::init(alias); let mut target_package_name = package_name_; + let mut target_package_id = package_id; let mut can_retry_without_native_binlink_optimization = false; let mut target_node_modules_path_opt: Option = None; let mut defer_this_bin = false; @@ -836,6 +840,7 @@ impl<'a> PackageInstaller<'a> { pkg_names[replacement_pkg_id as usize].slice(string_buf); target_package_name = strings::StringOrTinyString::init(replacement_name); + target_package_id = replacement_pkg_id; can_retry_without_native_binlink_optimization = true; } } @@ -865,6 +870,29 @@ impl<'a> PackageInstaller<'a> { } loop { + // `link_rel_buf` is free until the linker runs. + if let Some(source) = Self::symlink_install_source( + manager, + lockfile, + string_buf, + pkg_metas, + pkg_names, + self.resolutions.slice(), + tree_id, + dep_id, + target_package_id, + copied_tree, + link_rel_buf, + ) { + bin::make_bins_executable( + bin, + source, + (target_package_id != package_id).then_some(alias), + string_buf, + lockfile.buffers.extern_strings.as_slice(), + ); + } + // `node_modules_path` (mut) and `target_node_modules_path` // (read-only) refer to the same buffer when no replacement is // set. Derive both from a single `*mut` so the read pointer @@ -896,6 +924,7 @@ impl<'a> PackageInstaller<'a> { is_behind_link, err: None, skipped_due_to_missing_bin: false, + chmod_only: false, }; bin_linker.link(global); @@ -913,6 +942,7 @@ impl<'a> PackageInstaller<'a> { ); } target_package_name = package_name_; + target_package_id = package_id; target_node_modules_path_opt = None; continue; } @@ -1274,7 +1304,9 @@ impl<'a> PackageInstaller<'a> { /// The entry of the cache that a package which `can_enqueue_install_task` is linked from. fn cache_entry_name( - &self, + manager: &PackageManager, + string_buf: &[u8], + metas: &[Package::Meta], package_id: PackageID, pkg_name: String, resolution: &Resolution, @@ -1282,30 +1314,136 @@ impl<'a> PackageInstaller<'a> { ) -> &'static ZStr { match resolution.tag { resolution::Tag::Npm => package_manager::cached_npm_package_folder_name( - self.manager(), - pkg_name.slice(self.lockfile().buffers.string_bytes.as_slice()), + manager, + pkg_name.slice(string_buf), resolution.npm().version, patch_contents_hash, ), resolution::Tag::Git => package_manager::cached_git_folder_name( - self.manager(), + manager, resolution.git(), patch_contents_hash, ), resolution::Tag::Github => package_manager::cached_github_folder_name( - self.manager(), + manager, resolution.github(), patch_contents_hash, ), _ => package_manager::cached_tarball_folder_name( - self.manager(), - &self.metas[package_id as usize].integrity, + manager, + &metas[package_id as usize].integrity, resolution, patch_contents_hash, ), } } + /// The hash of the patch file applied to a package, `None` if it is not patched. + fn patch_contents_hash( + lockfile: &Lockfile, + string_buf: &[u8], + pkg_name: String, + resolution: &Resolution, + ) -> Option { + if lockfile.patched_dependencies.count() == 0 { + return None; + } + let mut name_and_version: Vec = Vec::new(); + use std::io::Write; + write!( + &mut name_and_version, + "{}@{}", + bstr::BStr::new(pkg_name.slice(string_buf)), + resolution.fmt(string_buf, PathSep::Posix), + ) + .expect("unreachable"); + lockfile + .patched_dependencies + .get(&bun_semver::string::Builder::string_hash(&name_and_version))? + .patchfile_hash() + } + + /// The directory that the files of `package_id` in `node_modules` are symlinks into: its cache + /// entry, or the folder of a `file:` dependency. `None` when they are the real files. The + /// cases follow `install_package`. + fn symlink_install_source<'b>( + manager: &mut PackageManager, + lockfile: &Lockfile, + string_buf: &[u8], + metas: &[Package::Meta], + pkg_names: &[String], + resolutions: &[Resolution], + tree_id: lockfile::tree::Id, + dependency_id: DependencyID, + package_id: PackageID, + copied_tree: bool, + buf: &'b mut [u8], + ) -> Option<&'b [u8]> { + let resolution = &resolutions[package_id as usize]; + let symlink_backend = + PackageInstall::supported_method() == package_install::Method::Symlink; + match resolution.tag { + resolution::Tag::Npm + | resolution::Tag::Git + | resolution::Tag::Github + | resolution::Tag::LocalTarball + | resolution::Tag::RemoteTarball + if symlink_backend && !copied_tree => + { + let pkg_name = pkg_names[package_id as usize]; + // Opens the cache directory, which sets its path. + let _ = package_manager::get_cache_directory(manager); + let cache_path = manager.cache_directory_path.as_bytes(); + let entry = Self::cache_entry_name( + manager, + string_buf, + metas, + package_id, + pkg_name, + resolution, + Self::patch_contents_hash(lockfile, string_buf, pkg_name, resolution), + ); + // A tarball without an integrity has no entry yet. + if entry.is_empty() { + return None; + } + join_abs_string_buf_z_checked::( + cache_path, + buf, + &[entry.as_bytes()], + ) + .map(ZStr::as_bytes) + } + resolution::Tag::Folder => { + let folder = resolution.folder().slice(string_buf); + let is_transitive = !lockfile.is_workspace_tree_id(tree_id); + // A folder that an npm package declares is a link to the directory. + if is_transitive + && !lockfile.is_folder_tree_id(tree_id) + && !lockfile.is_overridden_dependency(dependency_id) + { + return None; + } + // `get_install_method` symlinks a package from its own folder. + let own_folder = if is_transitive { + folder == b"." + } else { + matches!(folder, b"" | b".") + }; + if !symlink_backend && !own_folder { + return None; + } + join_abs_string_buf_z_checked::( + FileSystem::instance().top_level_dir(), + buf, + &[folder], + ) + .map(ZStr::as_bytes) + } + _ => None, + } + } + /// Returns true if the thread pool can link this package's files. The package is in the current /// tree. fn can_defer_link(&self, alias: &[u8], pkg_name: &[u8], package_id: PackageID) -> bool { @@ -1887,8 +2025,15 @@ impl<'a> PackageInstaller<'a> { | resolution::Tag::Github | resolution::Tag::LocalTarball | resolution::Tag::RemoteTarball => { - installer.cache_dir_subpath = - self.cache_entry_name(package_id, pkg_name, resolution, patch_contents_hash); + installer.cache_dir_subpath = Self::cache_entry_name( + self.manager(), + string_buf!(), + self.metas.slice(), + package_id, + pkg_name, + resolution, + patch_contents_hash, + ); installer.cache_dir = package_manager::get_cache_directory(self.manager_mut()); } resolution::Tag::Folder | resolution::Tag::Workspace => { diff --git a/src/install/PackageManager/patchPackage.rs b/src/install/PackageManager/patchPackage.rs index 48db5ebc3e70..ab81a69d945d 100644 --- a/src/install/PackageManager/patchPackage.rs +++ b/src/install/PackageManager/patchPackage.rs @@ -7,7 +7,7 @@ use bun_core::{ZBox, ZStr, handle_oom, strings}; use bun_install_types::NodeLinker::NodeLinker; use bun_paths::platform; use bun_paths::resolve_path; -use bun_paths::{AbsPath, Platform, SEP}; +use bun_paths::{Platform, SEP}; use bun_sys::{self as sys, Dir, Fd, FdDirExt as _, FdExt as _}; use crate::bin; @@ -524,13 +524,7 @@ pub fn do_patch_commit( let old_folder = resolve_path::join::(&[&cache_path, &cache_dir_subpath]).to_vec(); let new_folder = real_folder(&manager.lockfile, &cache_path, &target.folder, false); - make_bins_executable( - &manager.lockfile, - &pkg, - cache_dir, - &cache_dir_subpath, - &new_folder, - ); + make_bins_executable(&manager.lockfile, &pkg, &cache_path, &cache_dir_subpath); let Some(patchfile_contents) = diff_against_cache(manager, &old_folder, &new_folder) else { Global::crash(); @@ -1021,9 +1015,8 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> { make_bins_executable( &manager.lockfile, &pkg, - cache_result.cache_dir, + &cache_path, cache_result.cache_dir_subpath.as_bytes(), - &folder, ); // The package may be installed using the hard link method, @@ -1145,44 +1138,24 @@ fn download_if_not_cached(manager: &mut PackageManager, pkg: &Package) { fn make_bins_executable( lockfile: &Lockfile, pkg: &Package, - cache_dir: Fd, + cache_path: &[u8], cache_dir_subpath: &[u8], - folder: &[u8], ) { - if cfg!(windows) || pkg.bin.tag == bin::Tag::None { - return; - } - // `bin::Linker` sets the mode of the files it links to. The links themselves are not needed, so - // they go in a temporary folder. - let links = TempTree(temp_sibling(folder)); - let (Ok(cache_path), Ok(mut links_path)) = ( - ::init_fd_path(cache_dir), - ::from(links.0.as_slice()), + let mut buf = bun_paths::path_buffer_pool::get(); + let Some(cache_entry) = resolve_path::join_abs_string_buf_z_checked::( + cache_path, + &mut buf[..], + &[cache_dir_subpath], ) else { return; }; - let cache_entry = strings::StringOrTinyString::init(cache_dir_subpath); - let mut abs_target_buf = bun_paths::path_buffer_pool::get(); - let mut abs_dest_buf = bun_paths::path_buffer_pool::get(); - let mut rel_buf = bun_paths::path_buffer_pool::get(); - bin::Linker { - bin: pkg.bin, - target_node_modules_path: &raw const cache_path, - target_package_name: cache_entry, - seen: None, - node_modules_path: &mut links_path, - package_name: cache_entry, - global_bin_path: ZStr::EMPTY, - string_buf: lockfile.buffers.string_bytes.as_slice(), - extern_string_buf: lockfile.buffers.extern_strings.as_slice(), - abs_target_buf: abs_target_buf.as_mut_slice(), - abs_dest_buf: abs_dest_buf.as_mut_slice(), - rel_buf: rel_buf.as_mut_slice(), - is_behind_link: Some(false), - err: None, - skipped_due_to_missing_bin: false, - } - .link(false); + bin::make_bins_executable( + pkg.bin, + cache_entry.as_bytes(), + None, + lockfile.buffers.string_bytes.as_slice(), + lockfile.buffers.extern_strings.as_slice(), + ); } /// The patched package's cache entry can be missing, because an install that finds the patched diff --git a/src/install/bin.rs b/src/install/bin.rs index dac57fc7c87e..03b58c156ee1 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -858,6 +858,9 @@ pub struct Linker<'a> { pub err: Option, pub skipped_due_to_missing_bin: bool, + + /// Only sets the mode of the bin targets: no links, no shebang normalization. + pub chmod_only: bool, } #[cfg(not(windows))] @@ -975,6 +978,12 @@ impl<'a> Linker<'a> { } } + if self.chmod_only { + #[cfg(not(windows))] + Self::set_exec_mode(abs_target, &target_stat); + return; + } + if let Some(seen) = self.seen.as_deref() { // Skip seen destinations for this tree // https://github.com/npm/cli/blob/22731831e22011e32fa0ca12178e242c2ee2b33d/node_modules/bin-links/lib/link-gently.js#L30 @@ -1032,11 +1041,16 @@ impl<'a> Linker<'a> { #[cfg(not(windows))] fn make_executable(abs_target: &ZStr, target_stat: &sys::Stat) { + Self::set_exec_mode(abs_target, target_stat); + Self::try_normalize_shebang(abs_target, target_stat.st_size as u64); + } + + #[cfg(not(windows))] + fn set_exec_mode(abs_target: &ZStr, target_stat: &sys::Stat) { let mode = exec_mode(); if (target_stat.st_mode as Mode) & 0o777 != mode { let _ = sys::lchmod(abs_target, mode); } - Self::try_normalize_shebang(abs_target, target_stat.st_size as u64); } #[cfg(not(windows))] @@ -1975,7 +1989,60 @@ pub(crate) fn link_free_bins( is_behind_link: Some(false), err: None, skipped_due_to_missing_bin: false, + chmod_only: false, } .link(global_bin_path.is_some()); } } + +/// Sets the mode of the `bin` targets of a package in `source_dir`, the tree that a +/// symlink-backend install points into, without creating links. `redirected_from` is the package +/// whose `bin` field names the targets, when the native binlink optimization redirects its bins +/// into this one. +pub(crate) fn make_bins_executable( + bin: Bin, + source_dir: &[u8], + redirected_from: Option<&[u8]>, + string_buf: &[u8], + extern_string_buf: &[ExternalString], +) { + if cfg!(windows) || bin.tag == Tag::None { + return; + } + let source_parent = resolve_path::dirname::(source_dir); + let source_name = path::basename(source_dir); + if source_parent.is_empty() || source_name.is_empty() { + return; + } + // Two copies of the same path, because the linker reads `target_node_modules_path` while it + // holds `node_modules_path` by `&mut`. + let (Ok(target_node_modules_path), Ok(mut node_modules_path)) = + (AbsPath::from(source_parent), AbsPath::from(source_parent)) + else { + return; + }; + let source_name = strings::StringOrTinyString::init(source_name); + let mut abs_target_buf = path::path_buffer_pool::get(); + let mut abs_dest_buf = path::path_buffer_pool::get(); + let mut rel_buf = path::path_buffer_pool::get(); + Linker { + bin, + target_node_modules_path: &raw const target_node_modules_path, + target_package_name: source_name, + seen: None, + node_modules_path: &mut node_modules_path, + // A name that differs from `target_package_name` turns on the redirect's candidate lookup. + package_name: redirected_from.map_or(source_name, strings::StringOrTinyString::init), + global_bin_path: ZStr::EMPTY, + string_buf, + extern_string_buf, + abs_target_buf: abs_target_buf.as_mut_slice(), + abs_dest_buf: abs_dest_buf.as_mut_slice(), + rel_buf: rel_buf.as_mut_slice(), + is_behind_link: Some(false), + err: None, + skipped_due_to_missing_bin: false, + chmod_only: true, + } + .link(false); +} diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index f15efec8b351..08befac445d8 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -2442,6 +2442,7 @@ impl<'a> Installer<'a> { is_behind_link: links.is_behind_link, err: None, skipped_due_to_missing_bin: false, + chmod_only: false, }; bin_linker.link(global); diff --git a/src/runtime/cli/link_command.rs b/src/runtime/cli/link_command.rs index d0ec71f7306a..af035968de43 100644 --- a/src/runtime/cli/link_command.rs +++ b/src/runtime/cli/link_command.rs @@ -211,6 +211,7 @@ pub(crate) fn link_global_bins( is_behind_link: Some(false), err: None, skipped_due_to_missing_bin: false, + chmod_only: false, }; if unlink { bin_linker.unlink(true); diff --git a/test/cli/install/bun-install-native-binlink.test.ts b/test/cli/install/bun-install-native-binlink.test.ts index edbe55172151..fb9577f87a77 100644 --- a/test/cli/install/bun-install-native-binlink.test.ts +++ b/test/cli/install/bun-install-native-binlink.test.ts @@ -36,9 +36,19 @@ function readBinTarget(binDir: string, name: string) { return realpathSync(join(binDir, name)); } +// The hoisted linker also runs with the symlink backend, where the bins in node_modules are +// links into the cache and the cache files have to get the executable bit. +const hoistedBackends = isWindows ? [null] : [null, "symlink"]; +const linkersAndBackends = [ + ...hoistedBackends.map(backend => ["hoisted", backend] as const), + ["isolated", null] as const, +]; +const withBackend = (backend: string | null) => `${backend ? ` and --backend ${backend}` : ""}`; + describe.concurrent("native binlink optimization", () => { - for (const linker of ["hoisted", "isolated"]) { - test(`uses platform-specific bin instead of main package bin with linker ${linker}`, async () => { + for (const [linker, backend] of linkersAndBackends) { + const installArgs = backend ? [`--backend=${backend}`] : []; + test(`uses platform-specific bin instead of main package bin with linker ${linker}${withBackend(backend)}`, async () => { let env = { ...bunEnv }; const { packageDir, packageJson } = await verdaccio.createTestDir(); env.BUN_INSTALL_CACHE_DIR = join(packageDir, ".bun-cache"); @@ -75,7 +85,7 @@ describe.concurrent("native binlink optimization", () => { async function runInstall() { const proc = spawn({ - cmd: [bunExe(), "install"], + cmd: [bunExe(), "install", ...installArgs], cwd: packageDir, stdout: "inherit", stdin: "ignore", @@ -100,7 +110,12 @@ describe.concurrent("native binlink optimization", () => { } await runInstall(); - expect(readBinTarget(binDir, "test-binlink-cmd")).toContain(join("test-native-binlink-target", "bin", "main.js")); + // With the symlink backend the file behind the link is the platform package's cache entry. + expect(readBinTarget(binDir, "test-binlink-cmd")).toContain( + backend === "symlink" + ? join(packageDir, ".bun-cache", "test-native-binlink-target@") + : join("test-native-binlink-target", "bin", "main.js"), + ); await expectPlatformBin(); // Now delete the node_modules folder, keep the bun.lock, re-install @@ -205,7 +220,7 @@ describe.concurrent("native binlink optimization", () => { // fall back to linking the original package's bin. Previously the `seen` map // was poisoned by the failed redirect attempt, so the retry silently no-op'd // and `.bin/` was never created (broke `bunx @anthropic-ai/claude-code`). - test(`falls back to main package bin when platform dep has no matching bin file with linker ${linker}`, async () => { + test(`falls back to main package bin when platform dep has no matching bin file with linker ${linker}${withBackend(backend)}`, async () => { let env = { ...bunEnv }; const { packageDir, packageJson } = await verdaccio.createTestDir(); env.BUN_INSTALL_CACHE_DIR = join(packageDir, ".bun-cache"); @@ -236,7 +251,7 @@ describe.concurrent("native binlink optimization", () => { ); const installProc = spawn({ - cmd: [bunExe(), "install"], + cmd: [bunExe(), "install", ...installArgs], cwd: packageDir, stdout: "pipe", stdin: "ignore", @@ -253,7 +268,11 @@ describe.concurrent("native binlink optimization", () => { const binDir = join(packageDir, "node_modules", ".bin"); const binPath = binEntry(binDir, "fallback-cmd"); - expect(readBinTarget(binDir, "fallback-cmd")).toContain(join("test-native-binlink-fallback", "cli.js")); + expect(readBinTarget(binDir, "fallback-cmd")).toContain( + backend === "symlink" + ? join(packageDir, ".bun-cache", "test-native-binlink-fallback@") + : join("test-native-binlink-fallback", "cli.js"), + ); const binProc = spawn({ cmd: [binPath], @@ -270,7 +289,7 @@ describe.concurrent("native binlink optimization", () => { // Re-install with node_modules removed (lockfile-only path) await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); const installProc2 = spawn({ - cmd: [bunExe(), "install"], + cmd: [bunExe(), "install", ...installArgs], cwd: packageDir, stdout: "inherit", stdin: "ignore", diff --git a/test/regression/issue/46777.test.ts b/test/regression/issue/46777.test.ts new file mode 100644 index 000000000000..d40cf5cb0c95 --- /dev/null +++ b/test/regression/issue/46777.test.ts @@ -0,0 +1,222 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmod, lstat, readFile, readlink, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TestRegistry } from "registry"; + +// `bun install --backend symlink` fills `node_modules/` with symlinks into the cache. The bin +// linker does not set the mode through a link, so the file in the cache has to get the executable +// bit itself. The same applies to a `file:` folder, whose files are symlinked from the folder. + +const BIN = "#!/bin/sh\necho hello\n"; + +const registry = new TestRegistry(); +beforeAll(() => registry.start()); +afterAll(() => registry.stop()); + +async function run(cwd: string, args: string[], env: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(cwd, "cache"), ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: expect.not.stringContaining("error"), exitCode: 0 }); + return stdout; +} + +function install(cwd: string, args: string[], env: Record = {}) { + return run(cwd, ["install", "--linker=hoisted", ...args], env); +} + +async function runBin(cwd: string, name: string) { + await using proc = Bun.spawn({ + cmd: [join(cwd, "node_modules", ".bin", name)], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "hello\n", stderr: "", exitCode: 0 }); +} + +async function isExecutable(path: string) { + return ((await stat(path)).mode & 0o111) !== 0; +} + +/** A project that depends on a local tarball whose bin file is not executable. */ +async function projectWithTarball(name: string) { + const dir = tempDir(name, { + "package.json": JSON.stringify({ name: "app", dependencies: { pkg: "file:./pkg-1.0.0.tgz" } }), + }); + // `Bun.Archive` writes files with mode 0644, like `npm pack` does for a 0644 source file. + await Bun.Archive.write( + join(String(dir), "pkg-1.0.0.tgz"), + { + "package/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", bin: { pkg: "bin/cli.js" } }), + "package/bin/cli.js": BIN, + }, + { compress: "gzip" }, + ); + return dir; +} + +test.concurrent.skipIf(isWindows)("--backend symlink makes the bin file in the cache executable", async () => { + using dir = await projectWithTarball("bin-symlink-backend"); + const cwd = String(dir); + await install(cwd, ["--backend=symlink"]); + + const binTarget = join(cwd, "node_modules", "pkg", "bin", "cli.js"); + expect((await lstat(binTarget)).isSymbolicLink()).toBe(true); + const cacheFile = await readlink(binTarget); + expect(cacheFile.startsWith(join(cwd, "cache"))).toBe(true); + expect(await isExecutable(cacheFile)).toBe(true); + await runBin(cwd, "pkg"); +}); + +test.concurrent.skipIf(isWindows)("--backend symlink uses the cache directory from --cache-dir", async () => { + using dir = await projectWithTarball("bin-symlink-backend-cache-dir"); + const cwd = String(dir); + const cacheDir = join(cwd, "other-cache"); + await install(cwd, ["--backend=symlink", "--cache-dir", cacheDir], { BUN_INSTALL_CACHE_DIR: undefined }); + + const cacheFile = await readlink(join(cwd, "node_modules", "pkg", "bin", "cli.js")); + expect(cacheFile.startsWith(cacheDir)).toBe(true); + expect(await isExecutable(cacheFile)).toBe(true); + await runBin(cwd, "pkg"); +}); + +test.concurrent.skipIf(isWindows)("a second install makes a bin file in the cache executable again", async () => { + using dir = await projectWithTarball("bin-symlink-backend-reinstall"); + const cwd = String(dir); + await install(cwd, ["--backend=symlink"]); + const cacheFile = await readlink(join(cwd, "node_modules", "pkg", "bin", "cli.js")); + await chmod(cacheFile, 0o644); + + // The package is already installed, so only the bins are linked again. + await install(cwd, ["--backend=symlink"]); + expect(await isExecutable(cacheFile)).toBe(true); + await runBin(cwd, "pkg"); +}); + +test.concurrent.skipIf(isWindows)("--backend symlink makes the bin file of a file: folder executable", async () => { + using dir = tempDir("bin-symlink-backend-folder", { + "package.json": JSON.stringify({ name: "app", dependencies: { pkg: "file:./pkg" } }), + "pkg/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", bin: { pkg: "bin/cli.js" } }), + "pkg/bin/cli.js": BIN, + }); + const cwd = String(dir); + await chmod(join(cwd, "pkg", "bin", "cli.js"), 0o644); + await install(cwd, ["--backend=symlink"]); + + expect((await lstat(join(cwd, "node_modules", "pkg", "bin", "cli.js"))).isSymbolicLink()).toBe(true); + expect(await isExecutable(join(cwd, "pkg", "bin", "cli.js"))).toBe(true); + await runBin(cwd, "pkg"); +}); + +test.concurrent.skipIf(isWindows)( + "--backend symlink leaves a CRLF shebang in the cache and normalizes the copy", + async () => { + using dir = tempDir("bin-symlink-backend-crlf", { + "package.json": JSON.stringify({ name: "app", dependencies: { pkg: "file:./pkg-1.0.0.tgz" } }), + }); + const cwd = String(dir); + const CRLF_BIN = "#!/bin/sh\r\necho hello\n"; + await Bun.Archive.write( + join(cwd, "pkg-1.0.0.tgz"), + { + "package/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", bin: { pkg: "bin/cli.js" } }), + "package/bin/cli.js": CRLF_BIN, + }, + { compress: "gzip" }, + ); + await install(cwd, ["--backend=symlink"]); + + // The bin linker replaces the link with a normalized copy, as it does on every backend. + const binTarget = join(cwd, "node_modules", "pkg", "bin", "cli.js"); + expect((await lstat(binTarget)).isSymbolicLink()).toBe(false); + expect(await readFile(binTarget, "utf8")).toBe(BIN); + const cacheFile = await readlink(join(cwd, "node_modules", "pkg", "package.json")); + expect(await readFile(join(cacheFile, "..", "bin", "cli.js"), "utf8")).toBe(CRLF_BIN); + await runBin(cwd, "pkg"); + }, +); + +test.concurrent.skipIf(isWindows)("--backend symlink does not rewrite the bin file of a file: folder", async () => { + using dir = tempDir("bin-symlink-backend-folder-crlf", { + "package.json": JSON.stringify({ name: "app", dependencies: { pkg: "file:./pkg" } }), + "pkg/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", bin: { pkg: "bin/cli.js" } }), + "pkg/bin/cli.js": "#!/bin/sh\r\necho hello\n", + }); + const cwd = String(dir); + const sourceBin = join(cwd, "pkg", "bin", "cli.js"); + await chmod(sourceBin, 0o644); + await install(cwd, ["--backend=symlink"]); + + // The folder's file gets the mode and keeps its content. The copy in node_modules is normalized. + expect(await readFile(sourceBin, "utf8")).toBe("#!/bin/sh\r\necho hello\n"); + expect(await isExecutable(sourceBin)).toBe(true); + expect(await readFile(join(cwd, "node_modules", "pkg", "bin", "cli.js"), "utf8")).toBe(BIN); + await runBin(cwd, "pkg"); +}); + +/** A project that depends on registry packages, with the install cache inside it. */ +async function projectWithRegistry(name: string, dependencies: Record) { + const dir = tempDir(name, { "package.json": JSON.stringify({ name: "app", dependencies }) }); + await registry.writeBunfig(String(dir), { linker: "hoisted" }); + return dir; +} + +test.concurrent.skipIf(isWindows)( + "--backend symlink makes the files of a directories.bin entry executable", + async () => { + using dir = await projectWithRegistry("bin-symlink-backend-dir", { "dep-with-directory-bins": "1.0.0" }); + const cwd = String(dir); + await install(cwd, ["--backend=symlink"]); + + for (const name of ["directory-bin-1", "directory-bin-2"]) { + const binTarget = join(cwd, "node_modules", "dep-with-directory-bins", "bins", name); + expect((await lstat(binTarget)).isSymbolicLink()).toBe(true); + const cacheFile = await readlink(binTarget); + expect(cacheFile.startsWith(join(cwd, "cache"))).toBe(true); + expect(await isExecutable(cacheFile)).toBe(true); + } + }, +); + +test.concurrent.skipIf(isWindows)("--backend symlink makes the bin of a scoped package executable", async () => { + using dir = await projectWithRegistry("bin-symlink-backend-scoped", { "@scoped/has-bin-entry": "1.0.0" }); + const cwd = String(dir); + await install(cwd, ["--backend=symlink"]); + + // The package ships its bin as 0755. The second install has to set the mode again. + const cacheFile = await readlink(join(cwd, "node_modules", "@scoped", "has-bin-entry", "bin.js")); + expect(cacheFile.startsWith(join(cwd, "cache", "@scoped"))).toBe(true); + await chmod(cacheFile, 0o644); + await install(cwd, ["--backend=symlink"]); + expect(await isExecutable(cacheFile)).toBe(true); +}); + +test.concurrent.skipIf(isWindows)("--backend symlink makes the bin of a patched package executable", async () => { + using dir = await projectWithRegistry("bin-symlink-backend-patched", { "dep-with-file-bin": "1.0.0" }); + const cwd = String(dir); + await install(cwd, []); + await run(cwd, ["patch", "dep-with-file-bin"]); + const patchedBin = join(cwd, "node_modules", "dep-with-file-bin", "file-bin"); + await writeFile(patchedBin, BIN); + await chmod(patchedBin, 0o644); + await run(cwd, ["patch", "--commit", "node_modules/dep-with-file-bin"]); + + await install(cwd, ["--backend=symlink", "--force"]); + const binTarget = join(cwd, "node_modules", "dep-with-file-bin", "file-bin"); + expect((await lstat(binTarget)).isSymbolicLink()).toBe(true); + const cacheFile = await readlink(binTarget); + expect(cacheFile).toMatch(/@@@1_patch_hash=/); + await chmod(cacheFile, 0o644); + await install(cwd, ["--backend=symlink"]); + expect(await isExecutable(cacheFile)).toBe(true); + await runBin(cwd, "dep-with-file-bin"); +});