diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 381ba04fa6c..6ea7226e1ce 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3326,6 +3326,63 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } +// ─────────────────────────────────────────────────────────────────────────── +// escapeControlChars +// ─────────────────────────────────────────────────────────────────────────── + +/// `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. +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 bytes = s.as_bytes(); + let mut start = 0; + let mut cursor = 0; + // `\` doubles as the quote char so the scan stops at nothing else extra. + while let Some(offset) = + strings::index_of_needs_escape_for_java_script_string(&bytes[cursor..], b'\\') + { + let i = cursor + offset as usize; + let (code_point, len) = match bytes[i] { + byte @ (0x00..=0x1F | 0x7F) => (byte as u32, 1), + 0xC2 if matches!(bytes.get(i + 1), Some(0x80..=0x9F)) => (bytes[i + 1] as u32, 2), + byte => { + let char_len = strings::wtf8_byte_sequence_length(byte) as usize; + cursor = (i + char_len).min(bytes.len()); + continue; + } + }; + self.0.write_str(&s[start..i])?; + match code_point { + 0x0A => self.0.write_str("\\n")?, + 0x0D => self.0.write_str("\\r")?, + 0x09 => self.0.write_str("\\t")?, + 0x00..=0x7F => write!(self.0, "\\x{:02x}", code_point)?, + _ => write!(self.0, "\\u{:04x}", code_point)?, + } + start = i + len; + cursor = start; + } + 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/runtime/cli/pm_licenses_command.rs b/src/runtime/cli/pm_licenses_command.rs index 0079a85383e..b2176b5b9f9 100644 --- a/src/runtime/cli/pm_licenses_command.rs +++ b/src/runtime/cli/pm_licenses_command.rs @@ -1,11 +1,10 @@ -use std::borrow::Cow; use std::cmp::Ordering; use std::io::Write as _; use bstr::BStr; use bun_ast::{Expr, Log, Source}; use bun_collections::{DynamicBitSet, StringHashMap, index_sort}; -use bun_core::fmt::PathSep; +use bun_core::fmt::{PathSep, escape_control_chars}; use bun_core::{FileKind, Global, Output, strings}; use bun_install::isolated_install::store::entry::fmt_store_key; use bun_install::lockfile::{Lockfile, package::PackageColumns as _, reachable, tree}; @@ -335,19 +334,6 @@ fn license_order(a: &[u8], b: &[u8]) -> Ordering { .then_with(|| a.cmp(b)) } -fn printable(s: &[u8]) -> Cow<'_, [u8]> { - if s.iter().any(u8::is_ascii_control) { - Cow::Owned( - s.iter() - .copied() - .filter(|b| !b.is_ascii_control()) - .collect(), - ) - } else { - Cow::Borrowed(s) - } -} - fn tree_locations(lockfile: &Lockfile) -> Vec>> { let len = lockfile.packages.len(); let mut out: Vec>> = vec![None; len]; @@ -706,7 +692,7 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) { } bun_core::prettyln!( "{} ({})", - BStr::new(&printable(license)), + escape_control_chars(license), end - start ); for (i, entry) in entries[start..end].iter().enumerate() { @@ -714,8 +700,8 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) { bun_core::pretty!( "{} {}@{}", if last { "└──" } else { "├──" }, - BStr::new(&entry.name), - BStr::new(&entry.version) + escape_control_chars(&entry.name), + escape_control_chars(&entry.version) ); if entry.dev_only { bun_core::pretty!(" (dev)"); @@ -727,9 +713,9 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) { .flatten() { if last { - bun_core::prettyln!(" {}", BStr::new(&printable(field))); + bun_core::prettyln!(" {}", escape_control_chars(field)); } else { - bun_core::prettyln!("│ {}", BStr::new(&printable(field))); + bun_core::prettyln!("│ {}", escape_control_chars(field)); } } } diff --git a/test/cli/install/bun-pm-licenses.test.ts b/test/cli/install/bun-pm-licenses.test.ts index 791f5260f09..56ab9a107c1 100644 --- a/test/cli/install/bun-pm-licenses.test.ts +++ b/test/cli/install/bun-pm-licenses.test.ts @@ -69,6 +69,9 @@ function expectEmptyText(stdout: string, checked: number) { const MISSING_NOTE = "note: run 'bun install' first"; +// Text output is printable text and newlines; any other C0 byte, DEL or C1 character came through from a package.json unescaped. +const RAW_CONTROL = /[\x00-\x09\x0b-\x1f\x7f\x80-\x9f]/; + const fixturePackageJson = JSON.stringify({ name: "licenses-fixture", version: "1.0.0", @@ -724,7 +727,7 @@ describe("bun pm licenses", () => { const second = await licensesText(dir, "--long"); expect(second).toContain( - "├── no-deps@1.0.0\n│ only a description\n├── no-deps@1.0.1\n│ newest winsline two\n│ https://example.com/new\n└── one-dep@1.0.0\n", + "├── no-deps@1.0.0\n│ only a description\n├── no-deps@1.0.1\n│ newest wins\\nline two\n│ https://example.com/new\n└── one-dep@1.0.0\n", ); expect(second.split("\n").some(line => line.startsWith("line two"))).toBeFalse(); const parsed = await licensesJson(dir); @@ -788,7 +791,7 @@ describe("bun pm licenses", () => { }); test.concurrent( - "control characters from package.json are stripped in text output but preserved in --json", + "control characters from package.json are escaped in text output but preserved in --json", async () => { const dir = await setup(); const evilLicense = "MIT\u001b[31m\nEVIL"; @@ -797,17 +800,15 @@ describe("bun pm licenses", () => { patchInstalledManifest(dir, "no-deps", { license: "BSD\t2" }); const stdout = await licensesText(dir, "--long"); - expect(stdout).not.toContain("\u001b"); - expect(stdout).not.toContain("\r"); - expect(stdout).not.toContain("\t"); - expect(stdout).toContain("MIT[31mEVIL (1)\n└── a-dep@1.0.1 (dev)\n tabhere\n"); - expect(stdout).toContain("ISCGPL-3.0 (1)\n└── one-dep@1.0.0\n"); - expect(stdout).toContain("BSD2 (1)\n└── no-deps@1.0.0\n"); + expect(stdout).not.toMatch(RAW_CONTROL); + expect(stdout).toContain("MIT\\x1b[31m\\nEVIL (1)\n└── a-dep@1.0.1 (dev)\n tab\\there\\r\\n\n"); + expect(stdout).toContain("ISC\\nGPL-3.0 (1)\n└── one-dep@1.0.0\n"); + expect(stdout).toContain("BSD\\t2 (1)\n└── no-deps@1.0.0\n"); expect(stdout.split("\n").filter(line => / \(\d+\)$/.test(line))).toStrictEqual([ - "BSD2 (1)", - "ISCGPL-3.0 (1)", + "BSD\\t2 (1)", + "ISC\\nGPL-3.0 (1)", "MIT (2)", - "MIT[31mEVIL (1)", + "MIT\\x1b[31m\\nEVIL (1)", "Unknown (1)", ]); expect(stdout.split("\n").some(line => line.startsWith("GPL-3.0") || line.startsWith("EVIL"))).toBeFalse(); @@ -823,7 +824,7 @@ describe("bun pm licenses", () => { }, ); - test.concurrent("--long strips control characters from author, description and homepage", async () => { + test.concurrent("--long escapes control characters in author, description and homepage", async () => { const dir = await setup(); const author = "Eve\u001b]8;;https://evil.example\u0007click\u001b]8;;\u0007"; const description = "first\r\nsecond"; @@ -831,11 +832,13 @@ describe("bun pm licenses", () => { patchInstalledManifest(dir, "a-dep", { author, description, homepage }); const stdout = await licensesText(dir, "--long"); - expect(stdout).not.toContain("\u001b"); - expect(stdout).not.toContain("\u0007"); - expect(stdout).not.toContain("\r"); + expect(stdout).not.toMatch(RAW_CONTROL); expect(stdout).toContain( - "├── a-dep@1.0.1 (dev)\n│ Eve]8;;https://evil.exampleclick]8;;\n│ firstsecond\n│ https://example.com/[2Jx\n├── no-deps@1.0.0\n", + "├── a-dep@1.0.1 (dev)\n" + + "│ Eve\\x1b]8;;https://evil.example\\x07click\\x1b]8;;\\x07\n" + + "│ first\\r\\nsecond\n" + + "│ https://example.com/\\x1b[2Jx\n" + + "├── no-deps@1.0.0\n", ); expect(stdout.split("\n").some(line => line.startsWith("second"))).toBeFalse(); @@ -847,6 +850,47 @@ describe("bun pm licenses", () => { }); }); + // U+009B is the one-character form of ESC [ and, unlike the ASCII controls above, is accepted in a file name and a + // package name, so a tarball carries it into both halves of the name@version column. A backslash and U+00A9 (encoded + // with the same lead byte as U+009B) must come through unchanged. + test.concurrent( + "C1 controls and DEL are escaped in the license, the name@version column and --long fields", + async () => { + const C1 = "\u009b"; + const manifest = { + name: `dep-${C1}`, + version: "1.0.0", + license: `MIT${C1}31m`, + author: `Eve${C1}2J \\ \u007f \u00a9`, + description: `one${C1}two`, + homepage: `https://example.com/${C1}x`, + }; + const tarball = `dep-${C1}.tgz`; + const { packageDir: dir } = await registry.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { "package.json": pkg({ dependencies: { dep: `file:./${tarball}` } }) }, + }); + const archive = new Bun.Archive({ "package/package.json": JSON.stringify(manifest) }, { compress: "gzip" }); + writeFileSync(join(dir, tarball), await archive.bytes()); + await install(dir, "hoisted"); + + const stdout = await licensesText(dir, "--long"); + expect(stdout).not.toMatch(RAW_CONTROL); + expect(stdout).toContain( + "MIT\\u009b31m (1)\n" + + "└── dep-\\u009b@./dep-\\u009b.tgz\n" + + " Eve\\u009b2J \\ \\x7f \u00a9\n" + + " one\\u009btwo\n" + + " https://example.com/\\u009bx\n", + ); + + const { name, license, author, description, homepage } = manifest; + expect(await licensesJson(dir)).toStrictEqual({ + [license]: [{ name, versions: [`./${tarball}`], license, author, description, homepage }], + }); + }, + ); + test.concurrent("isolated linker matches hoisted: marker, --dev and --long", async () => { const dir = await setup("isolated"); const [[expected], [stdout, stderr, exitCode]] = await Promise.all([licenses(hoistedDir), licenses(dir)]);