Skip to content

pm licenses: escape control characters in text output (adds bun_core::fmt::escape_control_chars) - #39358

Open
robobun wants to merge 3 commits into
mainfrom
farm/9e594852/pm-licenses-escape-control-chars
Open

pm licenses: escape control characters in text output (adds bun_core::fmt::escape_control_chars)#39358
robobun wants to merge 3 commits into
mainfrom
farm/9e594852/pm-licenses-escape-control-chars

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun pm licenses (new in install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes #38333, not in any release yet) prints text it did not write: the license, author, description and homepage fields of every installed package.json, and each package's name and resolution.
  • Its sanitizer, printable() in src/runtime/cli/pm_licenses_command.rs, only dropped bytes matching u8::is_ascii_control. C1 controls (U+0080..U+009F; U+009B is the one-character form of ESC [, and xterm-style terminals execute it) went through raw in the license header and in the --long lines.
  • The name@version line was not passed through printable() at all. The version column is the resolution rendered to text (resolution.fmt(...) at the top of exec): for a tarball, folder or git dependency that is a path or URL taken from some package.json in the tree, and the name of such a package is whatever its own package.json says. A tarball named dep-<U+009B>.tgz whose manifest is named dep-<U+009B> installs today and was printed as-is on both sides of the @.
  • Dropping the bytes also misrepresented the data: a license of MIT<ESC>[31m<LF>EVIL was printed as MIT[31mEVIL.
  • Found while reviewing the command's output; there is no user report. Since the command is unreleased, changing what it prints for these fields costs nothing now.

Fix

Background

  • C0 controls are bytes 0x00..0x1F (ESC, BEL, CR, ...), DEL is 0x7F. C1 controls are the code points U+0080..U+009F, encoded in UTF-8 as C2 80..C2 9F; terminals treat several of them as one-byte equivalents of ESC sequences (U+009B = CSI, U+009D = OSC), so a byte-level is_ascii_control filter does not cover them.
  • The version column is the lockfile Resolution formatted as text: the version number for registry packages, otherwise the tarball path or URL, the git URL plus commit, or the folder path. The name column is the lockfile package name, which for tarball, folder and git packages is copied from the package's own package.json (install: reject dependency names containing control characters #38615 and install: reject tarball, folder and git packages whose package.json name is invalid #38633 are about rejecting such names at install time; this PR escapes whatever reaches the printer, and if they land the new test's package name moves to its tarball file name only).
  • escape_control_chars finds candidates with strings::index_of_needs_escape_for_java_script_string, a SIMD scan whose stop set is everything outside 0x20..0x7E plus the quote character (\ is passed as the quote so nothing extra is added), and only inspects those positions, stepping over ordinary multi-byte characters with wtf8_byte_sequence_length.

[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-pm-licenses.test.ts
bun test v1.4.0 (27562f40b)

test/cli/install/bun-pm-licenses.test.ts:
(pass) bun pm licenses > text output groups packages by license, Unknown last, dev-only packages marked [150.96ms]
(pass) bun pm licenses > --json shape [164.80ms]
(pass) bun pm licenses > legacy license shapes [419.32ms]
(pass) bun pm licenses > `license` array and legacy `name` key [435.29ms]
(pass) bun pm licenses > empty `license` falls through to `licenses`; `license` wins when both are present [385.08ms]
(pass) bun pm licenses > non-string license shapes are Unknown; entries with non-string type are skipped [418.69ms]
(pass) bun pm licenses > groups are ordered case-insensitively, ignoring a leading parenthesis, Unknown last [589.24ms]
(pass) bun pm licenses > repeated legacy entries are not deduplicated [408.56ms]
(pass) bun pm licenses > --json `license` follows each version's group; an empty description is omitted [451.67ms]
(pass) bun pm licenses > one package with two versions: per-license grouping, metadata from t
... (truncated)

release without fix: 80 FAILED
bun test v1.4.0-canary.1 (eabb96de7)

test/cli/install/bun-pm-licenses.test.ts:
269 |     [hoistedDir, monoDir] = await Promise.all([setup(), setup("hoisted", monorepoFiles)]);
270 |   });
271 | 
272 |   test.concurrent("text output groups packages by license, Unknown last, dev-only packages marked", async () => {
273 |     const [stdout, stderr, exitCode] = await licenses(hoistedDir);
274 |     expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
                                               ^
error: expect(received).toMatchInlineSnapshot(expected)

  
- "MIT (2)
- ├── path-parse@1.0.6
- └── resolve@1.9.0
+ "Usage: bun pm [flags] [<command>]
  
- Unknown (4)
- ├── a-dep@1.0.1 (dev)
- ├── no-deps@1.0.0
- ├── no-deps@1.0.1
- └── one-dep@1.0.0"
- 
+ Run package manager utilities.
+ 
+ Commands:
+ 
+ bun pm scan                 scan all packages in lockfile for security vulnerabilities
+ bun pm pack                 create a tarball of the current workspace
+ ├ --dry-run                 do everything except for writing the tarball to disk
+ ├ --destination             the directory the tarball will be saved in
+ ├ --fil
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-pm-licenses.test.ts
bun test v1.4.0 (27562f40b)

test/cli/install/bun-pm-licenses.test.ts:
(pass) bun pm licenses > text output groups packages by license, Unknown last, dev-only packages marked [154.89ms]
(pass) bun pm licenses > --json shape [167.51ms]
(pass) bun pm licenses > `license` array and legacy `name` key [445.06ms]
(pass) bun pm licenses > legacy license shapes [489.10ms]
(pass) bun pm licenses > empty `license` falls through to `licenses`; `license` wins when both are present [441.17ms]
(pass) bun pm licenses > non-string license shapes are Unknown; entries with non-string type are skipped [432.37ms]
(pass) bun pm licenses > groups are ordered case-insensitively, ignoring a leading parenthesis, Unknown last [668.44ms]
(pass) bun pm licenses > repeated legacy entries are not deduplicated [401.11ms]
(pass) bun pm licenses > --json `license` follows each version's group; an empty description is omitted [410.56ms]
(pass) bun pm licenses > one package with two versions: per-license grouping, metadata from t
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 671ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/228] fetch WebKit (prebuilt)
[WebKit] up to date
[2/228] fetch lsquic
[lsquic] up to date
[3/159] fetch hdrhistogram
[hdrhistogram] up to date
[4/156] fetch highway
[highway] up to date
[5/147] cc obj/packages/bun-usockets/src/context.c.o
[6/147] cc obj/packages/bun-usockets/src/node_quic_shim.c.o
[7/147] fetch lolhtml
[lolhtml] up to date
[8/147] cc obj/packages/bun-usockets/src/fault_inject.c.o
[9/147] cc obj/packages/bun-usockets/src/bsd.c.o
[10/147] cc obj/packages/bun-usockets/src/eventing/epoll_kqueue.c.o
[11/147] cc obj/packages/bun-usockets/src/eventing/libuv.c.o
[12/147] cc obj/packages/bun-usockets/src/quic.c.o
[13/147] cc obj/packages/bun-usockets/src/socket.c.o
[14/147] cc obj/packages/bun-usockets/src/udp.c.o
[15/147] cc obj/packages/bun-usockets/src/loop.c.o
[16/147] cc obj/packages/bun-usockets/src/crypto/openssl.c.o
[17/147] cc obj/src/jsc/bindings/uv-posix-polyfills.c.o
[18/147] cc obj/src/jsc/bindings/uv-posix-stubs.c.o
[19/147] cc obj/src/jsc/bindings/node/http/llhttp/api.c.o
[20/147]
... (truncated)
diff hotspot
src/bun_core/fmt.rs                      | 57 ++++++++++++++++++++++++
 src/runtime/cli/pm_licenses_command.rs   | 26 +++--------
 test/cli/install/bun-pm-licenses.test.ts | 76 +++++++++++++++++++++++++-------
 3 files changed, 123 insertions(+), 36 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                      reads  edits  tests
src/bun_core/fmt.rs                           0      0      0
src/runtime/cli/pm_licenses_command.rs        4      8      0
test/cli/install/bun-pm-licenses.test.ts      8      8      0

…name@version column

printable() only dropped ASCII controls, so C1 controls (U+0080..U+009F,
which terminals execute like ESC sequences) went through raw in the
license header and --long fields, and the name@version line was printed
unsanitized altogether; a tarball or git resolution is text from a
package.json. Print all of them through escape_control_chars, which
spells C0 controls, DEL and C1 controls out as \n, \x1b, \u009b escapes
instead of silently dropping them.
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on a debug build of main (8326d1b) with a project depending on a file: tarball named dep-<U+009B>.tgz whose package.json is named dep-<U+009B> and carries ESC and U+009B in license, author, description and homepage: bun pm licenses --long printed the U+009B bytes raw in every line, including both halves of dep-<U+009B>@./dep-<U+009B>.tgz, and dropped the ESC so the header read MIT[31m.... With this branch every line comes out with the characters spelled as \x1b / \u009b escapes; --json is unchanged.

Fail-before / pass-after: the four control-character cases in test/cli/install/bun-pm-licenses.test.ts fail against main and pass here together with the rest of the file (80/80).

This PR also carries the bun_core::fmt::escape_control_chars helper that #38631, #38525, #38536, #38557, #38615 and #38673 need (same hunk as #38631); the description lists what each of them drops or adjusts once this lands.

CI: build 99558 (first revision) was fully green; build 99745 (final revision, which only adds the name-column test case) is green on every lane except Windows 2019 x64, where test/bake/deinitialization.test.ts crashed during dev server teardown. That test is unrelated to this change (known intermittent, see #34850) and has been reported separately; bun-pm-licenses.test.ts passed on every platform.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7713cb77-ec80-4be6-8283-7f1ededfc9e1

📥 Commits

Reviewing files that changed from the base of the PR and between 8326d1b and 7a2f3f4.

📒 Files selected for processing (3)
  • src/bun_core/fmt.rs
  • src/runtime/cli/pm_licenses_command.rs
  • test/cli/install/bun-pm-licenses.test.ts

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

The PR adds EscapeControlChars and escape_control_chars for safe text formatting. The license command uses them for package metadata. Tests verify escaped terminal output and unchanged JSON values.

Changes

License output escaping

Layer / File(s) Summary
Control-character formatter
src/bun_core/fmt.rs
Adds a display adapter that escapes C0, DEL, and C1 control characters.
License command integration
src/runtime/cli/pm_licenses_command.rs
Applies escaping to license names, package fields, versions, and long-format metadata.
CLI output tests
test/cli/install/bun-pm-licenses.test.ts
Verifies escaped text output, preserved JSON values, and handling of C1 controls and DEL.

Possibly related PRs

  • oven-sh/bun#38525: Adds and uses the same control-character escaping functionality in another Bun CLI command.
  • oven-sh/bun#38952: Modifies the license command and tests for separate banner and summary output changes.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: escaping control characters in bun pm licenses text output.
Description check ✅ Passed The description explains the problem, fix, scope, compatibility behavior, and verification results, although it uses different headings than the template.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, self-contained hardening of bun pm licenses text output.

What was reviewed:

  • EscapeControlChars writer in fmt.rs: traced the scan against index_of_needs_escape_for_javascript_string (stops at <0x20, ≥0x7F, \\) and wtf8_byte_sequence_length; every &s[start..i] slice lands on a UTF-8 char boundary because the input is &str and cursor only advances by whole chars.
  • C1 detection (0xC2 + 0x80..=0x9F) and pass-through for U+00A0+ (same lead byte) and backslash — matches the new test's \\ / © assertions.
  • pm_licenses_command.rs: only the five text-output print sites change; grouping/sorting/version-matching still use the raw bytes and --json is untouched. printable() and the Cow import it needed are deleted.
Extended reasoning...

Overview

Three files: a new EscapeControlChars Display adapter added to src/bun_core/fmt.rs (~55 lines, pure safe Rust reusing the existing index_of_needs_escape_for_java_script_string SIMD scan and wtf8_byte_sequence_length); five call sites in src/runtime/cli/pm_licenses_command.rs::print_text swap printable() / raw BStr for escape_control_chars(), and the old printable() helper plus its Cow import are removed; and test/cli/install/bun-pm-licenses.test.ts updates three existing control-char tests from stripped→escaped expectations and adds one new test covering C1 controls, DEL, and the previously-unsanitized name@version column via a tarball specifier.

Security risks

The change reduces a terminal-injection risk: previously U+009B (single-byte CSI) and other C1 controls in package.json fields, plus any control byte in a tarball/git resolution string, reached the terminal raw. The new adapter renders C0, DEL, and C1 as \\n / \\xNN / \\uNNNN literals. Escaping happens only at the print layer, so grouping, sort keys, version equality against the lockfile, and --json output all still operate on the original bytes. There is no new parsing of untrusted input and no unsafe code.

Level of scrutiny

Moderate. bun pm licenses is a read-only diagnostic subcommand, and the diff is narrow: one new formatter and a handful of print-site substitutions. The only nontrivial logic is the char-boundary handling in write_str, which I verified — since the writer receives &str (valid UTF-8, including from bstr::BStr's Display, which emits U+FFFD for invalid input bytes), the scan always stops on either an ASCII byte or a UTF-8 lead byte, and start/cursor only advance by 1 (C0/DEL/backslash), 2 (C1), or the lead byte's declared length, so slicing cannot panic. Backslash falls through to the pass-through arm with char_len = 1, matching the documented behavior and the test.

Other factors

No CODEOWNERS entry covers these paths and there are no prior human review comments. The PR follows the repo's review checklist: dead code is deleted in the same commit, the helper lives in bun_core::fmt alongside escape_powershell_impl, tests go in the existing bun-pm-licenses.test.ts rather than a new file, and fail-before / pass-after was demonstrated (80/80 in the file). The one design choice — backslashes are not doubled, so the escaped form is for human display rather than round-trippable — is stated in the PR description and consistent with the --json path being the machine-readable one. The description also notes five sibling PRs add the identical fmt.rs hunk, so the shared placement is deliberate.

Comment thread src/bun_core/fmt.rs
Comment thread src/bun_core/fmt.rs
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:50 PM PT - Aug 16th, 2026

@robobun, your commit ac4cb95 has 1 failures in Build #99745 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 39358

That installs a local version of the PR into your bun-39358 executable, so you can run:

bun-39358 --bun

@robobun robobun changed the title pm licenses: escape control characters in text output, including the name@version column pm licenses: escape control characters in text output (adds bun_core::fmt::escape_control_chars) Aug 17, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it lands a new public bun_core::fmt helper that six other open PRs are meant to converge on (locking in the escape spelling and the backslash-pass-through choice), a human look at the API shape would still be worthwhile before it becomes the shared copy.

What was reviewed:

  • EscapeControlChars::write_str — cursor always stays on a UTF-8 char boundary (input is &str; wtf8_byte_sequence_length never returns 0), so no infinite loop or non-boundary slice panic.
  • Backslash (0x5C) and non-C1 0xC2-prefixed chars (e.g. U+00A9) fall through the third match arm and pass through unchanged; the new test pins both.
  • printable() and its Cow import are deleted; every text-output print site in print_text now goes through the escaper (name and version were previously unsanitized).
  • --json path is untouched; tests confirm raw values round-trip.
Extended reasoning...

Overview

The PR replaces printable() (which dropped ASCII control bytes) in bun pm licenses with a new bun_core::fmt::EscapeControlChars<T: Display> adapter that spells out C0, DEL and C1 controls as visible escapes. It also extends coverage to the name and version columns, which were previously printed raw. Three existing tests are updated from stripped→escaped assertions, and one new test installs a tarball carrying U+009B in its filename, package name and manifest fields to cover the name/version column and the C1 range.

Security risks

This is a defensive change against terminal escape injection from untrusted package.json fields. The implementation covers the full control range (C0, DEL, C1) and applies at every text-output print site; --json is left to its existing JSON-string escaper. I don't see a way for the escaper to emit a raw control byte given valid &str input, and the &[u8] entry point routes through bstr::BStr's Display which replaces invalid sequences with U+FFFD before they reach write_str.

Level of scrutiny

The Rust logic is small and self-contained, and the test coverage is thorough (fail-before/pass-after verified per the description). What warrants a human look is not correctness but the fact that this hunk is explicitly the one six other open PRs (#38631, #38525, #38536, #38557, #38615, #38673) will rebase onto — the escape spelling (\\xNN for C0/DEL, \\uNNNN for C1, backslash left raw) and the generic-over-Display design become the shared contract. That is an API-shape decision a maintainer should sign off on.

Other factors

The two comment-cop inline flags on fmt.rs are resolved false positives (the section-banner comment is the file's convention across ~34 formatters; the rustdoc is a normal three-line doc comment). Dead code (printable, Cow import) is removed in the same PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant