From 70dc3fd155839a9b75e11ddff9f7774af11ef9f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:03:29 +0000 Subject: [PATCH 1/3] install: escape control characters in the bun pm untrusted/trust script listing `bun pm untrusted` and `bun pm trust` print each blocked lifecycle script verbatim. A script containing ESC, CR, C1 or newline bytes could therefore erase or repaint the line it is listed on, so the command the user reviews on screen is not the command that runs once the package is trusted. Add bun_core::fmt::escape_control_chars, a Display adapter that spells out C0 controls, DEL and C1 controls (\x1b, \r, \n, \x7f, \u009b) and leaves everything else untouched, and use it for the script bodies, the package folder name and the resolution in that listing. --- src/bun_core/fmt.rs | 47 ++++++++++++++++++++ src/install/lockfile/Package/Scripts.rs | 17 ++++--- test/cli/install/bun-pm.test.ts | 59 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 2dc33cbfe4fc..7a17ad6d9167 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3325,6 +3325,53 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } +// ─────────────────────────────────────────────────────────────────────────── +// escapeControlChars +// ─────────────────────────────────────────────────────────────────────────── + +/// Renders the wrapped `Display` with C0 controls, DEL and C1 controls +/// spelled out (`\n`, `\r`, `\t`, `\x1b`, `\x7f`, `\u009b`) instead of +/// written raw. For text somebody else authored (a dependency's +/// `package.json`, a registry manifest): printed raw, an ESC/CR/C1 sequence +/// can erase or repaint the line it is shown on and a newline can forge +/// further lines of our output. Everything else passes through unchanged. +pub struct EscapeControlChars(pub T); + +/// [`EscapeControlChars`] over raw bytes; invalid UTF-8 renders as U+FFFD. +pub fn escape_control_chars(text: &[u8]) -> EscapeControlChars<&bstr::BStr> { + EscapeControlChars(bstr::BStr::new(text)) +} + +impl Display for EscapeControlChars { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut writer = EscapeControlCharsWriter(f); + write!(writer, "{}", self.0) + } +} + +struct EscapeControlCharsWriter<'a, 'f>(&'a mut Formatter<'f>); + +impl fmt::Write for EscapeControlCharsWriter<'_, '_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let mut start = 0; + for (i, c) in s.char_indices() { + if !matches!(c, '\0'..='\x1f' | '\x7f' | '\u{80}'..='\u{9f}') { + continue; + } + self.0.write_str(&s[start..i])?; + match c { + '\n' => self.0.write_str("\\n")?, + '\r' => self.0.write_str("\\r")?, + '\t' => self.0.write_str("\\t")?, + c if c.is_ascii() => write!(self.0, "\\x{:02x}", c as u32)?, + c => write!(self.0, "\\u{:04x}", c as u32)?, + } + start = i + c.len_utf8(); + } + self.0.write_str(&s[start..]) + } +} + // js_bindings (fmtString for highlighter.test.ts) lives in src/jsc/fmt_jsc.rs // alongside fmt_jsc.bind.ts; bun_core/ stays JSC-free. diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index a629ed57680b..8271fdf5d1ac 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -1,7 +1,7 @@ use bstr::BStr; use bun_core::ZBox; -use bun_core::fmt::PathSep; +use bun_core::fmt::{EscapeControlChars, PathSep, escape_control_chars}; use bun_core::strings; use bun_install::lockfile::Lockfile; use bun_install::lockfile::Scripts as LockfileScripts; @@ -426,21 +426,24 @@ impl List { resolution_buf: &[u8], format_type: PrintFormat, ) { + // Folder name, resolution and script bodies are all authored by the + // dependency; this listing is what the user reads before `bun pm trust`. + let resolution = EscapeControlChars(resolution.fmt(resolution_buf, PathSep::Posix)); let needle = bun_paths::NODE_MODULES_NEEDLE; if let Some(i) = strings::index_of(self.cwd.as_bytes(), needle) { bun_core::pretty!( ".{s}{s} @{f}\n", BStr::new(SEP_STR.as_bytes()), - BStr::new(strings::without_trailing_slash( + escape_control_chars(strings::without_trailing_slash( &self.cwd.as_bytes()[i + 1..] )), - resolution.fmt(resolution_buf, PathSep::Posix), + resolution, ); } else { bun_core::pretty!( "{s} @{f}\n", - BStr::new(strings::without_trailing_slash(self.cwd.as_bytes())), - resolution.fmt(resolution_buf, PathSep::Posix), + escape_control_chars(strings::without_trailing_slash(self.cwd.as_bytes())), + resolution, ); } @@ -451,12 +454,12 @@ impl List { PrintFormat::Completed => bun_core::pretty!( " [{s}]: {s}\n", BStr::new(name), - BStr::new(script), + escape_control_chars(script), ), PrintFormat::Untrusted => bun_core::pretty!( " » [{s}]: {s}\n", BStr::new(name), - BStr::new(script), + escape_control_chars(script), ), } } diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index d0c54586c249..7c8d74510c35 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -936,3 +936,62 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(stderr).not.toContain("error"); expect(exitCode).toBe(0); }); + +test("bun pm untrusted and bun pm trust escape control characters in dependency scripts", async () => { + // Everything after `#` is a shell comment, so only the echo runs once the package is + // trusted. Printed raw, though, the tail erases the listing line (ESC[2K), returns to + // column 1 (ESC[1G) and repaints it as the harmless looking command. `\r`, DEL and the + // 8-bit CSI (U+009B) are the other control characters a terminal would act on. + const script = 'echo "real command" #\x1b[2K\x1b[1G\r\x7f\u009b » [postinstall]: node scripts/postinstall.js'; + const shown = 'echo "real command" #\\x1b[2K\\x1b[1G\\r\\x7f\\u009b » [postinstall]: node scripts/postinstall.js'; + + using dir = tempDir("pm-untrusted-control-chars", { + "package.json": JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "nice-pkg": "file:./nice-pkg", + }, + }), + "nice-pkg/package.json": JSON.stringify({ + name: "nice-pkg", + version: "1.0.0", + scripts: { + postinstall: script, + }, + }), + }); + + async function run(...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env, + }); + return await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + } + + let [stdout, stderr, exitCode] = await run("install"); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("Blocked 1 postinstall"); + expect(exitCode).toBe(0); + + [stdout, stderr, exitCode] = await run("pm", "untrusted"); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain(` » [postinstall]: ${shown}\n`); + expect(stdout).not.toContain("\x1b"); + expect(stdout).not.toContain("\x7f"); + expect(stdout).not.toContain("\u009b"); + expect(exitCode).toBe(0); + + [stdout, stderr, exitCode] = await run("pm", "trust", "nice-pkg"); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain(` ✓ [postinstall]: ${shown}\n`); + expect(stdout).toContain("1 script ran across 1 package"); + expect(stdout).not.toContain("\x1b"); + expect(stdout).not.toContain("\x7f"); + expect(stdout).not.toContain("\u009b"); + expect(exitCode).toBe(0); +}); From e736797bff284d52d78a3808a3f765edd57cce2e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:18:31 +0000 Subject: [PATCH 2/3] test: cover newline/tab escapes and the package path and resolution fields --- test/cli/install/bun-pm.test.ts | 78 ++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 7c8d74510c35..6cd0ab124da8 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it, test } from "bun:test"; import { exists, mkdir, writeFile } from "fs/promises"; -import { bunEnv, bunExe, bunEnv as env, readdirSorted, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunEnv as env, isWindows, readdirSorted, tempDir, tmpdirSync } from "harness"; import { cpSync } from "node:fs"; import { join } from "path"; import { @@ -937,13 +937,26 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(exitCode).toBe(0); }); +async function runInDir(dir: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + env, + }); + return await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); +} + test("bun pm untrusted and bun pm trust escape control characters in dependency scripts", async () => { - // Everything after `#` is a shell comment, so only the echo runs once the package is - // trusted. Printed raw, though, the tail erases the listing line (ESC[2K), returns to - // column 1 (ESC[1G) and repaints it as the harmless looking command. `\r`, DEL and the - // 8-bit CSI (U+009B) are the other control characters a terminal would act on. - const script = 'echo "real command" #\x1b[2K\x1b[1G\r\x7f\u009b » [postinstall]: node scripts/postinstall.js'; - const shown = 'echo "real command" #\\x1b[2K\\x1b[1G\\r\\x7f\\u009b » [postinstall]: node scripts/postinstall.js'; + // Both lines of the script are a shell comment after the echo, so only the echo runs once + // the package is trusted. Printed raw, though, the first comment erases the listing line + // (ESC[2K) and returns to column 1 (ESC[1G), and the newline lets the second one pose as + // a further listing entry. `\r`, `\t`, DEL and the 8-bit CSI (U+009B) are the remaining + // kinds of control character a terminal acts on. + const script = 'echo "real command" #\x1b[2K\x1b[1G\r\x7f\u009b\n#\t» [postinstall]: node scripts/postinstall.js'; + const shown = + 'echo "real command" #\\x1b[2K\\x1b[1G\\r\\x7f\\u009b\\n#\\t» [postinstall]: node scripts/postinstall.js'; using dir = tempDir("pm-untrusted-control-chars", { "package.json": JSON.stringify({ @@ -962,23 +975,12 @@ test("bun pm untrusted and bun pm trust escape control characters in dependency }), }); - async function run(...args: string[]) { - await using proc = Bun.spawn({ - cmd: [bunExe(), ...args], - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - env, - }); - return await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - } - - let [stdout, stderr, exitCode] = await run("install"); + let [stdout, stderr, exitCode] = await runInDir(String(dir), "install"); expect(stderr).not.toContain("error:"); expect(stdout).toContain("Blocked 1 postinstall"); expect(exitCode).toBe(0); - [stdout, stderr, exitCode] = await run("pm", "untrusted"); + [stdout, stderr, exitCode] = await runInDir(String(dir), "pm", "untrusted"); expect(stderr).not.toContain("error:"); expect(stdout).toContain(` » [postinstall]: ${shown}\n`); expect(stdout).not.toContain("\x1b"); @@ -986,7 +988,7 @@ test("bun pm untrusted and bun pm trust escape control characters in dependency expect(stdout).not.toContain("\u009b"); expect(exitCode).toBe(0); - [stdout, stderr, exitCode] = await run("pm", "trust", "nice-pkg"); + [stdout, stderr, exitCode] = await runInDir(String(dir), "pm", "trust", "nice-pkg"); expect(stderr).not.toContain("error:"); expect(stdout).toContain(` ✓ [postinstall]: ${shown}\n`); expect(stdout).toContain("1 script ran across 1 package"); @@ -995,3 +997,37 @@ test("bun pm untrusted and bun pm trust escape control characters in dependency expect(stdout).not.toContain("\u009b"); expect(exitCode).toBe(0); }); + +// The dependency alias becomes the node_modules folder and the file: target becomes the +// resolution, so a dependent can put control characters in both. Windows does not allow +// them in file names, so the packages cannot be installed there in the first place. +test.skipIf(isWindows)("bun pm untrusted escapes control characters in the package path and resolution", async () => { + using dir = tempDir("pm-untrusted-control-chars-path", { + "package.json": JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "nice\x1b[2Kpkg": "file:./real\rpkg", + }, + }), + "real\rpkg/package.json": JSON.stringify({ + name: "real-pkg", + version: "1.0.0", + scripts: { + postinstall: "exit 0", + }, + }), + }); + + let [stdout, stderr, exitCode] = await runInDir(String(dir), "install"); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("Blocked 1 postinstall"); + expect(exitCode).toBe(0); + + [stdout, stderr, exitCode] = await runInDir(String(dir), "pm", "untrusted"); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("./node_modules/nice\\x1b[2Kpkg @real\\rpkg\n » [postinstall]: exit 0\n"); + expect(stdout).not.toContain("\x1b"); + expect(stdout).not.toContain("\r"); + expect(exitCode).toBe(0); +}); From 53a0e31b837cb89f807f9093d59cfd3a28812ed8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:32:19 +0000 Subject: [PATCH 3/3] Shorten the EscapeControlChars doc comment and drop the print_scripts one --- src/bun_core/fmt.rs | 9 +++------ src/install/lockfile/Package/Scripts.rs | 2 -- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 7a17ad6d9167..132d60c06697 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3329,12 +3329,9 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu // escapeControlChars // ─────────────────────────────────────────────────────────────────────────── -/// Renders the wrapped `Display` with C0 controls, DEL and C1 controls -/// spelled out (`\n`, `\r`, `\t`, `\x1b`, `\x7f`, `\u009b`) instead of -/// written raw. For text somebody else authored (a dependency's -/// `package.json`, a registry manifest): printed raw, an ESC/CR/C1 sequence -/// can erase or repaint the line it is shown on and a newline can forge -/// further lines of our output. Everything else passes through unchanged. +/// `Display` adapter that spells out C0 controls, DEL and C1 controls +/// (`\n`, `\x1b`, `\x7f`, `\u009b`, ...) so text authored by a dependency +/// cannot erase, repaint or forge lines of terminal output when printed. pub struct EscapeControlChars(pub T); /// [`EscapeControlChars`] over raw bytes; invalid UTF-8 renders as U+FFFD. diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index 8271fdf5d1ac..be2f130af8a7 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -426,8 +426,6 @@ impl List { resolution_buf: &[u8], format_type: PrintFormat, ) { - // Folder name, resolution and script bodies are all authored by the - // dependency; this listing is what the user reads before `bun pm trust`. let resolution = EscapeControlChars(resolution.fmt(resolution_buf, PathSep::Posix)); let needle = bun_paths::NODE_MODULES_NEEDLE; if let Some(i) = strings::index_of(self.cwd.as_bytes(), needle) {