From 598856c30f3f1a842dc969f0662bc679b7e50614 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:40:00 +0000 Subject: [PATCH 1/5] pack, publish: resolve each bin path through one helper get_package_bins now calls bin_subpath for every bin value, and normalize_bin in publish calls bin_target. A bin path is rejected when it names the package root, a path outside the package, or the root package.json. A bin file that ends in a slash is rejected too. For directories.bin the helper drops the trailing slash. The tree walks then skip the bin directory, so each of its files is packed once. --- src/runtime/cli/pack_command.rs | 48 ++++++------- src/runtime/cli/publish_command.rs | 93 ++++++++++--------------- test/cli/install/bun-pack.test.ts | 100 +++++++++++++++++++++++++++ test/cli/install/bun-publish.test.ts | 46 ++++++++++++ 4 files changed, 207 insertions(+), 80 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index e6d044b50cb0..fff1f4e6d9d4 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,13 +1480,9 @@ 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, - ); - 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, }); } @@ -1506,13 +1498,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, - ); - if !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, }); } @@ -1524,8 +1512,21 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { Ok(bins) } -fn bin_path_escapes_root(p: &[u8]) -> bool { - path::is_absolute_loose(p) || p == b".." || p.starts_with(b"../") +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, + }; + (!is_package_root_or_outside(subpath)).then_some(subpath) +} + +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 { @@ -1537,9 +1538,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/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index c33af8ff56fc..90c8eebd2f3f 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1589,6 +1589,11 @@ impl PublishCommand { None } + fn bin_target<'a>(value: &[u8], path_buf: &'a mut [u8]) -> Option<&'a ZStr> { + let target: &'a ZStr = normalize_buf_z::(value, path_buf); + (!pack::is_package_root_or_outside(target.as_bytes())).then_some(target) + } + fn normalize_bin( json: &mut Expr, bump: &bun_alloc::Arena, @@ -1608,31 +1613,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() @@ -1674,32 +1674,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()), @@ -1740,16 +1725,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 24df1f49a4ce..84ee569782db 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -2020,6 +2020,106 @@ describe.concurrent("bins", () => { ]); }); + // 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 => { + using dir = tempDir("pack-bins-not-a-file", { + "package.json": JSON.stringify({ name: "pack-bins-not-a-file", version: "1.0.0", bin }), + "cli.js": "console.log('cli')", + "lib/a.js": "console.log('a')", + }); + + const { err, exitCode } = await runPack(dir); + expect(err).toBe(""); + expect(exitCode).toBe(0); + + const tarball = readTarball(join(dir, "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 () => { + using dir = tempDir("pack-bins-partly-ignored", { + "package.json": JSON.stringify({ + name: "pack-bins-partly-ignored", + version: "1.0.0", + bin: { dir: "lib/", cli: "cli.js", pkg: "package.json" }, + }), + "cli.js": "console.log('cli')", + "lib/a.js": "console.log('a')", + }); + + const { err, exitCode } = await runPack(dir); + expect(err).toBe(""); + expect(exitCode).toBe(0); + + const tarball = readTarball(join(dir, "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. + 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) => { + using dir = tempDir("pack-bins-dir-trailing-slash", { + "package.json": JSON.stringify({ + name: "pack-bins-dir-trailing-slash", + version: "1.0.0", + files, + directories: { bin: "./lib/bins/" }, + }), + "index.js": "console.log('index')", + "lib/index.js": "console.log('lib')", + "lib/bins/bin.js": "console.log('bin')", + }); + + const { err, exitCode } = await runPack(dir); + expect(err).toBe(""); + expect(exitCode).toBe(0); + + const tarball = readTarball(join(dir, "pack-bins-dir-trailing-slash-1.0.0.tgz")); + expect(entryNames(tarball)).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 => { + using dir = tempDir("pack-bins-dir-root", { + "package.json": JSON.stringify({ name: "pack-bins-dir-root", version: "1.0.0", directories: { bin } }), + "index.js": "console.log('index')", + "lib/a.js": "console.log('a')", + }); + + const { err, exitCode } = await runPack(dir); + expect(err).toBe(""); + expect(exitCode).toBe(0); + + expect(tarballEntries(join(dir, "pack-bins-dir-root-1.0.0.tgz"))).toEqual([ + "package/package.json", + "package/index.js", + "package/lib/a.js", + ]); + }); + test('deduplicate with "files"', async () => { using dir = tempDir("pack-bins-and-files-dedupe", { "package.json": JSON.stringify({ diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..716a3936774f 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1323,6 +1323,52 @@ describe("readme", () => { }); }); +// 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.concurrent("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: ".", 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], + ])("%j", async (fields, 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 }); + }, + }); + + using packageDir = tempDir("publish-bin-manifest", { + "bunfig.toml": Bun.TOML.stringify({ + install: { + cache: false, + registry: { url: `http://localhost:${mock.port}`, token: "unused" }, + }, + }), + "package.json": JSON.stringify({ name: "bin-pkg", version: "1.0.0", ...fields }), + "cli.js": "#!/usr/bin/env node\n", + "lib/a.js": "module.exports = 1;", + "bins/a.js": "#!/usr/bin/env node\n", + }); + + const { err, exitCode } = await publish(env, String(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 0d5ee825ef29f9a7466c80d5d3b1b07439c73e37 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:08:26 +0000 Subject: [PATCH 2/5] pack, publish: read directories.bin when the bin string is empty bun install treats an empty "bin" string as absent and links the files in "directories.bin". Pack and publish now do the same, so the tarball and the manifest list the bins that a folder install links. --- src/runtime/cli/pack_command.rs | 8 +++++++- src/runtime/cli/publish_command.rs | 6 +++++- test/cli/install/bun-pack.test.ts | 28 ++++++++++++++++++++++++++++ test/cli/install/bun-publish.test.ts | 4 +++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index fff1f4e6d9d4..073d2e832923 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1461,7 +1461,7 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { let mut path_buf = PathBuffer::uninit(); - if let Some(bin) = json.as_property(b"bin") { + if let Some(bin) = non_empty_bin(json) { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { if let Some(subpath) = bin_subpath(bin_str, BinType::File, &mut path_buf) { bins.push(BinInfo { @@ -1512,6 +1512,12 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { Ok(bins) } +/// Like `bun install`, an empty `"bin"` string counts as absent. +pub(crate) fn non_empty_bin(json: &Expr) -> Option { + json.as_property(b"bin") + .filter(|bin| !matches!(&bin.expr.data, ExprData::EString(bin_str) if bin_str.is_blank())) +} + 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); diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 90c8eebd2f3f..bba55b3a82fe 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1609,6 +1609,7 @@ impl PublishCommand { }; } let mut path_buf = PathBuffer::uninit(); + let use_directories_bin = pack::non_empty_bin(json).is_none(); if let Some(bin_query) = json.as_property(b"bin") { match &bin_query.expr.data { ExprData::EString(bin_str) => { @@ -1719,7 +1720,10 @@ impl PublishCommand { } _ => {} } - } else if let Some(directories_query) = json.as_property(b"directories") { + } + + // An empty "bin" string is now `{}`. The listing below replaces it. + if use_directories_bin && let Some(directories_query) = json.as_property(b"directories") { if let Some(bin_query) = directories_query.expr.as_property(b"bin") { let Some(bin_dir_str) = bin_query.expr.as_string(bump) else { return Ok(()); diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 84ee569782db..63cc28f7197d 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -2120,6 +2120,34 @@ describe.concurrent("bins", () => { ]); }); + // An empty "bin" string counts as absent, as in `bun install`, so + // "directories.bin" applies. Any other "bin" string wins over it. + test.each([ + ["", ["package/bins/a.js"]], + ["cli.js", ["package/cli.js"]], + ])('"bin" of %p with "directories.bin"', async (bin, executable) => { + using dir = tempDir("pack-bins-with-dir", { + "package.json": JSON.stringify({ + name: "pack-bins-with-dir", + version: "1.0.0", + bin, + directories: { bin: "bins" }, + }), + "cli.js": "console.log('cli')", + "bins/a.js": "console.log('a')", + }); + + const { err, exitCode } = await runPack(dir); + expect(err).toBe(""); + expect(exitCode).toBe(0); + + const tarball = readTarball(join(dir, "pack-bins-with-dir-1.0.0.tgz")); + expect(entryNames(tarball)).toEqual(["package/package.json", "package/bins/a.js", "package/cli.js"]); + expect(tarball.entries.filter(entry => (entry.perm & 0o111) !== 0).map(entry => entry.pathname)).toEqual( + executable, + ); + }); + test('deduplicate with "files"', async () => { using dir = tempDir("pack-bins-and-files-dedupe", { "package.json": JSON.stringify({ diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 716a3936774f..bf119f892978 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1327,7 +1327,7 @@ describe("readme", () => { // 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. +// the tarball uses. An empty "bin" string counts as absent, as in `bun install`. describe.concurrent("bin in the published manifest", () => { test.each([ [{ bin: "cli.js" }, { "bin-pkg": "cli.js" }], @@ -1338,6 +1338,8 @@ describe.concurrent("bin in the published manifest", () => { [{ directories: { bin: "bins/" } }, { "a.js": "bins/a.js" }], [{ directories: { bin: "" } }, undefined], [{ directories: { bin: "." } }, undefined], + [{ bin: "", directories: { bin: "bins" } }, { "a.js": "bins/a.js" }], + [{ bin: "cli.js", directories: { bin: "bins" } }, { "bin-pkg": "cli.js" }], ])("%j", async (fields, expected) => { let captured: any = null; using mock = Bun.serve({ From e34a2bd71fd0430734de4d61218fc596c8895325 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:19:17 +0000 Subject: [PATCH 3/5] publish: skip a directories.bin that is not a directory normalize_bin crashed on ENOTDIR from openat. pack ignores such a bin directory, so publish now warns and leaves the manifest bin absent, the same as it does for a missing directory. --- src/runtime/cli/publish_command.rs | 16 ++++++++++++---- test/cli/install/bun-publish.test.ts | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index bba55b3a82fe..da5341be0490 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1743,14 +1743,22 @@ impl PublishCommand { 0, ) { Ok(fd) => fd, - Err(e) => { - if e.get_errno() == bun_sys::E::ENOENT { + Err(e) => match e.get_errno() { + bun_sys::E::ENOENT => { bun_core::warn!( "bin directory '{}' does not exist", bstr::BStr::new(normalized_bin_dir.as_bytes()), ); return Ok(()); - } else { + } + bun_sys::E::ENOTDIR => { + bun_core::warn!( + "bin directory '{}' is not a directory", + bstr::BStr::new(normalized_bin_dir.as_bytes()), + ); + return Ok(()); + } + _ => { Output::err( e, "failed to open bin directory: '{}'", @@ -1758,7 +1766,7 @@ impl PublishCommand { ); Global::crash(); } - } + }, }; let mut dirs: Vec<(Fd, Box<[u8]>, bool)> = Vec::new(); diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index bf119f892978..aad870f6caf5 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1338,6 +1338,8 @@ describe.concurrent("bin in the published manifest", () => { [{ directories: { bin: "bins/" } }, { "a.js": "bins/a.js" }], [{ directories: { bin: "" } }, undefined], [{ directories: { bin: "." } }, undefined], + [{ directories: { bin: "cli.js" } }, undefined], + [{ directories: { bin: "missing" } }, undefined], [{ bin: "", directories: { bin: "bins" } }, { "a.js": "bins/a.js" }], [{ bin: "cli.js", directories: { bin: "bins" } }, { "bin-pkg": "cli.js" }], ])("%j", async (fields, expected) => { From bb623989f0096f2343797177fa98b703176923c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:39:53 +0000 Subject: [PATCH 4/5] publish: close the directories.bin descriptor after the listing The root of the bin directory walk was pushed with close_dir false and nothing closed it afterwards. Every directory on the stack is opened by this function, so the flag goes and the guard closes each one. --- src/runtime/cli/publish_command.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index da5341be0490..d58d75a56eec 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -1769,16 +1769,13 @@ impl PublishCommand { }, }; - let mut dirs: Vec<(Fd, Box<[u8]>, bool)> = Vec::new(); + let mut dirs: Vec<(Fd, Box<[u8]>)> = Vec::new(); - dirs.push((bin_dir, normalized_bin_dir.as_bytes().into(), false)); + dirs.push((bin_dir, normalized_bin_dir.as_bytes().into())); - while let Some(dir_info) = dirs.pop() { - let (dir, dir_subpath, close_dir) = dir_info; - let _close = scopeguard::guard(dir, move |d| { - if close_dir { - let _ = d.close(); - } + while let Some((dir, dir_subpath)) = dirs.pop() { + let _close = scopeguard::guard(dir, |d| { + let _ = d.close(); }); let mut iter = DirIterator::iterate(dir); @@ -1838,7 +1835,7 @@ impl PublishCommand { else { continue; }; - dirs.push((subdir, subpath.as_bytes().into(), true)); + dirs.push((subdir, subpath.as_bytes().into())); } } } From b97b4bcf1d8e3073ce4814ea06f82c0a75fa988a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:55:07 +0000 Subject: [PATCH 5/5] ci: retrigger