From 3bb187aceb6250fc7796ffcd6b67c8561ad1a1c9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:43:14 +0000 Subject: [PATCH 1/7] pack: pack a file listed under several bin names once; strip the trailing slash from directories.bin get_package_bins pushed one BinInfo per bin name, and pack() queues every BinInfo, so a file shared by several bin names was added to the tarball once per name. For directories.bin, normalize_buf keeps a trailing slash, so "tools/" was stored as the bin path. The bin directory walk then produced "tools//x" entry names (which is_package_bin did not recognize, so they were not marked executable), and the project tree walks, which compare the bin path against slash-less entry subpaths, failed to skip the directory and packed its files a second time. Strip the slash once when collecting the bin, which also removes the need to strip it again in is_package_bin. A bin directory that normalizes to the package root ("", ".", "./") is ignored instead of walking the whole tree twice. --- src/runtime/cli/pack_command.rs | 24 +++++++-- test/cli/install/bun-pack.test.ts | 87 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 4b767be83280..64c1835a234e 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1488,7 +1488,13 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { bin_str, &mut path_buf, ); - if !bin_path_escapes_root(normalized) { + // Every entry is queued for packing, so a file listed + // under several bin names must only appear once here. + if !bin_path_escapes_root(normalized) + && !bins.iter().any(|existing| { + strings::eql_long(existing.path.as_bytes(), normalized, true) + }) + { bins.push(BinInfo { path: ZBox::from_bytes(normalized), ty: BinType::File, @@ -1506,11 +1512,20 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { if let ExprData::EObject(directories_obj) = &directories.expr.data { if let Some(bin) = directories_obj.as_property(b"bin") { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { + // `normalize_buf` keeps a trailing slash, but this path is + // compared against entry subpaths (which have none) and + // entry names are joined onto it with a slash. "" and "." + // (from ".", "./" and "") would make the package root the + // bin directory and pack the whole tree a second time. let normalized = resolve_path::normalize_buf::( bin_str, &mut path_buf, ); - if !bin_path_escapes_root(normalized) { + let normalized = strings::without_trailing_slash(normalized); + if !normalized.is_empty() + && normalized != b"." + && !bin_path_escapes_root(normalized) + { bins.push(BinInfo { path: ZBox::from_bytes(normalized), ty: BinType::Dir, @@ -1537,9 +1552,8 @@ fn is_package_bin(bins: &[BinInfo], maybe_bin_path: &[u8]) -> bool { } } BinType::Dir => { - let bin_without_trailing = strings::without_trailing_slash(bin.path.as_bytes()); - if maybe_bin_path.starts_with(bin_without_trailing) { - let remain = &maybe_bin_path[bin_without_trailing.len()..]; + if maybe_bin_path.starts_with(bin.path.as_bytes()) { + let remain = &maybe_bin_path[bin.path.as_bytes().len()..]; if remain.len() > 1 && remain[0] == b'/' && strings::index_of_char(&remain[1..], b'/').is_none() diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index b89765712514..ddd907e5b7f6 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1647,6 +1647,93 @@ describe("bins", () => { }, ]); }); + + test("the same file under several bin names is packed once", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-same-file", + version: "1.0.0", + bin: { + "one": "cli.js", + "two": "cli.js", + "three": "./cli.js", + }, + }), + ), + write(join(packageDir, "cli.js"), "console.log('hello')"), + ]); + + const { out } = await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-bins-same-file-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([{ pathname: "package/package.json" }, { pathname: "package/cli.js" }]); + expect(tarball.entries[1].perm & 0o111).toBe(0o111); + expect(out).toContain("Total files: 2"); + }); + + // The bin directory is packed by its own walk, and the walk over the rest of + // the package has to skip it. Each `files` value below routes that skip + // through a different walk. + describe('"directories.bin" with a trailing slash', () => { + test.each([ + [undefined, ["package/index.js", "package/lib/bins/bin.js", "package/lib/index.js"]], + [["index.js"], ["package/index.js", "package/lib/bins/bin.js"]], + [["lib"], ["package/lib/bins/bin.js", "package/lib/index.js"]], + [["lib/bins"], ["package/lib/bins/bin.js"]], + ])("files: %p", async (files, expected) => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-dir-trailing-slash", + version: "1.0.0", + files, + directories: { + bin: "./lib/bins/", + }, + }), + ), + write(join(packageDir, "index.js"), "console.log('index')"), + write(join(packageDir, "lib", "index.js"), "console.log('lib')"), + write(join(packageDir, "lib", "bins", "bin.js"), "console.log('bin')"), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-bins-dir-trailing-slash-1.0.0.tgz")); + expect(tarball.entries.map(entry => entry.pathname)).toEqual(["package/package.json", ...expected]); + const bin = tarball.entries.find(entry => entry.pathname === "package/lib/bins/bin.js"); + expect(bin.perm & 0o111).toBe(0o111); + }); + }); + + test.each(["", ".", "./"])('"directories.bin" of %p (the package root) is ignored', async bin => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-dir-root", + version: "1.0.0", + directories: { + bin, + }, + }), + ), + write(join(packageDir, "index.js"), "console.log('index')"), + write(join(packageDir, "lib", "a.js"), "console.log('a')"), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-bins-dir-root-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([ + { pathname: "package/package.json" }, + { pathname: "package/index.js" }, + { pathname: "package/lib/a.js" }, + ]); + }); }); test("unicode", async () => { From 477e1588a19968ea64503e5f4b0655e574862d0f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:52:35 +0000 Subject: [PATCH 2/7] pack: name the bin dedupe and package-root conditions instead of commenting them --- src/runtime/cli/pack_command.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 64c1835a234e..5bcd2ec80fd1 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1488,13 +1488,10 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { bin_str, &mut path_buf, ); - // Every entry is queued for packing, so a file listed - // under several bin names must only appear once here. - if !bin_path_escapes_root(normalized) - && !bins.iter().any(|existing| { - strings::eql_long(existing.path.as_bytes(), normalized, true) - }) - { + let already_listed = bins.iter().any(|existing| { + strings::eql_long(existing.path.as_bytes(), normalized, true) + }); + if !already_listed && !bin_path_escapes_root(normalized) { bins.push(BinInfo { path: ZBox::from_bytes(normalized), ty: BinType::File, @@ -1512,20 +1509,13 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { if let ExprData::EObject(directories_obj) = &directories.expr.data { if let Some(bin) = directories_obj.as_property(b"bin") { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { - // `normalize_buf` keeps a trailing slash, but this path is - // compared against entry subpaths (which have none) and - // entry names are joined onto it with a slash. "" and "." - // (from ".", "./" and "") would make the package root the - // bin directory and pack the whole tree a second time. let normalized = resolve_path::normalize_buf::( bin_str, &mut path_buf, ); let normalized = strings::without_trailing_slash(normalized); - if !normalized.is_empty() - && normalized != b"." - && !bin_path_escapes_root(normalized) - { + let is_package_root = normalized.is_empty() || normalized == b"."; + if !is_package_root && !bin_path_escapes_root(normalized) { bins.push(BinInfo { path: ZBox::from_bytes(normalized), ty: BinType::Dir, From 4d8ba0c6fef8442df86b658d3504ffeeea13326d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:23:39 +0000 Subject: [PATCH 3/7] pack: resolve every bin value through one bin_subpath helper; share it with publish The three arms of get_package_bins each normalized their value with a slightly different set of checks. bin_subpath applies one rule: a directories.bin value loses its trailing slash; a bin value that ends in a slash or names the root package.json is not a bin; anything that resolves to the package root is ignored. This also stops `"bin": ""` and a bin pointing at "dir/" from failing the pack with EISDIR, and stops `"bin": "package.json"` from archiving package.json twice. publish's manifest derives "bin" from directories.bin with the same helper, so directories.bin "" no longer lists every file of the package (tarball included) as a bin while the tarball ignores the field. --- src/runtime/cli/pack_command.rs | 55 +++++++++++++++----------- src/runtime/cli/publish_command.rs | 14 +++---- test/cli/install/bun-pack.test.ts | 59 ++++++++++++++++++++++++++++ test/cli/install/bun-publish.test.ts | 45 +++++++++++++++++++++ 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 5bcd2ec80fd1..a0a3506959e2 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1446,7 +1446,7 @@ fn get_bundled_deps( // ─────────────────────────────────────────────────────────────────────────── #[derive(Clone, Copy, PartialEq, Eq)] -enum BinType { +pub(crate) enum BinType { File, Dir, } @@ -1463,13 +1463,9 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { if let Some(bin) = json.as_property(b"bin") { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { - let normalized = resolve_path::normalize_buf::( - bin_str, - &mut path_buf, - ); - if !bin_path_escapes_root(normalized) { + if let Some(subpath) = bin_subpath(bin_str, BinType::File, &mut path_buf) { bins.push(BinInfo { - path: ZBox::from_bytes(normalized), + path: ZBox::from_bytes(subpath), ty: BinType::File, }); } @@ -1484,16 +1480,16 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { for bin_prop in bin_obj.properties.slice() { if let Some(bin_prop_value) = &bin_prop.value { if let Some(bin_str) = bin_prop_value.as_string(pack_bump()) { - let normalized = resolve_path::normalize_buf::( - bin_str, - &mut path_buf, - ); + let Some(subpath) = bin_subpath(bin_str, BinType::File, &mut path_buf) + else { + continue; + }; let already_listed = bins.iter().any(|existing| { - strings::eql_long(existing.path.as_bytes(), normalized, true) + strings::eql_long(existing.path.as_bytes(), subpath, true) }); - if !already_listed && !bin_path_escapes_root(normalized) { + if !already_listed { bins.push(BinInfo { - path: ZBox::from_bytes(normalized), + path: ZBox::from_bytes(subpath), ty: BinType::File, }); } @@ -1509,15 +1505,9 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { if let ExprData::EObject(directories_obj) = &directories.expr.data { if let Some(bin) = directories_obj.as_property(b"bin") { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { - let normalized = resolve_path::normalize_buf::( - bin_str, - &mut path_buf, - ); - let normalized = strings::without_trailing_slash(normalized); - let is_package_root = normalized.is_empty() || normalized == b"."; - if !is_package_root && !bin_path_escapes_root(normalized) { + if let Some(subpath) = bin_subpath(bin_str, BinType::Dir, &mut path_buf) { bins.push(BinInfo { - path: ZBox::from_bytes(normalized), + path: ZBox::from_bytes(subpath), ty: BinType::Dir, }); } @@ -1529,6 +1519,27 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { Ok(bins) } +/// The package subpath named by a `bin` value (`File`) or by `directories.bin` +/// (`Dir`), in the form the tree walks and `is_package_bin` compare against +/// entry subpaths. `None` when there is nothing to pack from it: the package +/// root, a path outside the package, a file spelled as a directory, or the root +/// package.json, which is always archived separately. +pub(crate) fn bin_subpath<'a>(value: &[u8], ty: BinType, buf: &'a mut [u8]) -> Option<&'a [u8]> { + let normalized: &'a [u8] = + resolve_path::normalize_buf::(value, buf); + let subpath = match ty { + BinType::Dir => strings::without_trailing_slash(normalized), + BinType::File if normalized.ends_with(b"/") || normalized == b"package.json" => { + return None; + } + BinType::File => normalized, + }; + if subpath.is_empty() || subpath == b"." || bin_path_escapes_root(subpath) { + return None; + } + Some(subpath) +} + fn bin_path_escapes_root(p: &[u8]) -> bool { path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index ac6e1736eb84..69f5ff5f6eab 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1761,16 +1761,12 @@ impl PublishCommand { return Ok(()); }; let mut bin_props: Vec = Vec::new(); - let normalized_bin_dir = bun_core::ZBox::from_bytes( - strings::without_trailing_slash(strings::without_prefix( - normalize_buf::(bin_dir_str, &mut *path_buf), - b"./", - )), - ); - - if normalized_bin_dir.is_empty() { + let Some(bin_dir_subpath) = + pack::bin_subpath(bin_dir_str, pack::BinType::Dir, &mut *path_buf) + else { return Ok(()); - } + }; + let normalized_bin_dir = bun_core::ZBox::from_bytes(bin_dir_subpath); let bin_dir = match bun_sys::openat( workspace_root, diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index ddd907e5b7f6..53c56f47e63d 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1673,6 +1673,65 @@ describe("bins", () => { expect(out).toContain("Total files: 2"); }); + // None of these name a file that can be packed as a bin: the package root, a + // file spelled as a directory, a directory, and the root package.json (which + // is always in the tarball). The package packs as if "bin" were absent. + test.each(["", ".", "cli.js/", "lib/", "package.json"])('"bin" of %p is ignored', async bin => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-not-a-file", + version: "1.0.0", + bin, + }), + ), + write(join(packageDir, "cli.js"), "console.log('cli')"), + write(join(packageDir, "lib", "a.js"), "console.log('a')"), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-bins-not-a-file-1.0.0.tgz")); + expect( + tarball.entries.map(entry => ({ pathname: entry.pathname, executable: (entry.perm & 0o111) !== 0 })), + ).toEqual([ + { pathname: "package/package.json", executable: false }, + { pathname: "package/cli.js", executable: false }, + { pathname: "package/lib/a.js", executable: false }, + ]); + }); + + test("ignored entries of a bin object do not affect the others", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-bins-partly-ignored", + version: "1.0.0", + bin: { + "dir": "lib/", + "cli": "cli.js", + "pkg": "package.json", + }, + }), + ), + write(join(packageDir, "cli.js"), "console.log('cli')"), + write(join(packageDir, "lib", "a.js"), "console.log('a')"), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-bins-partly-ignored-1.0.0.tgz")); + expect( + tarball.entries.map(entry => ({ pathname: entry.pathname, executable: (entry.perm & 0o111) !== 0 })), + ).toEqual([ + { pathname: "package/package.json", executable: false }, + { pathname: "package/cli.js", executable: true }, + { pathname: "package/lib/a.js", executable: false }, + ]); + }); + // The bin directory is packed by its own walk, and the walk over the rest of // the package has to skip it. Each `files` value below routes that skip // through a different walk. diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c7f9df4fc406..ebbfa94a88b6 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1207,6 +1207,51 @@ describe("readme", () => { }); }); +// The manifest's "bin" is derived from "directories.bin" with the same rule the +// tarball uses, so it only ever lists bins that the tarball ships. +describe('"directories.bin" in the published manifest', () => { + test.each([ + ["bins/", { "a.js": "bins/a.js" }], + // The package root is not a bin directory. + ["", undefined], + [".", undefined], + ])("%p", async (bin, expected) => { + let captured: any = null; + using mock = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") captured = await req.json(); + return new Response("OK", { status: 200 }); + }, + }); + + const packageDir = tmpdirSync(); + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: { url: `http://localhost:${mock.port}`, token: "unused" }, + }, + }), + ), + write( + join(packageDir, "package.json"), + JSON.stringify({ name: "bin-dir-pkg", version: "1.0.0", directories: { bin } }), + ), + write(join(packageDir, "index.js"), "module.exports = 1;"), + write(join(packageDir, "bins", "a.js"), "#!/usr/bin/env node\n"), + ]); + + const { err, exitCode } = await publish(env, packageDir); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect(captured.versions["1.0.0"].bin).toEqual(expected); + }); +}); + test("dist.tarball in the published manifest does not include userinfo from the registry url", async () => { let captured: any = null; using mock = Bun.serve({ From 7c41d5b9ed3f00065b77502ddaca0691d0ca1557 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:26:46 +0000 Subject: [PATCH 4/7] pack: shorten the bin_subpath doc comment --- src/runtime/cli/pack_command.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index a0a3506959e2..dfe81d863fd9 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1519,11 +1519,8 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { Ok(bins) } -/// The package subpath named by a `bin` value (`File`) or by `directories.bin` -/// (`Dir`), in the form the tree walks and `is_package_bin` compare against -/// entry subpaths. `None` when there is nothing to pack from it: the package -/// root, a path outside the package, a file spelled as a directory, or the root -/// package.json, which is always archived separately. +/// A `bin` (`File`) or `directories.bin` (`Dir`) value as the subpath the tree +/// walks compare against, or `None` when there is nothing to pack from it. pub(crate) fn bin_subpath<'a>(value: &[u8], ty: BinType, buf: &'a mut [u8]) -> Option<&'a [u8]> { let normalized: &'a [u8] = resolve_path::normalize_buf::(value, buf); From 766710c32020b4d9583f9cb7f627dc5d10026b4b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:30:47 +0000 Subject: [PATCH 5/7] pack: drop the bin_subpath doc comment --- src/runtime/cli/pack_command.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index dfe81d863fd9..e7c0a3c61e09 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1519,8 +1519,6 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { Ok(bins) } -/// A `bin` (`File`) or `directories.bin` (`Dir`) value as the subpath the tree -/// walks compare against, or `None` when there is nothing to pack from it. pub(crate) fn bin_subpath<'a>(value: &[u8], ty: BinType, buf: &'a mut [u8]) -> Option<&'a [u8]> { let normalized: &'a [u8] = resolve_path::normalize_buf::(value, buf); From d19c2a87a7d9014a4aaff2c6712e0fb07b8ed0c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:08:09 +0000 Subject: [PATCH 6/7] publish: resolve string and object bin values through one bin_target; drop the package root --- src/runtime/cli/publish_command.rs | 81 ++++++++++++---------------- test/cli/install/bun-publish.test.ts | 29 +++++----- 2 files changed, 50 insertions(+), 60 deletions(-) diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 69f5ff5f6eab..34d8af85cc6e 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1610,6 +1610,13 @@ impl PublishCommand { None } + /// `None` when a `bin` value resolves to the package root (`""`, `"."`), which npm drops too. + fn bin_target<'a>(value: &[u8], path_buf: &'a mut [u8]) -> Option<&'a ZStr> { + let target: &'a ZStr = normalize_buf_z::(value, path_buf); + let is_package_root = target.is_empty() || target.as_bytes() == b"."; + (!is_package_root).then_some(target) + } + fn normalize_bin( json: &mut Expr, bump: &bun_alloc::Arena, @@ -1629,31 +1636,26 @@ impl PublishCommand { match &bin_query.expr.data { ExprData::EString(bin_str) => { let mut bin_props: Vec = Vec::new(); - let normalized = strings::without_prefix_comptime_z( - normalize_buf_z::( - bin_str.string(bump)?, - &mut *path_buf, - ), - b"./", - ); - if !bun_sys::exists_at(workspace_root, normalized) { - bun_core::warn!( - "bin '{}' does not exist", - bstr::BStr::new(normalized.as_bytes()), - ); - } + if let Some(value) = Self::bin_target(bin_str.string(bump)?, &mut *path_buf) { + if !bun_sys::exists_at(workspace_root, value) { + bun_core::warn!( + "bin '{}' does not exist", + bstr::BStr::new(value.as_bytes()), + ); + } - bin_props.push(G::Property { - key: Some(Expr::init( - E::String::init(leak!(package_name)), - bun_ast::Loc::EMPTY, - )), - value: Some(Expr::init( - E::String::init(leak!(normalized.as_bytes())), - bun_ast::Loc::EMPTY, - )), - ..Default::default() - }); + bin_props.push(G::Property { + key: Some(Expr::init( + E::String::init(leak!(package_name)), + bun_ast::Loc::EMPTY, + )), + value: Some(Expr::init( + E::String::init(leak!(value.as_bytes())), + bun_ast::Loc::EMPTY, + )), + ..Default::default() + }); + } json.data .e_object_mut() @@ -1695,32 +1697,17 @@ impl PublishCommand { continue; } - let value: Option = 'value: { - if let Some(value) = &bin_prop.value { - if let Some(vs) = value.data.as_e_string() { - if vs.len() != 0 { - break 'value Some(bun_core::ZBox::from_bytes( - strings::without_prefix_comptime_z( - // replace separators - normalize_buf_z::( - vs.string(bump)?, - &mut *path_buf, - ), - b"./", - ) - .as_bytes(), - )); - } - } - } - None + let Some(value) = + bin_prop.value.as_ref().and_then(|v| v.data.as_e_string()) + else { + continue; }; - let Some(value) = value else { continue }; - if value.is_empty() { + let Some(value) = Self::bin_target(value.string(bump)?, &mut *path_buf) + else { continue; - } + }; - if !bun_sys::exists_at(workspace_root, &value) { + if !bun_sys::exists_at(workspace_root, value) { bun_core::warn!( "bin '{}' does not exist", bstr::BStr::new(value.as_bytes()), diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index ebbfa94a88b6..9854781cdfee 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1207,15 +1207,20 @@ describe("readme", () => { }); }); -// The manifest's "bin" is derived from "directories.bin" with the same rule the -// tarball uses, so it only ever lists bins that the tarball ships. -describe('"directories.bin" in the published manifest', () => { +// The manifest "bin" that reaches the registry. A value that resolves to the +// package root is dropped, as npm does; other values are kept as written (npm +// keeps "lib/" and "package.json" as well), and "directories.bin" is expanded +// with the same rule the tarball uses. +describe("bin in the published manifest", () => { test.each([ - ["bins/", { "a.js": "bins/a.js" }], - // The package root is not a bin directory. - ["", undefined], - [".", undefined], - ])("%p", async (bin, expected) => { + [{ bin: "cli.js" }, { "bin-pkg": "cli.js" }], + [{ bin: "" }, {}], + [{ bin: "." }, {}], + [{ bin: { x: "lib/", y: "./cli.js", z: "", w: "." } }, { x: "lib/", y: "cli.js" }], + [{ directories: { bin: "bins/" } }, { "a.js": "bins/a.js" }], + [{ directories: { bin: "" } }, undefined], + [{ directories: { bin: "." } }, undefined], + ])("%j", async (fields, expected) => { let captured: any = null; using mock = Bun.serve({ port: 0, @@ -1236,11 +1241,9 @@ describe('"directories.bin" in the published manifest', () => { }, }), ), - write( - join(packageDir, "package.json"), - JSON.stringify({ name: "bin-dir-pkg", version: "1.0.0", directories: { bin } }), - ), - write(join(packageDir, "index.js"), "module.exports = 1;"), + write(join(packageDir, "package.json"), JSON.stringify({ name: "bin-pkg", version: "1.0.0", ...fields })), + write(join(packageDir, "cli.js"), "#!/usr/bin/env node\n"), + write(join(packageDir, "lib", "a.js"), "module.exports = 1;"), write(join(packageDir, "bins", "a.js"), "#!/usr/bin/env node\n"), ]); From 10b42b00b0c8996dce64d13a8b8b47cea3848425 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:29:06 +0000 Subject: [PATCH 7/7] pack/publish: share the package-root-or-outside check between bin_subpath and bin_target normalize_buf already resolves "../x" to "x" and drops a leading slash, the same way npm resolves bin targets, so neither side could emit a path outside the package; the remaining absolute-path spelling was only rejected by pack. Both helpers now end in the same predicate, and tests pin "../cli.js" resolving to "cli.js" in the tarball and in the manifest. --- src/runtime/cli/pack_command.rs | 9 +++------ src/runtime/cli/publish_command.rs | 4 +--- test/cli/install/bun-pack.test.ts | 2 ++ test/cli/install/bun-publish.test.ts | 12 +++++++----- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index e7c0a3c61e09..82f00a0f2482 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1529,14 +1529,11 @@ pub(crate) fn bin_subpath<'a>(value: &[u8], ty: BinType, buf: &'a mut [u8]) -> O } BinType::File => normalized, }; - if subpath.is_empty() || subpath == b"." || bin_path_escapes_root(subpath) { - return None; - } - Some(subpath) + (!is_package_root_or_outside(subpath)).then_some(subpath) } -fn bin_path_escapes_root(p: &[u8]) -> bool { - path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") +pub(crate) fn is_package_root_or_outside(p: &[u8]) -> bool { + p.is_empty() || p == b"." || path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") } fn is_package_bin(bins: &[BinInfo], maybe_bin_path: &[u8]) -> bool { diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 34d8af85cc6e..73742a515d48 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1610,11 +1610,9 @@ impl PublishCommand { None } - /// `None` when a `bin` value resolves to the package root (`""`, `"."`), which npm drops too. fn bin_target<'a>(value: &[u8], path_buf: &'a mut [u8]) -> Option<&'a ZStr> { let target: &'a ZStr = normalize_buf_z::(value, path_buf); - let is_package_root = target.is_empty() || target.as_bytes() == b"."; - (!is_package_root).then_some(target) + (!pack::is_package_root_or_outside(target.as_bytes())).then_some(target) } fn normalize_bin( diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 53c56f47e63d..42601173b06b 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1648,6 +1648,7 @@ describe("bins", () => { ]); }); + // Paths resolve against the package root, as in npm, so all four name one file. test("the same file under several bin names is packed once", async () => { await Promise.all([ write( @@ -1659,6 +1660,7 @@ describe("bins", () => { "one": "cli.js", "two": "cli.js", "three": "./cli.js", + "four": "../cli.js", }, }), ), diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 9854781cdfee..c6e3caea89ff 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1207,16 +1207,18 @@ describe("readme", () => { }); }); -// The manifest "bin" that reaches the registry. A value that resolves to the -// package root is dropped, as npm does; other values are kept as written (npm -// keeps "lib/" and "package.json" as well), and "directories.bin" is expanded -// with the same rule the tarball uses. +// The manifest "bin" that reaches the registry. Paths are resolved against the +// package root the way npm does it ("../cli.js" is "cli.js"), a value that +// resolves to the root itself is dropped, other values are kept as written (npm +// keeps "lib/" as well), and "directories.bin" is expanded with the same rule +// the tarball uses. describe("bin in the published manifest", () => { test.each([ [{ bin: "cli.js" }, { "bin-pkg": "cli.js" }], + [{ bin: "../cli.js" }, { "bin-pkg": "cli.js" }], [{ bin: "" }, {}], [{ bin: "." }, {}], - [{ bin: { x: "lib/", y: "./cli.js", z: "", w: "." } }, { x: "lib/", y: "cli.js" }], + [{ bin: { x: "lib/", y: "./cli.js", z: "", w: ".", v: "../../cli.js" } }, { x: "lib/", y: "cli.js", v: "cli.js" }], [{ directories: { bin: "bins/" } }, { "a.js": "bins/a.js" }], [{ directories: { bin: "" } }, undefined], [{ directories: { bin: "." } }, undefined],