diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 2dc33cbfe4fc..ec5daecea5f0 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3325,6 +3325,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/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 6c96867f593b..0b6566163b3d 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -2057,7 +2057,9 @@ impl<'a> PackageInstaller<'a> { "Blocked {} scripts for: {}@{}\n", count, bstr::BStr::new(alias.slice(string_buf!())), - resolution.fmt(string_buf!(), PathSep::Posix), + bun_core::fmt::EscapeControlChars( + resolution.fmt(string_buf!(), PathSep::Posix) + ), ); } let entry = self diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index ca23f272fd55..8cc5b87f13d1 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -115,13 +115,13 @@ pub fn enqueue_dependency_list( // `format_args!` borrows temporaries — bind the // formatter first so it outlives the macro expansion. let realname = dependency.realname(); - let path_fmt = bun_fmt::fmt_path_u8( + let path_fmt = bun_fmt::EscapeControlChars(bun_fmt::fmt_path_u8( this.lockfile.str(&realname), bun_fmt::PathFormatOptions { path_sep, escape_backslashes: false, }, - ); + )); let log = this.log_mut(); if dependency.behavior.is_optional() || dependency.behavior.is_peer() { log.add_warning_with_note( @@ -806,8 +806,8 @@ pub fn enqueue_dependency_with_main_and_success_fn( bun_ast::Loc::EMPTY, format_args!( "Package \"{}\" with tag \"{}\" not found, but package exists", - bstr::BStr::new(this.lockfile.str(&name)), - bstr::BStr::new( + bun_fmt::escape_control_chars(this.lockfile.str(&name)), + bun_fmt::escape_control_chars( this.lockfile.str(&version.dist_tag().tag) ), ), @@ -825,8 +825,10 @@ pub fn enqueue_dependency_with_main_and_success_fn( None, bun_ast::Loc::EMPTY, "No version matching \"{}\" found for specifier \"{}\" (but package exists)", - bstr::BStr::new(this.lockfile.str(&version.literal)), - bstr::BStr::new(this.lockfile.str(&name)), + bun_fmt::escape_control_chars( + this.lockfile.str(&version.literal) + ), + bun_fmt::escape_control_chars(this.lockfile.str(&name)), ); } } @@ -844,8 +846,10 @@ pub fn enqueue_dependency_with_main_and_success_fn( None, bun_ast::Loc::EMPTY, "Package \"{}\" with tag \"{}\" not found (all versions blocked by minimum-release-age: {} seconds)", - bstr::BStr::new(this.lockfile.str(&name)), - bstr::BStr::new( + bun_fmt::escape_control_chars( + this.lockfile.str(&name) + ), + bun_fmt::escape_control_chars( this.lockfile.str(&version.dist_tag().tag) ), age_gate_ms / MS_PER_S, @@ -856,8 +860,10 @@ pub fn enqueue_dependency_with_main_and_success_fn( None, bun_ast::Loc::EMPTY, "No version matching \"{}\" found for specifier \"{}\" (blocked by minimum-release-age: {} seconds)", - bstr::BStr::new(this.lockfile.str(&name)), - bstr::BStr::new( + bun_fmt::escape_control_chars( + this.lockfile.str(&name) + ), + bun_fmt::escape_control_chars( this.lockfile.str(&version.literal) ), age_gate_ms / MS_PER_S, @@ -877,8 +883,8 @@ pub fn enqueue_dependency_with_main_and_success_fn( bun_ast::Loc::EMPTY, format_args!( "Could not find package.json for \"file:{}\" dependency \"{}\"", - bstr::BStr::new(this.lockfile.str(version.folder())), - bstr::BStr::new(this.lockfile.str(&name)), + bun_fmt::escape_control_chars(this.lockfile.str(version.folder())), + bun_fmt::escape_control_chars(this.lockfile.str(&name)), ), ); } else { @@ -887,7 +893,9 @@ pub fn enqueue_dependency_with_main_and_success_fn( bun_ast::Loc::EMPTY, format_args!( "Could not find package.json for dependency \"{}\"", - bstr::BStr::new(this.lockfile.str(&name)), + bun_fmt::escape_control_chars( + this.lockfile.str(&name) + ), ), ); } @@ -912,12 +920,12 @@ pub fn enqueue_dependency_with_main_and_success_fn( bun_core::pretty_errorln!( " -> \"{}\": \"{}\" -> {}@{}", bstr::BStr::new(this.lockfile.str(&result.package.name)), - bstr::BStr::new(label), + bun_fmt::escape_control_chars(label), bstr::BStr::new(this.lockfile.str(&result.package.name)), - result.package.resolution.fmt( + bun_fmt::EscapeControlChars(result.package.resolution.fmt( this.lockfile.buffers.string_bytes.as_slice(), bun_fmt::PathSep::Auto - ), + )), ); } // Resolve dependencies first @@ -1431,12 +1439,12 @@ pub fn enqueue_dependency_with_main_and_success_fn( bun_core::pretty_errorln!( " -> \"{}\": \"{}\" -> {}@{}", bstr::BStr::new(this.lockfile.str(&result.package.name)), - bstr::BStr::new(label), + bun_fmt::escape_control_chars(label), bstr::BStr::new(this.lockfile.str(&result.package.name)), - result.package.resolution.fmt( + bun_fmt::EscapeControlChars(result.package.resolution.fmt( this.lockfile.buffers.string_bytes.as_slice(), bun_fmt::PathSep::Auto - ), + )), ); } // We shouldn't see any dependencies @@ -2282,10 +2290,10 @@ fn get_or_put_resolved_package( existing_package .name .fmt(this.lockfile.buffers.string_bytes.as_slice()), - existing_package.resolution.fmt( + bun_fmt::EscapeControlChars(existing_package.resolution.fmt( this.lockfile.buffers.string_bytes.as_slice(), bun_fmt::PathSep::Auto - ), + )), ), ); success_fn(this, dependency_id, existing_id); @@ -2333,10 +2341,10 @@ fn get_or_put_resolved_package( existing_package .name .fmt(this.lockfile.buffers.string_bytes.as_slice()), - existing_package.resolution.fmt( + bun_fmt::EscapeControlChars(existing_package.resolution.fmt( this.lockfile.buffers.string_bytes.as_slice(), bun_fmt::PathSep::Auto - ), + )), ), ); success_fn(this, dependency_id, list[0]); @@ -2490,7 +2498,7 @@ fn get_or_put_resolved_package( bun_core::pretty_errorln!( "[minimum-release-age] {}@{} selected {} instead of {} due to {}-second filter", bstr::BStr::new(package_name), - bstr::BStr::new(tag_str), + bun_fmt::escape_control_chars(tag_str), result.version.fmt(manifest_buf), newest.fmt(manifest_buf), min_age_seconds, diff --git a/src/install/PackageManager/PackageManagerResolution.rs b/src/install/PackageManager/PackageManagerResolution.rs index 04093280799e..f2ec57c7bad9 100644 --- a/src/install/PackageManager/PackageManagerResolution.rs +++ b/src/install/PackageManager/PackageManagerResolution.rs @@ -352,14 +352,20 @@ impl PackageManager { { Output::err_generic( "{} failed to resolve", - (failed_dep.version.literal.fmt(string_buf),), + (bun_core::fmt::escape_control_chars( + failed_dep.version.literal.slice(string_buf), + ),), ); } else { Output::err_generic( "{}@{} failed to resolve", ( - bstr::BStr::new(failed_dep.name.slice(string_buf)), - failed_dep.version.literal.fmt(string_buf), + bun_core::fmt::escape_control_chars( + failed_dep.name.slice(string_buf), + ), + bun_core::fmt::escape_control_chars( + failed_dep.version.literal.slice(string_buf), + ), ), ); } diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 4cf780fb2d2c..b62b8d47ee55 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -1353,7 +1353,7 @@ fn add_dependency_error(manager: &mut PackageManager, dependency: &Dependency, e // taking `&mut` on `manager.log`. let realname = dependency.realname(); let path = manager.lockfile.str(&realname).to_vec(); - let path_fmt = bun_core::fmt::fmt_path( + let path_fmt = bun_core::fmt::EscapeControlChars(bun_core::fmt::fmt_path( &path, bun_core::fmt::PathFormatOptions { path_sep: match dependency.version.tag { @@ -1362,7 +1362,7 @@ fn add_dependency_error(manager: &mut PackageManager, dependency: &Dependency, e }, ..Default::default() }, - ); + )); let log = manager.log_mut(); if dependency.behavior.is_optional() || dependency.behavior.is_peer() { diff --git a/src/install/PackageManager/patchPackage.rs b/src/install/PackageManager/patchPackage.rs index 49029f7cd401..6ec7da32b61e 100644 --- a/src/install/PackageManager/patchPackage.rs +++ b/src/install/PackageManager/patchPackage.rs @@ -1396,7 +1396,7 @@ fn pkg_info_for_name_and_version( bun_core::pretty_error!( " {}@{}\n", bstr::BStr::new(pkg.name.slice(strbuf)), - pkg.resolution.fmt(strbuf, PathSep::Posix) + bun_fmt::EscapeControlChars(pkg.resolution.fmt(strbuf, PathSep::Posix)) ); if i + 1 < pairs.len() { diff --git a/src/install/PackageManager/processDependencyList.rs b/src/install/PackageManager/processDependencyList.rs index 7fdd1f654dac..c517e4994d90 100644 --- a/src/install/PackageManager/processDependencyList.rs +++ b/src/install/PackageManager/processDependencyList.rs @@ -164,7 +164,12 @@ impl PackageManager { Output::err( err, "failed to parse package.json for {}", - format_args!("{}", resolution.fmt_url(string_buf)), + format_args!( + "{}", + bun_core::fmt::EscapeControlChars( + resolution.fmt_url(string_buf) + ) + ), ); } Global::crash(); @@ -260,7 +265,7 @@ impl PackageManager { let string_buf = self.lockfile.buffers.string_bytes.as_slice(); bun_core::pretty_errorln!( "error: expected package.json in {} to be a JSON file: {}\n", - resolution.fmt_url(string_buf), + bun_core::fmt::EscapeControlChars(resolution.fmt_url(string_buf)), err.name(), ); } @@ -312,7 +317,9 @@ impl PackageManager { let string_buf = self.lockfile.buffers.string_bytes.as_slice(); bun_core::pretty_errorln!( "error: expected package.json in {} to be a JSON file: {}\n", - resolution.fmt_url(string_buf), + bun_core::fmt::EscapeControlChars( + resolution.fmt_url(string_buf) + ), err.name(), ); } diff --git a/src/install/PackageManager/runTasks.rs b/src/install/PackageManager/runTasks.rs index 05c3c87d1002..8dac6664bef4 100644 --- a/src/install/PackageManager/runTasks.rs +++ b/src/install/PackageManager/runTasks.rs @@ -28,7 +28,7 @@ use crate::isolated_install::store::{EntryColumns as _, NodeColumns as _}; use crate::lifecycle_script_runner::InstallCtx; use crate::network_task::{Authorization, ForTarballError}; use crate::package_manifest_map::Value as ManifestEntry; -use bun_core::fmt::PathSep; +use bun_core::fmt::{EscapeControlChars, PathSep, escape_control_chars}; use bun_install::lockfile::Package; use bun_install::package_manager_task as Task; // Import the *module* under the `Options` name so `Options::LogLevel` resolves as a path @@ -676,10 +676,12 @@ pub fn run_tasks( bun_ast::Loc::EMPTY, "warn: {} downloading tarball {}@{}. Retrying {}/{}...", bstr::BStr::new(err.name().as_bytes()), - bstr::BStr::new(extract.name.slice()), - extract - .resolution - .fmt(&manager.lockfile.buffers.string_bytes, PathSep::Auto,), + escape_control_chars(extract.name.slice()), + EscapeControlChars( + extract + .resolution + .fmt(&manager.lockfile.buffers.string_bytes, PathSep::Auto), + ), task.retried, manager.options.max_retry_count, ); @@ -746,10 +748,12 @@ pub fn run_tasks( bun_ast::Loc::EMPTY, "{} downloading tarball {}@{}", err.name(), - bstr::BStr::new(extract.name.slice()), - extract - .resolution - .fmt(&manager.lockfile.buffers.string_bytes, PathSep::Auto,), + escape_control_chars(extract.name.slice()), + EscapeControlChars( + extract + .resolution + .fmt(&manager.lockfile.buffers.string_bytes, PathSep::Auto), + ), ); } else { bun_ast::add_warning_pretty!( @@ -758,10 +762,12 @@ pub fn run_tasks( bun_ast::Loc::EMPTY, "{} downloading tarball {}@{}", err.name(), - bstr::BStr::new(extract.name.slice()), - extract - .resolution - .fmt(&manager.lockfile.buffers.string_bytes, PathSep::Auto,), + escape_control_chars(extract.name.slice()), + EscapeControlChars( + extract + .resolution + .fmt(&manager.lockfile.buffers.string_bytes, PathSep::Auto), + ), ); } if manager.subcommand != Subcommand::Remove { @@ -834,7 +840,7 @@ pub fn run_tasks( None, bun_ast::Loc::EMPTY, "GET {} - {}", - bstr::BStr::new(metadata.url.slice()), + escape_control_chars(metadata.url.slice()), response.status_code, ); } else { @@ -843,7 +849,7 @@ pub fn run_tasks( None, bun_ast::Loc::EMPTY, "GET {} - {}", - bstr::BStr::new(metadata.url.slice()), + escape_control_chars(metadata.url.slice()), response.status_code, ); } diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 3a2661f63ab6..689e742c567a 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -1455,7 +1455,7 @@ pub(crate) fn parse_with_tag( bun_ast::Loc::EMPTY, format_args!( "invalid or unsupported dependency \"{}\"", - bstr::BStr::new(dependency) + bun_core::fmt::escape_control_chars(dependency) ), ); } @@ -1576,7 +1576,10 @@ pub(crate) fn parse_with_tag( log.add_error_fmt( None, bun_ast::Loc::EMPTY, - format_args!("Unsupported protocol {}", bstr::BStr::new(dependency)), + format_args!( + "Unsupported protocol {}", + bun_core::fmt::escape_control_chars(dependency) + ), ); } return None; diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 9b94cdda2d30..92f0e1545cb9 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2414,7 +2414,9 @@ pub(crate) fn install_isolated_packages( "failed to enqueue package for download: {}@{}", ( BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_fmt::PathSep::Auto), + bun_fmt::EscapeControlChars( + pkg_res.fmt(string_buf, bun_fmt::PathSep::Auto), + ), ), ); Output::flush(); @@ -2472,7 +2474,9 @@ pub(crate) fn install_isolated_packages( "failed to enqueue github package for download: {}@{}", ( BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_fmt::PathSep::Auto), + bun_fmt::EscapeControlChars( + pkg_res.fmt(string_buf, bun_fmt::PathSep::Auto), + ), ), ); Output::flush(); @@ -2525,7 +2529,9 @@ pub(crate) fn install_isolated_packages( "failed to enqueue tarball for download: {}@{}", ( BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_fmt::PathSep::Auto), + bun_fmt::EscapeControlChars( + pkg_res.fmt(string_buf, bun_fmt::PathSep::Auto), + ), ), ); Output::flush(); diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index edb25516b394..d215dcf4a697 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -3,6 +3,7 @@ use std::io::Write as _; use bun_ast::Log; use bun_collections::{ArrayHashMap, DynamicBitSet, StringHashMap}; +use bun_core::fmt::{EscapeControlChars, escape_control_chars}; use bun_core::{Environment, Global, Output}; use bun_core::{ZStr, strings}; use bun_paths::{self as paths, AbsPath, AutoAbsPath, AutoRelPath}; @@ -255,10 +256,10 @@ impl<'a> Installer<'a> { Output::err_generic( "failed to download {}@{}: {}\n {}", ( - bstr::BStr::new(name), - resolution.fmt(string_buf, bun_core::fmt::PathSep::Auto), + escape_control_chars(name), + EscapeControlChars(resolution.fmt(string_buf, bun_core::fmt::PathSep::Auto)), bstr::BStr::new(download_error_reason(err)), - bstr::BStr::new(url), + escape_control_chars(url), ), ); Output::flush(); @@ -327,8 +328,8 @@ impl<'a> Installer<'a> { link_err.clone(), "failed to link package: {}@{}", ( - bstr::BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto), + escape_control_chars(pkg_name.slice(string_buf)), + EscapeControlChars(pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto)), ), ); } @@ -337,8 +338,8 @@ impl<'a> Installer<'a> { symlink_err.clone(), "failed to symlink dependencies for package: {}@{}", ( - bstr::BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto), + escape_control_chars(pkg_name.slice(string_buf)), + EscapeControlChars(pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto)), ), ); } @@ -346,8 +347,8 @@ impl<'a> Installer<'a> { Output::err_generic( "failed to patch package: {}@{}", ( - bstr::BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto), + escape_control_chars(pkg_name.slice(string_buf)), + EscapeControlChars(pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto)), ), ); let _ = patch_log.print(std::ptr::from_mut(Output::error_writer())); @@ -357,8 +358,8 @@ impl<'a> Installer<'a> { *bin_err, "failed to link binaries for package: {}@{}", ( - bstr::BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto), + escape_control_chars(pkg_name.slice(string_buf)), + EscapeControlChars(pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto)), ), ); } @@ -366,10 +367,10 @@ impl<'a> Installer<'a> { Output::err_generic( "failed to download {}@{}: {}\n {}", ( - bstr::BStr::new(pkg_name.slice(string_buf)), - pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto), + escape_control_chars(pkg_name.slice(string_buf)), + EscapeControlChars(pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto)), bstr::BStr::new(download_error_reason(dl.err)), - bstr::BStr::new(&dl.url), + escape_control_chars(&dl.url), ), ); } diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 43b9edf56896..fe09986050a9 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1826,7 +1826,9 @@ impl Package { format_args!( "No matching version for workspace dependency \"{}\". Version: \"{}\"", bstr::BStr::new(external_alias.slice(buf)), - bstr::BStr::new(dependency_version.literal.slice(buf)), + bun_core::fmt::escape_control_chars( + dependency_version.literal.slice(buf) + ), ), ); return Err(crate::Error::InstallFailed); diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index 66d7c3c067ce..ad9098a54dcd 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -1101,11 +1101,15 @@ impl Tree { format_args!( "Package \"{}@{}\" has a dependency loop\n Resolution: \"{}@{}\"\n Dependency: \"{}@{}\"", names[package_id as usize].fmt(buf), - resolutions[package_id as usize].fmt(buf, bun_core::fmt::PathSep::Auto), + bun_core::fmt::EscapeControlChars( + resolutions[package_id as usize].fmt(buf, bun_core::fmt::PathSep::Auto) + ), names[res_id as usize].fmt(buf), - resolutions[res_id as usize].fmt(buf, bun_core::fmt::PathSep::Auto), + bun_core::fmt::EscapeControlChars( + resolutions[res_id as usize].fmt(buf, bun_core::fmt::PathSep::Auto) + ), dependency.name.fmt(buf), - dependency.version.literal.fmt(buf), + bun_core::fmt::EscapeControlChars(dependency.version.literal.fmt(buf)), ), ); return Err(SubtreeError::DependencyLoop); diff --git a/src/install/lockfile/printer/tree_printer.rs b/src/install/lockfile/printer/tree_printer.rs index 9dad918b9f25..c7233ef72f48 100644 --- a/src/install/lockfile/printer/tree_printer.rs +++ b/src/install/lockfile/printer/tree_printer.rs @@ -4,7 +4,7 @@ use bun_semver as semver; use crate::lockfile_real::package::PackageColumns as _; use crate::package_manager_real::TrackInstalledBin; -use bun_core::fmt::PathSep; +use bun_core::fmt::{EscapeControlChars, PathSep, escape_control_chars}; use bun_install::lockfile::{Printer, package::Meta as PackageMeta}; use bun_install::{ self as install, Bin, Dependency, DependencyID, INVALID_PACKAGE_ID, PackageID, PackageManager, @@ -298,16 +298,16 @@ where "+ {s}@{f} (v{f} available)\n", true ), - bstr::BStr::new(name), - resolution.fmt(string_buf, PathSep::Posix), + escape_control_chars(name), + EscapeControlChars(resolution.fmt(string_buf, PathSep::Posix)), later_version_fmt, )?; } else { write!( writer, bun_core::pretty_fmt!("+ {s}@{f} (v{f} available)\n", false), - bstr::BStr::new(name), - resolution.fmt(string_buf, PathSep::Posix), + escape_control_chars(name), + EscapeControlChars(resolution.fmt(string_buf, PathSep::Posix)), later_version_fmt, )?; } @@ -319,15 +319,15 @@ where write!( writer, bun_core::pretty_fmt!("+ {s}@{f}\n", true), - bstr::BStr::new(name), - resolution.fmt(string_buf, PathSep::Posix), + escape_control_chars(name), + EscapeControlChars(resolution.fmt(string_buf, PathSep::Posix)), )?; } else { write!( writer, bun_core::pretty_fmt!("+ {s}@{f}\n", false), - bstr::BStr::new(name), - resolution.fmt(string_buf, PathSep::Posix), + escape_control_chars(name), + EscapeControlChars(resolution.fmt(string_buf, PathSep::Posix)), )?; } @@ -477,8 +477,8 @@ where writer, ENABLE_ANSI_COLORS, " {s}@{f}\n", - bstr::BStr::new(package_name), - resolved[package_id as usize].fmt(string_buf, PathSep::Auto), + escape_control_chars(package_name), + EscapeControlChars(resolved[package_id as usize].fmt(string_buf, PathSep::Auto)), )?; } } @@ -514,8 +514,10 @@ where writer, ENABLE_ANSI_COLORS, "installed {s}@{f}\n", - bstr::BStr::new(package_name), - resolved[package_id as usize].fmt(string_buf, PathSep::Posix), + escape_control_chars(package_name), + EscapeControlChars( + resolved[package_id as usize].fmt(string_buf, PathSep::Posix) + ), )?; } bin::Tag::Map | bin::Tag::File | bin::Tag::NamedFile => { @@ -539,8 +541,10 @@ where writer, ENABLE_ANSI_COLORS, "installed {s}@{f} with binaries:\n", - bstr::BStr::new(package_name), - resolved[package_id as usize].fmt(string_buf, PathSep::Posix), + escape_control_chars(package_name), + EscapeControlChars( + resolved[package_id as usize].fmt(string_buf, PathSep::Posix) + ), )?; } @@ -555,7 +559,7 @@ where writer, ENABLE_ANSI_COLORS, " - {s}\n", - bstr::BStr::new(&owned[..]), + escape_control_chars(&owned[..]), )?; manager.track_installed_bin = TrackInstalledBin::Basename(owned); @@ -567,7 +571,7 @@ where writer, ENABLE_ANSI_COLORS, " - {s}\n", - bstr::BStr::new(bin_name), + escape_control_chars(bin_name), )?; } } diff --git a/src/install/npm.rs b/src/install/npm.rs index 3af91eae2421..3a8ae25f7051 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -182,7 +182,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { if let Some(notice) = res.header_if_other_is_absent(b"npm-notice", b"x-local-cache") { Output::print_error("\n"); - bun_core::note!("{}", bstr::BStr::new(notice)); + bun_core::note!("{}", bun_fmt::escape_control_chars(notice)); Output::flush(); } @@ -248,7 +248,7 @@ pub fn response_error( } else { "" }, - bstr::BStr::new(res.status_text()), + bun_fmt::escape_control_chars(res.status_text()), bun_fmt::redacted_npm_url(req.url.href), ); @@ -272,7 +272,7 @@ pub fn response_error( Global::crash(); } } - bun_core::pretty_errorln!("\n - {}", bstr::BStr::new(msg)); + bun_core::pretty_errorln!("\n - {}", bun_fmt::escape_control_chars(msg)); } Global::crash(); @@ -2001,7 +2001,7 @@ impl PackageManifest { log.add_error_fmt( Some(&source), bun_ast::Loc::EMPTY, - format_args!("npm error: {}", bstr::BStr::new(err)), + format_args!("npm error: {}", bun_fmt::escape_control_chars(err)), ); return Ok(None); } @@ -2040,7 +2040,7 @@ impl PackageManifest { bun_core::warn!( "Package name mismatch. Expected \"{}\" but received \"{}\"", bstr::BStr::new(expected_name), - bstr::BStr::new(received_name), + bun_fmt::escape_control_chars(received_name), ); } } @@ -2082,7 +2082,7 @@ impl PackageManifest { prop.key_loc, format_args!( "Failed to parse dependency {}", - bstr::BStr::new(version_name) + bun_fmt::escape_control_chars(version_name) ), ); continue; diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index 368b8d07acc4..551004b02205 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -524,7 +524,7 @@ impl PatchTask { &e, format_args!( "failed trying to open temporary dir to apply patch to package: {}", - BStr::new(&resolution_label) + bun_core::fmt::escape_control_chars(&resolution_label) ), ); return Ok(()); diff --git a/src/install/repository.rs b/src/install/repository.rs index 7d86db59cd51..1a5007df4ce7 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -894,7 +894,7 @@ impl RepositoryExt for Repository { bun_ast::Loc::EMPTY, format_args!( "no commit matching \"{}\" found for \"{}\" (but repository exists)", - BStr::new(committish), + bun_core::fmt::escape_control_chars(committish), BStr::new(name) ), ); @@ -925,7 +925,7 @@ impl RepositoryExt for Repository { bun_ast::Loc::EMPTY, format_args!( "invalid git commit \"{}\" for \"{}\"", - BStr::new(resolved), + bun_core::fmt::escape_control_chars(resolved), BStr::new(name) ), ); diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 5f31eb8f7ff2..4275487ffcb1 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -614,21 +614,15 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; let name = dependencies[dependency_id as usize] .name .slice(string_bytes); - let resolution = - resolutions[package_id as usize].fmt(string_bytes, PathSep::Auto); + let name = bun_fmt::escape_control_chars(name); + let resolution = bun_fmt::EscapeControlChars( + resolutions[package_id as usize].fmt(string_bytes, PathSep::Auto), + ); if index < sorted_dependencies.len() - 1 { - bun_core::prettyln!( - "├── {}@{}\n", - bstr::BStr::new(name), - resolution, - ); + bun_core::prettyln!("├── {}@{}\n", name, resolution); } else { - bun_core::prettyln!( - "└── {}@{}\n", - bstr::BStr::new(name), - resolution, - ); + bun_core::prettyln!("└── {}@{}\n", name, resolution); } } } @@ -771,14 +765,14 @@ fn print_node_modules_folder_structure( if let Some(j) = strings::index_of(path, b"node_modules") { bun_core::prettyln!( "{}@{}", - bstr::BStr::new(&path[0..j - 1]), - bstr::BStr::new(directory_version), + bun_fmt::escape_control_chars(&path[0..j - 1]), + bun_fmt::escape_control_chars(directory_version), ); } else { bun_core::prettyln!( "{}@{}", - bstr::BStr::new(path), - bstr::BStr::new(directory_version), + bun_fmt::escape_control_chars(path), + bun_fmt::escape_control_chars(directory_version), ); } } else { @@ -896,8 +890,8 @@ fn print_node_modules_folder_structure( ); bun_core::prettyln!( "{}@{}", - bstr::BStr::new(package_name), - bstr::BStr::new(package_version), + bun_fmt::escape_control_chars(package_name), + bun_fmt::escape_control_chars(package_version), ); } @@ -963,19 +957,14 @@ fn print_trusted_dependencies_flat( for (index, &dep_id) in trusted.iter().enumerate() { let package_id = resolutions_buf[dep_id as usize]; let name = dependencies[dep_id as usize].name.slice(string_bytes); - let resolution = resolutions[package_id as usize].fmt(string_bytes, PathSep::Auto); + let name = bun_fmt::escape_control_chars(name); + let resolution = bun_fmt::EscapeControlChars( + resolutions[package_id as usize].fmt(string_bytes, PathSep::Auto), + ); if index + 1 < trusted.len() { - bun_core::prettyln!( - "├── {}@{}\n", - bstr::BStr::new(name), - resolution, - ); + bun_core::prettyln!("├── {}@{}\n", name, resolution); } else { - bun_core::prettyln!( - "└── {}@{}\n", - bstr::BStr::new(name), - resolution, - ); + bun_core::prettyln!("└── {}@{}\n", name, resolution); } } } diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index ac6e1736eb84..8272c55c6733 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -996,7 +996,7 @@ impl PublishCommand { Output::err_generic( "unable to authenticate, need: {}", - (bstr::BStr::new(www_authenticate),), + (bun_fmt::escape_control_chars(www_authenticate),), ); Global::crash(); } else if strings::contains(&response_buf.list, b"one-time pass") { @@ -1022,7 +1022,7 @@ impl PublishCommand { if let Some(notice) = res.header_if_other_is_absent(b"npm-notice", b"x-local-cache") { Output::print_error(format_args!("\n")); - bun_core::note!("{}", bstr::BStr::new(notice)); + bun_core::note!("{}", bun_fmt::escape_control_chars(notice)); Output::flush(); } @@ -1082,7 +1082,7 @@ impl PublishCommand { otp_res.header_if_other_is_absent(b"npm-notice", b"x-local-cache") { Output::print_error(format_args!("\n")); - bun_core::note!("{}", bstr::BStr::new(notice)); + bun_core::note!("{}", bun_fmt::escape_control_chars(notice)); Output::flush(); } } @@ -1358,7 +1358,7 @@ impl PublishCommand { res.header_if_other_is_absent(b"npm-notice", b"x-local-cache") { Output::print_error(format_args!("\n")); - bun_core::note!("{}", bstr::BStr::new(notice)); + bun_core::note!("{}", bun_fmt::escape_control_chars(notice)); Output::flush(); } diff --git a/src/runtime/cli/why_command.rs b/src/runtime/cli/why_command.rs index 095c8e8db2ce..8dee0a64ad8f 100644 --- a/src/runtime/cli/why_command.rs +++ b/src/runtime/cli/why_command.rs @@ -5,7 +5,7 @@ use std::io::Write as _; use bstr::BStr; use bun_collections::HashMap; -use bun_core::fmt::PathSep; +use bun_core::fmt::{PathSep, escape_control_chars}; use bun_core::strings; use bun_core::{Global, Output}; use bun_install::dependency::Behavior; @@ -476,8 +476,8 @@ impl WhyCommand { let target_name = pkg_names[target_version.pkg_id as usize].slice(string_bytes); bun_core::prettyln!( "{}@{}", - BStr::new(target_name), - BStr::new(&target_version.version) + escape_control_chars(target_name), + escape_control_chars(&target_version.version) ); if let Some(dependents) = all_dependents.get(&target_version.pkg_id) { @@ -545,19 +545,19 @@ fn print_package_with_type(prefix: &[u8], package: &DependentInfo) { } if package.workspace { - bun_core::pretty!("{}", BStr::new(&package.name)); + bun_core::pretty!("{}", escape_control_chars(&package.name)); if !package.version.is_empty() { bun_core::pretty!("@workspace"); } } else { - bun_core::pretty!("{}", BStr::new(&package.name)); + bun_core::pretty!("{}", escape_control_chars(&package.name)); if !package.version.is_empty() { - bun_core::pretty!("@{}", BStr::new(&package.version)); + bun_core::pretty!("@{}", escape_control_chars(&package.version)); } } if !package.spec.is_empty() { - bun_core::prettyln!(" (requires {})", BStr::new(&package.spec)); + bun_core::prettyln!(" (requires {})", escape_control_chars(&package.spec)); } else { bun_core::prettyln!(""); } diff --git a/test/cli/install/escape-control-characters.test.ts b/test/cli/install/escape-control-characters.test.ts new file mode 100644 index 000000000000..14cd2936dad7 --- /dev/null +++ b/test/cli/install/escape-control-characters.test.ts @@ -0,0 +1,313 @@ +/** + * Strings the package manager prints but did not write itself must not be able + * to smuggle terminal control sequences into the user's terminal: here, the + * resolution and version specifier of a dependency (which any package.json in + * the tree controls), bin names (which the package controls), and the text of a + * registry's error response. Each control character has to come out spelled + * as its escape (`\x1b`, `\u009b`, ...). + * + * One fake registry serves every test. Any package name resolves to a packument + * whose 1.0.0 installs cleanly and whose 2.0.0 (the `latest` tag) depends on a + * dist-tag that does not exist; every tarball carries a poisoned bin name. + */ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; + +// OSC "set window title" followed by CSI "clear screen". +const ESC = "\x1b]0;pwned\x07\x1b[2J"; +const ESC_ESCAPED = "\\x1b]0;pwned\\x07\\x1b[2J"; + +// U+009B is the one-character (C1) spelling of CSI. Unlike C0 bytes and DEL it +// is accepted in file names, in URLs (bun percent-encodes it on the wire but +// prints the specifier as written) and in an HTTP status line. +const C1 = "\u009b31m"; +const C1_ESCAPED = "\\u009b31m"; + +const BIN = `bin-${C1}-name`; +const BIN_ESCAPED = `bin-${C1_ESCAPED}-name`; +// Text that must come through untouched, next to the bytes that must not: a +// backslash, `\u00a9` (also a two-byte character starting with 0xC2, like the C1 +// controls), a three-byte character, a four-byte one, a tab and DEL. +const PASSTHROUGH = "a\\b \u00a9 \u20ac \u{1f600}"; +const BAD_TAG = `tag-${ESC}${PASSTHROUGH}\t${C1}\x7f`; +const BAD_TAG_ESCAPED = `tag-${ESC_ESCAPED}${PASSTHROUGH}\\t${C1_ESCAPED}\\x7f`; + +// bun's own output is printable text and newlines; any other C0 byte, DEL or +// C1 character in stdout/stderr came through from the fixture unescaped. +const RAW_CONTROL = /[\x00-\x09\x0b-\x1f\x7f\x80-\x9f]/; + +let registry: Bun.Server; +let registryUrl: string; +let tarball: Promise; +/** A tarball specifier that installs (the server ignores the path). */ +let tarballSpec: string; +let tarballSpecEscaped: string; +/** `dist.tarball` of `` for the packages whose tarball tests break on purpose. */ +const distTarball = (name: string) => `${registryUrl}/${name}/-/dep-${C1}.tgz`; +const distTarballEscaped = (name: string) => `${registryUrl}/${name}/-/dep-${C1_ESCAPED}.tgz`; +/** Decoded request paths the server answers with a 404. */ +const brokenTarballPaths = new Set(); + +function packumentFor(name: string) { + const manifest = (version: string, dependencies: Record) => ({ + name, + version, + dist: { tarball: distTarball(name) }, + bin: { [BIN]: "index.js" }, + dependencies, + }); + return { + name, + "dist-tags": { latest: "2.0.0", [`tag-${C1}`]: "1.0.0" }, + versions: { + "1.0.0": manifest("1.0.0", name === "needs-dep" ? { dep: "1.0.0" } : {}), + "2.0.0": manifest("2.0.0", { sub: BAD_TAG }), + }, + }; +} + +beforeAll(() => { + tarball = new Bun.Archive( + { + "package/package.json": JSON.stringify({ name: "dep", version: "1.0.0", bin: { [BIN]: "index.js" } }), + "package/index.js": "#!/usr/bin/env node\n", + }, + { compress: "gzip" }, + ).bytes(); + + registry = Bun.serve({ + port: 0, + async fetch(req) { + const pathname = decodeURIComponent(new URL(req.url).pathname); + if (brokenTarballPaths.has(pathname)) { + return new Response("gone", { status: 404 }); + } + if (pathname.startsWith("/failing/")) { + return new Response("try again", { status: 500 }); + } + if (pathname.endsWith(".tgz")) { + return new Response(await tarball); + } + return Response.json(packumentFor(pathname.slice(1))); + }, + }); + registryUrl = `http://127.0.0.1:${registry.port}`; + tarballSpec = `${registryUrl}/tarballs/dep-${C1}.tgz`; + tarballSpecEscaped = `${registryUrl}/tarballs/dep-${C1_ESCAPED}.tgz`; + brokenTarballPaths.add(decodeURIComponent(new URL(distTarball("broken-tarball")).pathname)); +}); + +afterAll(() => { + registry?.stop(true); +}); + +function project(name: string, packageJson: object, registry = registryUrl) { + return tempDir(`escape-controls-${name}`, { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", ...packageJson }), + "bunfig.toml": `[install]\nregistry = "${registry}"\n`, + }); +} + +async function run(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +async function installOk(dir: string) { + const result = await run(dir, "install"); + expect(result.stderr).not.toContain("error:"); + expect(result.exitCode).toBe(0); + return result; +} + +test.concurrent("bun install summary escapes the resolution", async () => { + using dir = project("install", { dependencies: { dep: tarballSpec } }); + const { stdout, stderr } = await installOk(String(dir)); + expect(stdout).toContain(`+ dep@${tarballSpecEscaped}`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); +}); + +test.concurrent("bun pm ls escapes the resolution", async () => { + using dir = project("pm-ls", { dependencies: { dep: tarballSpec } }); + await installOk(String(dir)); + for (const args of [ + ["pm", "ls"], + ["pm", "ls", "--all"], + ]) { + const { stdout, stderr, exitCode } = await run(String(dir), ...args); + expect(stdout).toContain(`dep@${tarballSpecEscaped}`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(0); + } +}); + +test.concurrent("bun why escapes the resolution and the specifier", async () => { + using dir = project("why", { dependencies: { dep: tarballSpec } }); + await installOk(String(dir)); + const { stdout, stderr, exitCode } = await run(String(dir), "why", "dep"); + expect(stdout).toContain(`dep@${tarballSpecEscaped}`); + expect(stdout).toContain(`(requires ${tarballSpecEscaped})`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(0); +}); + +test.concurrent("bun add escapes the bin names it reports", async () => { + using dir = project("add", {}); + const { stdout, stderr, exitCode } = await run(String(dir), "add", "has-bin@1.0.0"); + expect(stdout).toContain("installed has-bin@1.0.0 with binaries:"); + expect(stdout).toContain(` - ${BIN_ESCAPED}`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(0); +}); + +test.concurrent("resolution errors escape the dist-tag", async () => { + using dir = project("dist-tag", {}); + const { stdout, stderr, exitCode } = await run(String(dir), "add", "unresolvable"); + expect(stderr).toContain(`Package "sub" with tag "${BAD_TAG_ESCAPED}" not found, but package exists`); + expect(stderr).toContain(`sub@${BAD_TAG_ESCAPED} failed to resolve`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +}); + +test.concurrent("resolution errors escape a file: path", async () => { + using dir = project("file-path", { dependencies: { dep: `file:./missing-${C1}` } }); + const { stdout, stderr, exitCode } = await run(String(dir), "install"); + expect(stderr).toContain(`Could not find package.json for "file:missing-${C1_ESCAPED}" dependency "dep"`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +}); + +test.concurrent("bun patch lists the candidate resolutions escaped", async () => { + // Two lockfile entries named `dep`: the root's tarball and the registry's + // 1.0.0 that `needs-dep` depends on, so `bun patch dep` has to ask which one. + using dir = project("patch", { dependencies: { dep: tarballSpec, "needs-dep": "1.0.0" } }); + await installOk(String(dir)); + const { stdout, stderr, exitCode } = await run(String(dir), "patch", "dep"); + expect(stderr).toContain("Found multiple versions of dep"); + expect(stderr).toContain(`dep@${tarballSpecEscaped}`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).not.toBe(0); +}); + +test.concurrent("a workspace range that matches nothing is reported escaped", async () => { + using dir = tempDir("escape-controls-workspace-range", { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + workspaces: ["packages/*"], + dependencies: { ws: `workspace:^2.0.0-${C1}` }, + }), + "packages/ws/package.json": JSON.stringify({ name: "ws", version: "1.0.0" }), + }); + const { stdout, stderr, exitCode } = await run(String(dir), "install"); + expect(stderr).toContain( + `No matching version for workspace dependency "ws". Version: "workspace:^2.0.0-${C1_ESCAPED}"`, + ); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +}); + +test.concurrent("a tarball specifier bun rejects is reported with the specifier escaped", async () => { + // C0 bytes are not valid in a URL, so this never gets as far as a request. + using dir = project("invalid-url", { dependencies: { dep: `${registryUrl}/tarballs/dep-${ESC}.tgz` } }); + const { stdout, stderr, exitCode } = await run(String(dir), "install"); + expect(stderr).toContain(`InvalidURL downloading tarball dep@${registryUrl}/tarballs/dep-${ESC_ESCAPED}.tgz`); + expect(stderr).toContain(`dep@${registryUrl}/tarballs/dep-${ESC_ESCAPED}.tgz failed to resolve`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +}); + +test.concurrent("the verbose resolve trace escapes the specifier and the resolution", async () => { + using dir = tempDir("escape-controls-resolve-trace", { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + workspaces: ["packages/*"], + dependencies: { dep: `tag-${C1}`, ws: "workspace:*" }, + }), + "bunfig.toml": `[install]\nregistry = "${registryUrl}"\n`, + [`packages/ws-${C1}/package.json`]: JSON.stringify({ name: "ws", version: "1.0.0" }), + }); + const { stderr, exitCode } = await run(String(dir), "install", "--verbose"); + // --verbose also dumps the HTTP client's request trace, which is not package + // manager output, so only the package manager's own lines are checked here. + expect(stderr).toContain(`"dep": "tag-${C1_ESCAPED}"`); + expect(stderr).toContain(`"ws": "packages/ws-${C1_ESCAPED}"`); + // The trace prints the resolution with the platform's path separator. + expect(stderr).toContain(`ws@workspace:packages${isWindows ? "\\" : "/"}ws-${C1_ESCAPED}`); + expect(stderr).not.toContain(`tag-${C1}`); + expect(stderr).not.toContain(`ws-${C1}`); + expect(exitCode).toBe(0); +}); + +test.concurrent("the verbose retry warning escapes the resolution", async () => { + // A 5xx is retried, and each retry is reported under --verbose before the final error. + const spec = `${registryUrl}/failing/dep-${C1}.tgz`; + const specEscaped = `${registryUrl}/failing/dep-${C1_ESCAPED}.tgz`; + using dir = project("retry", { dependencies: { dep: spec } }); + const { stderr, exitCode } = await run(String(dir), "install", "--verbose"); + // --verbose also dumps the HTTP client's request trace, which is not package + // manager output, so only the package manager's own lines are checked here. + expect(stderr).toContain(`downloading tarball dep@${specEscaped}. Retrying 1/`); + expect(stderr).toContain(`GET ${specEscaped} - 500`); + expect(stderr).not.toContain(`dep@${spec}`); + expect(stderr).not.toContain(`${spec} - 500`); + expect(exitCode).toBe(1); +}); + +test.concurrent("a tarball the registry cannot serve is reported with its URL escaped", async () => { + using dir = project("tarball-404", { dependencies: { "broken-tarball": "1.0.0" } }); + const { stdout, stderr, exitCode } = await run(String(dir), "install"); + expect(stderr).toContain(`GET ${distTarballEscaped("broken-tarball")} - 404`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +}); + +test.concurrent("the isolated installer reports a failed download with its URL escaped", async () => { + // With a lockfile already present, the isolated installer downloads the + // tarballs itself and reports failures through its own message. + using dir = project("isolated-404", { dependencies: { "breaks-later": "1.0.0" } }); + await installOk(String(dir)); + brokenTarballPaths.add(decodeURIComponent(new URL(distTarball("breaks-later")).pathname)); + await rm(join(String(dir), "node_modules"), { recursive: true }); + await rm(join(String(dir), ".bun-cache"), { recursive: true }); + + const { stdout, stderr, exitCode } = await run(String(dir), "install", "--linker", "isolated"); + expect(stderr).toContain("failed to download breaks-later@1.0.0: 404 Not Found"); + expect(stderr).toContain(` ${distTarballEscaped("breaks-later")}`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +}); + +test.concurrent("registry error responses are escaped", async () => { + const body = JSON.stringify({ error: `denied${ESC}` }); + const response = + `HTTP/1.1 403 Nope${C1}\r\n` + + `Content-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n` + + body; + using rawRegistry = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + socket.end(response); + }, + }, + }); + using dir = project("registry-error", {}, `http://127.0.0.1:${rawRegistry.port}`); + + const { stdout, stderr, exitCode } = await run(String(dir), "pm", "view", "forbidden"); + expect(stderr).toContain(`403 Nope${C1_ESCAPED}: http://127.0.0.1:${rawRegistry.port}/forbidden`); + expect(stderr).toContain(` - denied${ESC_ESCAPED}`); + expect(stdout + stderr).not.toMatch(RAW_CONTROL); + expect(exitCode).toBe(1); +});