Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/bun_core/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3326,6 +3326,63 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu
write_bytes(writer, remain)
}

// ───────────────────────────────────────────────────────────────────────────
// escapeControlChars
// ───────────────────────────────────────────────────────────────────────────
Comment thread
robobun marked this conversation as resolved.

/// `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.
Comment thread
robobun marked this conversation as resolved.
pub struct EscapeControlChars<T>(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<T: Display> Display for EscapeControlChars<T> {
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.

Expand Down
26 changes: 6 additions & 20 deletions src/runtime/cli/pm_licenses_command.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<Option<Box<[u8]>>> {
let len = lockfile.packages.len();
let mut out: Vec<Option<Box<[u8]>>> = vec![None; len];
Expand Down Expand Up @@ -706,16 +692,16 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) {
}
bun_core::prettyln!(
"<b>{}<r> <d>({})<r>",
BStr::new(&printable(license)),
escape_control_chars(license),
end - start
);
for (i, entry) in entries[start..end].iter().enumerate() {
let last = start + i + 1 == end;
bun_core::pretty!(
"<d>{}<r> {}<d>@{}<r>",
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!(" <d>(dev)<r>");
Expand All @@ -727,9 +713,9 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) {
.flatten()
{
if last {
bun_core::prettyln!(" <d>{}<r>", BStr::new(&printable(field)));
bun_core::prettyln!(" <d>{}<r>", escape_control_chars(field));
} else {
bun_core::prettyln!("<d>│ {}<r>", BStr::new(&printable(field)));
bun_core::prettyln!("<d>│ {}<r>", escape_control_chars(field));
}
}
}
Expand Down
76 changes: 60 additions & 16 deletions test/cli/install/bun-pm-licenses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -823,19 +824,21 @@ 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";
const homepage = "https://example.com/\u001b[2Jx";
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();

Expand All @@ -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)]);
Expand Down
Loading