Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
47 changes: 47 additions & 0 deletions src/bun_core/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3325,6 +3325,53 @@ 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.

/// 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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 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.

Expand Down
17 changes: 10 additions & 7 deletions src/install/lockfile/Package/Scripts.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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`.
Comment thread
robobun marked this conversation as resolved.
Outdated
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!(
"<d>.{s}{s} @{f}<r>\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!(
"<d>{s} @{f}<r>\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,
);
}

Expand All @@ -451,12 +454,12 @@ impl List {
PrintFormat::Completed => bun_core::pretty!(
" <green>✓<r> [{s}]<d>:<r> <cyan>{s}<r>\n",
BStr::new(name),
BStr::new(script),
escape_control_chars(script),
),
PrintFormat::Untrusted => bun_core::pretty!(
" <yellow>»<r> [{s}]<d>:<r> <cyan>{s}<r>\n",
BStr::new(name),
BStr::new(script),
escape_control_chars(script),
),
}
}
Expand Down
97 changes: 96 additions & 1 deletion test/cli/install/bun-pm.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -936,3 +936,98 @@ 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);
});

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 () => {
// 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({
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,
},
}),
});

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(` » [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 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");
expect(stdout).not.toContain("\x1b");
expect(stdout).not.toContain("\x7f");
expect(stdout).not.toContain("\u009b");
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// 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);
});