From 511414f10b14ab32e650de5825090485e5298e85 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:53:13 +0000 Subject: [PATCH 1/3] pm pack: print the bytes of a non-UTF-8 tarball path when stdout is not a terminal `bun pm pack --destination` and `--filename` print the path of the tarball. The printer formatted that path through `bstr::BStr`, so each byte that is not UTF-8 became U+FFFD. The line then named a file that does not exist. The printer now takes the path as bytes. A path that is valid UTF-8 goes through the same statement as before. So does every path when stdout is a terminal. For any other path, each name that is valid UTF-8 goes through `EscapeControlChars`. In each other name, the ASCII bytes go through `EscapeControlChars` and the other bytes go to stdout unchanged. --- src/runtime/cli/pack_command.rs | 47 ++++++++-- test/cli/install/bun-pack.test.ts | 141 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 7 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index c82d34628c2a..b4606d43de9a 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -101,12 +101,48 @@ impl<'a> Context<'a> { } } - pub(crate) fn print_tarball_path(path: impl fmt::Display, log_level: LogLevel) { + fn begin_tarball_path(log_level: LogLevel) { // Quiet/silent output must be only the tarball path so `$(bun pm pack --quiet)` works. if log_level != LogLevel::Silent && log_level != LogLevel::Quiet { bun_core::pretty!("\n"); } - bun_core::pretty!("{}\n", bun_fmt::EscapeControlChars(path)); + } + + pub(crate) fn print_tarball_path(name: TarballNameFormatter<'_>, log_level: LogLevel) { + Self::begin_tarball_path(log_level); + bun_core::pretty!("{}\n", bun_fmt::EscapeControlChars(name)); + } + + /// `path` is the bytes that the kernel got, and a script gives this line back to the kernel. + /// Through `Display` a byte that is not UTF-8 becomes U+FFFD, and the line names another file. + pub(crate) fn print_tarball_abs_path(path: &[u8], log_level: LogLevel) { + Self::begin_tarball_path(log_level); + match strings::str_utf8(path) { + Some(path) => bun_core::pretty!("{}\n", bun_fmt::EscapeControlChars(path)), + // Some terminals run a byte that is not UTF-8 as a control character. So a terminal + // gets U+FFFD for it, as in `bun pm diff`. + None if Output::is_stdout_tty() => { + bun_core::pretty!("{}\n", bun_fmt::escape_control_chars(path)); + } + None => { + for (i, name) in strings::split(path, SEP_STR.as_bytes()).enumerate() { + if i > 0 { + Output::print_bytes(SEP_STR.as_bytes()); + } + // A name that is not UTF-8 is bytes in an encoding that we do not know. Only + // its ASCII bytes can be control characters. + let is_text = strings::str_utf8(name).is_some(); + for part in name.chunk_by(|a, b| is_text || a.is_ascii() == b.is_ascii()) { + if is_text || part.is_ascii() { + bun_core::pretty!("{}", bun_fmt::escape_control_chars(part)); + } else { + Output::print_bytes(part); + } + } + } + Output::print_bytes(b"\n"); + } + } } } @@ -1848,10 +1884,7 @@ pub(crate) fn pack( bare_filename, &mut dest_buf[..], ); - Context::print_tarball_path( - bstr::BStr::new(abs_tarball_dest.as_bytes()), - log_level, - ); + Context::print_tarball_abs_path(abs_tarball_dest.as_bytes(), log_level); } } @@ -2129,7 +2162,7 @@ pub(crate) fn pack( if prints_bare_filename { Context::print_tarball_path(bare_filename, log_level); } else { - Context::print_tarball_path(bstr::BStr::new(abs_tarball_dest.as_bytes()), log_level); + Context::print_tarball_abs_path(abs_tarball_dest.as_bytes(), log_level); } } diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 81778503351c..763b3d13e72a 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -5449,6 +5449,147 @@ test.concurrent.skipIf(!isLinux)("filenames that are not valid UTF-8 are stored ]); }); +// A script passes `$(bun pm pack --quiet --destination dir)` on as a path, and a path is bytes, not +// text. `printf` makes each name in `sh`, because a JavaScript string cannot hold a lone 0xFF byte. +// One character of a string in these tests is one byte. A Windows argument is UTF-16, so it cannot +// hold such a byte. +describe.concurrent.skipIf(isWindows)("a tarball path that is not UTF-8", () => { + const packageJson = JSON.stringify({ name: "pack-path-bytes", version: "1.0.0" }); + const tarball = "pack-path-bytes-1.0.0.tgz"; + const bytes = (text: string) => Buffer.from(text).toString("latin1"); + const arg = (name: string) => + `"$(printf '${Array.from(name, byte => "\\" + byte.charCodeAt(0).toString(8).padStart(3, "0")).join("")}')"`; + + // Runs `script` with `sh` in `dir`. `$0` is bun. + async function run(dir: string, script: string) { + await using proc = spawn({ + cmd: ["sh", "-c", script, bunExe()], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: bunEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + return { stdout: Buffer.from(stdout).toString("latin1"), stderr, exitCode }; + } + + // Only Linux lets a directory have such a name. + describe.skipIf(!isLinux)("a script finds the tarball at the path that it captured", () => { + test.each([ + ["0xFF", "d-\xff"], + ["é in Latin-1", "caf\xe9"], + ["a lone 0x9B", "d-\x9b"], + ["a UTF-8 sequence that is cut off", "d-\xe3\x81-d"], + ["日本 in EUC-JP", "\xc6\xfc\xcb\xdc"], + ["テ in Shift-JIS", "\x83\x65"], + // The second byte of つ and the first byte of く are C2 82, which is U+0082 in UTF-8. + ["つくる in Shift-JIS", "\x82\xc2\x82\xad\x82\xe9"], + ["聯合國 in GBK", "\xc2\x93\xba\xcf\x87\xf8"], + ])("in a directory named %s", async (_, name) => { + using dir = tempDir("pack-path-bytes", { "package.json": packageJson }); + const script = `tarball="$("$0" pm pack --quiet --destination ${arg(name)})" && test -f "$tarball" && printf '%s\\n' "$tarball"`; + expect(await run(String(dir), script)).toEqual({ + stdout: `${bytes(String(dir))}/${name}/${tarball}\n`, + stderr: "", + exitCode: 0, + }); + }); + + test("in a working directory that holds the byte", async () => { + using dir = tempDir("pack-path-bytes-cwd", { "package.json": packageJson }); + const project = arg("project-\xff"); + const script = `mkdir ${project} && cp package.json ${project} && cd ${project} && tarball="$("$0" pm pack --quiet --destination dist)" && test -f "$tarball" && printf '%s\\n' "$tarball"`; + expect(await run(String(dir), script)).toEqual({ + stdout: `${bytes(String(dir))}/project-\xff/dist/${tarball}\n`, + stderr: "", + exitCode: 0, + }); + }); + }); + + test("--filename that ends with the byte", async () => { + using dir = tempDir("pack-path-bytes-filename", { "package.json": packageJson }); + const script = `exec "$0" pm pack --quiet --dry-run --filename ${arg("f-\xff")}`; + expect(await run(String(dir), script)).toEqual({ + stdout: `${bytes(String(dir))}/f-\xff\n`, + stderr: "", + exitCode: 0, + }); + }); + + test("--silent and the default log level", async () => { + using dir = tempDir("pack-path-bytes-levels", { "package.json": packageJson }); + const path = `${bytes(String(dir))}/d-\xff/${tarball}`; + const [silent, normal] = await Promise.all( + ["--silent", ""].map(level => + run(String(dir), `exec "$0" pm pack ${level} --dry-run --destination ${arg("d-\xff")}`), + ), + ); + expect(silent).toEqual({ stdout: `${path}\n`, stderr: "", exitCode: 0 }); + expect(normal.stdout).toContain(`\n\n${path}\n\nTotal files: 1\n`); + expect(normal.exitCode).toBe(0); + }); + + // ESC is a control character in every encoding. C2 9B is U+009B only in a name that is UTF-8: the + // name of the tarball is, and a directory name that holds the 0xFF byte is not. + test("control characters are still spelled out", async () => { + using dir = tempDir("pack-path-bytes-control", { + "package.json": JSON.stringify({ name: "pack\u009bbytes", version: "1.0.0" }), + }); + const [text, mixed] = await Promise.all( + ["d-\x1b[2K-\xc2\x9b", "d-\x1b[2K-\xc2\x9b-\xff"].map(name => + run(String(dir), `exec "$0" pm pack --quiet --dry-run --destination ${arg(name)}`), + ), + ); + // A path that is UTF-8 is printed as before. + expect(text).toEqual({ + stdout: `${bytes(String(dir))}/d-\\x1b[2K-\\u009b/pack\\u009bbytes-1.0.0.tgz\n`, + stderr: "", + exitCode: 0, + }); + expect(mixed).toEqual({ + stdout: `${bytes(String(dir))}/d-\\x1b[2K-\xc2\x9b-\xff/pack\\u009bbytes-1.0.0.tgz\n`, + stderr: "", + exitCode: 0, + }); + }); + + // A lone 0x9B is a control character for some terminals. A terminal gets U+FFFD for it, as before. + test("a terminal gets text, and a pipe gets the bytes", async () => { + using dir = tempDir("pack-path-bytes-terminal", { "package.json": packageJson }); + const script = `exec "$0" pm pack --quiet --dry-run --destination ${arg("d-\x9b2K")}`; + let terminal = ""; + const closed = Promise.withResolvers(); + await using proc = spawn({ + cmd: ["sh", "-c", script, bunExe()], + cwd: String(dir), + env: bunEnv, + terminal: { + cols: 120, + rows: 50, + data(_, chunk) { + terminal += Buffer.from(chunk).toString("latin1"); + }, + // The terminal is closed once the process has exited and the rest of its output was delivered. + exit() { + closed.resolve(); + }, + }, + }); + const [exitCode] = await Promise.all([proc.exited, closed.promise]); + expect(terminal).toContain(`${bytes(String(dir))}/d-\xef\xbf\xbd2K/${tarball}\r\n`); + expect(terminal).not.toMatch(/[\x80-\x9f]/); + expect(exitCode).toBe(0); + + expect(await run(String(dir), script)).toEqual({ + stdout: `${bytes(String(dir))}/d-\x9b2K/${tarball}\n`, + stderr: "", + exitCode: 0, + }); + }); +}); + test.concurrent("$npm_command is accurate", async () => { using dir = tempDir("pack-command", { "package.json": JSON.stringify({ From 6c2c43890cc8eaa84662de8fa2638b0e3a87a7a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:57:07 +0000 Subject: [PATCH 2/3] test(pm pack): pack to a --filename that is not UTF-8 The `--filename` route had such a name only with `--dry-run`. This test packs for real and runs `test -f` on the captured line. --- test/cli/install/bun-pack.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 763b3d13e72a..140635c86ecf 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -5506,6 +5506,16 @@ describe.concurrent.skipIf(isWindows)("a tarball path that is not UTF-8", () => exitCode: 0, }); }); + + test("at a --filename that holds the byte", async () => { + using dir = tempDir("pack-path-bytes-filename-real", { "package.json": packageJson }); + const script = `tarball="$("$0" pm pack --quiet --filename ${arg("f-\xff.tgz")})" && test -f "$tarball" && printf '%s\\n' "$tarball"`; + expect(await run(String(dir), script)).toEqual({ + stdout: `${bytes(String(dir))}/f-\xff.tgz\n`, + stderr: "", + exitCode: 0, + }); + }); }); test("--filename that ends with the byte", async () => { From 66ccf4da44eed15dc72a322cf29e9bbef4c03cc7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:02:21 +0000 Subject: [PATCH 3/3] pm pack: shorten the comments in print_tarball_abs_path to one line each --- src/runtime/cli/pack_command.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index b4606d43de9a..ff9a8feeae35 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -113,14 +113,12 @@ impl<'a> Context<'a> { bun_core::pretty!("{}\n", bun_fmt::EscapeControlChars(name)); } - /// `path` is the bytes that the kernel got, and a script gives this line back to the kernel. - /// Through `Display` a byte that is not UTF-8 becomes U+FFFD, and the line names another file. + /// A script passes this line on as a path, so bytes that are not UTF-8 go out unchanged. pub(crate) fn print_tarball_abs_path(path: &[u8], log_level: LogLevel) { Self::begin_tarball_path(log_level); match strings::str_utf8(path) { Some(path) => bun_core::pretty!("{}\n", bun_fmt::EscapeControlChars(path)), - // Some terminals run a byte that is not UTF-8 as a control character. So a terminal - // gets U+FFFD for it, as in `bun pm diff`. + // Except on a terminal: some run a byte that is not UTF-8 as a control character. None if Output::is_stdout_tty() => { bun_core::pretty!("{}\n", bun_fmt::escape_control_chars(path)); } @@ -129,8 +127,7 @@ impl<'a> Context<'a> { if i > 0 { Output::print_bytes(SEP_STR.as_bytes()); } - // A name that is not UTF-8 is bytes in an encoding that we do not know. Only - // its ASCII bytes can be control characters. + // In a name that is not UTF-8, only ASCII bytes can be control characters. let is_text = strings::str_utf8(name).is_some(); for part in name.chunk_by(|a, b| is_text || a.is_ascii() == b.is_ascii()) { if is_text || part.is_ascii() {