diff --git a/src/ast/lib.rs b/src/ast/lib.rs index d7e9b3b51088..674fcdc89411 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1795,6 +1795,34 @@ impl Log { ) } + /// `add_warning` with `AddErrorOptions`, formatted, plus a free-standing `note:` line. + /// `redact_sensitive_information` masks a credential value in the warned line's + /// source frame; the note carries no frame. + #[cold] + pub fn add_warning_fmt_opts_with_note( + &mut self, + args: fmt::Arguments<'_>, + note_args: fmt::Arguments<'_>, + opts: AddErrorOptions<'_>, + ) { + if !Kind::Warn.should_print(self.level) { + return; + } + let notes: Box<[Data]> = Box::new([range_data(None, Range::NONE, alloc_print(note_args))]); + let text = alloc_print(args); + self.add_formatted_msg( + Kind::Warn, + opts.source, + Range { + loc: opts.loc, + len: opts.len, + }, + text, + notes, + opts.redact_sensitive_information, + ) + } + /// Use a bun.sys.Error's message in addition to some extra context. pub fn add_sys_error(&mut self, e: &bun_sys::Error, args: fmt::Arguments<'_>) { let Some((tag_name, sys_errno)) = e.get_error_code_tag_name() else { diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 381ba04fa6c8..b57447fae9be 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1716,6 +1716,15 @@ impl RedactedKeywords { b"_auth" | b"_authToken" | b"token" | b"_password" | b"password" | b"email" ) } + + /// Whether `s` STARTS WITH a redacted keyword. The ini parser recognizes a + /// credential option by substring, so redaction must be at least as loose: + /// `_auth` covers `_authToken`, and trailing junk stays redacted. + pub(crate) fn has_prefix(s: &[u8]) -> bool { + [b"_auth".as_slice(), b"token", b"_password", b"email"] + .iter() + .any(|k| s.starts_with(k)) + } } impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { @@ -2027,6 +2036,22 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { text = &text[i..]; continue 'outer; } + + // An ini credential key may be quoted: + // `"//host/:_authToken"=secret`. The identifier path + // never sees it, so arm the value redaction here, at + // least as loosely as the ini parser matches options. + // Runs after the value redactors above so a URL whose + // userinfo happens to start with a keyword does not + // carry an armed flag across `continue 'outer`. + let mut rest: &[u8] = inner; + while let Some(colon) = strings::index_of_char(rest, b':') { + rest = &rest[colon as usize + 1..]; + if RedactedKeywords::has_prefix(rest) { + should_redact_value = true; + break; + } + } } write!( diff --git a/src/ini/lib.rs b/src/ini/lib.rs index fed27f26583d..8769cba5b82c 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -64,7 +64,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 +95,22 @@ pub enum ConfigOpt { // 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. +#[derive(Clone)] pub struct ConfigItem { + /// npm's registry key: the raw text between `//` and `:=`, so + /// `//127.0.0.1:1234/api/:_authToken=T` yields `127.0.0.1:1234/api/`. Matched + /// literally, as npm does — `nerfDart` already lowercased the keys npm writes. 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 +131,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, ScopeItem, ScopeIterator, ToStringFormatter, apply_registry_auth, + load_npmrc, load_npmrc_config, }; mod draft { @@ -1022,6 +1032,7 @@ mod draft { pub(crate) log: &'a mut Log, pub(crate) prop_idx: usize, + pub(crate) source_idx: u32, } impl<'a> ConfigIterator<'a> { @@ -1075,6 +1086,7 @@ mod draft { optname: opt, loc: keyexpr.loc, optname_loc, + source_idx: self.source_idx, })); } } @@ -1088,103 +1100,6 @@ 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]>), - } - - 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); - } - RegistryCredential::Email(email) => registry.email.clone_from(email), - } - } - } - // ────────────────────────────────────────────────────────────────────────── // ScopeIterator // ────────────────────────────────────────────────────────────────────────── @@ -1248,15 +1163,24 @@ mod draft { // loadNpmrcConfig / loadNpmrc // ────────────────────────────────────────────────────────────────────────── + /// Read every `.npmrc` into `install` and return the collapsed credential lines, + /// which the caller then hands to `apply_registry_auth` for the registries in + /// `bunfig`. `bunfig` is only read here, so that the diagnostics cover those + /// registries too. pub fn load_npmrc_config( install: &mut BunInstall, + bunfig: &BunInstall, env: &DotEnvLoader, auto_loaded: bool, npmrc_paths: &[&ZStr], - ) -> Vec { + ) -> Vec { let mut log = Log::init(); - let mut configs: Vec = Vec::new(); + // npm collapses every `.npmrc` into one flat config map before resolving + // credentials, so all lines are accumulated and resolved once after the loop. + // Each `Source` stays alive so a diagnostic can name the file its line is in. + 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( @@ -1276,72 +1200,486 @@ 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(); - } - let _ = log.print(std::ptr::from_mut::( - Output::error_writer(), - )); + report_log(&mut log, npmrc_path.as_bytes()); } + + let mut registries = resolve_credentials(install, &configs); + registries.extend( + bunfig + .default_registry + .iter() + .chain( + bunfig + .scoped + .iter() + .flat_map(|scoped| scoped.scopes.values()), + ) + .filter_map(bunfig_registry_url) + .map(|url| RegistryKey::from_url(&url)), + ); + diagnose_config(&configs, &sources, ®istries, &mut log); + // The header names the file the error came from, so skip any warning ahead of it. + let path: Box<[u8]> = log + .msgs + .iter() + .find(|msg| msg.kind == bun_ast::Kind::Err) + .and_then(|msg| msg.data.location.as_ref()) + .map_or_else(Box::default, |loc| Box::from(&*loc.file)); + report_log(&mut log, &path); configs } - pub fn apply_registry_auth(install: &mut BunInstall, auth: &[RegistryAuth]) { - if auth.is_empty() { + /// Print and clear `log`. Errors get a header naming the file they came from; + /// the accumulated messages would otherwise reprint once per remaining file. + fn report_log(log: &mut Log, npmrc_path: &[u8]) { + if log.has_errors() { + if log.errors == 1 { + bun_core::warn!( + "Encountered an error while reading {}:\n\n", + bstr::BStr::new(npmrc_path), + ); + } else { + bun_core::warn!( + "Encountered errors while reading {}:\n\n", + bstr::BStr::new(npmrc_path), + ); + } + Output::flush(); + } + let _ = log.print(std::ptr::from_mut::( + Output::error_writer(), + )); + log.reset(); + } + + /// The single mechanism npm reads from the winning key: `_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. + #[derive(Clone, Copy)] + enum AuthMechanism { + Token, + Auth, + UserPass, + } + + /// Strip exactly one trailing `/`, possibly yielding an empty slice. + /// `bun_core::without_trailing_slash` keeps a lone `/` and also strips `\`. + fn strip_one_trailing_slash(pathname: &[u8]) -> &[u8] { + pathname.strip_suffix(b"/").unwrap_or(pathname) + } + + /// npm's `regKey.replace(/([^/]+|\/)$/, '')`: strip one trailing `/`, else the + /// trailing run of non-`/` bytes. + fn strip_one_key_component(key: &mut Vec) { + if key.last() == Some(&b'/') { + key.pop(); return; } - 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); + while key.last().is_some_and(|&b| b != b'/') { + key.pop(); + } + } + + /// The port a WHATWG URL drops from `host` for this scheme. + fn default_port_for(protocol: &[u8]) -> &'static [u8] { + let mut protocol = protocol.to_vec(); + protocol.make_ascii_lowercase(); + match &protocol[..] { + b"https" | b"wss" => b"443", + b"http" | b"ws" => b"80", + _ => b"", + } + } + + /// A registry as the walk sees it. npm builds the key from a WHATWG URL, whose + /// `host` is lowercased and drops a default port; `bun_url` does neither, so the + /// authority is normalized here. `default_port` is kept to respell a dead key. + struct RegistryKey { + host: Box<[u8]>, + pathname: Box<[u8]>, + default_port: &'static [u8], + } + + impl RegistryKey { + fn from_url(url_bytes: &[u8]) -> RegistryKey { + let url = URL::parse(url_bytes); + let default_port = default_port_for(url.protocol); + let host = if !default_port.is_empty() && url.port == default_port { + url.hostname + } else { + url.host + }; + let mut host = host.to_vec(); + host.make_ascii_lowercase(); + RegistryKey { + host: host.into_boxed_slice(), + pathname: Box::from(url.pathname), + default_port, + } + } + + /// The registry's own config key, `/`. Also npm's walk start: + /// `regFetch` appends `/` to the registry URL and the first iteration of + /// the walk strips it right back off. + fn own_key(&self) -> Vec { + let pathname = strip_one_trailing_slash(&self.pathname); + let mut key = Vec::with_capacity(self.host.len() + pathname.len() + 1); + key.extend_from_slice(&self.host); + key.extend_from_slice(pathname); + key.push(b'/'); + key + } + } + + /// The URL `apply_registry_auth` resolves a `bunfig.toml` registry under, or `None` + /// when `bunfig.toml` gave it credentials of its own: project config beats `.npmrc`, + /// so no `.npmrc` line can apply to such a registry and none is diagnosed against it. + fn bunfig_registry_url(registry: &NpmRegistry) -> Option> { + if registry.has_credentials() { + return None; + } + Some(if registry.url.is_empty() { + bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL + .as_bytes() + .into() + } else { + registry.url.clone() + }) + } + + /// The key `npm config set` would have written for the same authority: lowercase, and + /// without a default port. Paths are case-sensitive, so only the authority is touched. + /// Used to tell a user their hand-written key applies to nothing. + fn normalize_conf_key(key: &[u8], default_port: &[u8]) -> Box<[u8]> { + let end = bun_core::strings::index_of_char_usize(key, b'/').unwrap_or(key.len()); + let mut out = key.to_vec(); + out[..end].make_ascii_lowercase(); + if !default_port.is_empty() && end > default_port.len() + 1 { + let colon = end - default_port.len() - 1; + // In a bracketed IPv6 authority only the colon after `]` introduces a port; + // `[::80]` ends in the digits of http's default port without having one. + let is_port = out[colon] == b':' + && out[colon + 1..end] == *default_port + && (out[0] != b'[' || out[colon - 1] == b']'); + if is_port { + out.drain(colon..end); + } + } + out.into_boxed_slice() + } + + /// Whether `conf_key` names the registry itself: either spelling of `own_key`, + /// with or without its trailing `/`. + fn names_registry(own_key: &[u8], conf_key: &[u8]) -> bool { + conf_key == own_key || conf_key == &own_key[..own_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(configs: &[ConfigItem], key: &[u8], opt: ConfigOpt) -> Option { + configs + .iter() + .rposition(|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(configs: &[ConfigItem], key: &[u8], opt: ConfigOpt) -> Option { + lookup(configs, key, opt).filter(|&i| !configs[i].value.is_empty()) + } + + /// `lookup_truthy` against the registry's own key, preferring the slashed spelling + /// (the deeper of the two, and the one npm's walk visits first). + fn lookup_own(configs: &[ConfigItem], own_key: &[u8], opt: ConfigOpt) -> Option { + lookup_truthy(configs, own_key, opt) + .or_else(|| lookup_truthy(configs, &own_key[..own_key.len() - 1], opt)) + } + + /// npm's `hasAuth`, keyed on byte equality with the config key. + fn has_auth(configs: &[ConfigItem], key: &[u8]) -> Option { + if lookup_truthy(configs, key, ConfigOpt::_AuthToken).is_some() { + return Some(AuthMechanism::Token); + } + if lookup_truthy(configs, key, ConfigOpt::_Auth).is_some() { + return Some(AuthMechanism::Auth); + } + (lookup_truthy(configs, key, ConfigOpt::Username).is_some() + && lookup_truthy(configs, key, ConfigOpt::_Password).is_some()) + .then_some(AuthMechanism::UserPass) + } + + /// npm's `regFromURI`: walk the registry's config keys deepest-first until one + /// supplies auth. For `host=h, pathname=/a/` that is `h/a/`, `h/a`, `h/`, `h`. + fn auth_for_registry( + configs: &[ConfigItem], + registry: &RegistryKey, + ) -> Option<(Vec, AuthMechanism)> { + let mut key = registry.own_key(); + while !key.is_empty() { + if let Some(mechanism) = has_auth(configs, &key) { + return Some((key, mechanism)); + } + strip_one_key_component(&mut key); + } + None + } + + /// Indices into `configs` of the lines that apply to this registry, in file order + /// so the last write wins. + fn credential_items(configs: &[ConfigItem], registry: &RegistryKey) -> Vec { + let own_key = registry.own_key(); + let mut items: Vec = Vec::new(); + + match auth_for_registry(configs, registry) { + Some((key, AuthMechanism::Token)) => { + items.extend(lookup_truthy(configs, &key, ConfigOpt::_AuthToken)); + } + Some((key, AuthMechanism::Auth)) => { + items.extend(lookup_truthy(configs, &key, ConfigOpt::_Auth)); + } + Some((key, AuthMechanism::UserPass)) => { + items.extend(lookup_truthy(configs, &key, ConfigOpt::Username)); + items.extend(lookup_truthy(configs, &key, ConfigOpt::_Password)); + } + // Bun-only second credential layer: a lone `username`/`_password` layers over + // whatever the registry already stores (the userinfo of its URL). One key, and + // only the registry's own — neither an ancestor nor the other spelling may + // supply the half this one is missing. + None => { + for key in [&own_key[..], &own_key[..own_key.len() - 1]] { + let username = lookup_truthy(configs, key, ConfigOpt::Username); + let password = lookup_truthy(configs, key, ConfigOpt::_Password); + if username.is_some() || password.is_some() { + items.extend(username); + items.extend(password); + break; } } } } + + // `email` is not part of npm's auth at all, so it never walks. + items.extend(lookup_own(configs, &own_key, ConfigOpt::Email)); + items.sort_unstable(); + items + } + + /// Resolve the registries `.npmrc` itself declares (or npm's default, when it + /// declares none) against the fully-accumulated `configs`, exactly once. Returns + /// the registries resolved, for `diagnose_config`. + fn resolve_credentials(install: &mut BunInstall, configs: &[ConfigItem]) -> Vec { + let mut registries: Vec = Vec::new(); + if configs.is_empty() { + return registries; + } + + let default_key = RegistryKey::from_url(install.default_registry.as_ref().map_or( + bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes(), + |registry| ®istry.url, + )); + if !credential_items(configs, &default_key).is_empty() { + let v = install.default_registry.get_or_insert_with(|| NpmRegistry { + url: bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL + .as_bytes() + .into(), + ..Default::default() + }); + apply_to_registry(configs, &default_key, v); + } + registries.push(default_key); + + if let Some(scoped) = install.scoped.as_mut() { + for v in scoped.scopes.values_mut() { + let key = RegistryKey::from_url(&v.url); + apply_to_registry(configs, &key, v); + registries.push(key); + } + } + registries + } + + /// Write onto `v` whatever `configs` supplies for the registry keyed `registry`. + fn apply_to_registry(configs: &[ConfigItem], registry: &RegistryKey, v: &mut NpmRegistry) { + for &i in &credential_items(configs, registry) { + apply_conf_item(v, &configs[i]); + } + } + + /// Resolve the registries `bunfig.toml` declares against the collapsed `.npmrc` + /// config returned by `load_npmrc_config`. A registry whose `bunfig.toml` entry + /// already carries credentials keeps them (see `bunfig_registry_url`). + pub fn apply_registry_auth(install: &mut BunInstall, configs: &[ConfigItem]) { + if configs.is_empty() { + return; + } + if let Some(registry) = install.default_registry.as_mut() { + if let Some(url) = bunfig_registry_url(registry) { + apply_to_registry(configs, &RegistryKey::from_url(&url), registry); + } + } if let Some(scoped) = install.scoped.as_mut() { for registry in scoped.scopes.values_mut() { - if registry.has_credentials() { - continue; + if let Some(url) = bunfig_registry_url(registry) { + apply_to_registry(configs, &RegistryKey::from_url(&url), registry); } - for item in auth { - let matched = item.matches(®istry.url); - if matched { - item.apply_to(registry); - } + } + } + } + + fn apply_conf_item(v: &mut NpmRegistry, conf_item: &ConfigItem) { + let value: &[u8] = &conf_item.value; + match conf_item.optname { + ConfigOpt::_AuthToken => v.token = value.into(), + ConfigOpt::Username => v.username = value.into(), + ConfigOpt::_Password => { + // npm's `Buffer.from(value, "base64")` never fails: invalid bytes are + // skipped and as much as possible is decoded. + 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); + v.password = decoded.into_boxed_slice(); + } + // npm forwards `_auth` verbatim as `Basic `; `Scope::from_api` decodes + // it only to derive a username for `bun pm whoami`. + ConfigOpt::_Auth => v.auth = value.into(), + ConfigOpt::Email => v.email = value.into(), + ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(), + } + } + + /// The two ways an `.npmrc` line can silently supply nothing, reported against the + /// registries it could have applied to: every registry `.npmrc` declares plus the + /// ones `bunfig.toml` declares without credentials, since `apply_registry_auth` + /// resolves those against the same lines. + fn diagnose_config( + configs: &[ConfigItem], + sources: &[Source], + registries: &[RegistryKey], + log: &mut Log, + ) { + if configs.is_empty() { + return; + } + let opts_for = |conf_item: &ConfigItem| bun_ast::AddErrorOptions { + source: Some(&sources[conf_item.source_idx as usize]), + loc: conf_item.loc, + redact_sensitive_information: true, + ..Default::default() + }; + + // An empty `_auth` never wins `hasAuth`, so it never reaches `apply_conf_item`. + // npm walks past ancestor keys silently, so only a line naming a registry + // exactly is diagnosed, and only if it is the line the key collapsed to: one a + // later file overrides supplies nothing whatever its value. + let own_keys: Vec> = registries.iter().map(RegistryKey::own_key).collect(); + for (i, conf_item) in configs.iter().enumerate() { + if !matches!(conf_item.optname, ConfigOpt::_Auth) + || !conf_item.value.is_empty() + || lookup(configs, &conf_item.registry_url, ConfigOpt::_Auth) != Some(i) + { + continue; + } + if own_keys + .iter() + .any(|own_key| names_registry(own_key, &conf_item.registry_url)) + { + log.add_error_opts( + b"empty _auth value: this line supplies no credentials", + opts_for(conf_item), + ); + } + } + + // npm compares keys literally, and `nerfDart` writes them with a lowercase host and + // no default port, so a key spelled otherwise can silently supply nothing. Warn only + // where the line supplies nothing to ANY registry as written, but would if respelled. + let mut applied = vec![false; configs.len()]; + for registry in registries { + for i in credential_items(configs, registry) { + applied[i] = true; + } + } + + let mut dead: Vec>> = vec![None; configs.len()]; + for registry in registries { + let differs = |c: &ConfigItem| { + *normalize_conf_key(&c.registry_url, registry.default_port) != *c.registry_url + }; + if !configs.iter().any(differs) { + continue; + } + let mut normalized: Vec = Vec::with_capacity(configs.len()); + for conf_item in configs.iter() { + let mut dup = conf_item.clone(); + dup.registry_url = + normalize_conf_key(&conf_item.registry_url, registry.default_port); + normalized.push(dup); + } + for i in credential_items(&normalized, registry) { + if !applied[i] && differs(&configs[i]) && dead[i].is_none() { + dead[i] = Some(core::mem::take(&mut normalized[i].registry_url)); } } } + + for (i, conf_item) in configs.iter().enumerate() { + let Some(respelled) = &dead[i] else { + continue; + }; + log.add_warning_fmt_opts_with_note( + format_args!( + "the .npmrc key \"//{}\" matches no registry", + bstr::BStr::new(&conf_item.registry_url), + ), + format_args!( + "npm writes this key as \"//{}\"", + bstr::BStr::new(respelled) + ), + opts_for(conf_item), + ); + } } + /// 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, + configs: &mut Vec, + ) -> OOM<()> { + parse_npmrc_into(install, env, log, source, 0, configs)?; + let registries = resolve_credentials(install, configs); + diagnose_config(configs, std::slice::from_ref(source), ®istries, log); + Ok(()) + } + + /// Everything one `.npmrc` file contributes: options, `registry=` lines (last file + /// wins by overwrite) and its `//host/…:=` lines, pushed onto `configs`. + /// Credentials are resolved separately, once, over every file's lines. + 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; @@ -1572,11 +1910,14 @@ mod draft { } } + // 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() { @@ -1593,40 +1934,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(()) @@ -1749,68 +2062,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/PackageManager.rs b/src/install/PackageManager.rs index e1524675ffb9..59723b87830d 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1932,12 +1932,13 @@ pub fn init( let registry_auth = 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); diff --git a/src/install/npm.rs b/src/install/npm.rs index 1d1482ab76e2..96f6da8abc74 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -68,12 +68,8 @@ 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"); + print_buf.extend_from_slice(b"Bearer "); + print_buf.extend_from_slice(®istry.token); headers.count("authorization", &print_buf); print_buf.clear(); @@ -104,12 +100,8 @@ 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"); + print_buf.extend_from_slice(b"Bearer "); + print_buf.extend_from_slice(®istry.token); headers.append("authorization", &print_buf); print_buf.clear(); @@ -302,13 +294,11 @@ 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]>, @@ -317,6 +307,105 @@ pub mod registry { pub user: Box<[u8]>, } + /// 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, + /// `url.pathname` was rewritten, so the href must be rebuilt from the parts. + needs_normalize: bool, + } + + /// The rightmost `:=` credential marker in `pathname`, as `(colon, name_len)`. + /// Anchoring on the marker rather than on any `:` keeps a colon inside the value from + /// ending the scan, and leaves a colon that merely belongs to the path alone. + fn last_embedded_marker(pathname: &[u8]) -> Option<(usize, usize)> { + const MARKERS: [&[u8]; 4] = [b":_authToken=", b":_auth=", b":username=", b":_password="]; + MARKERS + .iter() + .filter_map(|marker| { + bun_core::last_index_of(pathname, marker).map(|i| (i, marker.len() - 2)) + }) + .max_by_key(|&(i, _)| i) + } + + /// Strip trailing yarn-style 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; + let mut needs_to_check_slash = true; + + // Right to left: the credentials are appended after the path. A terminal marker + // ends what is *read*, never what is stripped — a segment left of it is still a + // secret, and leaving it behind puts it in the request path. + while let Some((colon, name_len)) = last_embedded_marker(pathname) { + let name = &pathname[colon + 1..colon + 1 + name_len]; + let value = &pathname[colon + name_len + 2..]; + + let field = match name { + b"_authToken" => &mut out.token, + b"_auth" => &mut out.auth, + b"username" => &mut out.username, + b"_password" => &mut out.password, + _ => unreachable!(), + }; + // An empty marker supplies no credential; it must not end the scan or shadow + // a credential from `.npmrc`. The pathname is stripped either way. + if !out.terminal && !value.is_empty() { + *field = Some(value); + out.terminal = matches!(name, b"_authToken" | b"_auth"); + } + + pathname = &pathname[..colon]; + needs_to_check_slash = false; + out.needs_normalize = true; + if pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { + pathname = &pathname[..pathname.len() - 1]; + } + } + + // 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..]; + + let field = match segment { + b"_authToken" => Some(&mut out.token), + b"_auth" => Some(&mut out.auth), + b"username" => Some(&mut out.username), + b"_password" => Some(&mut out.password), + _ => None, + }; + + if let Some(field) = field { + out.needs_normalize = true; + pathname = &pathname[..last_slash + 1]; + if !value.is_empty() { + *field = Some(value); + out.terminal = true; + } + } + } + } + } + + url.pathname = pathname; + url.path = pathname; + out + } + impl Scope { pub fn hash(str: &[u8]) -> u64 { bun_semver::semver_string::Builder::string_hash(str) @@ -362,125 +451,77 @@ 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(); + // Unconditional: the scan both strips the credential from the pathname and + // reports it. Gating it on credential state would leave `:_authToken=SECRET` + // in the request path whenever an `.npmrc` line already supplied a token. + let embedded = parse_embedded_auth(&mut url); + let needs_normalize = embedded.needs_normalize; + if registry.token.is_empty() { 'outer: { - if registry.password.is_empty() { - let mut pathname: &[u8] = url.pathname; - // defer { url.pathname = pathname; url.path = pathname; } — applied below - 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; - } + // An `.npmrc` `_auth` supersedes URL-embedded credentials outright, + // so `user` can only ever come from the value that produced `auth`. + 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(); + } + // An embedded `_auth` is always terminal, so past this point + // `auth` is still empty and each branch below sets it exactly once. + 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; - } + // `.npmrc`'s `_auth`, forwarded verbatim: npm never decodes it, so an + // opaque blob or a blank password is a credential, not an error. The + // decode below only derives `user` for `bun pm whoami` and never + // gates the credential. + 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 password (`user:`) or blank username (`:pass`) is + // a real registry pattern; leave `user` empty for whoami 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; + // `_auth` is the chosen credential either way; falling through + // would let a bunfig/npmrc username set `user` to an identity + // the wire never sends. + 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); diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index bc31d3f18821..d8d8592ec7a4 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::{ConfigItem, load_npmrc}; use bun_install::npm::Registry; let arg = frame.argument(0); @@ -82,7 +82,7 @@ impl IniTestingAPIs { }; let mut install = Box::new(BunInstall::default()); - let mut configs: Vec = Vec::new(); + let mut configs: Vec = Vec::new(); if load_npmrc(&mut install, env, &mut log, &source, &mut configs).is_err() { return bun_ast_jsc::log_to_js(&log, global, b"error"); } @@ -93,6 +93,7 @@ impl IniTestingAPIs { 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 ( @@ -101,6 +102,7 @@ impl IniTestingAPIs { BunString::empty(), BunString::empty(), BunString::empty(), + BunString::empty(), ); }; @@ -110,6 +112,7 @@ impl IniTestingAPIs { BunString::from_bytes(&default_registry.username), BunString::from_bytes(&default_registry.password), BunString::from_bytes(&default_registry.email), + BunString::from_bytes(&default_registry.auth), ) }; // `defer { *.deref() }` deleted — bun_core::String impls Drop. @@ -124,9 +127,10 @@ impl IniTestingAPIs { 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; + const FIELD_COUNT: usize = 6; fn put_fields( &self, global: &JSGlobalObject, @@ -152,6 +156,10 @@ impl IniTestingAPIs { b"default_registry_email", self.default_registry_email.to_js(global)?, )?; + put( + b"default_registry_auth", + self.default_registry_auth.to_js(global)?, + )?; Ok(()) } } @@ -161,6 +169,7 @@ impl IniTestingAPIs { 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 ffde83bb9211..c6e8e5290c2e 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -150,6 +150,9 @@ pub mod api { 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]>, } impl NpmRegistry { @@ -173,7 +176,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() } } diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index f23723595e2d..84a8ddda7af1 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -719,15 +719,11 @@ fn send_audit_request( 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)), - ); + // `format_args!`/`BStr` Display is lossy for non-UTF-8 credentials (U+FFFD + // expands 1->3 bytes) and overruns the byte count reserved above. Raw bytes. + headers.append_bytes_value(b"authorization", b"Bearer ", ®istry.token); } else if !registry.auth.is_empty() { - headers.append_fmt( - b"authorization", - format_args!("Basic {}", BStr::new(®istry.auth)), - ); + headers.append_bytes_value(b"authorization", b"Basic ", ®istry.auth); } let mut url_str: Vec = Vec::new(); diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index ebeca184aac6..084fdbfc2832 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -108,15 +108,11 @@ pub(crate) fn view( 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)), - ); + // `format_args!`/`BStr` Display is lossy for non-UTF-8 credentials (U+FFFD + // expands 1->3 bytes) and overruns the byte count reserved above. Raw bytes. + headers.append_bytes_value(b"Authorization", b"Bearer ", &scope.token); } else if !scope.auth.is_empty() { - headers.append_fmt( - b"Authorization", - format_args!("Basic {}", BStr::new(&*scope.auth)), - ); + headers.append_bytes_value(b"Authorization", b"Basic ", &scope.auth); } let mut response_buf = MutableString::init(2048)?; diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 60a861015688..14933ba38304 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -777,14 +777,12 @@ impl PublishCommand { 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; - } + auth_buf.extend_from_slice(b"Bearer "); + auth_buf.extend_from_slice(®istry.token); 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; - } + auth_buf.extend_from_slice(b"Basic "); + auth_buf.extend_from_slice(®istry.auth); headers.count(b"authorization", &auth_buf); } @@ -793,17 +791,7 @@ impl PublishCommand { } 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; - } + if !auth_buf.is_empty() { headers.append(b"authorization", &auth_buf); } @@ -1897,11 +1885,13 @@ impl PublishCommand { headers.count(b"accept-encoding", b"gzip,deflate"); if !registry.token.is_empty() { - let _ = write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)); + print_buf.extend_from_slice(b"Bearer "); + print_buf.extend_from_slice(®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)); + print_buf.extend_from_slice(b"Basic "); + print_buf.extend_from_slice(®istry.auth); headers.count(b"authorization", &**print_buf); print_buf.clear(); } @@ -1948,11 +1938,13 @@ impl PublishCommand { headers.append(b"accept-encoding", b"gzip,deflate"); if !registry.token.is_empty() { - let _ = write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)); + print_buf.extend_from_slice(b"Bearer "); + print_buf.extend_from_slice(®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)); + print_buf.extend_from_slice(b"Basic "); + print_buf.extend_from_slice(®istry.auth); headers.append(b"authorization", &**print_buf); print_buf.clear(); } diff --git a/test/cli/install/bun-info.test.ts b/test/cli/install/bun-info.test.ts index 04b1d9fe80b7..00e0a0cae20f 100644 --- a/test/cli/install/bun-info.test.ts +++ b/test/cli/install/bun-info.test.ts @@ -342,6 +342,66 @@ 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"); + // The registry sits at a subpath while `_auth` sits at the host root, so the + // header only appears if `pm view` resolves credentials by npm's config-key walk. + const registryPath = "/npm/sub/"; + 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 583fceefe6c7..b4ad90ba8322 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -749,6 +749,805 @@ 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 () => { + const value = b64("ab:cd"); + 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) { + 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("npmrc-embedded-auth", { + ".npmrc": `@myorg:registry=http://${host}${registryPath}\n${authLines(host)}\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 [, 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]!; + } + + 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" }); + }); + + // 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. + it("reads the rightmost terminal marker, not the leftmost", async () => { + const seen = await probeEmbedded("/api/:_authToken=T:_auth=WDpZ", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic WDpZ" }); + }); + + 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 partial credential only layers over stored credentials at the registry's own path", () => { + 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}`); + }); + + it("the registry's own path still layers a lone username over the stored password", async () => { + const auth = await probeAuthorization(host => `//${host}${registryPath}:username=npmrc-user`, stored); + expect(auth).toBe(`Basic ${Buffer.from("npmrc-user:url-pass").toString("base64")}`); + }); + }); + + // 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-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..4b95ca93e068 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -510,6 +510,49 @@ 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 { promise: sawPublish, resolve: onPublish, reject: onUnexpected } = Promise.withResolvers(); + using mockRegistry = Bun.serve({ + port: 0, + fetch(req) { + const pathname = new URL(req.url).pathname; + if (req.method === "PUT" && pathname === "/npmrc-auth-pkg") { + onPublish(req.headers.get("authorization")); + return new Response("OK", { status: 200 }); + } + onUnexpected(new Error(`unexpected request: ${req.method} ${pathname}`)); + return new Response("not found", { status: 404 }); + }, + }); + + const host = `localhost:${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(await sawPublish).toBe(`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..825c448fa47f 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -498,10 +498,61 @@ ${Object.keys(opts) dotEnv: { SECRET_AUTH: "" }, }, (stdout: string, stderr: string) => { - expect(stderr).toContain("received an empty string"); + expect(stderr).toContain("supplies no credentials"); }, ); + 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([["email", "user@example.com"]], result => { expect(result.default_registry_url).toEqual("https://registry.npmjs.org/"); expect(result.default_registry_email).toEqual("user@example.com"); @@ -551,14 +602,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. + // Config keys are matched literally against the keys walked up from the registry + // URL, so the bracketed authority only has to survive the registry side's parse. 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({ @@ -567,6 +620,7 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_email: "", + default_registry_auth: "", }); }); @@ -580,15 +634,17 @@ registry=https://somehost.com/org1/npm/registry/ 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_username: "", + default_registry_password: "", default_registry_email: "", + default_registry_auth: auth, }); }); @@ -603,27 +659,251 @@ 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`, + 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"); + }); + + // `email` is not part of npm's auth (`npm-registry-fetch`'s `getAuth` never reads + // it), so it does not walk: only a line naming the registry's own path applies. + test("an ancestor's email does not apply to a deeper registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:email=bilbo@example.com +`); + expect(result.default_registry_email).toBe(""); + }); + + test("the registry's own email applies", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:email=gandalf@example.com +//somehost.com/org1/npm/registry/:email=bilbo@example.com +`); + expect(result.default_registry_email).toBe("bilbo@example.com"); + }); + }); + + describe("credentials that did not come from .npmrc survive resolution", () => { + test("an invalid _auth does not discard 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("TOK"); + }); + + test("an .npmrc username/_password does not discard 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("TOK"); + 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"], + 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).not.toContain("supplies no credentials"); + 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", "--no-save"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + }); - expect(stderr).toContain("_password is not valid base64"); - expect(stderr).toContain("_password=" + Buffer.alloc(secret.length, "*").toString()); - expect(stderr).not.toContain(secret); + 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); }); + + // `Scope::from_api` decodes `_auth` solely to derive the identity `bun pm whoami` + // prints; the credential itself is always forwarded verbatim. + 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 }; + } + + function whoami(authValue: string) { + return whoamiWith({ + ".npmrc": `registry=https://somehost.com/\n//somehost.com/:_auth=${authValue}\n`, + }); + } + + test("a decodable _auth prints its username", async () => { + const { stdout, exitCode } = await whoami(Buffer.from("alice:s3cret").toString("base64")); + expect(stdout).toBe("alice\n"); + expect(exitCode).toBe(0); + }); + + test("a non-decodable _auth carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami("!!not-base64!!"); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + test("an _auth with a blank username carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami(Buffer.from(":s3cret").toString("base64")); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + test("an _auth with a blank password carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami(Buffer.from("tok:").toString("base64")); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + // 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, stderr, exitCode } = await whoamiWith({ + ".npmrc": `registry=https://url-user:url-pass@somehost.com/\n//somehost.com/:_auth=${authValue}\n`, + }); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + // 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://somehost.com/"\nusername = "bunfig-user"\npassword = "bunfig-pass"\n`, + ".npmrc": `//somehost.com/:_auth=!!not-base64!!\n`, + }); + expect(stdout).toBe("bunfig-user\n"); + expect(exitCode).toBe(0); + }); + }); }); describe("scoped registry routing", () => { @@ -705,6 +985,262 @@ 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 compares config keys literally, and its own `nerfDart` lowercases the keys it + // writes, so a hand-written uppercase host applies to nothing. Matched, plus a warning. + it("does not match an uppercase key, even against an uppercase registry host", () => { + expect(token(`registry=https://Registry.Example.COM/api/\n//Registry.Example.COM/:_authToken=T\n`)).toBe(""); + }); + + 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's key never spells out a default port, so neither does ours: a key written + // as `//host:443/` matches nothing. Released Bun matched it. + it("does not match a key that spells out the default port", () => { + expect(token(`registry=https://example.com:443/api/\n//example.com:443/:_authToken=T\n`)).toBe(""); + }); +}); + +// A key that would have matched but for its host's case is almost always a mistake. +// Dropping the credential silently is how #30311 went unnoticed, so say something — +// without echoing the secret into the log. +describe("a config key that differs from the registry only by host case", () => { + async function stderrOf(npmrc: string, bunfig?: object) { + using dir = tempDir("npmrc-case-warning", { + ".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"], + 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, and redacts the credential", async () => { + const stderr = await stderrOf( + `registry=https://Registry.Example.COM/api/\n//Registry.Example.COM/:_authToken=SECRETTOKEN\n`, + ); + expect(stderr).toContain('the .npmrc key "//Registry.Example.COM/" matches no registry'); + expect(stderr).toContain('npm writes this key as "//registry.example.com/"'); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("says nothing when the key already matches", async () => { + const stderr = await stderrOf( + `registry=https://Registry.Example.COM/api/\n//registry.example.com/:_authToken=SECRETTOKEN\n`, + ); + expect(stderr).not.toContain("matches no registry"); + }); + + it("says nothing about an uppercase key for an unrelated host", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//Other.Example.COM/:_authToken=X\n`); + expect(stderr).not.toContain("matches no registry"); + }); + + it("warns about a key that spells out the default port", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//example.com:443/:_authToken=SECRETTOKEN\n`); + expect(stderr).toContain('the .npmrc key "//example.com:443/" matches no registry'); + expect(stderr).toContain('npm writes this key as "//example.com/"'); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("says nothing about a non-default port spelled out", async () => { + const stderr = await stderrOf(`registry=https://example.com:8443/api/\n//example.com:8443/:_authToken=S\n`); + expect(stderr).not.toContain("matches no registry"); + }); + + // The warning promises that respelling the key changes something. These are the shapes + // where it would not, so it must stay quiet or the advice is a lie. + it("says nothing when a deeper lowercase key already wins", async () => { + const stderr = await stderrOf( + `registry=https://example.com/api/\n//example.com/api/:_authToken=GOOD\n//Example.COM/:_authToken=BAD\n`, + ); + expect(stderr).not.toContain("matches no registry"); + }); + + it("says nothing about an ancestor email, which never walks", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//Example.COM/:email=me@x.com\n`); + expect(stderr).not.toContain("matches no registry"); + }); + + it("says nothing about an ancestor's lone username, which never applies", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//Example.COM/:username=bob\n`); + expect(stderr).not.toContain("matches no registry"); + }); + + it("says nothing about an empty value", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//Example.COM/:_authToken=\n`); + expect(stderr).not.toContain("matches no registry"); + }); + + it("says nothing when the path case differs, since paths are case-sensitive", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//example.com/API/:_authToken=X\n`); + expect(stderr).not.toContain("matches no registry"); + }); + + // A default port is a property of the scheme, so a key is only dead if it supplies + // nothing to EVERY registry. `:443` is not http's default port. + it("says nothing about a :443 key that is legitimate for an http registry", async () => { + const npmrc = + `registry=http://example.com:443/api/\n` + + `@s:registry=https://example.com/api/\n` + + `//example.com:443/api/:_authToken=SECRETTOKEN\n`; + expect(await stderrOf(npmrc)).not.toContain("matches no registry"); + expect(loadNpmrc(npmrc).default_registry_token).toBe("SECRETTOKEN"); + }); + + // Respelling only matters when it would change which credential is chosen. `lookup` + // takes the last duplicate, so an uppercase twin is only live when it comes last. + it("says nothing about an uppercase twin that a lowercase key already outranks", async () => { + const stderr = await stderrOf( + `registry=https://example.com/\n//Example.COM/:_authToken=A\n//example.com/:_authToken=B\n`, + ); + expect(stderr).not.toContain("matches no registry"); + }); + + // 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.toContain("invalid _auth"); + expect({ stdout, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "1.0.0\n", + exitCode: 0, + signalCode: null, + }); + }); + } + + it("warns about an uppercase twin that would outrank the lowercase key", async () => { + const stderr = await stderrOf( + `registry=https://example.com/\n//example.com/:_authToken=B\n//Example.COM/:_authToken=A\n`, + ); + expect(stderr).toContain('the .npmrc key "//Example.COM/" matches no registry'); + expect(stderr).toContain('npm writes this key as "//example.com/"'); + }); + + // Registries declared in bunfig.toml are resolved against the same lines (in a + // later pass), so a key that misses one of them is just as dead. + describe("registries declared in bunfig.toml", () => { + it("warns about a key that would match a bunfig.toml scope", async () => { + const stderr = await stderrOf(`//Example.COM/:_authToken=SECRETTOKEN\n`, { + install: { scopes: { myorg: { url: "https://example.com/api/" } } }, + }); + expect(stderr).toContain('the .npmrc key "//Example.COM/" matches no registry'); + expect(stderr).toContain('npm writes this key as "//example.com/"'); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("warns about a key that would match the bunfig.toml default registry", async () => { + const stderr = await stderrOf(`//example.com:443/:_authToken=SECRETTOKEN\n`, { + install: { registry: "https://example.com/api/" }, + }); + expect(stderr).toContain('the .npmrc key "//example.com:443/" matches no registry'); + expect(stderr).toContain('npm writes this key as "//example.com/"'); + }); + + // No .npmrc line can apply to a registry bunfig.toml gave credentials, so there is + // nothing a respelling would change. + it("says nothing when bunfig.toml gave that registry credentials", async () => { + const stderr = await stderrOf(`//Example.COM/:_authToken=SECRETTOKEN\n`, { + install: { scopes: { myorg: { url: "https://example.com/api/", token: "BUNFIGTOKEN" } } }, + }); + expect(stderr).not.toContain("matches no registry"); + }); + + 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"); + }); + }); + + describe("bracketed IPv6 authorities", () => { + it("respells a spelled-out default port after the bracket", async () => { + const stderr = await stderrOf(`registry=http://[::1]/\n//[::1]:80/:_authToken=SECRETTOKEN\n`); + expect(stderr).toContain('the .npmrc key "//[::1]:80/" matches no registry'); + expect(stderr).toContain('npm writes this key as "//[::1]/"'); + }); + + it("says nothing about an address whose last group spells the default port", async () => { + const stderr = await stderrOf(`registry=http://[::80]/\n//[::80]/:_authToken=SECRETTOKEN\n`); + expect(stderr).not.toContain("matches no registry"); + }); + }); +}); + 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"); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 6c49d43123b4..26d4d25383fe 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,28 +194,57 @@ 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: "", + }, + { + // A quoted key is a string literal, not an identifier, so it took a different + // path through the highlighter and the value came out verbatim under color. + // The uppercase host is what makes a diagnostic print this line at all. + title: "quoted _authToken key", + npmrc: '"//REGISTRY.NPMJS.ORG/:_authToken"=npm_notarealtokenvalue', expected: "*", + secret: "npm_notarealtokenvalue", + }, + { + title: "quoted _auth key", + npmrc: 'registry=https://Registry.Example.COM/api/\n"//Registry.Example.COM/:_auth"=does-not-decode', + expected: "*", + secret: "does-not-decode", + }, + { + // 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(); await Promise.all([ @@ -225,7 +264,9 @@ 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); + if (secret) expect(err1).not.toContain(secret); + if (forbidden) expect(err1).not.toContain(forbidden); // once with color await using proc2 = Bun.spawn({ @@ -239,7 +280,23 @@ 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); + 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"); +});