diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index 6e734c9f32bc..7f2f4ef46458 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -83,8 +83,8 @@ Bun supports the following options: - `_authToken` - `username` - `_password` (base64 encoded password) -- `_auth` (base64 encoded username:password, for example `btoa(username + ":" + password)`) -- `email` +- `_auth` (sent as written in a `Basic` header; npm's form is base64 encoded username:password, for example `btoa(username + ":" + password)`) +- `email` (accepted and ignored, as npm does) The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runtime/bunfig#install-scopes): diff --git a/src/api/lib.rs b/src/api/lib.rs index 5ea6fdaf5719..1c8639c80dcf 100644 --- a/src/api/lib.rs +++ b/src/api/lib.rs @@ -9,7 +9,7 @@ // ────────────────────────────────────────────────────────────────────────── pub use bun_options_types::schema::api::{ - BunInstall, Ca, NodeLinker, NpmRegistry, NpmRegistryMap, PnpmMatcher, + BunInstall, Ca, NodeLinker, NpmRegistry, NpmRegistryMap, NpmUrlAuth, PnpmMatcher, }; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 8f450860cfc8..2d1b31e93b30 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1867,6 +1867,35 @@ impl Log { ) } + /// A warning placed at `loc` that never prints the source line: for a line whose + /// text may hold a secret the redactor cannot recognise. + #[cold] + pub fn add_warning_fmt_no_excerpt( + &mut self, + source: &Source, + loc: Loc, + args: fmt::Arguments<'_>, + ) { + if !Kind::Warn.should_print(self.level) { + return; + } + self.warnings += 1; + let mut location = Location::init_or_null(Some(source), Range { loc, len: 1 }); + if let Some(location) = location.as_mut() { + location.line_text = None; + } + let data = Data { + text: alloc_print(args), + location, + } + .clone_line_text(self.clone_line_text); + self.add_msg(Msg { + kind: Kind::Warn, + data, + ..Default::default() + }) + } + #[cold] pub fn add_warning_fmt_line_col( &mut self, diff --git a/src/ini/lib.rs b/src/ini/lib.rs index a9de2636cb6b..d7d2ec4131df 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -47,15 +47,11 @@ pub(crate) fn next_dot(key: &[u8]) -> Option { pub(crate) enum IniOption { Some(T), None, -} - -impl IniOption { - fn get(self) -> Option { - match self { - IniOption::Some(v) => Some(v), - IniOption::None => None, - } - } + /// A `//host/...:=` line whose `` is not a known option. + Unknown { + suffix: Box<[u8]>, + loc: bun_ast::Loc, + }, } // ────────────────────────────────────────────────────────────────────────── @@ -64,7 +60,7 @@ impl IniOption { #[derive(Clone, Copy, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] pub enum ConfigOpt { - /// `${username}:${password}` encoded in base64 + /// usually `${username}:${password}` encoded in base64, but sent verbatim #[strum(serialize = "_auth")] _Auth, @@ -95,12 +91,21 @@ pub enum ConfigOpt { // ConfigItem // ────────────────────────────────────────────────────────────────────────── -pub struct ConfigItem { +/// One `//:=` line, from whichever `.npmrc` declared it. Every +/// file's lines are collected into one flat list and resolved together, the way +/// npm collapses its config files into a single map before reading credentials. +pub(crate) struct ConfigItem { + /// npm's registry key: the text between `//` and `:=` after + /// `normalize_key`, so `//127.0.0.1:1234/api/:_authToken=T` yields + /// `127.0.0.1:1234/api/`. Compared byte for byte from then on. pub(crate) registry_url: Box<[u8]>, pub(crate) optname: ConfigOpt, pub(crate) value: Box<[u8]>, pub(crate) loc: Loc, pub(crate) optname_loc: Loc, + /// Index into the `.npmrc` files parsed by `load_npmrc_config`, so a + /// diagnostic points at the file the line came from. + pub(crate) source_idx: u32, } // ────────────────────────────────────────────────────────────────────────── @@ -121,8 +126,8 @@ bun_core::comptime_string_map! { } pub use draft::{ - ConfigIterator, Parser, RegistryAuth, ScopeItem, ScopeIterator, ToStringFormatter, - apply_registry_auth, load_npmrc, load_npmrc_config, + ConfigIterator, Parser, RegistryKey, ScopeItem, ScopeIterator, ToStringFormatter, load_npmrc, + load_npmrc_config, }; mod draft { @@ -131,7 +136,7 @@ mod draft { use core::ptr; use bun_alloc::{AllocError, Arena, ArenaVec, ArenaVecExt as _}; - use bun_api::{self, BunInstall, NpmRegistry, npm_registry}; + use bun_api::{self, BunInstall, NpmRegistry, NpmUrlAuth, npm_registry}; use bun_ast::E::Rope; use bun_ast::{E, Expr, ExprData, StoreRef}; use bun_ast::{Loc, Log, Source}; @@ -1009,6 +1014,7 @@ mod draft { pub(crate) log: &'a mut Log, pub(crate) prop_idx: usize, + pub(crate) source_idx: u32, } impl<'a> ConfigIterator<'a> { @@ -1024,8 +1030,6 @@ mod draft { if let Some(keyexpr) = prop.key { if let Some(key) = keyexpr.as_utf8_string_literal() { if bun_core::has_prefix(key, b"//") { - // Order matters: `_authToken` must be - // matched before `_auth`. const OPTNAMES: &[(&[u8], ConfigOpt)] = &[ (b"keyfile", ConfigOpt::Keyfile), (b"certfile", ConfigOpt::Certfile), @@ -1043,7 +1047,9 @@ mod draft { buf[1..1 + name.len()].copy_from_slice(name); let name_with_eq = &buf[..1 + name.len()]; - if let Some(index) = bun_core::last_index_of(key, name_with_eq) { + if let Some(index) = bun_core::last_index_of(key, name_with_eq) + .filter(|&index| index + name_with_eq.len() == key.len()) + { let url_part = &key[2..index]; if let Some(value_expr) = prop.value { if let Some(value) = value_expr.as_utf8_string_literal() { @@ -1057,14 +1063,40 @@ mod draft { None => keyexpr.loc, }; return Some(IniOption::Some(ConfigItem { - registry_url: Box::<[u8]>::from(url_part), + registry_url: normalize_key(url_part), value: Box::<[u8]>::from(value), optname: opt, loc: keyexpr.loc, optname_loc, + source_idx: self.source_idx, })); } } + // A known option whose value is not a string (`=true`, no `=`): + // nothing to apply, and not a typo to warn about. + return Some(IniOption::None); + } + } + // `//host/...:word=` where `word` is none of the seven options. Only a + // misspelling of a credential option warns (`_authtoken`, `authToken`, + // `password`); `always-auth`, `tokenHelper`, `cafile` and anything else + // npm or pnpm accept per registry are ignored silently, as npm ignores + // them. The word sits right after the key's last `/` as `:word`, with no + // second colon: `//host/:_auth:dXNl…` (a `:` typed for `=`) would + // otherwise name the credential as the option. + if let Some(colon) = bun_core::strings::last_index_of_char(key, b':') + .filter(|&colon| !bun_core::strings::contains_char(&key[colon..], b'/')) + .filter(|&colon| colon > 0 && key[colon - 1] == b'/') + { + let word = &key[colon + 1..]; + let has_string_value = prop + .value + .is_some_and(|value| value.as_utf8_string_literal().is_some()); + if has_string_value && folds_to_credential_option(word) { + return Some(IniOption::Unknown { + suffix: Box::from(word), + loc: keyexpr.loc, + }); } } } @@ -1075,101 +1107,49 @@ mod draft { } } - // ────────────────────────────────────────────────────────────────────────── - // RegistryAuth - // ────────────────────────────────────────────────────────────────────────── - - pub struct RegistryAuth { - host: Box<[u8]>, - pathname: Box<[u8]>, - credential: RegistryCredential, - } - - enum RegistryCredential { - Token(Box<[u8]>), - Username(Box<[u8]>), - Password(Box<[u8]>), - UsernamePassword { - username: Box<[u8]>, - password: Box<[u8]>, - }, - Email(Box<[u8]>), + /// `word` spells a credential option once case, `_` and `-` are ignored. + fn folds_to_credential_option(word: &[u8]) -> bool { + const NAMES: [&[u8]; 4] = [b"_authToken", b"_auth", b"username", b"_password"]; + let fold = |s: &[u8]| -> Vec { + s.iter() + .filter(|&&c| c != b'_' && c != b'-') + .map(u8::to_ascii_lowercase) + .collect() + }; + let folded = fold(word); + !folded.is_empty() && NAMES.iter().any(|name| fold(name) == folded) } - impl RegistryAuth { - pub(crate) fn from_config_item( - item: ConfigItem, - log: &mut Log, - source: &Source, - ) -> Option { - let ConfigItem { - registry_url, - optname, - value, - loc, - optname_loc: _, - } = item; - let credential = match optname { - ConfigOpt::_AuthToken => RegistryCredential::Token(value), - ConfigOpt::Username => RegistryCredential::Username(value), - ConfigOpt::Email => RegistryCredential::Email(value), - ConfigOpt::_Password => { - if value.is_empty() { - RegistryCredential::Password(Box::default()) - } else { - let mut decoded = vec![0u8; bun_base64::decode_len(&value)]; - let result = bun_base64::decode(&mut decoded[..], &value); - if !result.is_successful() { - log.add_error_fmt_opts( - format_args!( - "{} is not valid base64", - <&'static str>::from(optname) - ), - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - decoded.truncate(result.count); - RegistryCredential::Password(decoded.into_boxed_slice()) - } - } - ConfigOpt::_Auth => { - let (username, password) = parse_auth(&value, loc, log, source)?; - RegistryCredential::UsernamePassword { username, password } - } - ConfigOpt::Certfile | ConfigOpt::Keyfile => return None, - }; - let url = URL::parse(®istry_url); - Some(RegistryAuth { - host: bun_core::without_trailing_slash(url.host).into(), - pathname: bun_core::without_trailing_slash(url.pathname).into(), - credential, - }) - } - - pub(crate) fn matches(&self, registry_url: &[u8]) -> bool { - let url = URL::parse(registry_url); - bun_core::without_trailing_slash(url.host) == &*self.host - && bun_core::without_trailing_slash(url.pathname) == &*self.pathname - } - - pub(crate) fn apply_to(&self, registry: &mut NpmRegistry) { - match &self.credential { - RegistryCredential::Token(token) => registry.token.clone_from(token), - RegistryCredential::Username(username) => registry.username.clone_from(username), - RegistryCredential::Password(password) => registry.password.clone_from(password), - RegistryCredential::UsernamePassword { username, password } => { - registry.username.clone_from(username); - registry.password.clone_from(password); + /// npm writes a key with `nerfDart(new URL(registry))`: no scheme, a lowercase + /// host, and no default port (URL parsing drops it). A hand-written key is + /// compared as written, so a port in it stays, whatever the scheme turns out to + /// be: `//host:443/` is `http://host:443/`'s key, not `https://host/`'s. Bun's + /// docs long showed `//http://host/:_authToken=`; with a scheme spelled out, that + /// scheme's default port is dropped the way URL parsing would. + fn normalize_key(raw: &[u8]) -> Box<[u8]> { + let has_scheme = |scheme: &[u8]| { + raw.len() >= scheme.len() && raw[..scheme.len()].eq_ignore_ascii_case(scheme) + }; + let (default_port, rest): (Option<&[u8]>, &[u8]) = if has_scheme(b"https://") { + (Some(b"443"), &raw[b"https://".len()..]) + } else if has_scheme(b"http://") { + (Some(b"80"), &raw[b"http://".len()..]) + } else { + (None, raw) + }; + let host_end = bun_core::strings::index_of_char_usize(rest, b'/').unwrap_or(rest.len()); + let mut key = rest.to_vec(); + key[..host_end].make_ascii_lowercase(); + // In a bracketed IPv6 authority only a colon after `]` introduces a port. + if let Some(default_port) = default_port { + if let Some(colon) = bun_core::strings::last_index_of_char(&key[..host_end], b':') { + let is_port = key[0] != b'[' || (colon > 0 && key[colon - 1] == b']'); + if is_port && key[colon + 1..host_end] == *default_port { + key.drain(colon..host_end); } - RegistryCredential::Email(email) => registry.email.clone_from(email), } } + key.into_boxed_slice() } // ────────────────────────────────────────────────────────────────────────── @@ -1213,7 +1193,10 @@ mod draft { log: &mut *self.log, source: self.source, }; - break 'brk parser.parse_registry_url_string_impl(str_)?; + let mut registry = + parser.parse_registry_url_string_impl(str_)?; + registry.credentials_from_url = true; + break 'brk registry; } } return Ok(Some(IniOption::None)); @@ -1235,15 +1218,19 @@ mod draft { // loadNpmrcConfig / loadNpmrc // ────────────────────────────────────────────────────────────────────────── + /// Read every `.npmrc` into `install`, then resolve the collapsed credential lines + /// for the registries `install` and `bunfig` declare. A `bunfig` registry that came + /// with its own credentials keeps them: project config beats `.npmrc`. pub fn load_npmrc_config( install: &mut BunInstall, + bunfig: &BunInstall, env: &DotEnvLoader, auto_loaded: bool, npmrc_paths: &[&ZStr], - ) -> Vec { + ) { let mut log = Log::init(); - - let mut configs: Vec = Vec::new(); + let mut configs: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); for &npmrc_path in npmrc_paths { let source = match bun_ast::source_from_file( @@ -1263,72 +1250,327 @@ mod draft { Global::crash(); } }; - // `source.contents` is owned; drops at end of loop iteration. - match load_npmrc(install, env, &mut log, &source, &mut configs) { + let source_idx = sources.len() as u32; + sources.push(source); + + match parse_npmrc_into( + install, + env, + &mut log, + &sources[source_idx as usize], + source_idx, + &mut configs, + ) { Ok(()) => {} Err(AllocError) => bun_core::out_of_memory(), } - if log.has_errors() { - if log.errors == 1 { - bun_core::warn!( - "Encountered an error while reading {}:\n\n", - bstr::BStr::new(npmrc_path.as_bytes()), - ); - } else { - bun_core::warn!( - "Encountered errors while reading {}:\n\n", - bstr::BStr::new(npmrc_path.as_bytes()), - ); - } - Output::flush(); + report_log(&mut log); + } + + install.url_auth = collapse_url_auth(&configs); + if !configs.is_empty() { + let registries = declared_registry_keys(install, bunfig); + diagnose_config(&configs, &sources, ®istries, &mut log); + } + report_log(&mut log); + } + + /// Print and clear `log`. Errors get a header naming the file the first one came + /// from; the accumulated messages would otherwise reprint once per remaining file. + fn report_log(log: &mut Log) { + if log.has_errors() { + let path: &[u8] = log + .msgs + .iter() + .find(|msg| msg.kind == bun_ast::Kind::Err) + .and_then(|msg| msg.data.location.as_ref()) + .map_or(b"", |loc| &loc.file); + if log.errors == 1 { + bun_core::warn!( + "Encountered an error while reading {}:\n\n", + bstr::BStr::new(path), + ); + } else { + bun_core::warn!( + "Encountered errors while reading {}:\n\n", + bstr::BStr::new(path), + ); + } + Output::flush(); + } + let _ = log.print(std::ptr::from_mut::( + Output::error_writer(), + )); + log.reset(); + } + + /// npm's `regKey.replace(/([^/]+|\/)$/, '')`: the length left after stripping one + /// trailing `/`, else the trailing run of non-`/` bytes. + fn strip_one_key_component(key: &[u8]) -> usize { + let mut end = key.len(); + if key.last() == Some(&b'/') { + return end - 1; + } + while end > 0 && key[end - 1] != b'/' { + end -= 1; + } + end + } + + fn url_or_default(url: &[u8]) -> &[u8] { + if url.is_empty() { + bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes() + } else { + url + } + } + + /// A registry as the walk sees it. npm builds the key from a WHATWG URL: the host + /// lowercased, a default port dropped, the query left out. Built from the same + /// serialisation `Scope::set_url` stores, so the key and the request agree on the + /// authority; a URL WHATWG rejects (`$VAR`) falls back to the raw text. + pub struct RegistryKey { + /// `/`: npm's key for the registry itself, and where the walk + /// starts (`regFetch` appends `/` to the registry URL and the first + /// iteration of the walk strips it right back off). + key: Box<[u8]>, + } + + impl RegistryKey { + pub fn from_url(url_bytes: &[u8]) -> RegistryKey { + let owned = URL::from_string(&bun_core::String::borrow_utf8(url_bytes)).ok(); + let url = match &owned { + Some(owned) => owned.url(), + None => URL::parse(url_bytes), + }; + RegistryKey::from_parsed(&url) + } + + /// The key of a URL that is already a WHATWG serialisation, without parsing it again. + pub fn from_parsed(url: &URL) -> RegistryKey { + // `pathname` carries the query; `path` is query-free but collapses a + // one-byte path such as `/r/` to `/`, so cut the query off `pathname`. + let pathname = url.pathname; + let path_end = + bun_core::strings::index_of_char_usize(pathname, b'?').unwrap_or(pathname.len()); + let path = pathname[..path_end] + .strip_suffix(b"/") + .unwrap_or(&pathname[..path_end]); + let mut key = Vec::with_capacity(url.host.len() + path.len() + 1); + key.extend_from_slice(url.host); + key[..url.host.len()].make_ascii_lowercase(); + key.extend_from_slice(path); + key.push(b'/'); + RegistryKey { + key: key.into_boxed_slice(), } - let _ = log.print(std::ptr::from_mut::( - Output::error_writer(), - )); } + + /// npm's `regFromURI`: this key, then one component shorter each time, down + /// to the bare host. For `h/a/` that is `h/a/`, `h/a`, `h/`, `h`. + pub fn walk(&self) -> impl Iterator { + let mut next = Some(self.key.len()); + core::iter::from_fn(move || { + let end = next.take()?; + let current = &self.key[..end]; + let shorter = strip_one_key_component(current); + next = (shorter > 0).then_some(shorter); + Some(current) + }) + } + + /// The key for a registry `bunfig.toml` declared, or `None` when `bunfig.toml` + /// also gave it credentials: no `.npmrc` line can apply to it then, so none is + /// resolved or diagnosed against it. + fn for_bunfig(registry: &NpmRegistry) -> Option { + (!registry.has_credentials()) + .then(|| RegistryKey::from_url(url_or_default(®istry.url))) + } + + /// The key without its trailing `/`: a distinct config key that npm's walk + /// visits right after the slashed one. + fn unslashed(&self) -> &[u8] { + &self.key[..self.key.len() - 1] + } + } + + /// npm's config is a flat map, so a key repeated across `.npmrc` files collapses + /// to the last one read before any credential resolution happens. + fn lookup<'a>(configs: &'a [ConfigItem], key: &[u8], opt: ConfigOpt) -> Option<&'a ConfigItem> { configs + .iter() + .rfind(|conf_item| conf_item.optname == opt && *conf_item.registry_url == *key) + } + + /// `lookup` plus npm's `opts[k]` truthiness test: an empty value supplies nothing. + /// The emptiness test comes AFTER the collapse, so a later `username=` clears an + /// earlier one rather than losing to it. + fn lookup_truthy<'a>( + configs: &'a [ConfigItem], + key: &[u8], + opt: ConfigOpt, + ) -> Option<&'a ConfigItem> { + lookup(configs, key, opt).filter(|conf_item| !conf_item.value.is_empty()) + } + + /// What one config key supplies, in npm's order: `_authToken`, else `_auth`, else + /// `username` + `_password`. `certfile`/`keyfile` are absent because Bun has no + /// mTLS, and honouring them would stop the walk on a key that supplies no credential. + enum Auth<'a> { + Token(&'a ConfigItem), + Basic(&'a ConfigItem), + UserPass { + username: &'a ConfigItem, + password: &'a ConfigItem, + }, } - pub fn apply_registry_auth(install: &mut BunInstall, auth: &[RegistryAuth]) { - if auth.is_empty() { - return; + /// npm's `hasAuth`, keyed on byte equality with the config key. + fn has_auth<'a>(configs: &'a [ConfigItem], key: &[u8]) -> Option> { + if let Some(token) = lookup_truthy(configs, key, ConfigOpt::_AuthToken) { + return Some(Auth::Token(token)); } - if let Some(registry) = install.default_registry.as_mut() { - if !registry.has_credentials() { - for item in auth { - let matched = item.matches(if registry.url.is_empty() { - bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes() - } else { - ®istry.url - }); - if matched { - item.apply_to(registry); - } - } + if let Some(auth) = lookup_truthy(configs, key, ConfigOpt::_Auth) { + return Some(Auth::Basic(auth)); + } + let username = lookup_truthy(configs, key, ConfigOpt::Username)?; + let password = lookup_truthy(configs, key, ConfigOpt::_Password)?; + Some(Auth::UserPass { username, password }) + } + + /// npm's `Buffer.from(value, "base64")` never fails: invalid bytes are skipped and + /// as much as possible is decoded. + fn decode_password(value: &[u8]) -> Box<[u8]> { + let mut decoded = vec![0u8; bun_base64::decode_lenient_len(value.len())]; + let count = bun_base64::decode_lenient(&mut decoded[..], value, false); + decoded.truncate(count); + decoded.into_boxed_slice() + } + + /// What one config key supplies, in the shape `Scope::from_api` consumes. + fn apply_auth(auth: &Auth<'_>, v: &mut NpmRegistry) { + match auth { + Auth::Token(token) => v.token.clone_from(&token.value), + // npm forwards `_auth` verbatim as `Basic `; `Scope::from_api` + // decodes it only to derive a username for `bun pm whoami`. + Auth::Basic(auth) => v.auth.clone_from(&auth.value), + Auth::UserPass { username, password } => { + v.username.clone_from(&username.value); + v.password = decode_password(&password.value); } } - if let Some(scoped) = install.scoped.as_mut() { - for registry in scoped.scopes.values_mut() { - if registry.has_credentials() { - continue; - } - for item in auth { - let matched = item.matches(®istry.url); - if matched { - item.apply_to(registry); - } - } + } + + /// npm's config map, reduced to one entry per key that carries a complete + /// credential. The package manager walks it for every registry it ends up with. + fn collapse_url_auth(configs: &[ConfigItem]) -> Vec { + let mut out: Vec = Vec::new(); + for conf_item in configs { + if out + .iter() + .any(|entry| *entry.key == *conf_item.registry_url) + { + continue; + } + let Some(auth) = has_auth(configs, &conf_item.registry_url) else { + continue; + }; + let mut credentials = NpmRegistry::default(); + apply_auth(&auth, &mut credentials); + out.push(NpmUrlAuth { + key: conf_item.registry_url.clone(), + credentials, + }); + } + out + } + + /// The keys of the registries `.npmrc` and `bunfig.toml` declare (or npm's default, + /// when neither declares one), for the diagnostics that name a registry. + fn declared_registry_keys(install: &BunInstall, bunfig: &BunInstall) -> Vec { + let default_url: &[u8] = match &install.default_registry { + Some(registry) => ®istry.url, + None => b"", + }; + let mut registries = vec![RegistryKey::from_url(url_or_default(default_url))]; + if let Some(scoped) = &install.scoped { + registries.extend( + scoped + .scopes + .values() + .iter() + .map(|v| RegistryKey::from_url(&v.url)), + ); + } + for registry in bunfig + .default_registry + .iter() + .chain(bunfig.scoped.iter().flat_map(|s| s.scopes.values())) + { + registries.extend(RegistryKey::for_bunfig(registry)); + } + registries + } + + /// An empty `_auth` on a registry's own key supplies nothing; main errored on it and + /// so does this, once, against the line the key collapsed to. + fn diagnose_config( + configs: &[ConfigItem], + sources: &[Source], + registries: &[RegistryKey], + log: &mut Log, + ) { + for conf_item in configs.iter() { + if conf_item.optname != ConfigOpt::_Auth + || !conf_item.value.is_empty() + || !lookup(configs, &conf_item.registry_url, ConfigOpt::_Auth) + .is_some_and(|collapsed| core::ptr::eq(collapsed, conf_item)) + { + continue; + } + if registries.iter().any(|registry| { + *conf_item.registry_url == *registry.key + || *conf_item.registry_url == *registry.unslashed() + }) { + log.add_error_opts( + b"empty _auth value: this line supplies no credentials", + bun_ast::AddErrorOptions { + source: Some(&sources[conf_item.source_idx as usize]), + loc: conf_item.loc, + redact_sensitive_information: true, + ..Default::default() + }, + ); } } } + /// Single-file entry point (the `bun:internal-for-testing` hook). pub fn load_npmrc( install: &mut BunInstall, env: &DotEnvLoader, log: &mut Log, source: &Source, - configs: &mut Vec, + ) -> OOM<()> { + let mut configs: Vec = Vec::new(); + parse_npmrc_into(install, env, log, source, 0, &mut configs)?; + install.url_auth = collapse_url_auth(&configs); + if !configs.is_empty() { + let registries = declared_registry_keys(install, &BunInstall::default()); + diagnose_config(&configs, std::slice::from_ref(source), ®istries, log); + } + Ok(()) + } + + /// One file's options, `registry=` lines and `//host/…:=` lines (onto `configs`). + fn parse_npmrc_into( + install: &mut BunInstall, + env: &DotEnvLoader, + log: &mut Log, + source: &Source, + source_idx: u32, + configs: &mut Vec, ) -> OOM<()> { let arena = Arena::new(); let bump = &arena; @@ -1346,8 +1588,9 @@ mod draft { log: &mut *log, source, }; - install.default_registry = - Some(p.parse_registry_url_string_impl(&Box::<[u8]>::from(str_))?); + let mut registry = p.parse_registry_url_string_impl(&Box::<[u8]>::from(str_))?; + registry.credentials_from_url = true; + install.default_registry = Some(registry); } } @@ -1550,23 +1793,40 @@ mod draft { iter.count = false; while let Some(val) = iter.next()? { - if let Some(result) = val.get() { + if let IniOption::Some(result) = val { let registry = result.registry.clone(); registry_map.scopes.put(&*result.scope, registry)?; } } } + // Collect this file's `//host/…:=` lines. Credentials are resolved + // later, once, over the lines of every `.npmrc`. { let mut iter = ConfigIterator { config: out_obj, log, prop_idx: 0, + source_idx, }; while let Some(val) = iter.next() { - let Some(conf_item) = val.get() else { - continue; + let conf_item = match val { + IniOption::Some(conf_item) => conf_item, + IniOption::None => continue, + IniOption::Unknown { suffix, loc } => { + // No source excerpt: the value after `=` may be a secret under a + // misspelt name the redactor cannot recognise (`:authToken=`). + iter.log.add_warning_fmt_no_excerpt( + source, + loc, + format_args!( + "{} is not a known .npmrc option; ignoring this line", + bstr::BStr::new(&suffix), + ), + ); + continue; + } }; if matches!(conf_item.optname, ConfigOpt::Certfile | ConfigOpt::Keyfile) { bun_ast::add_warning_pretty!( @@ -1578,40 +1838,12 @@ mod draft { ); continue; } - if let Some(auth) = RegistryAuth::from_config_item(conf_item, iter.log, source) { - configs.push(auth); - } - } - - if !configs.is_empty() { - for auth in configs.iter() { - let matched = auth.matches(install.default_registry.as_ref().map_or( - bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes(), - |r| &*r.url, - )); - if matched { - auth.apply_to(install.default_registry.get_or_insert_with(|| { - NpmRegistry { - url: bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL - .as_bytes() - .into(), - ..Default::default() - } - })); - } - for registry in registry_map.scopes.values_mut() { - if auth.matches(®istry.url) { - auth.apply_to(registry); - } - } - } + configs.push(conf_item); } } - // The single write-back happens here, after the registry-config loop - // has finished mutating the scope *values* in place. (An - // OOM `?` above leaves `install.scoped` as `None`, which is moot — install - // aborts on OOM.) + // An OOM `?` above leaves `install.scoped` as `None`, which is moot — + // install aborts on OOM. install.scoped = Some(registry_map); Ok(()) @@ -1696,68 +1928,4 @@ mod draft { behavior, }) } - - fn parse_auth( - value: &[u8], - loc: Loc, - log: &mut Log, - source: &Source, - ) -> Option<(Box<[u8]>, Box<[u8]>)> { - if value.is_empty() { - log.add_error_opts( - b"invalid _auth value, expected base64 encoded \":\", received an empty string", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - let mut decoded = vec![0u8; bun_base64::decode_len(value)]; - let result = bun_base64::decode(&mut decoded[..], value); - if !result.is_successful() { - log.add_error_opts( - b"invalid _auth value, expected valid base64", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - let username_password = &decoded[..result.count]; - let Some(colon_idx) = bun_core::strings::index_of_char_usize(username_password, b':') - else { - log.add_error_opts( - b"invalid _auth value, expected base64 encoded \":\"", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - }; - if colon_idx + 1 >= username_password.len() { - log.add_error_opts( - b"invalid _auth value, expected base64 encoded \":\"", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - Some(( - username_password[..colon_idx].into(), - username_password[colon_idx + 1..].into(), - )) - } } // mod draft diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 0abb6e90d0b1..862565f73da2 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -387,26 +387,19 @@ fn append_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) // Routing through `format_args!`/`BStr` Display would be // lossy for non-UTF-8 tokens (U+FFFD expands 1→3 bytes) and overrun the // exact byte count reserved by `count_auth`. Use raw-byte append. - if !scope.token.is_empty() { - header_builder.append_bytes_value("Authorization", b"Bearer ", &scope.token); - } else if !scope.auth.is_empty() { - header_builder.append_bytes_value("Authorization", b"Basic ", &scope.auth); - } else { + let Some((scheme, value)) = scope.authorization_parts() else { return; - } + }; + header_builder.append_bytes_value("Authorization", scheme, value); header_builder.append("npm-auth-type", "legacy"); } fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) { - if !scope.token.is_empty() { - header_builder.count("Authorization", ""); - header_builder.content.cap += "Bearer ".len() + scope.token.len(); - } else if !scope.auth.is_empty() { - header_builder.count("Authorization", ""); - header_builder.content.cap += "Basic ".len() + scope.auth.len(); - } else { + let Some((scheme, value)) = scope.authorization_parts() else { return; - } + }; + header_builder.count("Authorization", ""); + header_builder.content.cap += scheme.len() + value.len(); header_builder.count("npm-auth-type", "legacy"); } @@ -716,6 +709,13 @@ impl NetworkTask { Ok(()) } + /// Moves the fully written header block into `self.header_buf` and returns a view of it. + fn store_header_buf<'a>(&'a mut self, header_builder: &mut HeaderBuilder) -> &'a [u8] { + debug_assert_eq!(header_builder.content.len, header_builder.content.cap); + self.header_buf = header_builder.content.move_to_slice(); + &self.header_buf + } + pub(crate) fn get_completion_callback(&mut self) -> HTTPClientResultCallback { // `HTTPClientResultCallback::new` // performs type erasure over a `fn(*mut T, *mut AsyncHTTP, _)`. @@ -823,60 +823,52 @@ impl NetworkTask { None => None, }; - // Only attach the registry `Authorization` header when the tarball URL - // origin matches the configured registry scope origin. The npm manifest - // is registry-controlled, so a malicious registry could otherwise point - // the tarball at an attacker-controlled host and receive the scope - // credentials. The empty-`tarball_url` branch builds the URL from - // `scope.url.href()`, so its origin matches and authorized downloads - // keep working. - // Compare (protocol, hostname, effective port) rather than the raw - // `URL.origin` slice — `origin` is a borrowed prefix of the input - // string and is not normalized for default ports, so a tarball URL of - // `https://host:443/...` would not byte-match a `.npmrc` registry of - // `https://host/...` even though they are the same origin. Some - // registries emit `dist.tarball` URLs with the default port spelled - // out; without normalization those installs lose the `Authorization` - // header and fail with 401. - let send_auth = matches!(authorization, Authorization::AllowAuthorization) && { - let tarball = URL::parse(&self.url_buf); - let registry = scope.url.url(); - tarball.protocol == registry.protocol - && tarball.hostname == registry.hostname - && tarball.get_port_auto() == registry.get_port_auto() + // One parse decides the authority, the wire path and the `.npmrc` key; a URL it cannot settle is requested as written with no line. + let normalized = match npm::registry::normalize_tarball_url(&self.url_buf) { + Some(normalized) => { + self.url_buf = normalized; + true + } + None => false, + }; + + let credentials = match authorization { + Authorization::NoAuthorization => None, + Authorization::AllowAuthorization => { + pm.options + .tarball_credentials(scope, &URL::parse(&self.url_buf), normalized) + } }; self.response_buffer = MutableString::init_empty(); let mut header_builder = HeaderBuilder::default(); - if send_auth { - count_auth(&mut header_builder, scope); - } - - // Registry credentials win over URL userinfo, as in npm. - let url_authorization = match url_authorization { - Some(value) if header_builder.header_count == 0 => { + // Configured credentials win over the URL's userinfo, as in npm. + let header_buf: &[u8] = match (credentials, url_authorization) { + (Some(credentials), _) => { + count_auth(&mut header_builder, credentials); + header_builder.allocate()?; + append_auth(&mut header_builder, credentials); + self.store_header_buf(&mut header_builder) + } + (None, Some(value)) => { header_builder.count("Authorization", &value); - Some(value) + header_builder.allocate()?; + header_builder.append("Authorization", &value); + self.store_header_buf(&mut header_builder) } - _ => None, - }; - - let header_buf: &'static [u8] = if header_builder.header_count > 0 { - header_builder.allocate()?; - match &url_authorization { - Some(value) => header_builder.append("Authorization", value), - None => append_auth(&mut header_builder, scope), + (None, None) => { + self.header_buf = Box::default(); + b"" } - debug_assert_eq!(header_builder.content.len, header_builder.content.cap); - self.header_buf = header_builder.content.move_to_slice(); - // SAFETY: `self.header_buf` outlives the request; it is freed when the slot returns to the pool. - unsafe { bun_ptr::detach_lifetime(&*self.header_buf) } - } else { - self.header_buf = Box::default(); - b"" }; + // SAFETY: lifetime extension. `header_buf` is `b""` or a view of the heap + // allocation `self.header_buf` owns, which is freed only when the slot returns + // to the pool, after the request completes. `AsyncHTTP::init` demands a + // `'static` borrow because the HTTP thread reads it concurrently, as for + // `url_buf` below. + let header_buf: &'static [u8] = unsafe { bun_ptr::detach_lifetime(header_buf) }; // SAFETY: lifetime extension — `url_buf` is a heap allocation owned by // `*self`, which outlives the HTTP request. `AsyncHTTP::init` demands a diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e2c97f58844d..ed6718ac3537 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1382,6 +1382,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall let Api::BunInstall { default_registry, scoped, + url_auth, lockfile_path, save_lockfile_path, cache_directory, @@ -1431,6 +1432,8 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall } } + install.url_auth.extend(url_auth); + macro_rules! overlay { ($($field:ident),* $(,)?) => { $( if $field.is_some() { install.$field = $field; } )* @@ -1489,6 +1492,10 @@ pub fn init( cli: CommandLineArguments, subcommand: Subcommand, ) -> Result<(&'static mut PackageManager, Box<[u8]>), Error> { + // `.npmrc` is read below, before `Options::load` applies the log level. + if cli.log_level.is_silent() { + bun_ast::DEFAULT_LOG_LEVEL.store(bun_ast::Level::Err); + } if cli.global { // Non-consuming peek: `ctx.install` is // `Option>` borrowed via `&mut ContextData`; reborrow with @@ -1922,7 +1929,7 @@ pub fn init( { // npmrc < bunfig < CLI - let mut bunfig_install = ctx + let bunfig_install = ctx .install .take() .map_or_else(Api::BunInstall::default, |b| *b); @@ -1951,18 +1958,18 @@ pub fn init( } } - let registry_auth = if global_len > 0 { + if global_len > 0 { ini::load_npmrc_config( &mut install, + &bunfig_install, env, true, &[ZStr::from_buf(&buf[..], global_len), &*npmrc_local], - ) + ); } else { - ini::load_npmrc_config(&mut install, env, true, &[&*npmrc_local]) - }; + ini::load_npmrc_config(&mut install, &bunfig_install, env, true, &[&*npmrc_local]); + } - ini::apply_registry_auth(&mut bunfig_install, ®istry_auth); overlay_bunfig_install(&mut install, bunfig_install); ctx.install = Some(Box::new(install)); } diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index e89a22f8b2e0..b1182c6ed518 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -39,6 +39,11 @@ pub struct Options { pub scope: Npm::registry::Scope, pub(crate) registries: Npm::registry::Map, + /// `.npmrc` `//host/path/` credential keys, matched against request URLs. Fills in + /// a registry configured without credentials of its own (`--registry`, + /// `$NPM_CONFIG_REGISTRY`) and authenticates tarballs served elsewhere than their + /// registry's origin. + pub(crate) url_auth: Vec, pub(crate) cache_directory: &'static [u8], pub enable: Enable, pub do_: Do, @@ -126,6 +131,7 @@ impl Default for Options { // Always assigned in `load()` before read. scope: Npm::registry::Scope::default(), registries: Npm::registry::Map::default(), + url_auth: Vec::new(), cache_directory: b"", enable: Enable::default(), do_: Do::default(), @@ -275,6 +281,103 @@ impl Options { _ => &self.scope, } } + + /// npm's `getAuth` order: the tarball's own `.npmrc` line, else the registry's credentials on its origin. + pub fn tarball_credentials<'a>( + &'a self, + scope: &'a Npm::registry::Scope, + tarball: &bun_url::URL, + // `tarball` is the WHATWG serialisation the request goes to; a URL one parse could not settle gets no line. + normalized: bool, + ) -> Option<&'a Npm::registry::Scope> { + let registry_url = scope.url.url(); + let on_registry_origin = same_origin(tarball, ®istry_url); + // A `.npmrc` line's credential goes over plaintext only back to the registry's own origin. + let own = if normalized && (tarball.is_https() || on_registry_origin) { + Npm::registry::UrlAuth::find_entry_serialized(&self.url_auth, tarball) + } else { + None + }; + if scope.has_credentials() && on_registry_origin { + // The key the registry itself resolved to is already reflected in `scope`, behind any bunfig, env or CLI credential. + return match own { + Some(entry) if scope.url_auth_key.as_deref() != Some(entry.key()) => { + Some(entry.credentials()) + } + _ => Some(scope), + }; + } + own.map(|entry| entry.credentials()) + } + + /// A `--registry` or env registry under the default keeps its credentials unless `.npmrc` has a deeper line. + fn inherits_default_credentials(&self, new_registry: &[u8]) -> bool { + let owned = bun_url::URL::from_string(&bun_core::String::borrow_utf8(new_registry)).ok(); + let new_url = &match &owned { + Some(owned) => owned.url(), + None => bun_url::URL::parse(new_registry), + }; + let old_url = self.scope.url.url(); + if !registry_under(new_url, &old_url) { + return false; + } + match Npm::registry::UrlAuth::find_entry(&self.url_auth, new_url) { + Some(own) => Npm::registry::UrlAuth::find_entry(&self.url_auth, &old_url) + .is_some_and(|old| old.key() == own.key()), + None => true, + } + } + + /// Give every scope that ended up without credentials the ones `.npmrc` + /// configures for its registry URL, by npm's key walk. A registry from + /// `bunfig.toml`, `--registry` or `$NPM_CONFIG_REGISTRY` is only known here, and + /// a credential from any of those sources outranks a `.npmrc` line; one written + /// into a `.npmrc` `registry=` URL does not. + fn fill_credentials_from_url_auth(&mut self) { + if self.url_auth.is_empty() { + return; + } + let url_auth = &self.url_auth; + for scope in core::iter::once(&mut self.scope).chain(self.registries.values_mut()) { + let own = Npm::registry::UrlAuth::find_entry(url_auth, &scope.url.url()); + scope.url_auth_key = own.map(|entry| Box::from(entry.key())); + if scope.has_credentials() && !scope.credentials_from_url { + continue; + } + if let Some(entry) = own { + scope.copy_credentials_from(entry.credentials()); + } + } + } +} + +/// Same scheme, host and effective port (some registries spell out `:443` in +/// `dist.tarball`). +fn same_origin(url: &bun_url::URL, base: &bun_url::URL) -> bool { + !base.hostname.is_empty() + && url.protocol.eq_ignore_ascii_case(base.protocol) + && url.hostname.eq_ignore_ascii_case(base.hostname) + && url.get_port_auto() == base.get_port_auto() +} + +/// `url` names the registry `base` already holds credentials for, or one below it: +/// same host and port text, no https-to-http downgrade, and `base`'s path is a +/// segment-wise prefix of `url`'s. A registry on a sibling path of the same host +/// inherits nothing; its own `.npmrc` line applies through the key walk instead. +fn registry_under(url: &bun_url::URL, base: &bun_url::URL) -> bool { + bun_core::without_trailing_slash(url.host) == bun_core::without_trailing_slash(base.host) + && (url.is_https() || !base.is_https()) + && path_under( + Npm::registry::query_free_path(url), + Npm::registry::query_free_path(base), + ) +} + +/// `base`'s path is a segment-wise prefix of `path`, so `/npm/team-ab/x` is not under +/// `/npm/team-a/`. +fn path_under(path: &[u8], base: &[u8]) -> bool { + let mut segments = bun_core::strings::tokenize(path, b"/"); + bun_core::strings::tokenize(base, b"/").all(|expected| segments.next() == Some(expected)) } #[derive(Copy, Clone, PartialEq, Eq, Default, Debug)] @@ -467,6 +570,12 @@ impl Options { } } + for url_auth in &config.url_auth { + if let Some(url_auth) = Npm::registry::UrlAuth::from_api(url_auth, env)? { + self.url_auth.push(url_auth); + } + } + if let Some(ca) = &config.ca { match ca { Api::Ca::List(ca_list) => { @@ -631,13 +740,10 @@ impl Options { let mut api_registry = Api::NpmRegistry::from_url(registry_); // Credentials in the URL win, as they do for `registry=` in .npmrc. if !api_registry.has_credentials() { - let prev_url = self.scope.url.url(); - let new_url = bun_url::URL::parse(&api_registry.url); - if bun_core::without_trailing_slash(new_url.host) - == bun_core::without_trailing_slash(prev_url.host) - && (new_url.is_https() || !prev_url.is_https()) - { + if self.inherits_default_credentials(&api_registry.url) { api_registry.token = core::mem::take(&mut self.scope.token); + api_registry.auth = core::mem::take(&mut self.scope.auth); + api_registry.credentials_from_url = self.scope.credentials_from_url; } } self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; @@ -649,24 +755,18 @@ impl Options { if let Some(cli) = &maybe_cli { if !cli.registry.is_empty() { - let api_registry = Api::NpmRegistry::from_url(cli.registry); - if api_registry.has_credentials() { - self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; - } else { - let new_url = bun_url::URL::parse(&api_registry.url); - let same_origin = { - let prev_url = self.scope.url.url(); - bun_core::without_trailing_slash(new_url.host) - == bun_core::without_trailing_slash(prev_url.host) - && (new_url.is_https() || !prev_url.is_https()) - }; - if !same_origin { - self.scope.token = Box::default(); - self.scope.auth = Box::default(); - self.scope.user = Box::default(); + let mut api_registry = Api::NpmRegistry::from_url(cli.registry); + // Credentials in the URL win, as they do for `registry=` in .npmrc. + if !api_registry.has_credentials() { + if self.inherits_default_credentials(&api_registry.url) { + api_registry.token = core::mem::take(&mut self.scope.token); + api_registry.auth = core::mem::take(&mut self.scope.auth); + api_registry.credentials_from_url = self.scope.credentials_from_url; } - self.scope.set_url(api_registry.url); } + // Through the same builder as every other registry, so a credential + // embedded in the URL is stripped from the request path here too. + self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; } } @@ -681,6 +781,7 @@ impl Options { if let Some(token) = env.get(token_key) { if !token.is_empty() { self.scope.token = token.into(); + self.scope.credentials_from_url = false; break; } } @@ -736,6 +837,7 @@ impl Options { if !cli.token.is_empty() { self.scope.token = cli.token.into(); + self.scope.credentials_from_url = false; } if cli.no_save { @@ -932,6 +1034,10 @@ impl Options { } // moved from `defer { ... }` after scope assignment (see note above). + // After every source that can set a registry URL (bunfig, .npmrc, environment, + // command line) has been applied. + self.fill_credentials_from_url_auth(); + self.did_override_default_scope = self.scope.url_hash != *Npm::registry::DEFAULT_URL_HASH; // The manifest cache is the data source for --prefer-offline/--offline; keep it on diff --git a/src/install/npm.rs b/src/install/npm.rs index 0addc627fc1f..23930f5bcf75 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -51,9 +51,10 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { return Ok(registry_url.username.to_vec()); } - if registry.token.is_empty() { + // An `_auth` whose username could not be derived still authenticates the request. + let Some(authorization) = registry.authorization() else { return Err(WhoamiError::NeedAuth); - } + }; let auth_type: &[u8] = match &manager.options.publish_config.auth_type { Some(auth_type) => auth_type.as_str().as_bytes(), @@ -68,15 +69,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { { headers.count("accept", "*/*"); headers.count("accept-encoding", "gzip,deflate"); - - write!( - &mut print_buf, - "Bearer {}", - bstr::BStr::new(®istry.token) - ) - .expect("infallible: in-memory write"); - headers.count("authorization", &print_buf); - print_buf.clear(); + headers.count("authorization", &authorization); // no otp needed, just use auth-type from options headers.count("npm-auth-type", auth_type); @@ -104,15 +97,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { { headers.append("accept", "*/*"); headers.append("accept-encoding", "gzip/deflate"); - - write!( - &mut print_buf, - "Bearer {}", - bstr::BStr::new(®istry.token) - ) - .expect("infallible: in-memory write"); - headers.append("authorization", &print_buf); - print_buf.clear(); + headers.append("authorization", &authorization); headers.append("npm-auth-type", auth_type); headers.append("npm-command", "whoami"); @@ -302,19 +287,155 @@ pub mod registry { pub struct Scope { pub name: Box<[u8]>, // https://github.com/npm/npm-registry-fetch/blob/main/lib/auth.js#L96 - // base64("${username}:${password}") + // Sent verbatim as `Basic `. Usually base64("${username}:${password}"), + // but npm forwards whatever `_auth` holds — do not assume it decodes. pub auth: Box<[u8]>, - // URL may contain these special suffixes in the pathname: - // :_authToken - // :username - // :_password - // :_auth + // Registry href; yarn-style `:_authToken`/`:username`/`:_password`/`:_auth` + // pathname suffixes are always stripped by `parse_embedded_auth`. pub url: OwnedURL, pub url_hash: u64, pub token: Box<[u8]>, // username and password combo, `user:pass` pub user: Box<[u8]>, + + /// The registry came from a `.npmrc` `registry=` line, so any credential in + /// its URL (userinfo, a `:_authToken=` segment) is the weakest source: a + /// `.npmrc` line for the URL replaces it, where a bunfig, env or CLI credential + /// stands. Cleared when an explicit token is set later. + pub credentials_from_url: bool, + + /// The `.npmrc` key this registry's URL resolves to, computed once after every source applied. + pub url_auth_key: Option>, + } + + /// yarn-style credentials embedded in a registry URL's pathname, e.g. + /// `https://host/api/:_authToken=TOKEN`. + #[derive(Default)] + struct EmbeddedAuth<'a> { + token: Option<&'a [u8]>, + auth: Option<&'a [u8]>, + username: Option<&'a [u8]>, + password: Option<&'a [u8]>, + /// The scan hit a credential that ends it; nothing after it is read. + terminal: bool, + } + + #[derive(Clone, Copy)] + enum EmbeddedOpt { + Token, + Auth, + Username, + Password, + } + + impl<'a> EmbeddedAuth<'a> { + fn slot(&mut self, opt: EmbeddedOpt) -> &mut Option<&'a [u8]> { + match opt { + EmbeddedOpt::Token => &mut self.token, + EmbeddedOpt::Auth => &mut self.auth, + EmbeddedOpt::Username => &mut self.username, + EmbeddedOpt::Password => &mut self.password, + } + } + } + + /// `:=` is yarn's shape (`/api/:_authToken=T`); `/=` is the one Bun's + /// JFrog guide shows (`/npm/_auth=`). + const EMBEDDED_MARKERS: [(&[u8], EmbeddedOpt); 8] = [ + (b":_authToken=", EmbeddedOpt::Token), + (b":_auth=", EmbeddedOpt::Auth), + (b":username=", EmbeddedOpt::Username), + (b":_password=", EmbeddedOpt::Password), + (b"/_authToken=", EmbeddedOpt::Token), + (b"/_auth=", EmbeddedOpt::Auth), + (b"/username=", EmbeddedOpt::Username), + (b"/_password=", EmbeddedOpt::Password), + ]; + + /// The rightmost credential marker in `pathname`, as `(start, marker, opt)`. + /// Anchoring on the marker rather than on any `:` or `/` keeps a colon or slash + /// inside the value (base64 holds `/`) from ending the scan, and leaves one that + /// merely belongs to the path alone. A name is matched as spelled, as on a + /// `.npmrc` line: `:_authtoken=` is a misspelling, stripped by + /// `trailing_misspelt_marker` and never adopted. + fn last_embedded_marker(pathname: &[u8]) -> Option<(usize, &'static [u8], EmbeddedOpt)> { + EMBEDDED_MARKERS + .iter() + .filter_map(|&(marker, opt)| { + bun_core::last_index_of(pathname, marker).map(|i| (i, marker, opt)) + }) + .max_by_key(|&(i, _, _)| i) + } + + /// A `:=` run at the very end of the path, the one place yarn writes a + /// credential, whose word is no known option (`:_passwd=`, `:authtoken=`): a + /// misspelt credential, stripped from the request path and never adopted. A `=` + /// anywhere else in the path is a plain path segment and stays. + fn trailing_misspelt_marker(pathname: &[u8]) -> Option { + let unslashed = pathname.strip_suffix(b"/").unwrap_or(pathname); + let start = strings::last_index_of_char(unslashed, b'/')? + 1; + let tail = &unslashed[start..]; + let word = &tail.get(1..)?[..strings::index_of_char_usize(tail.get(1..)?, b'=')?]; + let shaped = tail[0] == b':' + && word + .first() + .is_some_and(|&c| c == b'_' || c.is_ascii_alphabetic()) + && word + .iter() + .all(|&c| c == b'_' || c == b'-' || c.is_ascii_alphanumeric()); + shaped.then_some(start) + } + + /// Strip trailing credential segments out of `url.pathname`. Runs before the + /// registry's own credentials are consulted: the pathname must be sanitized + /// whether or not the credential is adopted, or the secret ships in the request path. + /// + /// + fn parse_embedded_auth<'a>(url: &mut URL<'a>) -> EmbeddedAuth<'a> { + let mut out = EmbeddedAuth::default(); + let mut pathname: &'a [u8] = url.pathname; + + // Right to left: the credentials are appended after the path. + loop { + let Some((start, marker, opt)) = last_embedded_marker(pathname) else { + // No known marker left; a misspelt one at the end still must not ship in the path. + let Some(start) = trailing_misspelt_marker(pathname) else { + break; + }; + pathname = &pathname[..start]; + if pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { + pathname = &pathname[..pathname.len() - 1]; + } + continue; + }; + let mut value = &pathname[start + marker.len()..]; + // The slash that closes the URL (`/:_authToken=S/`) is not part of the value. + if let Some(unslashed) = value.strip_suffix(b"/") { + value = unslashed; + } + // An empty marker supplies no credential; it must not end the scan or shadow + // a credential from `.npmrc`. + if !out.terminal && !value.is_empty() { + *out.slot(opt) = Some(value); + out.terminal = matches!(opt, EmbeddedOpt::Token | EmbeddedOpt::Auth); + } + + // A `/=` segment leaves the slash that closes the path; yarn's `:=` + // is written after that slash, so the slash goes with it. + pathname = if marker[0] == b'/' { + &pathname[..start + 1] + } else { + &pathname[..start] + }; + if marker[0] == b':' && pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { + pathname = &pathname[..pathname.len() - 1]; + } + } + + url.pathname = pathname; + url.path = pathname; + out } impl Scope { @@ -322,6 +443,25 @@ pub mod registry { bun_semver::semver_string::Builder::string_hash(str) } + /// The `Authorization` header as `(scheme, value)`, unallocated; a token + /// outranks a Basic credential, as in npm. + pub fn authorization_parts(&self) -> Option<(&'static [u8], &[u8])> { + if !self.token.is_empty() { + Some((b"Bearer ", &self.token)) + } else if !self.auth.is_empty() { + Some((b"Basic ", &self.auth)) + } else { + None + } + } + + /// The `Authorization` value for this registry, or `None` when it has no + /// credentials. Raw bytes: a credential need not be UTF-8. + pub fn authorization(&self) -> Option> { + self.authorization_parts() + .map(|(scheme, value)| [scheme, value].concat()) + } + /// Stores the WHATWG serialization (the base `bun_url::join` resolves against) so same-origin checks, concatenated tarball URLs and `url_hash` agree with the requests; credentials must already be split off. pub fn set_url(&mut self, href: Box<[u8]>) { self.url = URL::from_string(&bun_core::String::borrow_utf8(&href)) @@ -329,6 +469,19 @@ pub mod registry { self.url_hash = Self::hash(strings::without_trailing_slash(self.url.href())); } + /// Whether requests to this scope carry an `Authorization` header. + pub(crate) fn has_credentials(&self) -> bool { + !self.token.is_empty() || !self.auth.is_empty() + } + + /// Take `other`'s credentials, leaving this scope's registry URL as is. + pub(crate) fn copy_credentials_from(&mut self, other: &Scope) { + self.token.clone_from(&other.token); + self.auth.clone_from(&other.auth); + self.user.clone_from(&other.user); + self.credentials_from_url = other.credentials_from_url; + } + pub(crate) fn get_name(name: &[u8]) -> &[u8] { if name.is_empty() || name[0] != b'@' { return name; @@ -362,124 +515,64 @@ pub mod registry { // of parsing. The final href is moved into `Scope.url: OwnedURL` // (owned `Box<[u8]>`). let registry_url: Box<[u8]> = core::mem::take(&mut registry.url); + let registry_auth: Box<[u8]> = core::mem::take(&mut registry.auth); let mut url = URL::parse(®istry_url); let mut auth: &[u8] = b""; let mut user: &mut [u8] = &mut []; - let mut needs_normalize = false; // Backing storage for `user`/`auth` when synthesized from // username:password. let mut output_buf_owned: Box<[u8]> = Box::default(); + let original_pathname_len = url.pathname.len(); + let embedded = parse_embedded_auth(&mut url); + let needs_normalize = url.pathname.len() != original_pathname_len; + if registry.token.is_empty() { 'outer: { - if registry.password.is_empty() { - let mut pathname: &[u8] = url.pathname; - let mut needs_to_check_slash = true; - while let Some(colon) = strings::last_index_of_char(pathname, b':') { - let mut segment = &pathname[colon + 1..]; - pathname = &pathname[..colon]; - needs_to_check_slash = false; - needs_normalize = true; - if pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { - pathname = &pathname[..pathname.len() - 1]; - } - - let Some(eql_i) = strings::index_of_char(segment, b'=') else { - continue; - }; - let value = &segment[eql_i as usize + 1..]; - segment = &segment[..eql_i as usize]; - - // https://github.com/yarnpkg/yarn/blob/6db39cf0ff684ce4e7de29669046afb8103fce3d/src/registries/npm-registry.js#L364 - // Bearer Token - if segment == b"_authToken" { - registry.token = value.into(); - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"_auth" { - auth = value; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"username" { - registry.username = value.into(); - continue; - } - - if segment == b"_password" { - registry.password = value.into(); - continue; - } + if registry.password.is_empty() && registry_auth.is_empty() { + if let Some(token) = embedded.token { + registry.token = token.into(); } + if let Some(embedded_auth) = embedded.auth { + auth = embedded_auth; + } + if let Some(username) = embedded.username { + registry.username = username.into(); + } + if let Some(password) = embedded.password { + registry.password = password.into(); + } + if embedded.terminal { + break 'outer; + } + } - // In this case, there is only one. - if needs_to_check_slash { - if let Some(last_slash) = strings::last_index_of_char(pathname, b'/') { - let remain = &pathname[last_slash + 1..]; - if let Some(eql_i) = strings::index_of_char(remain, b'=') { - let segment = &remain[..eql_i as usize]; - let value = &remain[eql_i as usize + 1..]; - - // https://github.com/yarnpkg/yarn/blob/6db39cf0ff684ce4e7de29669046afb8103fce3d/src/registries/npm-registry.js#L364 - // Bearer Token - if segment == b"_authToken" { - registry.token = value.into(); - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"_auth" { - auth = value; - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"username" { - registry.username = value.into(); - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"_password" { - registry.password = value.into(); - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } + // npm forwards `_auth` verbatim; the decode only derives `user` for `bun pm whoami`. + if !registry_auth.is_empty() { + auth = ®istry_auth; + let decode_len = bun_base64::decode_len(®istry_auth); + let mut decoded = vec![0u8; decode_len].into_boxed_slice(); + let result = bun_base64::decode(&mut decoded[..], ®istry_auth); + if result.is_successful() { + let count = result.count; + // A blank username or password is a real pattern: no identity then. + if let Some(colon_idx) = + strings::index_of_char_usize(&decoded[..count], b':') + { + if colon_idx > 0 && colon_idx + 1 < count { + output_buf_owned = decoded; + user = &mut output_buf_owned[..count]; } } } - - // The pathname write-back is applied at every `break 'outer` - // above and once more here at fallthrough. - url.pathname = pathname; - url.path = pathname; + break 'outer; } registry.username = env.get_auto(®istry.username).into(); registry.password = env.get_auto(®istry.password).into(); - if !registry.username.is_empty() - && !registry.password.is_empty() - && auth.is_empty() - { + if !registry.username.is_empty() && !registry.password.is_empty() { let combo_len = registry.username.len() + registry.password.len() + 1; let total = combo_len + bun_core::base64::standard_encoder_calc_size(combo_len); @@ -508,9 +601,6 @@ pub mod registry { let final_href: Box<[u8]> = if needs_normalize { url.href_without_auth() } else { - // reshaped for borrowck — `url` (borrowing - // `registry_url`) is dead on this branch (every path that - // mutated `url.pathname` also set `needs_normalize = true`). registry_url }; @@ -519,6 +609,7 @@ pub mod registry { token: registry.token, auth, user, + credentials_from_url: registry.credentials_from_url, ..Default::default() }; scope.set_url(final_href); @@ -529,6 +620,113 @@ pub mod registry { // Keys are pre-hashed (`Scope::hash`), so don't re-hash them. pub type Map = HashMap>; + /// One `.npmrc` credential key with the credential it collapsed to, matched + /// against registry URLs with npm's walk (`regFromURI`): build the URL's own key, + /// then try it and every shorter key down to the bare host. Keys carry no scheme, + /// so a key applies to `http` and `https` alike. + pub(crate) struct UrlAuth { + /// npm's config key, already normalized by `bun_ini` (`host/path/`). + key: Box<[u8]>, + /// Only the credential fields are meaningful; `url` is empty. + credentials: Scope, + } + + impl UrlAuth { + pub(crate) fn from_api( + api: &api::NpmUrlAuth, + env: &mut DotEnv, + ) -> Result, AllocError> { + let credentials = Scope::from_api(b"", api.credentials.clone(), env)?; + if !credentials.has_credentials() { + return Ok(None); + } + Ok(Some(UrlAuth { + key: api.key.clone(), + credentials, + })) + } + + pub(crate) fn find_entry<'a>(list: &'a [UrlAuth], url: &URL) -> Option<&'a UrlAuth> { + if list.is_empty() { + return None; + } + UrlAuth::find_key(list, &bun_ini::RegistryKey::from_url(url.href)) + } + + /// For a URL that is already a WHATWG serialisation (a tarball after `normalize_tarball_url`). + pub(crate) fn find_entry_serialized<'a>( + list: &'a [UrlAuth], + url: &URL, + ) -> Option<&'a UrlAuth> { + if list.is_empty() { + return None; + } + UrlAuth::find_key(list, &bun_ini::RegistryKey::from_parsed(url)) + } + + fn find_key<'a>(list: &'a [UrlAuth], key: &bun_ini::RegistryKey) -> Option<&'a UrlAuth> { + key.walk() + .find_map(|key| list.iter().find(|entry| *entry.key == *key)) + } + + pub(crate) fn credentials(&self) -> &Scope { + &self.credentials + } + + pub(crate) fn key(&self) -> &[u8] { + &self.key + } + } + + /// `dist.tarball` as npm's `new URL()` serialises it, or `None` for a URL one parse cannot settle. + pub fn normalize_tarball_url(raw: &[u8]) -> Option> { + if raw.iter().any(|&b| b <= 0x20 || b == 0x7f) { + return None; + } + let href = URL::from_string(&bun_core::String::borrow_utf8(raw)) + .ok()? + .into_href(); + if hides_dot_segment(query_free_path(&URL::parse(&href))) { + return None; + } + Some(href) + } + + /// A `.` or `..` segment once `%2f` and `%5c` are read as separators. + fn hides_dot_segment(path: &[u8]) -> bool { + let mut start = 0; + let mut i = 0; + let mut found = false; + let mut check = |segment: &[u8]| { + const DOTS: [&[u8]; 6] = [b".", b"..", b"%2e", b"%2e.", b".%2e", b"%2e%2e"]; + found |= DOTS.iter().any(|dot| segment.eq_ignore_ascii_case(dot)); + }; + while i < path.len() { + let encoded_separator = path[i] == b'%' + && i + 2 < path.len() + && matches!( + &path[i + 1..i + 3], + [b'2', b'f' | b'F'] | [b'5', b'c' | b'C'] + ); + if path[i] == b'/' || encoded_separator { + check(&path[start..i]); + i += if encoded_separator { 3 } else { 1 }; + start = i; + } else { + i += 1; + } + } + check(&path[start..]); + found + } + + /// `url.pathname` without its query: `url.path` is query-free too, but collapses a + /// one-byte path such as `/r/` to `/`. + pub(crate) fn query_free_path<'a>(url: &URL<'a>) -> &'a [u8] { + let pathname = url.pathname; + &pathname[..strings::index_of_char_usize(pathname, b'?').unwrap_or(pathname.len())] + } + pub(crate) enum PackageVersionResponse { Cached(PackageManifest), Fresh(PackageManifest), diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index 2bcce6c2e184..b65511b56cbb 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -29,7 +29,7 @@ impl IniTestingAPIs { use bun_ast::{Log, Source}; use bun_core::String as BunString; use bun_dotenv as dotenv; - use bun_ini::{RegistryAuth, load_npmrc}; + use bun_ini::load_npmrc; use bun_install::npm::Registry; let arg = frame.argument(0); @@ -80,17 +80,43 @@ impl IniTestingAPIs { }; let mut install = Box::new(BunInstall::default()); - let mut configs: Vec = Vec::new(); - if load_npmrc(&mut install, env, &mut log, &source, &mut configs).is_err() { + if load_npmrc(&mut install, env, &mut log, &source).is_err() { return log.to_js(global, format_args!("error")); } + // The package manager applies `url_auth` in `Options::load`; the same walk + // here reports what a request to the default registry would carry. + let default_url: Box<[u8]> = match install.default_registry.as_ref() { + Some(registry) => registry.url.clone(), + None => Registry::DEFAULT_URL.as_bytes().into(), + }; + let found = bun_ini::RegistryKey::from_url(&default_url) + .walk() + .find_map(|key| install.url_auth.iter().find(|entry| *entry.key == *key)); + if let Some(found) = found { + let registry = install + .default_registry + .get_or_insert_with(|| bun_api::NpmRegistry { + url: default_url.clone(), + ..Default::default() + }); + if !registry.has_credentials() || registry.credentials_from_url { + registry.token = Box::default(); + registry.auth = Box::default(); + registry.username = Box::default(); + registry.password = Box::default(); + registry.token.clone_from(&found.credentials.token); + registry.auth.clone_from(&found.credentials.auth); + registry.username.clone_from(&found.credentials.username); + registry.password.clone_from(&found.credentials.password); + } + } let ( default_registry_url, default_registry_token, default_registry_username, default_registry_password, - default_registry_email, + default_registry_auth, ) = 'brk: { let Some(default_registry) = install.default_registry.as_ref() else { break 'brk ( @@ -107,7 +133,7 @@ impl IniTestingAPIs { BunString::from_bytes(&default_registry.token), BunString::from_bytes(&default_registry.username), BunString::from_bytes(&default_registry.password), - BunString::from_bytes(&default_registry.email), + BunString::from_bytes(&default_registry.auth), ) }; // Rust has no field reflection; mirror struct-literal object creation with @@ -119,7 +145,7 @@ impl IniTestingAPIs { default_registry_token: BunString, default_registry_username: BunString, default_registry_password: BunString, - default_registry_email: BunString, + default_registry_auth: BunString, } impl bun_jsc::js_object::PojoFields for Pojo { const FIELD_COUNT: usize = 5; @@ -145,8 +171,8 @@ impl IniTestingAPIs { self.default_registry_password.to_js(global)?, )?; put( - b"default_registry_email", - self.default_registry_email.to_js(global)?, + b"default_registry_auth", + self.default_registry_auth.to_js(global)?, )?; Ok(()) } @@ -156,7 +182,7 @@ impl IniTestingAPIs { default_registry_token, default_registry_username, default_registry_password, - default_registry_email, + default_registry_auth, }; Ok(bun_jsc::JSObject::create(&pojo, global)?.to_js()) } diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index 22e987d1d11b..b221d5feac82 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -150,8 +150,14 @@ pub mod api { pub password: Box<[u8]>, /// token pub token: Box<[u8]>, - /// email - pub email: Box<[u8]>, + /// `.npmrc`'s `_auth`, verbatim. npm never decodes it, so neither may we. + /// Not read from `bunfig.toml`; it only carries the value to `Scope::from_api`. + pub auth: Box<[u8]>, + /// The registry was declared by a `.npmrc` `registry=` line. A credential inside + /// that URL (userinfo, or a `:_authToken=` segment) is then the weakest source, + /// which any `.npmrc` line for the key replaces; a URL typed into `bunfig.toml`, + /// the environment or the command line keeps its credential. + pub credentials_from_url: bool, } impl NpmRegistry { @@ -175,7 +181,10 @@ pub mod api { } pub fn has_credentials(&self) -> bool { - !self.token.is_empty() || !self.username.is_empty() || !self.password.is_empty() + !self.token.is_empty() + || !self.auth.is_empty() + || !self.username.is_empty() + || !self.password.is_empty() } } @@ -199,6 +208,18 @@ pub mod api { /// same type. pub use bun_install_types::NodeLinker::{NodeLinker, PnpmMatcher}; + /// The credential one `.npmrc` key resolved to (`//host/path/:_authToken=...`, + /// `:_auth=`, or `:username=` + `:_password=`), kept past config loading so the + /// package manager can match it against a registry URL it only learns later + /// (`--registry`, `$NPM_CONFIG_REGISTRY`, a bunfig registry without credentials). + #[derive(Clone, Debug, Default)] + pub struct NpmUrlAuth { + /// npm's config key as written between `//` and `:=`, e.g. `host/path/`. + pub key: Box<[u8]>, + /// Only `token`, `auth`, `username` and `password` are set; `url` stays empty. + pub credentials: NpmRegistry, + } + /// Full field set. /// `Default` is every field `None`/empty. /// @@ -210,6 +231,10 @@ pub mod api { pub default_registry: Option, /// scoped pub scoped: Option, + /// One entry per `.npmrc` credential key that carries a complete credential, + /// after every file was collapsed into one map. `Options::load` walks it for + /// every registry that ended up without credentials. + pub url_auth: Vec, /// lockfile_path pub lockfile_path: Option>, /// save_lockfile_path diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index ce1235df90c2..1b9f441fa253 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -342,8 +342,7 @@ fn build_dependency_tree( struct AuditRegistry { href: Box<[u8]>, url_hash: u64, - token: Box<[u8]>, - auth: Box<[u8]>, + authorization: Option>, is_default: bool, } @@ -352,8 +351,7 @@ impl AuditRegistry { AuditRegistry { href: Box::<[u8]>::from(strings::without_trailing_slash(scope.url.href())), url_hash: scope.url_hash, - token: scope.token.clone(), - auth: scope.auth.clone(), + authorization: scope.authorization(), is_default, } } @@ -706,27 +704,15 @@ fn send_audit_request( headers.count(b"accept", b"application/json"); headers.count(b"content-type", b"application/json"); headers.count(b"content-encoding", b"gzip"); - if !registry.token.is_empty() { - headers.count(b"authorization", b""); - headers.content.cap += b"Bearer ".len() + registry.token.len(); - } else if !registry.auth.is_empty() { - headers.count(b"authorization", b""); - headers.content.cap += b"Basic ".len() + registry.auth.len(); + if let Some(authorization) = ®istry.authorization { + headers.count(b"authorization", authorization); } headers.allocate()?; headers.append(b"accept", b"application/json"); headers.append(b"content-type", b"application/json"); headers.append(b"content-encoding", b"gzip"); - if !registry.token.is_empty() { - headers.append_fmt( - b"authorization", - format_args!("Bearer {}", BStr::new(®istry.token)), - ); - } else if !registry.auth.is_empty() { - headers.append_fmt( - b"authorization", - format_args!("Basic {}", BStr::new(®istry.auth)), - ); + if let Some(authorization) = ®istry.authorization { + headers.append(b"authorization", authorization); } let mut url_str: Vec = Vec::new(); diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 6a0a3bf9c792..229aece3e09e 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -721,6 +721,7 @@ fn fetch_registry_tree( pm, scope, URL::parse(manifest_url), + true, // The abbreviated packument has versions + dist, all this needs; full ones run to tens of MB. b"application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*", Some((name, version)), @@ -820,10 +821,17 @@ fn fetch_registry_tree( }, )?; } + // The same serialisation `bun install` requests and keys `.npmrc` lines by. + let (tarball_url, normalized) = + match bun_install::npm::registry::normalize_tarball_url(&tarball_url) { + Some(normalized) => (normalized, true), + None => (tarball_url.into_boxed_slice(), false), + }; let tarball = registry_get( pm, scope, URL::parse(&tarball_url), + normalized, b"application/octet-stream", None, )?; @@ -848,40 +856,27 @@ fn registry_get( pm: &PackageManager, scope: &npm::registry::Scope, url: URL<'_>, + normalized: bool, accept: &[u8], for_error: Option<(&[u8], &[u8])>, ) -> Result { let mut headers = http::HeaderBuilder::default(); headers.count(b"Accept", accept); - // `dist.tarball` is registry-controlled; credentials only go back to the registry's own origin. - let same_origin = { - let registry = scope.url.url(); - url.protocol == registry.protocol - && url.hostname == registry.hostname - && url.get_port_auto() == registry.get_port_auto() - }; - let (token, auth): (&[u8], &[u8]) = if same_origin { - (&scope.token, &scope.auth) - } else { - (b"", b"") + // The same rule as `bun install` decides which credentials, if any, follow the URL. + let authorization = match pm.options.tarball_credentials(scope, &url, normalized) { + Some(credentials) => credentials.authorization_parts(), + None => None, }; - if !token.is_empty() { + if let Some((scheme, value)) = authorization { headers.count(b"Authorization", b""); - headers.content.cap += b"Bearer ".len() + token.len(); - headers.count(b"npm-auth-type", b"legacy"); - } else if !auth.is_empty() { - headers.count(b"Authorization", b""); - headers.content.cap += b"Basic ".len() + auth.len(); + headers.content.cap += scheme.len() + value.len(); headers.count(b"npm-auth-type", b"legacy"); } headers.allocate()?; headers.append(b"Accept", accept); // Raw-byte append: a non-UTF-8 token through Display would grow past the reserved count. - if !token.is_empty() { - headers.append_bytes_value(b"Authorization", b"Bearer ", token); - headers.append(b"npm-auth-type", b"legacy"); - } else if !auth.is_empty() { - headers.append_bytes_value(b"Authorization", b"Basic ", auth); + if let Some((scheme, value)) = authorization { + headers.append_bytes_value(b"Authorization", scheme, value); headers.append(b"npm-auth-type", b"legacy"); } diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index 066c8d55bcb2..d91a4a7a5bc8 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -95,27 +95,16 @@ pub(crate) fn view( ); let url = URL::parse(url_slice); + let authorization = scope.authorization(); let mut headers = http::HeaderBuilder::default(); headers.count(b"Accept", b"application/json"); - if !scope.token.is_empty() { - headers.count(b"Authorization", b""); - headers.content.cap += b"Bearer ".len() + scope.token.len(); - } else if !scope.auth.is_empty() { - headers.count(b"Authorization", b""); - headers.content.cap += b"Basic ".len() + scope.auth.len(); + if let Some(authorization) = &authorization { + headers.count(b"Authorization", authorization); } headers.allocate()?; headers.append(b"Accept", b"application/json"); - if !scope.token.is_empty() { - headers.append_fmt( - b"Authorization", - format_args!("Bearer {}", BStr::new(&*scope.token)), - ); - } else if !scope.auth.is_empty() { - headers.append_fmt( - b"Authorization", - format_args!("Basic {}", BStr::new(&*scope.auth)), - ); + if let Some(authorization) = &authorization { + headers.append(b"Authorization", authorization); } let mut response_buf = MutableString::init(2048)?; diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 888310142752..ad7bf5a9e983 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -771,40 +771,19 @@ impl PublishCommand { return false; }; + let authorization = registry.authorization(); let mut headers = http::HeaderBuilder::default(); headers.count(b"accept", b"application/json"); - - let mut auth_buf: Vec = Vec::new(); - - if !registry.token.is_empty() { - if write!(&mut auth_buf, "Bearer {}", bstr::BStr::new(®istry.token)).is_err() { - return false; - } - headers.count(b"authorization", &auth_buf); - } else if !registry.auth.is_empty() { - if write!(&mut auth_buf, "Basic {}", bstr::BStr::new(®istry.auth)).is_err() { - return false; - } - headers.count(b"authorization", &auth_buf); + if let Some(authorization) = &authorization { + headers.count(b"authorization", authorization); } if headers.allocate().is_err() { return false; } headers.append(b"accept", b"application/json"); - - if !registry.token.is_empty() { - auth_buf.clear(); - if write!(&mut auth_buf, "Bearer {}", bstr::BStr::new(®istry.token)).is_err() { - return false; - } - headers.append(b"authorization", &auth_buf); - } else if !registry.auth.is_empty() { - auth_buf.clear(); - if write!(&mut auth_buf, "Basic {}", bstr::BStr::new(®istry.auth)).is_err() { - return false; - } - headers.append(b"authorization", &auth_buf); + if let Some(authorization) = &authorization { + headers.append(b"authorization", authorization); } let mut req = http::AsyncHTTP::init_sync( @@ -1887,19 +1866,14 @@ impl PublishCommand { b"legacy" }; let ci_name = ci::detect_ci_name(); + let authorization = registry.authorization(); { headers.count(b"accept", b"*/*"); headers.count(b"accept-encoding", b"gzip,deflate"); - if !registry.token.is_empty() { - let _ = write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)); - headers.count(b"authorization", &**print_buf); - print_buf.clear(); - } else if !registry.auth.is_empty() { - let _ = write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)); - headers.count(b"authorization", &**print_buf); - print_buf.clear(); + if let Some(authorization) = &authorization { + headers.count(b"authorization", authorization); } if maybe_json_len.is_some() { @@ -1943,14 +1917,8 @@ impl PublishCommand { headers.append(b"accept", b"*/*"); headers.append(b"accept-encoding", b"gzip,deflate"); - if !registry.token.is_empty() { - let _ = write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)); - headers.append(b"authorization", &**print_buf); - print_buf.clear(); - } else if !registry.auth.is_empty() { - let _ = write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)); - headers.append(b"authorization", &**print_buf); - print_buf.clear(); + if let Some(authorization) = &authorization { + headers.append(b"authorization", authorization); } if maybe_json_len.is_some() { diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index d33de89ed240..b0a2dd12a024 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -4422,16 +4422,7 @@ where } pub(crate) fn get_remote_socket_info(&self) -> Option { - let resp = self.live_resp()?; - // `AnyResponse::get_remote_socket_info` returns the uws_sys - // variant; convert to the owned `bun_uws::SocketAddress`. - // SAFETY: FFI handle - let info = resp.get_remote_socket_info()?; - Some(uws::SocketAddress { - ip: info.ip().to_vec().into_boxed_slice(), - port: info.port, - is_ipv6: info.is_ipv6, - }) + remote_socket_info(self.live_resp()?) } pub(crate) fn set_timeout(&self, seconds: c_uint) -> bool { @@ -4448,6 +4439,16 @@ where } } +/// `AnyResponse::get_remote_socket_info` returns the uws_sys variant; convert to the owned `bun_uws::SocketAddress`. +fn remote_socket_info(resp: uws::AnyResponse) -> Option { + let info = resp.get_remote_socket_info()?; + Some(uws::SocketAddress { + ip: info.ip().to_vec().into_boxed_slice(), + port: info.port, + is_ipv6: info.is_ipv6, + }) +} + const MAX_REQUEST_BODY_PREALLOCATE_LENGTH: usize = 1024 * 256; const MUX_REQUEST_BODY_PREALLOCATE_LENGTH: usize = 64 * 1024; diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index b0197c013fb3..0c12936d6949 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -890,6 +890,50 @@ describe("`bun audit` with a secret in the registry URL", () => { }); }); +// `bun audit` builds its header from the same credentials as `bun install`: a +// `.npmrc` line on the registry's key, `_auth` sent as written. +describe("`bun audit` with a credential from a .npmrc line", () => { + const cases: Array<[line: string, header: string]> = [ + [":_authToken=npm_audit_token", "Bearer npm_audit_token"], + [":_auth=!!opaque-blob!!", "Basic !!opaque-blob!!"], + ]; + + test.each(cases)("%s reaches the bulk advisory endpoint as %s", async (line, header) => { + const seen: Array = []; + using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push(req.headers.get("authorization")); + return Response.json({}); + }, + }); + const host = `127.0.0.1:${registry.port}`; + const dependencies = { "a-dep": "1.0.4" }; + using dir = tempDir("audit-npmrc-line-", { + "package.json": JSON.stringify({ name: "app", dependencies }), + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + workspaces: { "": { name: "app", dependencies } }, + packages: { "a-dep": ["a-dep@1.0.4", "", {}, ""] }, + }), + ".npmrc": `registry=http://${host}/\n//${host}/${line}\n`, + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = spawn({ + cmd: [bunExe(), "audit"], + cwd: String(dir), + env: { ...bunEnv, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(seen).toEqual([header]); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); + }); +}); + describe("`bun audit --prod`", () => { // pnpm#13605: an optional peer that only a devDependency brought in is not a production dependency. test.concurrent("bun audit --prod skips a dev-only optional peer of a production package", async () => { diff --git a/test/cli/install/bun-info.test.ts b/test/cli/install/bun-info.test.ts index 04b1d9fe80b7..0a191a576b48 100644 --- a/test/cli/install/bun-info.test.ts +++ b/test/cli/install/bun-info.test.ts @@ -342,6 +342,64 @@ describe.concurrent("bun info", () => { expect(code).toBe(0); }); + it("sends Basic <_auth> on the manifest request when only .npmrc _auth is configured", async () => { + const basic = Buffer.from("alice:hunter2").toString("base64"); + const registryPath = "/"; + const paths: string[] = []; + const authorizations: (string | null)[] = []; + + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req, server) { + paths.push(new URL(req.url).pathname); + authorizations.push(req.headers.get("authorization")); + return Response.json({ + "name": "pkg", + "dist-tags": { latest: "1.0.0" }, + "versions": { + "1.0.0": { + name: "pkg", + version: "1.0.0", + dist: { + tarball: `http://127.0.0.1:${server.port}${registryPath}pkg/-/pkg-1.0.0.tgz`, + shasum: "0000000000000000000000000000000000000000", + }, + }, + }, + }); + }, + }); + + const host = `127.0.0.1:${registry.port}`; + const testDir = tempDirWithFiles("view-auth", { + ".npmrc": `registry=http://${host}${registryPath}\n//${host}/:_auth=${basic}\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + // An empty home: the developer's own `.npmrc` declares a `registry=` that + // would replace the one under test. + "home/.gitkeep": "", + }); + const home = join(testDir, "home"); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: testDir, + env: { ...bunEnv, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + }); + const [output, error, code] = await Promise.all([stdout.text(), stderr.text(), exited]); + + expect({ paths, authorizations, output, error }).toEqual({ + paths: [`${registryPath}pkg`], + authorizations: [`Basic ${basic}`], + output: "1.0.0\n", + error: "", + }); + expect(code).toBe(0); + }); + it("should handle dist-tags like latest", async () => { const testDir = await setupTest(); const { output, error, code } = await runCommand([bunExe(), "pm", "view", "fs@latest"], testDir); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index d85d714fead9..1aea9a930851 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -749,6 +749,962 @@ describe.concurrent("bun-install", () => { }); }); + // https://github.com/oven-sh/bun/issues/30311 + // npm resolves `.npmrc` credentials by walking UP the registry URL's path + // segments and applying the LONGEST matching ancestor. A bare string prefix + // (`/projects/12` for `/projects/123/...`) is NOT an ancestor and must not match. + describe(".npmrc auth resolves by path-segment ancestor", () => { + const scope = "myorg"; + const registryPath = "/api/v4/projects/123/packages/npm/"; + + type ProbeOptions = { + /** `@myorg:registry=` (the default) or the unscoped `registry=` line. */ + registry?: "scoped" | "default"; + /** Userinfo embedded in the registry URL, e.g. `"user:pass@"`. */ + userinfo?: string; + /** Declare the registry in `bunfig.toml` with this token instead of in `.npmrc`. */ + bunfigToken?: string; + /** Declare the registry in `bunfig.toml` with these credentials instead of in `.npmrc`. */ + bunfigBasic?: { username: string; password: string }; + /** Declare the registry in `bunfig.toml` with no credentials, leaving them to `.npmrc`. */ + bunfigBare?: true; + /** Lines for `$HOME/.npmrc`, which npm/Bun read before the project's `.npmrc`. */ + homeNpmrc?: (host: string) => string; + /** The registry's path. Both the registry line and the manifest request use it. */ + path?: string; + }; + + // Runs `bun install` against a local registry mounted at `registryPath` and + // returns the `Authorization` header it received (or null). + async function probeAuthorization( + authLines: (host: string) => string, + { + registry: registryKind = "scoped", + userinfo = "", + bunfigToken, + bunfigBasic, + bunfigBare, + homeNpmrc, + path: regPath = registryPath, + }: ProbeOptions = {}, + ): Promise { + const scoped = registryKind === "scoped"; + const depName = scoped ? `@${scope}/pkg` : "pkg"; + const manifestPath = `${regPath}${scoped ? `@${scope}%2fpkg` : "pkg"}`; + const authorizations: (string | null)[] = []; + const paths: string[] = []; + + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + paths.push(new URL(req.url).pathname); + authorizations.push(req.headers.get("authorization")); + return new Response("not found", { status: 404 }); + }, + }); + + const host = `127.0.0.1:${registry.port}`; + const registryUrl = `http://${userinfo}${host}${regPath}`; + const registryLine = scoped ? `@${scope}:registry=${registryUrl}` : `registry=${registryUrl}`; + + // A `registry=` line in `.npmrc` replaces the registry it names, discarding the + // credentials `bunfig.toml` gave it, so the two spellings are exclusive. + const inBunfig = bunfigToken !== undefined || bunfigBasic !== undefined || bunfigBare === true; + const files: Record = { + ".npmrc": `${inBunfig ? "" : `${registryLine}\n`}${authLines(host)}\n`, + "package.json": JSON.stringify({ + name: "probe", + version: "0.0.0", + dependencies: { [depName]: "1.0.0" }, + }), + // `HOME` points here, not at the project dir: pointing it at the project would + // load the same `.npmrc` twice and hide cross-file resolution bugs. + ...(homeNpmrc ? { "home/.npmrc": `${homeNpmrc(host)}\n` } : { "home/.gitkeep": "" }), + }; + if (inBunfig) { + const creds = + bunfigToken !== undefined + ? { token: bunfigToken } + : bunfigBasic !== undefined + ? { username: bunfigBasic.username, password: bunfigBasic.password } + : {}; + const registryEntry = { url: registryUrl, ...creds }; + files["bunfig.toml"] = Bun.TOML.stringify( + scoped ? { install: { scopes: { [scope]: registryEntry } } } : { install: { registry: registryEntry } }, + ); + } + + using dir = tempDir("npmrc-auth-ancestor", files); + const home = join(String(dir), "home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + // An empty home: the developer's own `.npmrc` declares a `registry=` that + // would replace the one under test. + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The manifest request must actually have reached our registry, otherwise + // the assertions below would be vacuous. + expect({ paths, saw404: stderr.includes("404"), startedInstall: stdout.includes("bun install") }).toEqual({ + paths: [manifestPath], + saw404: true, + startedInstall: true, + }); + expect(exitCode).not.toBe(0); + + return authorizations[0]!; + } + + const token = "walkup-secret-token"; + const b64 = (s: string) => Buffer.from(s).toString("base64"); + + // Matrix measured against npm 10.9.3 / 11.15.0 for registry pathname + // `/api/v4/projects/123/packages/npm/`. + const matrix: Array<[name: string, confPath: string, expected: string | null]> = [ + ["host root", "/", `Bearer ${token}`], + ["shallow ancestor", "/api/", `Bearer ${token}`], + ["mid ancestor", "/api/v4/projects/", `Bearer ${token}`], + ["exact match with trailing slash", registryPath, `Bearer ${token}`], + ["exact match without trailing slash", registryPath.slice(0, -1), `Bearer ${token}`], + // SECURITY: `/api/v4/projects/12` is a string prefix of the registry path + // but not a path-segment ancestor. A `startsWith` implementation leaks + // project 123's credentials to whoever controls project 12. + ["string prefix, not an ancestor (trailing slash)", "/api/v4/projects/12/", null], + ["string prefix, not an ancestor (no trailing slash)", "/api/v4/projects/12", null], + ["unrelated sibling path", "/api/v4/projects/123/packages/other/", null], + ["deeper than the registry path", `${registryPath}deeper/`, null], + ]; + + it.each(matrix)("%s", async (_name, confPath, expected) => { + const auth = await probeAuthorization(host => `//${host}${confPath}:_authToken=${token}`); + expect(auth).toBe(expected); + }); + + // Longest ancestor wins, independent of the order the lines appear in. + it("longest match wins: root then deep", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:_authToken=DEEP`, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + it("longest match wins: deep then root", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:_authToken=DEEP\n//${host}/:_authToken=ROOT`, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + it("longest match wins: mid ancestor beats root", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/:_authToken=MID\n//${host}/:_authToken=ROOT`); + expect(auth).toBe("Bearer MID"); + }); + + it("longest match wins: non-ancestor deeper line does not shadow the root line", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}/api/v4/projects/12/:_authToken=ATTACKER`, + ); + expect(auth).toBe("Bearer ROOT"); + }); + + // Ancestor matching governs the whole config item, not just `_authToken`. + it("walks up for _auth", async () => { + const basic = Buffer.from("linus:verysecure").toString("base64"); + const auth = await probeAuthorization(host => `//${host}/:_auth=${basic}`); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("walks up for username + _password", async () => { + const password = Buffer.from("verysecure").toString("base64"); + const auth = await probeAuthorization(host => `//${host}/:username=gandalf\n//${host}/:_password=${password}`); + expect(auth).toBe(`Basic ${Buffer.from("gandalf:verysecure").toString("base64")}`); + }); + + it("sends no Authorization header when no config item matches the host", async () => { + const auth = await probeAuthorization(() => `//other.example.com/:_authToken=${token}`); + expect(auth).toBe(null); + }); + + // Measured against npm 10.9.3 and 11.15.0: npm picks ONE config path per + // registry — the deepest ancestor carrying `_authToken`, `_auth`, or a complete + // `username` + `_password` pair — and reads every credential from that path + // alone. Options are never resolved independently of each other. Within that one + // path npm's `Auth` ctor picks by precedence, not file order: + // `_authToken` > `_auth` > `username` + `_password`. + describe("credentials resolve per config path, not per option", () => { + const password = Buffer.from("verysecure").toString("base64"); + const basic = Buffer.from("gandalf:verysecure").toString("base64"); + const frodo = Buffer.from("frodo:onering").toString("base64"); + + it("a split username/_password pair authenticates with neither", async () => { + const auth = await probeAuthorization( + host => `//${host}/:username=gandalf\n//${host}${registryPath}:_password=${password}`, + ); + expect(auth).toBe(null); + }); + + it("a split _password/username pair authenticates with neither", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe(null); + }); + + it("a deeper username + _password shadows a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => + `//${host}/:_authToken=ROOT\n//${host}${registryPath}:username=gandalf\n//${host}${registryPath}:_password=${password}`, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("a deeper _authToken shadows a shallower username + _password", async () => { + const auth = await probeAuthorization( + host => + `//${host}${registryPath}:_authToken=DEEP\n//${host}/:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + it("a deeper _auth shadows a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:_auth=${basic}`, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + + // Same path, both credentials: npm's `Auth` ctor is + // `if (token) … else if (auth) … else if (username && password)`, so `_auth` + // wins no matter which line the file lists first. + it("_auth beats username + _password at the same path: _auth first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_auth=${frodo}\n//${host}/:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe(`Basic ${frodo}`); + }); + + it("_auth beats username + _password at the same path: username first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:username=gandalf\n//${host}/:_password=${password}\n//${host}/:_auth=${frodo}`, + ); + expect(auth).toBe(`Basic ${frodo}`); + }); + + // `if (token)` comes before `else if (auth)`, so `_authToken` wins over + // `_auth` at the same path regardless of line order. + it("_authToken beats _auth at the same path: _authToken first", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=TOKEN\n//${host}/:_auth=${frodo}`); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("_authToken beats _auth at the same path: _auth first", async () => { + const auth = await probeAuthorization(host => `//${host}/:_auth=${frodo}\n//${host}/:_authToken=TOKEN`); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("_authToken beats username + _password at the same path: _authToken first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=TOKEN\n//${host}/:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("_authToken beats username + _password at the same path: username first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:username=gandalf\n//${host}/:_password=${password}\n//${host}/:_authToken=TOKEN`, + ); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("a deeper email is not a credential and does not shadow a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:email=gandalf@example.com`, + ); + expect(auth).toBe("Bearer ROOT"); + }); + + it("a deeper username without a _password does not shadow a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:username=gandalf`, + ); + expect(auth).toBe("Bearer ROOT"); + }); + + it("a deeper username without a _password does not shadow a shallower username + _password", async () => { + const auth = await probeAuthorization( + host => + `//${host}/:username=gandalf\n//${host}/:_password=${password}\n//${host}${registryPath}:username=saruman`, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + }); + + // npm's walk strips a trailing `/` and the segment before it in separate steps, + // so `//host/api/v4/` and `//host/api/v4` are two different config paths (the + // slashed one checked first), as are `//host/` and `//host`. + describe("a trailing slash makes a distinct config path", () => { + const password = Buffer.from("verysecure").toString("base64"); + + // The slashed line comes first, so file order cannot be what picks it. + it("the slashed path is deeper than its unslashed twin", async () => { + const auth = await probeAuthorization( + host => `//${host}/api/v4/:_authToken=SLASH\n//${host}/api/v4:_authToken=NOSLASH`, + ); + expect(auth).toBe("Bearer SLASH"); + }); + + it("the slashed host root is deeper than the bare host", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=SLASH\n//${host}:_authToken=BARE`); + expect(auth).toBe("Bearer SLASH"); + }); + + it("the bare host still matches when nothing deeper does", async () => { + const auth = await probeAuthorization(host => `//${host}:_authToken=BARE`); + expect(auth).toBe("Bearer BARE"); + }); + + // `//host:username` and `//host/:_password` are two config keys, neither of which + // is a complete credential. npm composes nothing from them, and neither may Bun — + // including when the registry sits at the host root, so both keys are its own. + it("a username and a _password split across the two host-root spellings authenticate with neither", async () => { + const auth = await probeAuthorization(host => `//${host}:username=gandalf\n//${host}/:_password=${password}`); + expect(auth).toBe(null); + }); + + it("the same split, with the registry at the host root, still authenticates with neither", async () => { + const auth = await probeAuthorization(host => `//${host}:username=gandalf\n//${host}/:_password=${password}`, { + path: "/", + }); + expect(auth).toBe(null); + }); + }); + + // Bun does not implement certificate auth: it warns and ignores `certfile`/`keyfile`. + // An unsupported option must never suppress credentials Bun can actually send. + describe("certfile + keyfile are ignored", () => { + it("a complete pair does not stop a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => + `//${host}/:_authToken=${token}\n//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone certfile does not stop a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=${token}\n//${host}${registryPath}:certfile=a.pem`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone keyfile does not stop a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=${token}\n//${host}${registryPath}:keyfile=b.key`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a deeper _authToken still beats a shallower complete pair", async () => { + const auth = await probeAuthorization( + host => `//${host}/:certfile=a.pem\n//${host}/:keyfile=b.key\n//${host}${registryPath}:_authToken=${token}`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + // `bunfig.toml` and registry-URL userinfo are Bun's second credential layer. + it("a complete pair leaves a bunfig.toml token intact on a scoped registry", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + { bunfigToken: token }, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a complete pair leaves a bunfig.toml token intact on the default registry", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + { registry: "default", bunfigToken: token }, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a complete pair leaves the registry URL's userinfo intact", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + { registry: "default", userinfo: "user:pass@" }, + ); + expect(auth).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("a complete pair on a shallower path leaves a bunfig.toml token intact", async () => { + const auth = await probeAuthorization(host => `//${host}/:certfile=a.pem\n//${host}/:keyfile=b.key`, { + bunfigToken: token, + }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone certfile leaves a bunfig.toml token intact", async () => { + const auth = await probeAuthorization(host => `//${host}${registryPath}:certfile=a.pem`, { + bunfigToken: token, + }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone keyfile leaves the registry URL's userinfo intact", async () => { + const auth = await probeAuthorization(host => `//${host}${registryPath}:keyfile=b.key`, { + registry: "default", + userinfo: "user:pass@", + }); + expect(auth).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("a bunfig.toml token with no matching .npmrc line is sent", async () => { + const auth = await probeAuthorization(() => `//other.example.com/:_authToken=OTHER`, { bunfigToken: token }); + expect(auth).toBe(`Bearer ${token}`); + }); + }); + + // npm merges every `.npmrc` into ONE flat config map before it resolves anything, + // so a key repeated across files collapses last-write-wins *before* `hasAuth` runs. + // Resolving per-file instead makes the home file's `_authToken` win and the + // project's `_auth` unreachable. + describe("keys collapse across .npmrc files before resolution", () => { + const path = "/api/v4/"; + const basic = Buffer.from("alice:s3cret").toString("base64"); + + // Measured against npm 10.9.3: `Basic `. + it("the project's empty _authToken falsifies the home file's token, so _auth wins", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_authToken=\n//${host}${path}:_auth=${basic}`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN`, + }); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("a project token overrides the home file's token at the same key", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + + it("the home file's token applies when the project declares nothing", async () => { + const auth = await probeAuthorization(() => "", { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN`, + }); + expect(auth).toBe("Bearer HOMETOKEN"); + }); + + // The home file's key is a strict ancestor, so it is never even consulted: + // `hasAuth` stops at the deeper key the project file supplies. + it("a deeper project token beats a shallower home token", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}/:_authToken=HOMETOKEN`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + + // The emptiness test happens AFTER the collapse, so a later empty value clears an + // earlier one instead of losing to it. npm 10.9.3 sends no header for both. + const homeBasic = (host: string) => + `//${host}${path}:username=alice\n//${host}${path}:_password=${Buffer.from("s3cret").toString("base64")}`; + + it("the project's empty username clears the home file's username and password pair", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:username=`, { path, homeNpmrc: homeBasic }); + expect(auth).toBe(null); + }); + + it("the project's empty _password clears the home file's username and password pair", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_password=`, { path, homeNpmrc: homeBasic }); + expect(auth).toBe(null); + }); + + it("a project username overrides the home file's at the same key", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:username=bob`, { path, homeNpmrc: homeBasic }); + expect(auth).toBe(`Basic ${Buffer.from("bob:s3cret").toString("base64")}`); + }); + }); + + // npm never decodes `_auth`; it forwards the value as `Basic `. An opaque + // blob and a blank password are credentials, not errors. + describe("_auth is forwarded verbatim", () => { + it("sends an opaque _auth blob that does not decode to user:pass", async () => { + const blob = b64("opaquetokenblob"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("sends an _auth that is not even valid base64", async () => { + const blob = "!!not-base64!!"; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("sends an _auth with a blank password", async () => { + const value = b64("tok:"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${value}`); + expect(auth).toBe(`Basic ${value}`); + }); + + it("prefers an opaque _auth over username and _password at the same key", async () => { + const blob = b64("opaquetokenblob"); + const auth = await probeAuthorization( + host => + `//${host}${registryPath}:_auth=${blob}\n` + + `//${host}${registryPath}:username=x\n` + + `//${host}${registryPath}:_password=${b64("y")}`, + ); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("sends a decodable _auth verbatim rather than re-encoding it", async () => { + // Unpadded: a decode/re-encode round trip would add the `=`. + const value = "YWI6Y2Q"; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${value}`); + expect(auth).toBe(`Basic ${value}`); + }); + + // Which credential wins must not depend on whether `_auth` decodes: with a + // username + password also stored on the registry (here from the URL's + // userinfo), all three blobs go out verbatim. + const userinfo = { registry: "default", userinfo: "url-user:url-pass@" } as const; + + it("a non-decodable _auth wins over the registry's username + password", async () => { + const blob = "!!not-base64!!"; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`, userinfo); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("a colon-less _auth wins over the registry's username + password", async () => { + const blob = b64("opaquetokenblob"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`, userinfo); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("a decodable _auth wins over the registry's username + password, verbatim", async () => { + const value = b64("alice:s3cret"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${value}`, userinfo); + expect(auth).toBe(`Basic ${value}`); + }); + }); + + // A yarn-style credential inside the registry URL is stripped from the path before + // the request goes out, whether or not `.npmrc` also supplies one. Otherwise the + // secret ships in the request path, where proxies and logs can see it. + describe("credentials embedded in the registry URL", () => { + // Returns the Authorization header and the request path the registry saw. + async function probeEmbedded( + registryPath: string, + authLines: (host: string) => string, + userinfo = "", + { cli = false, source = "scoped" }: { cli?: boolean; source?: "scoped" | "default" | "env" | "bunfig" } = {}, + ) { + const seen: Array<{ path: string; auth: string | null }> = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + const registryUrl = `http://${userinfo}${host}${registryPath}`; + const npmrcRegistryLine = + cli || source === "env" || source === "bunfig" + ? "" + : source === "default" + ? `registry=${registryUrl}\n` + : `@myorg:registry=${registryUrl}\n`; + using dir = tempDir("npmrc-embedded-auth", { + ".npmrc": `${npmrcRegistryLine}${authLines(host)}\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), + "home/.gitkeep": "", + ...(source === "bunfig" ? { "bunfig.toml": `[install]\nregistry = "${registryUrl}"\n` } : {}), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache", ...(cli ? ["--registry", registryUrl] : [])], + cwd: String(dir), + env: { + ...env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + ...(source === "env" ? { NPM_CONFIG_REGISTRY: registryUrl } : {}), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ requests: seen.length, exitCode, stderr }).toEqual({ + requests: 1, + exitCode: 1, + stderr: expect.any(String), + }); + return seen[0]!; + } + + // The bunfig object form is the one way into the scope builder with a token already + // set, so it pins that the strip does not depend on the credential being adopted. + async function probeBunfig(registryPath: string, token: string) { + const seen: Array<{ path: string; auth: string | null }> = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("bunfig-embedded-auth", { + "bunfig.toml": `[install.scopes]\nmyorg = { url = "http://${host}${registryPath}", token = "${token}" }\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ requests: seen.length, exitCode }).toEqual({ requests: 1, exitCode: 1 }); + return seen[0]!; + } + + // Bun's JFrog guide writes the credential as a path segment, `/npm/_auth=`. + // Base64 holds `/`, so the marker, not the last slash, must anchor the strip. + it("strips a slash-form _auth whose base64 value contains a slash", async () => { + const seen = await probeEmbedded("/api/npm/_auth=YWJj/ZGVm", () => ""); + expect(seen).toEqual({ path: "/api/npm/@myorg%2fpkg", auth: "Basic YWJj/ZGVm" }); + }); + + it("strips a slash-form _authToken whose value contains a slash", async () => { + const seen = await probeEmbedded("/api/_authToken=a/b", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer a/b" }); + }); + + it("strips slash-form username and _password segments", async () => { + const seen = await probeEmbedded("/api/username=u/_password=p/q", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p/q").toString("base64")}` }); + }); + + it("leaves a plain `=` in a path segment alone", async () => { + const seen = await probeEmbedded("/a=b/c/", () => ""); + expect(seen).toEqual({ path: "/a=b/c/@myorg%2fpkg", auth: null }); + }); + + it("leaves a plain `=` in a trailing path segment alone", async () => { + const seen = await probeEmbedded("/api/npm=1/", () => ""); + expect(seen).toEqual({ path: "/api/npm=1/@myorg%2fpkg", auth: null }); + }); + + // A marker is matched as spelled, as a `.npmrc` line is: a case variant is a + // misspelling, stripped from the path and never adopted. + it("strips an embedded _AuthToken spelled with a capital without adopting it", async () => { + const seen = await probeEmbedded("/api/:_AuthToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: null }); + }); + + // A `:word=` run at the very end of the path is the one place yarn writes a + // credential; a misspelt name there is still a credential, stripped and never + // adopted. main's blunt colon split removed it too. + it.each(["/api/:_passwd=SECRET", "/api/:authtoken=SECRET"])( + "strips a misspelt credential segment %s at the end of the path without adopting it", + async registryPath => { + const seen = await probeEmbedded(registryPath, () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: null }); + }, + ); + + // A `:word=` or `_word=` segment anywhere else is a plain path segment: npm has + // no heuristic for it, and stripping it lost the rest of the registry path. + it.each([ + ["/a:b=c/npm/", "/a:b=c/npm/@myorg%2fpkg"], + ["/api/_v=1/npm/", "/api/_v=1/npm/@myorg%2fpkg"], + ["/api/_secret=x/", "/api/_secret=x/@myorg%2fpkg"], + ])("leaves a `=` segment inside the registry path alone: %s", async (registryPath, expectedPath) => { + const seen = await probeEmbedded(registryPath, () => ""); + expect(seen).toEqual({ path: expectedPath, auth: null }); + }); + + it("leaves a `:word=` segment inside a --registry path alone", async () => { + const seen = await probeEmbedded("/a:b=c/npm/", () => "", "", { cli: true }); + expect(seen).toEqual({ path: "/a:b=c/npm/@myorg%2fpkg", auth: null }); + }); + + // The default registry reaches the scope builder from a `.npmrc` `registry=` line, + // `$NPM_CONFIG_REGISTRY` and bunfig's string form as well; the strip is the same. + it.each(["default", "env", "bunfig"] as const)( + "strips an embedded _authToken from a %s registry URL", + async source => { + const seen = await probeEmbedded("/api/:_authToken=S", () => "", "", { source }); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer S" }); + }, + ); + + // `--registry` used to bypass the scope builder; with a trailing slash after the + // marker the token went out in every request path. + it("strips an embedded _authToken from a --registry URL with a trailing slash", async () => { + const seen = await probeEmbedded("/api/:_authToken=S/", () => "", "", { cli: true }); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer S" }); + }); + + it("keeps the .npmrc token of the same host over a --registry URL's embedded one", async () => { + const seen = await probeEmbedded( + "/api/:_authToken=S/", + host => `registry=http://${host}/\n//${host}/:_authToken=T`, + "", + { cli: true }, + ); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips an embedded _auth when bunfig already supplies a token", async () => { + const seen = await probeBunfig("/api/:_auth=opaque-blob", "T"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("keeps bunfig's token over an embedded _authToken", async () => { + const seen = await probeBunfig("/api/:_authToken=EMBEDDED", "T"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips an embedded _authToken from the path and uses it when .npmrc has none", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); + }); + + it("strips an embedded _authToken from the path even when an .npmrc ancestor wins", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", host => `//${host}/:_authToken=FROM_NPMRC`); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer FROM_NPMRC" }); + }); + + // main only stripped the marker while looking for a credential to adopt; with + // userinfo already in the URL it skipped the scan and the token shipped in the path. + it("strips an embedded _authToken from the path when the URL also carries userinfo", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => "", "u:p@"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${btoa("u:p")}` }); + }); + + // A bare `:` belongs to the path. Only `name=value` segments naming a credential + // are stripped, so a registry mounted under `/a:b/c/` keeps its path. + it("leaves a plain colon in the registry path alone", async () => { + const seen = await probeEmbedded("/a:b/c/", host => `//${host}/a:b/c/:_authToken=TOK`); + expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: "Bearer TOK" }); + }); + + it("leaves a plain colon in the registry path alone when no credential is configured", async () => { + const seen = await probeEmbedded("/a:b/c/", () => ""); + expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: null }); + }); + + // An empty marker supplies nothing: it is stripped from the path, but it must not + // end the scan or shadow the credential the .npmrc supplies. + it("an empty embedded _auth does not discard the .npmrc credential", async () => { + const seen = await probeEmbedded("/api/:_auth=", host => `//${host}/:_auth=b3BhcXVlYmxvYg`); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic b3BhcXVlYmxvYg" }); + }); + + it("an empty embedded _authToken does not discard the .npmrc credential", async () => { + const seen = await probeEmbedded("/api/:_authToken=", host => `//${host}/:_auth=b3BhcXVlYmxvYg`); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic b3BhcXVlYmxvYg" }); + }); + + // An `.npmrc` `_auth` supersedes URL-embedded credentials outright, so `bun pm + // whoami` can never report an identity the registry did not authenticate. + it("an .npmrc _auth wins over embedded username and _password", async () => { + const seen = await probeEmbedded( + "/api/:username=embeddeduser/:_password=embeddedpass", + host => `//${host}/:_auth=T1BBUVVFQkxPQg==`, + ); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic T1BBUVVFQkxPQg==" }); + }); + + // The scan anchors on the `:=` marker, not on any `:`, so a colon inside the + // value neither ends it nor splits it. + it("strips an embedded _authToken whose value contains a colon", async () => { + const seen = await probeEmbedded("/api/:_authToken=aa:bb", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer aa:bb" }); + }); + + // An embedded `_password` is used verbatim, unlike an `.npmrc` one, which is base64. + it("strips an embedded username and _password whose value contains a colon", async () => { + const seen = await probeEmbedded("/api/:username=u/:_password=p:q", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p:q").toString("base64")}` }); + }); + + // `_authToken` ends what is read, not what is stripped: a segment to its left is + // still a secret, and leaving it behind puts it in the request path. + it("strips a _password written to the left of the _authToken it loses to", async () => { + const seen = await probeEmbedded("/api/:_password=cA==:_authToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips a username written to the left of the _authToken it loses to", async () => { + const seen = await probeEmbedded("/api/:username=u:_authToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + // The rightmost terminal marker is the one that is read; a second one to its left + // is stripped but never recorded, so it cannot outrank the first. + // The `_auth` values are ones a base64 round trip would change, so the header + // proves the value went out as written. + it.each([ + ["an opaque value", "opaque-blob"], + ["a blank password", btoa("u:")], + ])("reads the rightmost terminal marker, not the leftmost (%s)", async (_name, value) => { + const seen = await probeEmbedded(`/api/:_authToken=T:_auth=${value}`, () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${value}` }); + }); + + it("reads the leftmost of two duplicate non-terminal markers", async () => { + const seen = await probeEmbedded("/api/:username=a:username=b:_password=p", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("a:p").toString("base64")}` }); + }); + }); + + // npm's walk decides which ancestor supplies auth. Layering a half credential over + // one the registry already stores (from its URL's userinfo) is Bun-only and stays + // exact-path: an ancestor's stray `username=` must not rebind a deeper registry's + // stored password to a new identity. + describe("a lone username or _password supplies nothing, as in npm's hasAuth", () => { + const stored = { registry: "default", userinfo: "url-user:url-pass@" } as const; + const storedBasic = `Basic ${Buffer.from("url-user:url-pass").toString("base64")}`; + + it("an ancestor's lone username does not rebind the stored password", async () => { + const auth = await probeAuthorization(host => `//${host}/:username=attacker`, stored); + expect(auth).toBe(storedBasic); + }); + + it("an ancestor's lone _password does not rebind the stored username", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_password=${Buffer.from("other").toString("base64")}`, + stored, + ); + expect(auth).toBe(storedBasic); + }); + + it("an ancestor's empty _authToken does not clear the stored token", async () => { + const auth = await probeAuthorization(host => `//${host}/:username=u\n//${host}/:_authToken=`, { + registry: "default", + userinfo: `:${token}@`, + }); + expect(auth).toBe(`Bearer ${token}`); + }); + }); + + // A registry declared in `bunfig.toml` resolves through the same walk, unless + // `bunfig.toml` itself gave it credentials: project config beats `.npmrc`. + describe("registries declared in bunfig.toml", () => { + it.each([ + ["scoped", { bunfigBare: true }], + ["default", { registry: "default", bunfigBare: true }], + ] as const)("a host-root _authToken applies to a credential-less %s registry", async (_name, opts) => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=${token}`, opts); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a string prefix of the path is still not an ancestor", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/projects/12/:_authToken=${token}`, { + bunfigBare: true, + }); + expect(auth).toBeNull(); + }); + + it("walks up for _auth", async () => { + const blob = b64("alice:s3cret"); + const auth = await probeAuthorization(host => `//${host}/api/:_auth=${blob}`, { bunfigBare: true }); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("a bunfig.toml token is kept even when an ancestor key would supply another", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=FROM_NPMRC`, { bunfigToken: token }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("bunfig.toml username + password are kept even when the registry's own key supplies _auth", async () => { + const basic = { username: "bunfig-user", password: "bunfig-pass" }; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${b64("x:y")}`, { + bunfigBasic: basic, + }); + expect(auth).toBe(`Basic ${b64(`${basic.username}:${basic.password}`)}`); + }); + }); + + // The unscoped `registry=` line resolves credentials through the same walk. It is + // a separate branch in `src/ini/lib.rs`, and it is the only one that can carry + // credentials from `bunfig.toml` or from userinfo in the registry URL. + describe("the default registry walks up too", () => { + const asDefault = { registry: "default" } as const; + + it("host root", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=${token}`, asDefault); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("string prefix, not an ancestor (trailing slash)", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/projects/12/:_authToken=${token}`, asDefault); + expect(auth).toBe(null); + }); + + it("string prefix, not an ancestor (no trailing slash)", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/projects/12:_authToken=${token}`, asDefault); + expect(auth).toBe(null); + }); + + it("longest match wins: root then deep", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:_authToken=DEEP`, + asDefault, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + it("longest match wins: deep then root", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:_authToken=DEEP\n//${host}/:_authToken=ROOT`, + asDefault, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + // Userinfo in the registry URL is the registry's pre-`.npmrc` credential. No + // config path matches, so nothing may overwrite or clear it. + it("userinfo in the registry URL survives a non-matching auth line", async () => { + const auth = await probeAuthorization(() => `//other.example.com/:_authToken=${token}`, { + ...asDefault, + userinfo: "user:pass@", + }); + expect(auth).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("a matching auth line replaces the registry URL's userinfo", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=${token}`, { + ...asDefault, + userinfo: "user:pass@", + }); + expect(auth).toBe(`Bearer ${token}`); + }); + }); + }); + // The Rust port adds a same-origin guard in `NetworkTask::for_tarball` so a // malicious registry can't point `dist.tarball` at a third-party host and // harvest the scope's `Authorization` header. The guard must compare diff --git a/test/cli/install/bun-pm-diff.test.ts b/test/cli/install/bun-pm-diff.test.ts index 70d18fe8d43e..5a86d57f7b7f 100644 --- a/test/cli/install/bun-pm-diff.test.ts +++ b/test/cli/install/bun-pm-diff.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isDebug, normalizeBunSnapshot, tempDir, tls } from "harness"; import { chmodSync, readdirSync, symlinkSync } from "node:fs"; import { join } from "node:path"; @@ -352,6 +352,68 @@ diffme@1.0.0 → diffme@2.0.0 expect(exitCode).toBe(0); }); + test("a dist.tarball on another https host carries that host's own .npmrc line", async () => { + // The same rule as `bun install`: the tarball's own `.npmrc` line, not the registry's token. + const seen = { registry: [] as (string | null)[], cdn: [] as (string | null)[] }; + using cdn = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls, + fetch(req) { + seen.cdn.push(req.headers.get("authorization")); + return new Response(Bun.file(tarballs["2.0.0"])); + }, + }); + using authed = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.registry.push(req.headers.get("authorization")); + if (req.headers.get("authorization") !== "Bearer sekrit") return new Response("no", { status: 401 }); + const url = new URL(req.url); + if (url.pathname === "/diffme") { + return Response.json({ + name: "diffme", + "dist-tags": { latest: "2.0.0" }, + versions: { + "1.0.0": { + name: "diffme", + version: "1.0.0", + dist: { tarball: `${authed.url.origin}/diffme/-/diffme-1.0.0.tgz` }, + }, + "2.0.0": { + name: "diffme", + version: "2.0.0", + dist: { tarball: `${cdn.url.origin}/npm/diffme-2.0.0.tgz` }, + }, + }, + }); + } + return new Response(Bun.file(tarballs["1.0.0"])); + }, + }); + using dir = tempDir("pm-diff-cdn-line", { + "ca.pem": tls.cert, + "bunfig.toml": `[install]\nregistry = { url = "${authed.url.origin}/", token = "sekrit" }\ncafile = "ca.pem"\n`, + ".npmrc": `//${cdn.url.host}/:_authToken=cdn-line\n`, + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using p = Bun.spawn({ + cmd: [bunExe(), "pm", "diff", "diffme@1.0.0", "2.0.0", "--name-only"], + cwd: String(dir), + env: { ...bunEnv, NO_COLOR: "1", HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(seen.cdn).toEqual(["Bearer cdn-line"]); + expect(seen.registry.every(a => a === "Bearer sekrit")).toBe(true); + expect(stderr).toBe(""); + expect(stdout.split("\n")[0]).toBe("diffme@1.0.0 → diffme@2.0.0"); + expect(exitCode).toBe(0); + }); + test("the registry token is sent to the registry, never to a foreign dist.tarball host", async () => { // Registry A demands a bearer token and points 2.0.0's tarball at host B; B must not see the token. const seen = { a: [] as (string | null)[], b: [] as (string | null)[] }; diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..1a1195bc0685 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -510,6 +510,47 @@ test("can publish a package then install it", async () => { await runBunInstall(env, packageDir); expect(await exists(join(packageDir, "node_modules", "publish-pkg-1", "package.json"))).toBeTrue(); }); + +describe("can publish with only _auth from .npmrc", () => { + // npm forwards whatever `_auth` holds as `Basic `, decodable or not. + const cases: Array<[name: string, blob: string]> = [ + ["decodable base64", Buffer.from("alice:s3cret").toString("base64")], + ["opaque non-base64", "!!not-base64!!"], + ]; + + test.each(cases)("%s reaches the registry verbatim", async (_name, blob) => { + const requests: Array<{ method: string; path: string; auth: string | null }> = []; + using mockRegistry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const pathname = new URL(req.url).pathname; + requests.push({ method: req.method, path: pathname, auth: req.headers.get("authorization") }); + if (req.method === "PUT" && pathname === "/npmrc-auth-pkg") return new Response("OK", { status: 200 }); + return new Response("not found", { status: 404 }); + }, + }); + + const host = `127.0.0.1:${mockRegistry.port}`; + using dir = tempDir("publish-npmrc-auth", { + "package.json": JSON.stringify({ name: "npmrc-auth-pkg", version: "1.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:_auth=${blob}\n`, + // An empty home so the developer's own `.npmrc`/global bunfig can't leak in. + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + + const { out, err, exitCode } = await publish( + { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + String(dir), + ); + expect(err).not.toContain("error:"); + expect(out).toContain("+ npmrc-auth-pkg@1.0.0"); + expect(requests).toEqual([{ method: "PUT", path: "/npmrc-auth-pkg", auth: `Basic ${blob}` }]); + expect(exitCode).toBe(0); + }); +}); + test("can publish from a tarball", async () => { const { packageDir, packageJson } = await registry.createTestDir(); const bunfig = await registry.authBunfig("tarball"); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ec2e6adfcaee..d5e4c01f08e7 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -1,7 +1,8 @@ import { write } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { rm } from "fs/promises"; -import { VerdaccioRegistry, bunExe, bunEnv as env, isIPv6, tempDir } from "harness"; +import { VerdaccioRegistry, bunExe, bunEnv as env, isIPv6, tempDir, tls } from "harness"; +import { createServer as createTlsServer } from "node:tls"; import { join } from "path"; const { iniInternals } = require("bun:internal-for-testing"); const { loadNpmrc } = iniInternals; @@ -289,7 +290,6 @@ registry=http://localhost:\${PORT}/ default_registry_token: string; default_registry_username: string; default_registry_password: string; - default_registry_email: string; }) => void, ) { const optionName = await Promise.all(options.map(async ([name, val]) => `${name} = ${val}`)); @@ -498,26 +498,70 @@ ${Object.keys(opts) dotEnv: { SECRET_AUTH: "" }, }, (stdout: string, stderr: string) => { - expect(stderr).toContain("received an empty string"); + expect(stderr).toContain("supplies no credentials"); }, ); - await makeTest([["email", "user@example.com"]], result => { - expect(result.default_registry_url).toEqual("https://registry.npmjs.org/"); - expect(result.default_registry_email).toEqual("user@example.com"); + describe("empty _auth across the home and project .npmrc", () => { + const blob = Buffer.from("alice:s3cret").toString("base64"); + + // Returns whether the empty-`_auth` diagnostic was printed; the install itself + // must still succeed either way. + async function diagnosed(homeNpmrc: string, projectNpmrc: string) { + using dir = tempDir("npmrc-empty-auth-two-files", { + "home/.npmrc": homeNpmrc, + ".npmrc": projectNpmrc, + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + const homeDir = join(String(dir), "home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--dry-run"], + cwd: String(dir), + env: { ...env, HOME: homeDir, USERPROFILE: homeDir, XDG_CONFIG_HOME: homeDir }, + stdout: "pipe", + stderr: "pipe", + }); + + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(exitCode).toBe(0); + return stderr.includes("supplies no credentials"); + } + + test("a home line is diagnosed against a registry the project declares", async () => { + expect(await diagnosed(`//somehost.com/:_auth=\n`, `registry=http://somehost.com/\n`)).toBe(true); + }); + + test("a line that only matches a registry's path ancestor is not diagnosed", async () => { + expect( + await diagnosed(`//somehost.com/:_auth=\n`, `@myorg:registry=https://somehost.com/api/v4/packages/npm/\n`), + ).toBe(false); + }); + + // The key collapses to the project's value, so the home line supplies nothing + // either way and the credential is sent. + test("a home line the project overrides with a value is not diagnosed", async () => { + expect( + await diagnosed(`//somehost.com/:_auth=\n`, `registry=http://somehost.com/\n//somehost.com/:_auth=${blob}\n`), + ).toBe(false); + }); + + test("a project line that clears the home file's value is diagnosed", async () => { + expect( + await diagnosed(`//somehost.com/:_auth=${blob}\n`, `registry=http://somehost.com/\n//somehost.com/:_auth=\n`), + ).toBe(true); + }); }); await makeTest( [ ["username", "testuser"], ["_password", "testpass"], - ["email", "test@example.com"], ], result => { expect(result.default_registry_url).toEqual("https://registry.npmjs.org/"); expect(result.default_registry_username).toEqual("testuser"); expect(result.default_registry_password).toEqual("testpass"); - expect(result.default_registry_email).toEqual("test@example.com"); }, ); @@ -551,14 +595,16 @@ registry=https://somehost.com/org1/npm/registry/ }); describe("credentials keyed to a bracketed IPv6 host", () => { - // The `//` is stripped off the key before it is parsed as a URL, leaving - // `[::1]:4873/`. A leading `[` used to parse to an empty host, so these keys - // never matched the registry they were written for. + // Keys and registry URLs are both parsed as URLs and compared on host and path, so + // the bracketed authority has to survive both parses. test.each([ ["loopback with a port", "http://[::1]:4873/", "//[::1]:4873/"], ["loopback without a port", "http://[::1]/", "//[::1]/"], ["full address with a path", "http://[2001:db8::1]:4873/npm/registry/", "//[2001:db8::1]:4873/npm/registry/"], ["key without the trailing slash", "http://[::1]:4873/", "//[::1]:4873"], + ["host-root key for a registry under a path", "http://[::1]:4873/npm/registry/", "//[::1]:4873/"], + // The address ends in the scheme's default port digits; they are not a port. + ["address whose last group spells the default port", "http://[::80]/", "//[::80]/"], ])("_authToken is applied: %s", (_, registryUrl, key) => { const result = loadNpmrc(`registry=${registryUrl}\n${key}:_authToken=v6-token\n`); expect(result).toEqual({ @@ -566,7 +612,7 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_token: "v6-token", default_registry_username: "", default_registry_password: "", - default_registry_email: "", + default_registry_auth: "", }); }); @@ -579,16 +625,16 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_token: "", default_registry_username: "v6-user", default_registry_password: "v6-password", - default_registry_email: "", + default_registry_auth: "", }); const auth = Buffer.from("v6-user:v6-password").toString("base64"); expect(loadNpmrc(`registry=http://[::1]:4873/\n//[::1]:4873/:_auth=${auth}\n`)).toEqual({ default_registry_url: "http://[::1]:4873/", default_registry_token: "", - default_registry_username: "v6-user", - default_registry_password: "v6-password", - default_registry_email: "", + default_registry_username: "", + default_registry_password: "", + default_registry_auth: auth, }); }); @@ -603,27 +649,402 @@ registry=https://somehost.com/org1/npm/registry/ }); }); - it("does not print an undecodable _password value", async () => { - const secret = "s!ecret!pass"; - using dir = tempDir("npmrc-password-decode", { - ".npmrc": `//registry.npmjs.org/:_password=${secret}\n`, + // `$npm_config_registry` pointing at the host `.npmrc` already configured rebuilds the + // scope; the `.npmrc` `_auth` must survive that the way `_authToken` does. + test("an env registry on the same host keeps the .npmrc _auth", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:_auth=${blob}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + + describe("default registry resolves auth by path-segment ancestor", () => { + // https://github.com/oven-sh/bun/issues/30311 + test("host-root auth applies to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:_authToken=root +`); + expect(result.default_registry_url).toEqual("https://somehost.com/org1/npm/registry/"); + expect(result.default_registry_token).toBe("root"); + }); + + test("mid-path ancestor auth applies to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/org1/:_authToken=mid +`); + expect(result.default_registry_token).toBe("mid"); + }); + + test.each([ + [ + "shallow first", + ` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:_authToken=root +//somehost.com/org1/:_authToken=mid +//somehost.com/org1/npm/registry/:_authToken=exact +`, + ], + [ + "deep first", + ` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/org1/npm/registry/:_authToken=exact +//somehost.com/org1/:_authToken=mid +//somehost.com/:_authToken=root +`, + ], + ])("longest matching ancestor wins (%s)", (_name, ini) => { + expect(loadNpmrc(ini).default_registry_token).toBe("exact"); + }); + + test.each([ + ["trailing slash", "//somehost.com/api/v4/projects/12/:_authToken=attacker"], + ["no trailing slash", "//somehost.com/api/v4/projects/12:_authToken=attacker"], + ])("a path prefix that is not a segment ancestor never matches (%s)", (_name, line) => { + const result = loadNpmrc(` +registry=https://somehost.com/api/v4/projects/123/packages/npm/ +${line} +`); + expect(result.default_registry_url).toEqual("https://somehost.com/api/v4/projects/123/packages/npm/"); + expect(result.default_registry_token).toBe(""); + }); + + test("host-root _auth applies to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:_auth=${Buffer.from("bilbo:verysecure").toString("base64")} +`); + // `_auth` is forwarded verbatim; the config layer never decodes it into + // username/password (whoami derives the username in `Scope::from_api`). + expect(result.default_registry_auth).toBe(Buffer.from("bilbo:verysecure").toString("base64")); + expect(result.default_registry_username).toBe(""); + expect(result.default_registry_password).toBe(""); + }); + + test("host-root username + _password apply to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:username=bilbo +//somehost.com/:_password=${Buffer.from("verysecure").toString("base64")} +`); + expect(result.default_registry_username).toBe("bilbo"); + expect(result.default_registry_password).toBe("verysecure"); + }); + }); + + describe("credentials that did not come from .npmrc survive resolution", () => { + // A credential written into the registry URL is the weakest source: npm's + // `getAuth` never reads it, so any complete `.npmrc` line for the key replaces it. + test("an .npmrc _auth replaces the registry URL's token", () => { + const result = loadNpmrc(` +registry=https://:TOK@somehost.com/ +//somehost.com/:_auth=not-valid-base64 +`); + expect(result.default_registry_token).toBe(""); + expect(result.default_registry_auth).toBe("not-valid-base64"); + }); + + test("an .npmrc username/_password replaces the registry URL's token", () => { + const result = loadNpmrc(` +registry=https://:TOK@somehost.com/ +//somehost.com/:username=gandalf +//somehost.com/:_password=${Buffer.from("verysecure").toString("base64")} +`); + expect(result.default_registry_token).toBe(""); + expect(result.default_registry_username).toBe("gandalf"); + expect(result.default_registry_password).toBe("verysecure"); + }); + }); + + test("an empty _auth for an ancestor path of a registry is not an error", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("{}") }); + const host = `127.0.0.1:${server.port}`; + using dir = tempDir("npmrc-empty-auth-ancestor-2", { + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + ".npmrc": `@myorg:registry=http://${host}/deep/\n//${host}/:_auth=\n`, + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-save"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toMatch(/_auth/); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); + }); + + test("an empty _auth naming a registry's own path is still an error", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("{}") }); + const host = `127.0.0.1:${server.port}`; + using dir = tempDir("npmrc-empty-auth-exact", { "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + ".npmrc": `@myorg:registry=http://${host}/deep/\n//${host}/deep/:_auth=\n`, + "home/.gitkeep": "", }); + const home = join(String(dir), "home"); await using proc = Bun.spawn({ - cmd: [bunExe(), "install"], + cmd: [bunExe(), "install", "--no-save"], cwd: String(dir), - env: { ...env, NO_COLOR: "1" }, + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", stderr: "pipe", }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("supplies no credentials"); + expect(exitCode).toBe(0); + }); - expect(stderr).toContain("_password is not valid base64"); - expect(stderr).toContain("_password=" + Buffer.alloc(secret.length, "*").toString()); - expect(stderr).not.toContain(secret); + // `Scope::from_api` decodes `_auth` solely to derive the identity `bun pm whoami` + // prints; the credential itself is always forwarded verbatim. + test("an env registry on the same host keeps the .npmrc username and _password", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:username=alice\n//${host}/:_password=${Buffer.from("hunter2").toString("base64")}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); expect(exitCode).toBe(0); }); + test("an env registry on the same host keeps the registry URL's userinfo", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://alice:hunter2@${host}/\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + // A `.npmrc` key carries no scheme, so the line applies to the http registry too, as + // in npm; only a credential carried over from another registry refuses a downgrade. + test("an env registry that downgrades https to http still gets the .npmrc _auth", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=https://${host}/\n//${host}/:_auth=${blob}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + + describe("bun pm whoami derives the username from _auth", () => { + async function whoamiWith(files: Record) { + using dir = tempDir("npmrc-whoami-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + ...files, + }); + const homeDir = join(String(dir), "home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "whoami"], + cwd: String(dir), + env: { ...env, HOME: homeDir, USERPROFILE: homeDir, XDG_CONFIG_HOME: homeDir }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // Answers `/-/whoami` with a fixed name and records the Authorization header, so a + // request that goes out is visible even when Bun cannot derive a name locally. + async function whoamiAgainstRegistry(authLine: (host: string) => string, userinfo = "") { + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ username: "from-registry" }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + const result = await whoamiWith({ + ".npmrc": `registry=http://${userinfo}${host}/\n${authLine(host)}\n`, + }); + return { ...result, authorizations }; + } + + test("a decodable _auth prints its username without a request", async () => { + const blob = Buffer.from("alice:s3cret").toString("base64"); + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${blob}`); + expect({ stdout, authorizations }).toEqual({ stdout: "alice\n", authorizations: [] }); + expect(exitCode).toBe(0); + }); + + // No local identity in these values, so the credential goes to the registry as + // written and the registry answers; main gave up with "missing authentication". + test.each([ + ["opaque", "!!not-base64!!"], + ["blank username", Buffer.from(":s3cret").toString("base64")], + ["blank password", Buffer.from("tok:").toString("base64")], + ])("an _auth without a local identity asks the registry (%s)", async (_name, authValue) => { + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${authValue}`); + expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); + expect(exitCode).toBe(0); + }); + + // The wire sends `Basic <_auth>` here (auth beats the username + password the + // registry URL's userinfo stored), so whoami must not report that username: it + // would be an identity from a credential never sent. + test.each([ + ["opaque", "!!not-base64!!"], + ["blank-password", Buffer.from("tok:").toString("base64")], + ])("the registry URL's username/password do not leak an identity past _auth (%s)", async (_name, authValue) => { + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry( + host => `//${host}/:_auth=${authValue}`, + "url-user:url-pass@", + ); + expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); + expect(exitCode).toBe(0); + }); + + // Credentials declared in bunfig.toml beat every .npmrc line for that registry, + // so whoami reports the bunfig identity and the _auth line is never consulted. + test("bunfig.toml username/password are the identity when bunfig declares the registry", async () => { + const { stdout, exitCode } = await whoamiWith({ + "bunfig.toml": `[install.registry]\nurl = "https://registry.invalid/"\nusername = "bunfig-user"\npassword = "bunfig-pass"\n`, + ".npmrc": `//registry.invalid/:_auth=!!not-base64!!\n`, + }); + expect(stdout).toBe("bunfig-user\n"); + expect(exitCode).toBe(0); + }); + }); }); describe("scoped registry routing", () => { @@ -705,6 +1126,199 @@ describe("scoped registry routing", () => { }); }); +// npm keys on a WHATWG URL's `host`, which is lowercased and drops a default port. +// The config key's path stays case-sensitive; only its authority is folded. +describe("the config key's authority is normalized like a WHATWG URL", () => { + const token = (ini: string) => loadNpmrc(ini).default_registry_token; + + it("matches a lowercase key against an uppercase registry host", () => { + expect(token(`registry=https://Registry.Example.COM/api/\n//registry.example.com/:_authToken=T\n`)).toBe("T"); + }); + + // npm's `nerfDart` lowercases the keys it writes; a hand-written key is folded the + // same way when read, so an uppercase host still applies. + it("lowercases the key's host, so an uppercase key matches", () => { + expect(token(`registry=https://Registry.Example.COM/api/\n//Registry.Example.COM/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://registry.example.com/api/\n//Registry.Example.COM/:_authToken=T\n`)).toBe("T"); + }); + + it("keeps the key's path case-sensitive", () => { + expect(token(`registry=https://example.com/API/\n//example.com/api/:_authToken=T\n`)).toBe(""); + }); + + it("drops a default https port from the registry host", () => { + expect(token(`registry=https://example.com:443/api/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + it("drops a default http port from the registry host", () => { + expect(token(`registry=http://example.com:80/api/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + it("keeps a non-default port in the registry host", () => { + expect(token(`registry=https://example.com:8443/api/\n//example.com:8443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com:8443/api/\n//example.com/:_authToken=T\n`)).toBe(""); + }); + + it("drops a default port from an uppercase scheme too", () => { + expect(token(`registry=HTTPS://example.com:443/api/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + // npm compares a hand-written key as written: a port in it stays, so `//host:443/` + // is the key of `http://host:443/` (a TLS-terminating proxy) and not of `https://host/`. + // Bun's fast URL parser collapses a one-byte path (`/r/`) to `/`; the key must not. + it("keeps a one-character registry path in the key", () => { + expect(token(`registry=https://example.com/r/\n//example.com/r/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/r/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + it("keeps a port written in a scheme-less key", () => { + expect(token(`registry=http://example.com:443/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/api/\n//example.com:443/:_authToken=T\n`)).toBe(""); + }); + + // Bun's docs long showed keys with a scheme (`//http://localhost:4873/:_authToken=`); + // npm never writes one. The scheme is dropped when read, and names the default port. + it("drops a leading scheme from a key", () => { + expect(token(`registry=http://localhost:4873/\n//http://localhost:4873/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/api/\n//https://example.com/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=http://example.com/api/\n//http://example.com:80/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=http://example.com/api/\n//HTTP://Example.COM/:_authToken=T\n`)).toBe("T"); + }); + + it("drops a default port after a bracketed IPv6 host, not inside it", () => { + expect(token(`registry=https://[::1]/\n//https://[::1]:443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=http://[::80]/\n//[::80]/:_authToken=T\n`)).toBe("T"); + }); + + // The option name must end the key. `_authtoken` (lowercase t) used to match `_auth` + // as a substring and go out as `Basic `; now it is an unknown option. + it("does not read a misspelt option as a shorter one it contains", () => { + expect(token(`registry=https://example.com/\n//example.com/:_authtoken=T\n`)).toBe(""); + expect(loadNpmrc(`registry=https://example.com/\n//example.com/:_authtoken=T\n`).default_registry_auth).toBe(""); + }); +}); + +// Diagnostics printed while reading .npmrc must never echo a credential. +describe(".npmrc diagnostics", () => { + async function stderrOf(npmrc: string, bunfig?: object, args: string[] = []) { + using dir = tempDir("npmrc-diagnostics", { + ".npmrc": npmrc, + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + ...(bunfig ? { "bunfig.toml": Bun.TOML.stringify(bunfig) } : {}), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache", ...args], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return stderr; + } + + it("warns about an unknown option name without printing its value", async () => { + const stderr = await stderrOf(`registry=https://example.com/\n//example.com/:_authtoken=SECRETTOKEN\n`); + expect(stderr).toContain("_authtoken is not a known .npmrc option"); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("names the file and line of an unknown option", async () => { + const stderr = await stderrOf(`registry=https://example.com/\n\n//example.com/:_authtoken=SECRETTOKEN\n`); + expect(stderr).toMatch(/_authtoken is not a known .npmrc option[^\n]*\n\s+at .npmrc:3:/); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("says nothing about an unknown option under --silent", async () => { + const stderr = await stderrOf(`registry=https://example.com/\n//example.com/:_authtoken=SECRETTOKEN\n`, undefined, [ + "--silent", + ]); + expect(stderr).not.toMatch(/\b(warn|error):/); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("accepts per-registry options npm or pnpm know without a warning", async () => { + const stderr = await stderrOf( + [ + "registry=https://example.com/", + "//example.com/:always-auth=true", + "//example.com/:tokenHelper=/usr/local/bin/token", + "//example.com/:cafile=/etc/ssl/ca.pem", + "", + ].join("\n"), + ); + expect(stderr).toBe("No packages! Deleted empty lockfile\n"); + }); + + it("says nothing about a key that matches once normalized", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//Example.COM:443/:_authToken=SECRETTOKEN\n`); + expect(stderr).toBe("No packages! Deleted empty lockfile\n"); + }); + + it("a known option with a non-string value is ignored without a warning", async () => { + const stderr = await stderrOf("registry=http://127.0.0.1:1/\n//127.0.0.1:1/:_authToken=true\n"); + expect(stderr).toBe("No packages! Deleted empty lockfile\n"); + }); + + it("an empty _auth naming a bunfig.toml scope is an error", async () => { + const stderr = await stderrOf(`//example.com/api/:_auth=\n`, { + install: { scopes: { myorg: { url: "https://example.com/api/" } } }, + }); + expect(stderr).toContain("empty _auth value"); + }); + + // A credential can be arbitrary bytes. `bun pm view` panicked on non-UTF-8 (lossy + // Display expanded U+FFFD past the reserved byte count) until the header append went + // raw. A JS `\xff` escape lands as valid UTF-8, so the bytes are written raw here. + for (const [opt, scheme] of [ + ["_auth", "Basic"], + ["_authToken", "Bearer"], + ] as const) { + it(`a non-UTF-8 ${opt} reaches the registry verbatim from bun pm view`, async () => { + const seen: Buffer[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push(Buffer.from(req.headers.get("authorization") ?? "", "binary")); + return Response.json({ + "name": "pkg", + "dist-tags": { latest: "1.0.0" }, + "versions": { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-raw-bytes", { + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const prefix = Buffer.from(`registry=http://${host}/\n//${host}/:${opt}=`); + await write(join(String(dir), ".npmrc"), Buffer.concat([prefix, Buffer.from([0xff, 0xfe, 0xfd, 0x0a])])); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(seen).toEqual([Buffer.concat([Buffer.from(`${scheme} `), Buffer.from([0xff, 0xfe, 0xfd])])]); + expect(stderr).not.toMatch(/_auth/); + expect({ stdout, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "1.0.0\n", + exitCode: 0, + signalCode: null, + }); + }); + } +}); + describe("--registry override", () => { test("does not send the token configured for the previous registry host to the --registry host", async () => { const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); @@ -814,3 +1428,1102 @@ describe.skipIf(!isIPv6())("registry on a bracketed IPv6 host", () => { expect(exitCode).not.toBe(0); }); }); + +// `//host/path/:_authToken=` lines are keyed by URL, not by a registry declared +// in the same file. Like npm, they have to apply to whatever request ends up on +// that host: a registry that only exists on the command line or in the +// environment, or a tarball served from a different host than the registry. +describe.concurrent("//host/ credential lines are matched against the request URL", () => { + const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); + const basic = (user: string, password: string) => `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; + const packageJson = JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }); + + type Req = { path: string; auth: string | null }; + + type MockRegistryOptions = { + /** Serve dist.tarball from another server instead of this one. */ + tarballOrigin?: () => string; + /** Mount the registry under this path prefix instead of `/`. */ + registryPath?: string; + /** Path of the tarball on its server. */ + tarballPath?: string; + /** Let the manifest through without credentials; only the tarball is protected. */ + publicManifest?: boolean; + /** Serve over https with the harness certificate (`install` passes it as `--ca`). */ + secure?: boolean; + /** The package the manifest describes (default `no-deps`); a scoped name is served at `@scope%2fname`. */ + packageName?: string; + /** A query string appended to the advertised `dist.tarball` URL. */ + tarballQuery?: string; + }; + + // Serves no-deps@1.0.0 and answers 401 to any request that does not carry + // exactly `expectedAuth`. + function mockRegistry(expectedAuth: string, options: MockRegistryOptions = {}) { + const { + tarballOrigin, + registryPath = "", + tarballPath = "/no-deps/-/no-deps-1.0.0.tgz", + publicManifest, + secure, + packageName = "no-deps", + tarballQuery = "", + } = options; + const requests: Req[] = []; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + ...(secure ? { tls } : {}), + fetch(req) { + const url = new URL(req.url); + const auth = req.headers.get("authorization"); + requests.push({ path: url.pathname, auth }); + const isManifest = url.pathname === `${registryPath}/${packageName.replace("/", "%2f")}`; + if (auth !== expectedAuth && !(isManifest && publicManifest)) { + return new Response("unauthorized", { status: 401 }); + } + if (url.pathname === tarballPath) return new Response(Bun.file(tgz)); + if (isManifest) { + const origin = tarballOrigin ? tarballOrigin() : `${secure ? "https" : "http"}://127.0.0.1:${server.port}`; + return Response.json({ + name: packageName, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: packageName, + version: "1.0.0", + dist: { tarball: `${origin}${tarballPath}${tarballQuery}` }, + }, + }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { + requests, + host: `127.0.0.1:${server.port}`, + origin: `${secure ? "https" : "http"}://127.0.0.1:${server.port}`, + [Symbol.dispose]() { + server.stop(true); + }, + }; + } + + // Records each request line and Authorization header exactly as they arrive on the + // wire (Bun.serve would hand the test a parsed URL), then answers 404. + async function rawServer() { + const requests: Req[] = []; + const server = createTlsServer({ key: tls.key, cert: tls.cert }, (socket: any) => { + let head = ""; + socket.on("data", (chunk: Buffer) => { + head += chunk.toString("latin1"); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) return; + const lines = head.slice(0, end).split("\r\n"); + const auth = lines.find(l => l.toLowerCase().startsWith("authorization:")); + requests.push({ path: lines[0].split(" ")[1], auth: auth ? auth.slice("authorization:".length).trim() : null }); + head = ""; + socket.end("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + socket.on("error", () => {}); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + return { + requests, + host: `127.0.0.1:${port}`, + origin: `https://127.0.0.1:${port}`, + [Symbol.dispose]() { + server.close(); + }, + }; + } + + // A registry on its own origin that records the request line as it arrives on the + // wire (Bun.serve would resolve `..` before the test sees the path) and serves the + // manifest and the tarball whatever the credentials. + async function rawRegistry(registryPath: string, tarballPath: string) { + const requests: Req[] = []; + const tgzBytes = await Bun.file(tgz).bytes(); + let origin = ""; + const respond = (socket: any, status: string, type: string, body: Uint8Array) => { + socket.write( + `HTTP/1.1 ${status}\r\nContent-Type: ${type}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`, + ); + socket.end(body); + }; + const server = createTlsServer({ key: tls.key, cert: tls.cert }, (socket: any) => { + let head = ""; + socket.on("data", (chunk: Buffer) => { + head += chunk.toString("latin1"); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) return; + const lines = head.slice(0, end).split("\r\n"); + const path = lines[0].split(" ")[1]; + const auth = lines.find(l => l.toLowerCase().startsWith("authorization:")); + requests.push({ path, auth: auth ? auth.slice("authorization:".length).trim() : null }); + head = ""; + if (path === `${registryPath}/no-deps`) { + const manifest = JSON.stringify({ + name: "no-deps", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "no-deps", version: "1.0.0", dist: { tarball: `${origin}${tarballPath}` } } }, + }); + respond(socket, "200 OK", "application/json", new TextEncoder().encode(manifest)); + } else { + respond(socket, "200 OK", "application/octet-stream", tgzBytes); + } + }); + socket.on("error", () => {}); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + origin = `https://127.0.0.1:${port}`; + return { + requests, + host: `127.0.0.1:${port}`, + origin, + [Symbol.dispose]() { + server.close(); + }, + }; + } + + async function install(dir: string, args: string[] = [], extraEnv: Record = {}) { + // bunEnv spreads process.env; the user-level .npmrc of the machine running the + // tests must not leak in, and the cache must be cold so the tarball is fetched. + const spawnEnv: Record = { + ...(env as Record), + HOME: join(dir, "home"), + USERPROFILE: join(dir, "home"), + BUN_INSTALL_CACHE_DIR: join(dir, ".cache"), + ...extraEnv, + }; + delete spawnEnv.XDG_CONFIG_HOME; + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--ca", tls.cert, ...args], + cwd: dir, + env: spawnEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const manifestPath = "/no-deps"; + const tarballPath = "/no-deps/-/no-deps-1.0.0.tgz"; + + test("--registry uses the token the user-level .npmrc has for that host", async () => { + using registry = mockRegistry("Bearer user-npmrc-token"); + using dir = tempDir("npmrc-url-auth-cli-registry", { + "package.json": packageJson, + "home/.npmrc": `//${registry.host}/:_authToken=user-npmrc-token\n`, + }); + + const { stderr, exitCode } = await install(String(dir), ["--registry", `${registry.origin}/`]); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: manifestPath, auth: "Bearer user-npmrc-token" }, + { path: tarballPath, auth: "Bearer user-npmrc-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + // The default registry's token used to follow any `--registry`/env registry on the + // same host, path ignored, so a sibling path got the wrong token and its own line + // was never consulted. Only a registry at or under the old one inherits now. + test.each([ + ["--registry", (origin: string) => [["--registry", `${origin}/npm/team-b/`], {}] as const], + ["NPM_CONFIG_REGISTRY", (origin: string) => [[], { NPM_CONFIG_REGISTRY: `${origin}/npm/team-b/` }] as const], + ])( + "a registry from %s on a sibling path uses that path's own line, not the default registry's token", + async (_source, overrideFor) => { + using registry = mockRegistry("Bearer team-b-token", { + registryPath: "/npm/team-b", + tarballPath: "/npm/team-b/no-deps/-/no-deps-1.0.0.tgz", + }); + using dir = tempDir("npmrc-url-auth-sibling-registry", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/npm/team-a/`, + `//${registry.host}/npm/team-a/:_authToken=team-a-token`, + `//${registry.host}/npm/team-b/:_authToken=team-b-token`, + "", + ].join("\n"), + "home/.gitkeep": "", + }); + const [args, extraEnv] = overrideFor(registry.origin); + + const { stderr, exitCode } = await install(String(dir), [...args], { ...extraEnv }); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: "/npm/team-b/no-deps", auth: "Bearer team-b-token" }, + { path: "/npm/team-b/no-deps/-/no-deps-1.0.0.tgz", auth: "Bearer team-b-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }, + ); + + // A deeper line wins over inheritance, as npm resolves it from the request URL. + test.each([ + ["--registry", (origin: string) => [["--registry", `${origin}/npm/team-a/sub/`], {}] as const], + ["NPM_CONFIG_REGISTRY", (origin: string) => [[], { NPM_CONFIG_REGISTRY: `${origin}/npm/team-a/sub/` }] as const], + ])("a registry from %s below the default registry uses its own deeper line", async (_source, overrideFor) => { + using registry = mockRegistry("Bearer sub-token", { + registryPath: "/npm/team-a/sub", + tarballPath: "/npm/team-a/sub/no-deps/-/no-deps-1.0.0.tgz", + }); + using dir = tempDir("npmrc-url-auth-deeper-line", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/npm/team-a/`, + `//${registry.host}/npm/team-a/:_authToken=team-a-token`, + `//${registry.host}/npm/team-a/sub/:_authToken=sub-token`, + "", + ].join("\n"), + "home/.gitkeep": "", + }); + const [args, extraEnv] = overrideFor(registry.origin); + + const { stderr, exitCode } = await install(String(dir), [...args], { ...extraEnv }); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: "/npm/team-a/sub/no-deps", auth: "Bearer sub-token" }, + { path: "/npm/team-a/sub/no-deps/-/no-deps-1.0.0.tgz", auth: "Bearer sub-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + test("--registry below the default registry keeps its token", async () => { + using registry = mockRegistry("Bearer team-a-token", { + registryPath: "/npm/team-a/sub", + tarballPath: "/npm/team-a/sub/no-deps/-/no-deps-1.0.0.tgz", + }); + using dir = tempDir("npmrc-url-auth-child-registry", { + "package.json": packageJson, + ".npmrc": `registry=${registry.origin}/npm/team-a/\n//${registry.host}/npm/team-a/:_authToken=team-a-token\n`, + "home/.gitkeep": "", + }); + + const { stderr, exitCode } = await install(String(dir), ["--registry", `${registry.origin}/npm/team-a/sub/`]); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: "/npm/team-a/sub/no-deps", auth: "Bearer team-a-token" }, + { path: "/npm/team-a/sub/no-deps/-/no-deps-1.0.0.tgz", auth: "Bearer team-a-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + test.each(["NPM_CONFIG_REGISTRY", "BUN_CONFIG_REGISTRY"])( + "a registry from %s uses the token the project .npmrc has for that host", + async variable => { + using registry = mockRegistry("Bearer env-registry-token"); + using dir = tempDir("npmrc-url-auth-env-registry", { + "package.json": packageJson, + ".npmrc": `//${registry.host}/:_authToken=env-registry-token\n`, + }); + + const { exitCode } = await install(String(dir), [], { [variable]: `${registry.origin}/` }); + + expect({ requests: registry.requests, exitCode }).toEqual({ + requests: [ + { path: manifestPath, auth: "Bearer env-registry-token" }, + { path: tarballPath, auth: "Bearer env-registry-token" }, + ], + exitCode: 0, + }); + }, + ); + + test("username and _password lines for the --registry host are sent as basic auth", async () => { + using registry = mockRegistry(basic("alice", "open sesame")); + using dir = tempDir("npmrc-url-auth-basic", { + "package.json": packageJson, + ".npmrc": [ + `//${registry.host}/:username=alice`, + `//${registry.host}/:_password=${Buffer.from("open sesame").toString("base64")}`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir), ["--registry", `${registry.origin}/`]); + + expect({ requests: registry.requests, exitCode }).toEqual({ + requests: [ + { path: manifestPath, auth: basic("alice", "open sesame") }, + { path: tarballPath, auth: basic("alice", "open sesame") }, + ], + exitCode: 0, + }); + }); + + test("a line for the tarball host takes precedence over userinfo in the dist.tarball url", async () => { + using cdn = mockRegistry("Bearer cdn-token", { secure: true }); + using registry = mockRegistry("Bearer registry-token", { + tarballOrigin: () => `https://carol:s3cret@${cdn.host}`, + }); + using dir = tempDir("npmrc-url-auth-line-over-userinfo", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/:_authToken=cdn-token`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect({ cdn: cdn.requests, exitCode }).toEqual({ + cdn: [{ path: tarballPath, auth: "Bearer cdn-token" }], + exitCode: 0, + }); + }); + + test("a tarball on a different host than its registry gets that host's own line", async () => { + using cdn = mockRegistry("Bearer cdn-token", { secure: true }); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin }); + using dir = tempDir("npmrc-url-auth-tarball-host", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/:_authToken=cdn-token`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect({ registry: registry.requests, cdn: cdn.requests, exitCode }).toEqual({ + registry: [{ path: manifestPath, auth: "Bearer registry-token" }], + cdn: [{ path: tarballPath, auth: "Bearer cdn-token" }], + exitCode: 0, + }); + }); + + test("username and _password lines for the tarball host are sent to it as basic auth", async () => { + using cdn = mockRegistry(basic("cdn-user", "cdn-pass"), { secure: true }); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin }); + using dir = tempDir("npmrc-url-auth-tarball-host-basic", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/:username=cdn-user`, + `//${cdn.host}/:_password=${Buffer.from("cdn-pass").toString("base64")}`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect({ registry: registry.requests, cdn: cdn.requests, exitCode }).toEqual({ + registry: [{ path: manifestPath, auth: "Bearer registry-token" }], + cdn: [{ path: tarballPath, auth: basic("cdn-user", "cdn-pass") }], + exitCode: 0, + }); + }); + + test("the line with the deepest path covering the tarball url wins; other paths on the host do not apply", async () => { + using cdn = mockRegistry("Bearer deep-token", { secure: true }); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin }); + using dir = tempDir("npmrc-url-auth-tarball-path", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/no-deps-other/:_authToken=other-token`, + `//${cdn.host}/:_authToken=shallow-token`, + `//${cdn.host}/no-deps/:_authToken=deep-token`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect({ cdn: cdn.requests, exitCode }).toEqual({ + cdn: [{ path: tarballPath, auth: "Bearer deep-token" }], + exitCode: 0, + }); + }); + + test("a line for another path on the tarball host does not authenticate it, even if it is a string prefix", async () => { + // https, so the key walk is what decides, not the plaintext guard. + using cdn = mockRegistry("Bearer other-token", { secure: true }); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin }); + using dir = tempDir("npmrc-url-auth-tarball-wrong-path", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + // "/no" is a prefix of "/no-deps/..." but not a parent directory of it. + `//${cdn.host}/no/:_authToken=other-token`, + "", + ].join("\n"), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect({ cdn: cdn.requests, exitCode }).toEqual({ + cdn: [{ path: tarballPath, auth: null }], + exitCode: 1, + }); + expect(stderr).toContain(`error: GET ${cdn.origin}${tarballPath} - 401`); + }); + + // https://github.com/oven-sh/bun/issues/30513: GitLab resolves packages through + // an instance-level registry path but serves tarballs from (and keys tokens to) + // a project-level path. The token line covers the tarball URL and nothing else. + test("a line keyed to the tarball path authenticates the tarball when the registry itself has no credentials", async () => { + const registryPath = "/api/v4/packages/npm"; + const tarballPath = "/api/v4/projects/123/packages/npm/no-deps/-/no-deps-1.0.0.tgz"; + using registry = mockRegistry("Bearer project-token", { registryPath, tarballPath, publicManifest: true }); + using dir = tempDir("npmrc-url-auth-divergent-path", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}/api/v4/projects/123/packages/npm/:_authToken=project-token`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode }).toEqual({ + requests: [ + { path: `${registryPath}/no-deps`, auth: null }, + { path: tarballPath, auth: "Bearer project-token" }, + ], + exitCode: 0, + }); + }); + + test("a line keyed to a parent path of the registry url applies to the registry", async () => { + const registryPath = "/api/v4/packages/npm"; + const tarballPath = `${registryPath}/no-deps/-/no-deps-1.0.0.tgz`; + using registry = mockRegistry("Bearer host-token", { registryPath, tarballPath }); + using dir = tempDir("npmrc-url-auth-parent-path", { + "package.json": packageJson, + ".npmrc": [`registry=${registry.origin}${registryPath}/`, `//${registry.host}/:_authToken=host-token`, ""].join( + "\n", + ), + }); + + const { exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode }).toEqual({ + requests: [ + { path: `${registryPath}/no-deps`, auth: "Bearer host-token" }, + { path: tarballPath, auth: "Bearer host-token" }, + ], + exitCode: 0, + }); + }); + + test("a lockfile recorded against a registry that has since moved still downloads from the old host", async () => { + using oldRegistry = mockRegistry("Bearer old-token", { secure: true }); + using newRegistry = mockRegistry("Bearer new-token"); + using dir = tempDir("npmrc-url-auth-moved-registry", { + "package.json": packageJson, + ".npmrc": [`registry=${oldRegistry.origin}/`, `//${oldRegistry.host}/:_authToken=old-token`, ""].join("\n"), + }); + + const first = await install(String(dir)); + expect(first.exitCode).toBe(0); + const lockfile = await Bun.file(join(String(dir), "bun.lock")).text(); + expect(lockfile).toContain(`${oldRegistry.origin}${tarballPath}`); + oldRegistry.requests.length = 0; + + // The registry moves; the user keeps credentials for both hosts, as npm + // needs them to for the same lockfile. + await Bun.write( + join(String(dir), ".npmrc"), + [ + `registry=${newRegistry.origin}/`, + `//${newRegistry.host}/:_authToken=new-token`, + `//${oldRegistry.host}/:_authToken=old-token`, + "", + ].join("\n"), + ); + await rm(join(String(dir), "node_modules"), { recursive: true, force: true }); + await rm(join(String(dir), ".cache"), { recursive: true, force: true }); + + const second = await install(String(dir), ["--frozen-lockfile"]); + + expect({ old: oldRegistry.requests, new: newRegistry.requests, exitCode: second.exitCode }).toEqual({ + old: [{ path: tarballPath, auth: "Bearer old-token" }], + new: [], + exitCode: 0, + }); + }); + + // npm's fallback: a tarball with no `.npmrc` line of its own gets the registry's + // credentials when it is on the registry's origin, whatever its path. GitLab's + // instance-level registry serves tarballs under a project path, so this is the + // documented setup (#30513). + test("registry credentials follow a tarball on a sibling path of the same origin", async () => { + const registryPath = "/npm/team-a"; + const tarballPath = "/npm/team-b/no-deps/-/no-deps-1.0.0.tgz"; + using registry = mockRegistry("Bearer team-a-token", { registryPath, tarballPath }); + using dir = tempDir("npmrc-url-auth-sibling-path", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}${registryPath}/:_authToken=team-a-token`, + "", + ].join("\n"), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: `${registryPath}/no-deps`, auth: "Bearer team-a-token" }, + { path: tarballPath, auth: "Bearer team-a-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + test("GitLab's instance-level registry: the project-path tarball gets the instance token", async () => { + const registryPath = "/api/v4/packages/npm"; + const tarballPath = "/api/v4/projects/123/packages/npm/no-deps/-/no-deps-1.0.0.tgz"; + using registry = mockRegistry("Bearer instance-token", { registryPath, tarballPath }); + using dir = tempDir("npmrc-url-auth-gitlab-instance", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}${registryPath}/:_authToken=instance-token`, + "", + ].join("\n"), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: `${registryPath}/no-deps`, auth: "Bearer instance-token" }, + { path: tarballPath, auth: "Bearer instance-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + // A line specific to the tarball's path beats the registry's credentials, as in + // npm; a line the registry itself resolves to does not (bunfig, env and CLI + // credentials stay ahead of `.npmrc`, as they are for the manifest). + test("a tarball path with its own .npmrc line uses that line, not the registry's token", async () => { + const registryPath = "/npm/team-a"; + const tarballPath = "/npm/team-b/no-deps/-/no-deps-1.0.0.tgz"; + using registry = mockRegistry("Bearer team-b-token", { registryPath, tarballPath, publicManifest: true }); + using dir = tempDir("npmrc-url-auth-own-line", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}${registryPath}/:_authToken=team-a-token`, + `//${registry.host}/npm/team-b/:_authToken=team-b-token`, + "", + ].join("\n"), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: `${registryPath}/no-deps`, auth: "Bearer team-a-token" }, + { path: tarballPath, auth: "Bearer team-b-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + // A shallower line the registry never reached is the tarball's own line, as in npm: + // the registry stopped at its team key, the tarball outside that tree walks to root. + test("a same-origin tarball outside the registry's tree uses the host-root line", async () => { + const registryPath = "/npm/team-a"; + const tarballPath = "/cdn/no-deps/-/no-deps-1.0.0.tgz"; + using registry = mockRegistry("Bearer root-token", { registryPath, tarballPath, publicManifest: true }); + using dir = tempDir("npmrc-url-auth-root-line", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}${registryPath}/:_authToken=team-a-token`, + `//${registry.host}/:_authToken=root-token`, + "", + ].join("\n"), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode, stderr }).toEqual({ + requests: [ + { path: `${registryPath}/no-deps`, auth: "Bearer team-a-token" }, + { path: tarballPath, auth: "Bearer root-token" }, + ], + exitCode: 0, + stderr: expect.not.stringContaining("401"), + }); + }); + + // `.npmrc` keys carry no scheme. A registry-supplied `http://` tarball must not + // receive a token the user wrote for an https host: the manifest is registry-controlled. + test("an http tarball from an https registry gets no .npmrc credentials", async () => { + const tarballRequests: (string | null)[] = []; + using plain = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + tarballRequests.push(req.headers.get("authorization")); + return new Response(Bun.file(tgz)); + }, + }); + using secure = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls, + fetch() { + return Response.json({ + name: "no-deps", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "no-deps", + version: "1.0.0", + dist: { tarball: `http://127.0.0.1:${plain.port}/no-deps/-/no-deps-1.0.0.tgz` }, + }, + }, + }); + }, + }); + using dir = tempDir("npmrc-url-auth-http-tarball", { + "package.json": packageJson, + ".npmrc": [ + `registry=https://127.0.0.1:${secure.port}/`, + `//127.0.0.1:${secure.port}/:_authToken=registry-token`, + `//127.0.0.1:${plain.port}/:_authToken=must-not-leak`, + "", + ].join("\n"), + }); + + await install(String(dir)); + + expect(tarballRequests).toEqual([null]); + }); + + // The same holds when the registry itself is http: a plaintext registry (or anyone + // on its path) must not be able to steer another host's token over plaintext. + test("an http tarball on another host gets no .npmrc credentials even from an http registry", async () => { + const tarballRequests: (string | null)[] = []; + using other = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + tarballRequests.push(req.headers.get("authorization")); + return new Response(Bun.file(tgz)); + }, + }); + using registry = mockRegistry("Bearer registry-token", { + tarballOrigin: () => `http://127.0.0.1:${other.port}`, + publicManifest: true, + }); + using dir = tempDir("npmrc-url-auth-http-other-host", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//127.0.0.1:${other.port}/:_authToken=must-not-leak`, + "", + ].join("\n"), + }); + + await install(String(dir)); + + expect(tarballRequests).toEqual([null]); + }); + + // On the registry's own origin the request goes to the resolved path and the line + // for that path decides: a tarball resolving into team-b's tree carries team-b's + // token, not the registry's, while one staying in team-a's tree keeps team-a's. + test.each([ + ["/npm/team-a/../team-b/x.tgz", "/npm/team-b/x.tgz", "Bearer team-b-token"], + ["/npm/team-a/%2e%2e/team-b/x.tgz", "/npm/team-b/x.tgz", "Bearer team-b-token"], + ["/npm/team-a/./x.tgz", "/npm/team-a/x.tgz", "Bearer team-a-token"], + ])( + "a dist.tarball of %s on the registry's origin is requested at %s with that path's line", + async (tarballPath, resolvedPath, auth) => { + const registryPath = "/npm/team-a"; + using registry = await rawRegistry(registryPath, tarballPath); + using dir = tempDir("npmrc-url-auth-dot-segments", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}/npm/team-a/:_authToken=team-a-token`, + `//${registry.host}/npm/team-b/:_authToken=team-b-token`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect(registry.requests[0]).toEqual({ path: `${registryPath}/no-deps`, auth: "Bearer team-a-token" }); + expect(registry.requests.length).toBeGreaterThan(1); + expect(registry.requests.slice(1)).toEqual(registry.requests.slice(1).map(() => ({ path: resolvedPath, auth }))); + expect(exitCode).toBe(0); + }, + ); + + // `dist.tarball` is parsed once, by the WHATWG parser, as npm's `new URL()` does: the + // path on the wire is the resolved path, and the `.npmrc` line is the one for that + // path. No spelling of a dot segment can put one team's token on a request the server + // could route to another team's tree, because the request names the tree the key names. + test.each([ + "/npm/team-a/../team-b/x.tgz", + "/npm/team-a/./x.tgz", + "/npm/team-a/%2e%2e/team-b/x.tgz", + "/npm/team-a/%2E%2E/team-b/x.tgz", + "/npm/team-a/%2e./team-b/x.tgz", + "/npm/team-a/.%2e/team-b/x.tgz", + "/npm/team-b/..\\team-a/x.tgz", + "/npm/team-b\\..\\team-a/x.tgz", + "/npm/@scope%2fpkg/-/team-a/x.tgz", + ])("a dist.tarball at %s is requested at its resolved path with that path's line", async tarballPath => { + using cdn = await rawServer(); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin, tarballPath }); + using dir = tempDir("npmrc-url-auth-cdn-dot-segments", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/npm/team-a/:_authToken=cdn-a`, + `//${cdn.host}/npm/team-b/:_authToken=cdn-b`, + "", + ].join("\n"), + }); + + await install(String(dir)); + + const resolved = new URL(cdn.origin + tarballPath).pathname; + const lineFor = (path: string) => + path.startsWith("/npm/team-a/") ? "Bearer cdn-a" : path.startsWith("/npm/team-b/") ? "Bearer cdn-b" : null; + expect(registry.requests[0]).toEqual({ path: "/no-deps", auth: "Bearer registry-token" }); + expect(cdn.requests.length).toBeGreaterThan(0); + expect(cdn.requests).toEqual(cdn.requests.map(() => ({ path: resolved, auth: lineFor(resolved) }))); + }); + + // The one spelling a single parse cannot settle: a dot segment behind an encoded `/` or + // `\`, which WHATWG keeps opaque and a decoding server re-routes. Requested as written, + // with no `.npmrc` line of its own. + test.each([ + "/npm/team-a/..%2Fteam-b/x.tgz", + "/npm/team-a/%2f../team-b/x.tgz", + "/npm/team-a/a%2f..%2fteam-b/x.tgz", + "/npm/team-a/%5c..%5cteam-b/x.tgz", + "/npm/team-a/%2e%2e%5Cteam-b/x.tgz", + ])("a dist.tarball at %s is requested as written with no line", async tarballPath => { + using cdn = await rawServer(); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin, tarballPath }); + using dir = tempDir("npmrc-url-auth-cdn-encoded-separator", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/npm/team-a/:_authToken=cdn-a`, + "", + ].join("\n"), + }); + + await install(String(dir)); + + expect(cdn.requests.length).toBeGreaterThan(0); + expect(cdn.requests).toEqual(cdn.requests.map(() => ({ path: tarballPath, auth: null }))); + }); + + // On the registry's own origin such a tarball carries the registry's credentials: + // the encoded separator is never read as `/`, so team-a's line, which a decoding + // server would route the request to, is not selected. + test("a dist.tarball with an encoded separator on the registry's origin carries the registry's credentials", async () => { + const registryPath = "/npm/team-b"; + const tarballPath = "/npm/team-b/..%2fteam-a/x.tgz"; + using registry = mockRegistry("Bearer team-b-token", { registryPath, tarballPath }); + using dir = tempDir("npmrc-url-auth-encoded-separator-same-origin", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}${registryPath}/`, + `//${registry.host}/npm/team-a/:_authToken=team-a-token`, + `//${registry.host}/npm/team-b/:_authToken=team-b-token`, + "", + ].join("\n"), + }); + + await install(String(dir)); + + expect(registry.requests.length).toBeGreaterThan(1); + expect(registry.requests.slice(1)).toEqual( + registry.requests.slice(1).map(() => ({ path: tarballPath, auth: "Bearer team-b-token" })), + ); + }); + + // A tab or another control byte never reaches the wire: the HTTP client refuses the URL, as before. + test.each(["/npm/team-a/.\t./team-b/x.tgz", "/npm/team-a/%2e\t%2e/team-b/x.tgz"])( + "a dist.tarball at %s is not requested at all", + async tarballPath => { + using cdn = await rawServer(); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin, tarballPath }); + using dir = tempDir("npmrc-url-auth-cdn-control-byte", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/npm/team-a/:_authToken=cdn-a`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect(cdn.requests).toEqual([]); + expect(exitCode).toBe(1); + }, + ); + + // `https://#@/x.tgz`: everything after `#` is a fragment, so the + // request goes to the registry's root and the cdn is never contacted, let alone + // handed the registry's token. + test("a dist.tarball with `#@host` in it is requested from the authority before the #", async () => { + using cdn = await rawServer(); + using registry = mockRegistry("Bearer registry-token", { + secure: true, + tarballPath: "/x.tgz", + tarballOrigin: () => `${registry.origin}#@${cdn.host}`, + }); + using dir = tempDir("npmrc-url-auth-parser-split", { + "package.json": packageJson, + ".npmrc": [`registry=${registry.origin}/`, `//${registry.host}/:_authToken=registry-token`, ""].join("\n"), + }); + + await install(String(dir)); + + expect(registry.requests.slice(0, 2)).toEqual([ + { path: "/no-deps", auth: "Bearer registry-token" }, + { path: "/", auth: "Bearer registry-token" }, + ]); + expect(cdn.requests).toEqual([]); + }); + + // The `..` in the new URL resolves to a sibling of the default registry, so the + // default's token must not follow it. + test.each([ + ["--registry", (origin: string) => [["--registry", `${origin}/npm/team-a/../team-b/`], {}] as const], + [ + "NPM_CONFIG_REGISTRY", + (origin: string) => [[], { NPM_CONFIG_REGISTRY: `${origin}/npm/team-a/../team-b/` }] as const, + ], + ])( + "a registry from %s that dot-segments to a sibling path does not inherit the default's token", + async (_source, overrideFor) => { + using registry = mockRegistry("Bearer team-a-token", { registryPath: "/npm/team-b" }); + using dir = tempDir("npmrc-url-auth-dot-segment-registry", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/npm/team-a/`, + `//${registry.host}/npm/team-a/:_authToken=team-a-token`, + "", + ].join("\n"), + "home/.gitkeep": "", + }); + const [args, extraEnv] = overrideFor(registry.origin); + + const { exitCode } = await install(String(dir), [...args], { ...extraEnv }); + + expect(registry.requests[0]).toEqual({ path: "/npm/team-b/no-deps", auth: null }); + expect(exitCode).toBe(1); + }, + ); + + // A bare `$VAR` registry URL is expanded only when the scope is built, so its + // `.npmrc` line can only be found by the walk that runs after every source. + test("a scoped registry written as $VAR picks up its .npmrc line", async () => { + using registry = mockRegistry("Bearer corp-token", { registryPath: "/npm", packageName: "@corp/no-deps" }); + using dir = tempDir("npmrc-url-auth-env-var-scope", { + "package.json": JSON.stringify({ name: "foo", version: "1.0.0", dependencies: { "@corp/no-deps": "1.0.0" } }), + ".npmrc": [`@corp:registry=$CORP_REG`, `//${registry.host}/npm/:_authToken=corp-token`, ""].join("\n"), + "home/.gitkeep": "", + }); + + const { exitCode } = await install(String(dir), [], { CORP_REG: `${registry.origin}/npm/` }); + + expect(registry.requests.map(r => r.auth)).toEqual(["Bearer corp-token", "Bearer corp-token"]); + expect(exitCode).toBe(0); + }); + + test("a bunfig registry written as $VAR picks up its .npmrc line", async () => { + using registry = mockRegistry("Bearer corp-token", { registryPath: "/npm" }); + using dir = tempDir("npmrc-url-auth-env-var-bunfig", { + "package.json": packageJson, + "bunfig.toml": `[install]\nregistry = "$MY_REG"\n`, + ".npmrc": [`//${registry.host}/npm/:_authToken=corp-token`, ""].join("\n"), + "home/.gitkeep": "", + }); + + const { exitCode } = await install(String(dir), [], { MY_REG: `${registry.origin}/npm/` }); + + expect(registry.requests.map(r => r.auth)).toEqual(["Bearer corp-token", "Bearer corp-token"]); + expect(exitCode).toBe(0); + }); + + // `_auth` goes out as `Basic ` verbatim at request time too. + test("an _auth line applies to a --registry registry and its tarball", async () => { + const blob = "opaque-blob"; + using registry = mockRegistry(`Basic ${blob}`); + using dir = tempDir("npmrc-url-auth-basic-cli", { + "package.json": packageJson, + ".npmrc": [`//${registry.host}/:_auth=${blob}`, ""].join("\n"), + "home/.gitkeep": "", + }); + + const { exitCode } = await install(String(dir), ["--registry", `${registry.origin}/`]); + + expect(registry.requests.map(r => r.auth)).toEqual([`Basic ${blob}`, `Basic ${blob}`]); + expect(exitCode).toBe(0); + }); + + test("an _auth line keyed to the tarball host applies to the tarball", async () => { + const blob = "opaque-blob"; + using cdn = mockRegistry(`Basic ${blob}`, { secure: true }); + using registry = mockRegistry("Bearer registry-token", { tarballOrigin: () => cdn.origin }); + using dir = tempDir("npmrc-url-auth-basic-cdn", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/:_auth=${blob}`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect(cdn.requests).toEqual([{ path: "/no-deps/-/no-deps-1.0.0.tgz", auth: `Basic ${blob}` }]); + expect(exitCode).toBe(0); + }); + + test("a query on a cross-host dist.tarball still resolves the deeper line", async () => { + using cdn = mockRegistry("Bearer cdn-token", { secure: true, tarballPath: "/npm/x.tgz" }); + using registry = mockRegistry("Bearer registry-token", { + tarballOrigin: () => cdn.origin, + tarballPath: "/npm/x.tgz", + tarballQuery: "?sig=abc", + }); + using dir = tempDir("npmrc-url-auth-query-cdn", { + "package.json": packageJson, + ".npmrc": [ + `registry=${registry.origin}/`, + `//${registry.host}/:_authToken=registry-token`, + `//${cdn.host}/npm/:_authToken=cdn-token`, + "", + ].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect(cdn.requests).toEqual([{ path: "/npm/x.tgz", auth: "Bearer cdn-token" }]); + expect(exitCode).toBe(0); + }); + + // Bun's docs long showed the key with a scheme; npm never writes one, but it must keep + // working. The scheme is dropped when the line is read. + test("a key written with a scheme, as Bun's docs showed it, still applies", async () => { + using registry = mockRegistry("Bearer docs-token"); + using dir = tempDir("npmrc-url-auth-scheme-key", { + "package.json": packageJson, + ".npmrc": [`registry=${registry.origin}/`, `//http://${registry.host}/:_authToken=docs-token`, ""].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect({ requests: registry.requests, exitCode }).toEqual({ + requests: [ + { path: manifestPath, auth: "Bearer docs-token" }, + { path: tarballPath, auth: "Bearer docs-token" }, + ], + exitCode: 0, + }); + }); + + // Rule 3: the registry's own key is already reflected in its credentials, behind a + // bunfig, env or CLI credential, so a tarball resolving to that same key carries the + // bunfig token, not the line's. + test("a same-key dist.tarball carries the registry's bunfig token over the .npmrc line", async () => { + using registry = mockRegistry("Bearer bunfig-token"); + using dir = tempDir("npmrc-url-auth-rule-3-bunfig", { + "package.json": packageJson, + "bunfig.toml": `[install] +registry = { url = "${registry.origin}/", token = "bunfig-token" } +`, + ".npmrc": [`//${registry.host}/:_authToken=line-token`, ""].join("\n"), + }); + + const { exitCode } = await install(String(dir)); + + expect(registry.requests.map(r => r.auth)).toEqual(["Bearer bunfig-token", "Bearer bunfig-token"]); + expect(exitCode).toBe(0); + }); + + // A credential carried over to an env registry never goes from https to http. + test("an env registry that downgrades the bunfig registry from https to http gets no carried-over token", async () => { + const seen: Array = []; + using plain = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push(req.headers.get("authorization")); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${plain.port}`; + using dir = tempDir("npmrc-url-auth-env-downgrade", { + "package.json": packageJson, + "bunfig.toml": `[install] +registry = { url = "https://${host}/", token = "https-only" } +`, + }); + + await install(String(dir), [], { NPM_CONFIG_REGISTRY: `http://${host}/` }); + + expect(seen).toEqual([null]); + }); + + // `_authtoken` used to match `_auth` as a substring and go out as `Basic `. + test("a misspelt option sends nothing and warns, without printing the value", async () => { + using registry = mockRegistry("Bearer typo-token"); + using dir = tempDir("npmrc-url-auth-typo", { + "package.json": packageJson, + ".npmrc": [`registry=${registry.origin}/`, `//${registry.host}/:_authtoken=typo-token`, ""].join("\n"), + }); + + const { stderr, exitCode } = await install(String(dir)); + + expect(registry.requests).toEqual([{ path: manifestPath, auth: null }]); + expect(stderr).toContain("_authtoken is not a known .npmrc option"); + expect(stderr).not.toContain("typo-token"); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 6c49d43123b4..c07e48d6b92a 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -1,4 +1,5 @@ import { write } from "bun"; +import { iniInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness"; import { join } from "path"; @@ -148,6 +149,15 @@ describe.concurrent("redact", async () => { bunfig: `install.registry = "https://user:pass@registry.org`, expected: `"https://user:****@registry.org`, }, + { + // A userinfo password that happens to start with a redacted keyword must not + // arm value redaction for the rest of the line; the quoted-key scan runs only + // on the fall-through path after the URL/UUID/npm-secret redactors. + title: "url password starting with a redacted keyword", + bunfig: `install.scopes.x = { url = "https://user:token123@registry.org/", username = "alice" };bad`, + expected: `, username = `, + secret: "token123", + }, { title: "empty url password", bunfig: `install.registry = "https://user:@registry.org`, @@ -184,40 +194,120 @@ describe.concurrent("redact", async () => { expected: "*", }, { + // npm forwards these verbatim, so there is no diagnostic to redact: the + // assertion is that neither an error nor the value reaches stderr. title: "invalid _auth", npmrc: "//registry.npmjs.org/:_auth = does-not-decode", - expected: "****************", + expected: "", + secret: "does-not-decode", }, { title: "unexpected _auth", npmrc: "//registry.npmjs.org/:_auth=:secret", - expected: "*******", + expected: "", + secret: ":secret", }, { title: "_auth zero length", npmrc: "//registry.npmjs.org/:_auth=", - expected: "received an empty string", + expected: "supplies no credentials", }, { - title: "_auth one length", - npmrc: "//registry.npmjs.org/:_auth=1", - expected: "*", + // The unknown-option warning names the option but never echoes the line, so no + // value can leak whatever the key's spelling: quoted, misspelt, or without the + // underscore. These rows pin that the warning prints and the value does not. + title: "quoted _authToken key", + npmrc: '"//registry.npmjs.org/:_authtoken"=npm_notarealtokenvalue', + expected: "is not a known .npmrc option", + secret: "npm_notarealtokenvalue", + }, + { + title: "quoted _auth key", + npmrc: 'registry=https://registry.example.com/api/\n"//registry.example.com/:_auth_"=does-not-decode', + expected: "is not a known .npmrc option", + secret: "does-not-decode", + }, + { + title: "quoted password key", + npmrc: '"//registry.npmjs.org/:password"=SUPERSECRETVALUE', + expected: "is not a known .npmrc option", + secret: "SUPERSECRETVALUE", + }, + { + title: "misspelt option without the underscore", + npmrc: "//registry.npmjs.org/:authToken=npm_notarealtokenvalue", + expected: "is not a known .npmrc option", + secret: "npm_notarealtokenvalue", + }, + { + title: "misspelt _authToken key", + npmrc: "//registry.npmjs.org/:_authtoken=npm_notarealtokenvalue", + expected: "is not a known .npmrc option", + secret: "npm_notarealtokenvalue", + }, + // Lines that are not `//host/…:option=value` never reach the unknown-option + // warning, since the word after the last colon could be the value itself. + { + title: "credential key without an equals sign", + npmrc: "//registry.npmjs.org/:_authToken", + expected: "", + secret: "_authToken", + }, + { + title: "credential key with a colon instead of an equals sign", + npmrc: "//registry.npmjs.org/:_authToken:npm_notarealtokenvalue", + expected: "", + secret: "npm_notarealtokenvalue", + }, + { + title: "bare host and port", + npmrc: "//localhost:4873", + expected: "", + secret: "4873", + }, + { + title: "bare bracketed IPv6 host", + npmrc: "//[::1]", + expected: "", + secret: "::1", + }, + { + // `:` typed for `=`: the parser splits at the base64 padding, so the credential + // bytes sit where an option name would; the warning must not name them. + title: "credential key with a colon and a padded base64 value", + npmrc: "//registry.npmjs.org/:_auth:dXNlcjpwdw==", + expected: "", + secret: "dXNlcjpwdw", + }, + { + // The most common .npmrc authoring mistake, and the value is always a live secret. + // npm decodes _password with Buffer.from(v, "base64"), which never throws — it + // skips invalid bytes — so there is no diagnostic and nothing may reach stderr. + title: "plaintext _password", + npmrc: "//registry.npmjs.org/:username=alice\n//registry.npmjs.org/:_password=p@ssw0rd!", + expected: "", + secret: "p@ssw0rd!", + forbidden: "is not valid base64", }, ]; - for (const { title, bunfig, npmrc, expected } of tests) { + for (const { title, bunfig, npmrc, expected, secret, forbidden } of tests) { test(title + (bunfig ? " (bunfig)" : " (npmrc)"), async () => { const testDir = tmpdirSync(); + // An empty home, so the machine's own `.npmrc` cannot add a warning or an error. + const home = join(testDir, "home"); await Promise.all([ write(join(testDir, bunfig ? "bunfig.toml" : ".npmrc"), (bunfig || npmrc)!), write(join(testDir, "package.json"), "{}"), + write(join(home, ".gitkeep"), ""), ]); + const isolated = { HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }; // once without color await using proc1 = Bun.spawn({ cmd: [bunExe(), "install"], cwd: testDir, - env: { ...bunEnv, NO_COLOR: "1" }, + env: { ...bunEnv, NO_COLOR: "1", ...isolated }, stdout: "pipe", stderr: "pipe", }); @@ -225,13 +315,16 @@ describe.concurrent("redact", async () => { const [out1, err1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]); expect(exitCode1).toBe(+!!bunfig); - expect(err1).toContain(expected || "*"); + if (expected) expect(err1).toContain(expected); + else expect(err1).not.toMatch(/\b(error|warn):/); + if (secret) expect(err1).not.toContain(secret); + if (forbidden) expect(err1).not.toContain(forbidden); // once with color await using proc2 = Bun.spawn({ cmd: [bunExe(), "install"], cwd: testDir, - env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1", ...isolated }, stdout: "pipe", stderr: "pipe", }); @@ -239,7 +332,24 @@ describe.concurrent("redact", async () => { const [out2, err2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); expect(exitCode2).toBe(+!!bunfig); - expect(err2).toContain(expected || "*"); + if (expected) expect(err2).toContain(expected); + else expect(err2).not.toMatch(/\b(error|warn):/); + if (secret) expect(err2).not.toContain(secret); + if (forbidden) expect(err2).not.toContain(forbidden); }); } }); + +// The retention half of the "plaintext _password" case above: Buffer.from(v, "base64") +// parity means an invalid-base64 _password is decoded leniently (invalid bytes skipped), +// not dropped — "aGVsbG8*!" must yield the same credential npm derives: "hello". +test("invalid base64 _password keeps the lenient-decoded credential", () => { + const result = iniInternals.loadNpmrc( + "registry=https://registry.npmjs.org/\n" + + "//registry.npmjs.org/:username=alice\n" + + "//registry.npmjs.org/:_password=aGVsbG8*!", + ); + expect(result.default_registry_username).toBe("alice"); + expect(result.default_registry_password).toBe(Buffer.from("aGVsbG8*!", "base64").toString()); + expect(result.default_registry_password).toBe("hello"); +});