use base64::Engine as _;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NpmrcSource {
User,
PnpmAuth,
Project,
NpmrcAuthFile,
Env,
}
impl NpmrcSource {
fn is_trusted_for_subprocess_settings(self) -> bool {
matches!(self, Self::User | Self::PnpmAuth | Self::Env)
}
}
#[derive(Debug, Clone)]
pub struct NpmConfig {
pub registry: String,
pub scoped_registries: BTreeMap<String, String>,
pub auth_by_uri: BTreeMap<String, AuthConfig>,
pub global_auth_token: Option<String>,
pub https_proxy: Option<String>,
pub http_proxy: Option<String>,
pub no_proxy: Option<String>,
pub strict_ssl: bool,
pub local_address: Option<std::net::IpAddr>,
pub max_sockets: Option<usize>,
pub npmrc_proxy: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AuthConfig {
pub auth_token: Option<String>,
pub auth: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub token_helper: Option<String>,
pub tls: TlsConfig,
}
#[derive(Debug, Clone, Default)]
pub struct TlsConfig {
pub ca: Vec<String>,
pub cafile: Option<PathBuf>,
pub cert: Option<String>,
pub key: Option<String>,
}
impl Default for NpmConfig {
fn default() -> Self {
Self {
registry: String::new(),
scoped_registries: BTreeMap::new(),
auth_by_uri: BTreeMap::new(),
global_auth_token: None,
https_proxy: None,
http_proxy: None,
no_proxy: None,
strict_ssl: true,
local_address: None,
max_sockets: None,
npmrc_proxy: None,
}
}
}
impl NpmConfig {
pub fn load(project_dir: &Path) -> Self {
let env: Vec<(String, String)> = std::env::vars().collect();
Self::load_with_env(project_dir, &env)
}
#[cfg(test)]
pub(crate) fn load_isolated(project_dir: &Path) -> Self {
let home = tempfile::tempdir().expect("tempdir for isolated config load");
let mut config = Self {
registry: "https://registry.npmjs.org/".to_string(),
..Default::default()
};
config.apply(load_npmrc_entries_with_home(
Some(home.path()),
None,
project_dir,
None,
));
config.apply_builtin_scoped_defaults();
config
}
pub(crate) fn load_with_env(project_dir: &Path, env: &[(String, String)]) -> Self {
let mut config = Self {
registry: "https://registry.npmjs.org/".to_string(),
..Default::default()
};
let xdg = aube_util::env::xdg_config_home();
let home = home_dir();
let user_rc_override = userconfig_override_from_env(env, home.as_deref());
let mut tagged = load_npmrc_entries_tagged_with_home(
home.as_deref(),
xdg.as_deref(),
project_dir,
user_rc_override.as_deref(),
);
tagged.extend(
npm_config_env_entries_from(env)
.into_iter()
.map(|(k, v)| (NpmrcSource::Env, k, v)),
);
config.apply_tagged(tagged);
config.apply_proxy_env();
config.apply_builtin_scoped_defaults();
config
}
fn apply_builtin_scoped_defaults(&mut self) {
self.scoped_registries
.entry(crate::jsr::JSR_NPM_SCOPE.to_string())
.or_insert_with(|| crate::jsr::JSR_DEFAULT_REGISTRY.to_string());
}
pub fn apply_proxy_env(&mut self) {
if self.https_proxy.is_none() {
self.https_proxy = self
.npmrc_proxy
.clone()
.or_else(|| env_any(&["HTTPS_PROXY", "https_proxy"]));
}
if self.http_proxy.is_none() {
self.http_proxy = self
.https_proxy
.clone()
.or_else(|| env_any(&["HTTP_PROXY", "http_proxy"]))
.or_else(|| env_any(&["PROXY", "proxy"]));
}
if self.no_proxy.is_none() {
self.no_proxy = env_any(&["NO_PROXY", "no_proxy"]);
}
}
pub fn registry_for(&self, package_name: &str) -> &str {
if let Some(scope) = package_scope(package_name)
&& let Some(url) = self.scoped_registries.get(&scope.to_lowercase())
{
return url;
}
&self.registry
}
pub fn auth_token_for(&self, registry_url: &str) -> Option<&str> {
if let Some(auth) = self.registry_config_for(registry_url)
&& let Some(ref token) = auth.auth_token
{
return Some(token);
}
self.global_auth_token.as_deref()
}
pub fn token_helper_for(&self, registry_url: &str) -> Option<&str> {
self.registry_config_for(registry_url)
.and_then(|auth| auth.token_helper.as_deref())
}
pub fn basic_auth_for(&self, registry_url: &str) -> Option<String> {
let auth = self.registry_config_for(registry_url)?;
if let Some(ref a) = auth.auth {
return Some(a.clone());
}
let username = auth.username.as_ref()?;
let password = auth.password.as_ref()?;
let password = base64::engine::general_purpose::STANDARD
.decode(password)
.ok()?;
let mut raw = Vec::with_capacity(username.len() + 1 + password.len());
raw.extend_from_slice(username.as_bytes());
raw.push(b':');
raw.extend_from_slice(&password);
Some(base64::engine::general_purpose::STANDARD.encode(raw))
}
pub fn registry_config_for(&self, registry_url: &str) -> Option<&AuthConfig> {
let uri_key = registry_uri_key(registry_url);
lookup_by_uri_prefix(&self.auth_by_uri, &uri_key)
}
#[cfg(test)]
fn apply(&mut self, entries: Vec<(String, String)>) {
self.apply_tagged(
entries
.into_iter()
.map(|(k, v)| (NpmrcSource::User, k, v))
.collect(),
);
}
fn apply_tagged(&mut self, entries: Vec<(NpmrcSource, String, String)>) {
for (source, key, value) in entries {
if key == "registry" {
self.registry = normalize_registry_url(&value);
} else if key == "_authToken" {
self.global_auth_token = Some(value);
} else if matches!(
key.as_str(),
"https-proxy"
| "httpsProxy"
| "http-proxy"
| "httpProxy"
| "proxy"
| "noproxy"
| "noProxy"
| "no-proxy"
) {
if !source.is_trusted_for_subprocess_settings() {
tracing::warn!(
"ignoring {key} from untrusted source {source:?}: committed `.npmrc` cannot set registry proxies"
);
} else {
match key.as_str() {
"https-proxy" | "httpsProxy" => {
self.https_proxy = non_empty(value);
}
"http-proxy" | "httpProxy" => {
self.http_proxy = non_empty(value);
}
"proxy" => {
self.npmrc_proxy = non_empty(value);
}
_ => {
self.no_proxy = non_empty(value);
}
}
}
} else if matches!(key.as_str(), "strict-ssl" | "strictSsl") {
if let Some(b) = aube_settings::parse_bool(&value) {
if !b && !source.is_trusted_for_subprocess_settings() {
tracing::warn!(
"ignoring strict-ssl=false: {source:?} source is not trusted (committed `.npmrc` cannot disable TLS validation)"
);
} else {
self.strict_ssl = b;
}
}
} else if matches!(key.as_str(), "local-address" | "localAddress") {
match value.trim().parse::<std::net::IpAddr>() {
Ok(ip) => self.local_address = Some(ip),
Err(e) => tracing::warn!("ignoring invalid local-address {value:?}: {e}"),
}
} else if key == "maxsockets" {
match value.trim().parse::<usize>() {
Ok(n) if n > 0 => self.max_sockets = Some(n),
Ok(_) => tracing::warn!("ignoring maxsockets=0"),
Err(e) => tracing::warn!("ignoring invalid maxsockets {value:?}: {e}"),
}
} else if let Some(scope) = key.strip_suffix(":registry") {
if scope.starts_with('@') {
self.scoped_registries
.insert(scope.to_lowercase(), normalize_registry_url(&value));
}
} else if key.starts_with("//") {
if let Some((uri, suffix)) = key.rsplit_once(':') {
let entry = self
.auth_by_uri
.entry(normalize_npmrc_uri_key(uri))
.or_default();
match suffix {
"_authToken" => entry.auth_token = Some(value),
"_auth" => entry.auth = Some(value),
"username" => entry.username = Some(value),
"_password" => entry.password = Some(value),
"tokenHelper" | "token-helper" => {
if !source.is_trusted_for_subprocess_settings() {
tracing::warn!(
"ignoring tokenHelper for {uri}: {source:?} source is not trusted for subprocess settings (committed `.npmrc` cannot set this)"
);
continue;
}
let Some(sanitized) = sanitize_token_helper(&value) else {
tracing::warn!(
"ignoring tokenHelper for {uri}: value is not a bare absolute path: {value:?}"
);
continue;
};
entry.token_helper = Some(sanitized);
}
"ca" | "ca[]" => entry.tls.ca.push(pem_value(value)),
"cafile" | "caFile" => entry.tls.cafile = Some(PathBuf::from(value)),
"cert" => entry.tls.cert = Some(pem_value(value)),
"key" => entry.tls.key = Some(pem_value(value)),
_ => {} }
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FetchPolicy {
pub timeout_ms: u64,
pub retries: u32,
pub retry_factor: u32,
pub retry_min_timeout_ms: u64,
pub retry_max_timeout_ms: u64,
pub warn_timeout_ms: u64,
pub min_speed_kibps: u64,
pub packument_max_bytes: u64,
pub tarball_max_bytes: u64,
}
impl Default for FetchPolicy {
fn default() -> Self {
Self {
timeout_ms: 300_000,
retries: 2,
retry_factor: 10,
retry_min_timeout_ms: 10_000,
retry_max_timeout_ms: 60_000,
warn_timeout_ms: 10_000,
min_speed_kibps: 50,
packument_max_bytes: 200 << 20,
tarball_max_bytes: 1 << 30,
}
}
}
impl FetchPolicy {
pub fn from_ctx(ctx: &aube_settings::ResolveCtx<'_>) -> Self {
Self {
timeout_ms: aube_settings::resolved::fetch_timeout(ctx),
retries: clamp_u32(aube_settings::resolved::fetch_retries(ctx)),
retry_factor: clamp_u32(aube_settings::resolved::fetch_retry_factor(ctx)),
retry_min_timeout_ms: aube_settings::resolved::fetch_retry_mintimeout(ctx),
retry_max_timeout_ms: aube_settings::resolved::fetch_retry_maxtimeout(ctx),
warn_timeout_ms: aube_settings::resolved::fetch_warn_timeout_ms(ctx),
min_speed_kibps: aube_settings::resolved::fetch_min_speed_ki_bps(ctx),
packument_max_bytes: aube_settings::resolved::packument_max_bytes(ctx),
tarball_max_bytes: aube_settings::resolved::tarball_max_bytes(ctx),
}
}
pub fn backoff_for_attempt(&self, attempt: u32) -> std::time::Duration {
let attempt = attempt.max(1);
let factor = u64::from(self.retry_factor.max(1));
let exp = attempt.saturating_sub(1);
let mut wait = self.retry_min_timeout_ms;
for _ in 0..exp {
wait = wait.saturating_mul(factor);
if wait >= self.retry_max_timeout_ms {
wait = self.retry_max_timeout_ms;
break;
}
}
let clamped = wait
.max(self.retry_min_timeout_ms)
.min(self.retry_max_timeout_ms);
std::time::Duration::from_millis(clamped)
}
}
fn clamp_u32(v: u64) -> u32 {
v.min(u64::from(u32::MAX)) as u32
}
fn npm_config_env_entries_from(env: &[(String, String)]) -> Vec<(String, String)> {
env.iter()
.filter_map(|(n, v)| translate_npm_config_env(n, v))
.collect()
}
fn translate_npm_config_env(name: &str, value: &str) -> Option<(String, String)> {
let suffix = name
.strip_prefix("npm_config_")
.or_else(|| name.strip_prefix("NPM_CONFIG_"))?;
if suffix.starts_with("//") {
return Some((suffix.to_string(), value.to_string()));
}
if let Some(rest) = suffix.strip_prefix('@')
&& let Some((scope, tail)) = rest.split_once(':')
&& tail.eq_ignore_ascii_case("registry")
{
return Some((
format!("@{}:registry", scope.to_ascii_lowercase()),
value.to_string(),
));
}
let npmrc_key = match suffix.to_ascii_lowercase().as_str() {
"registry" => "registry",
"https_proxy" => "https-proxy",
"http_proxy" => "http-proxy",
"proxy" => "proxy",
"noproxy" => "noproxy",
"strict_ssl" => "strict-ssl",
"local_address" => "local-address",
"maxsockets" => "maxsockets",
_ => return None,
};
Some((npmrc_key.to_string(), value.to_string()))
}
pub fn load_npmrc_entries(project_dir: &Path) -> Vec<(String, String)> {
use std::sync::{Mutex, OnceLock};
type CacheMap = std::collections::HashMap<PathBuf, Vec<(String, String)>>;
static CACHE: OnceLock<Mutex<CacheMap>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
if let Ok(map) = cache.lock()
&& let Some(hit) = map.get(project_dir)
{
return hit.clone();
}
let xdg = aube_util::env::xdg_config_home();
let home = home_dir();
let user_rc_override = std::env::var("NPM_CONFIG_USERCONFIG")
.ok()
.or_else(|| std::env::var("npm_config_userconfig").ok())
.and_then(|raw| expand_userconfig_path(&raw, home.as_deref()));
let entries = load_npmrc_entries_with_home(
home.as_deref(),
xdg.as_deref(),
project_dir,
user_rc_override.as_deref(),
);
if let Ok(mut map) = cache.lock() {
map.insert(project_dir.to_path_buf(), entries.clone());
}
entries
}
fn load_npmrc_entries_tagged_with_home(
home: Option<&Path>,
xdg_config_home: Option<&Path>,
project_dir: &Path,
user_rc_override: Option<&Path>,
) -> Vec<(NpmrcSource, String, String)> {
let mut out: Vec<(NpmrcSource, String, String)> = Vec::new();
let user_rc = user_rc_override
.map(PathBuf::from)
.or_else(|| home.map(|h| h.join(".npmrc")));
if let Some(user_rc) = user_rc
&& user_rc.exists()
&& let Ok(entries) = parse_npmrc(&user_rc)
{
out.extend(entries.into_iter().map(|(k, v)| (NpmrcSource::User, k, v)));
}
if let Some(home) = home {
let auth_ini = pnpm_global_auth_ini_path(home, xdg_config_home);
if auth_ini.exists()
&& let Ok(entries) = parse_npmrc(&auth_ini)
{
out.extend(
entries
.into_iter()
.map(|(k, v)| (NpmrcSource::PnpmAuth, k, v)),
);
}
}
let project_rc = project_dir.join(".npmrc");
if project_rc.exists()
&& let Ok(entries) = parse_npmrc(&project_rc)
{
out.extend(
entries
.into_iter()
.map(|(k, v)| (NpmrcSource::Project, k, v)),
);
}
if let Some(auth_path) = resolve_npmrc_auth_file(
home,
project_dir,
out.iter().map(|(_, k, v)| (k.as_str(), v.as_str())),
) && auth_path.exists()
&& let Ok(entries) = parse_npmrc(&auth_path)
{
out.extend(
entries
.into_iter()
.map(|(k, v)| (NpmrcSource::NpmrcAuthFile, k, v)),
);
}
out
}
fn sanitize_token_helper(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let is_unix_absolute = trimmed.starts_with('/');
let is_windows_absolute = trimmed.starts_with("\\\\")
|| trimmed.as_bytes().get(1).is_some_and(|&b| b == b':')
&& trimmed
.as_bytes()
.first()
.is_some_and(|&b| b.is_ascii_alphabetic())
&& matches!(trimmed.as_bytes().get(2), Some(b'/' | b'\\'));
if !(is_unix_absolute || is_windows_absolute) {
return None;
}
if trimmed.chars().any(|c| {
c.is_ascii_whitespace()
|| matches!(
c,
'"' | '\'' | '`' | '$' | '&' | '|' | ';' | '<' | '>' | '(' | ')' | '*' | '?' | '\0'
)
}) {
return None;
}
Some(trimmed.to_string())
}
fn load_npmrc_entries_with_home(
home: Option<&Path>,
xdg_config_home: Option<&Path>,
project_dir: &Path,
user_rc_override: Option<&Path>,
) -> Vec<(String, String)> {
let mut out = Vec::new();
let user_rc = user_rc_override
.map(PathBuf::from)
.or_else(|| home.map(|h| h.join(".npmrc")));
if let Some(user_rc) = user_rc
&& user_rc.exists()
&& let Ok(entries) = parse_npmrc(&user_rc)
{
out.extend(entries);
}
if let Some(home) = home {
let auth_ini = pnpm_global_auth_ini_path(home, xdg_config_home);
if auth_ini.exists()
&& let Ok(entries) = parse_npmrc(&auth_ini)
{
out.extend(entries);
}
}
let project_rc = project_dir.join(".npmrc");
if project_rc.exists()
&& let Ok(entries) = parse_npmrc(&project_rc)
{
out.extend(entries);
}
if let Some(auth_path) = resolve_npmrc_auth_file(
home,
project_dir,
out.iter().map(|(k, v)| (k.as_str(), v.as_str())),
) && auth_path.exists()
&& let Ok(entries) = parse_npmrc(&auth_path)
{
out.extend(entries);
}
out
}
fn resolve_npmrc_auth_file<'a, I>(
home: Option<&Path>,
project_dir: &Path,
entries: I,
) -> Option<PathBuf>
where
I: DoubleEndedIterator<Item = (&'a str, &'a str)>,
{
let raw = entries
.rev()
.find(|(k, _)| matches!(*k, "npmrcAuthFile" | "npmrc-auth-file"))
.map(|(_, v)| v)?;
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
home.map(|h| h.join(rest))?
} else if raw == "~" {
home.map(PathBuf::from)?
} else {
PathBuf::from(raw)
};
if expanded.is_absolute() {
Some(expanded)
} else {
Some(project_dir.join(expanded))
}
}
fn expand_userconfig_path(raw: &str, home: Option<&Path>) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix("~/") {
return home.map(|h| h.join(rest));
}
if trimmed == "~" {
return home.map(PathBuf::from);
}
Some(PathBuf::from(trimmed))
}
fn userconfig_override_from_env(env: &[(String, String)], home: Option<&Path>) -> Option<PathBuf> {
let raw = env
.iter()
.find(|(name, _)| name == "NPM_CONFIG_USERCONFIG")
.or_else(|| env.iter().find(|(name, _)| name == "npm_config_userconfig"))?;
expand_userconfig_path(&raw.1, home)
}
fn parse_npmrc(path: &Path) -> Result<Vec<(String, String)>, std::io::Error> {
let raw_content = std::fs::read_to_string(path)?;
let content = raw_content.strip_prefix('\u{feff}').unwrap_or(&raw_content);
let mut entries = Vec::new();
let mut logical: Vec<String> = Vec::new();
let mut acc = String::new();
for raw in content.lines() {
if let Some(stripped) = raw.strip_suffix('\\') {
acc.push_str(stripped);
continue;
}
acc.push_str(raw);
logical.push(std::mem::take(&mut acc));
}
if !acc.is_empty() {
logical.push(acc);
}
for line in &logical {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = substitute_env(strip_matched_quotes(value.trim()));
entries.push((key, value));
}
}
Ok(entries)
}
fn strip_matched_quotes(value: &str) -> &str {
let bytes = value.as_bytes();
if bytes.len() >= 2
&& (bytes[0] == b'"' || bytes[0] == b'\'')
&& bytes[bytes.len() - 1] == bytes[0]
{
&value[1..value.len() - 1]
} else {
value
}
}
fn substitute_env(value: &str) -> String {
let mut result = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next(); let mut var_name = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
var_name.push(c);
}
if let Ok(val) = std::env::var(&var_name) {
result.push_str(&val);
}
} else {
result.push(c);
}
}
result
}
fn package_scope(name: &str) -> Option<&str> {
if name.starts_with('@') {
name.find('/').map(|idx| &name[..idx])
} else {
None
}
}
fn registry_uri_key(url: &str) -> String {
let (rest, default_port) = if let Some(rest) = url.strip_prefix("https:") {
(rest, ":443")
} else if let Some(rest) = url.strip_prefix("http:") {
(rest, ":80")
} else {
return url.to_string();
};
strip_authority_port_suffix(rest, default_port)
}
fn normalize_npmrc_uri_key(key: &str) -> String {
let stripped = strip_authority_port_suffix(key, ":443");
if stripped != key {
return stripped;
}
strip_authority_port_suffix(key, ":80")
}
fn strip_authority_port_suffix(key: &str, port_suffix: &str) -> String {
let Some(after) = key.strip_prefix("//") else {
return key.to_string();
};
let (authority, path) = match after.find('/') {
Some(idx) => (&after[..idx], &after[idx..]),
None => (after, ""),
};
let Some(authority) = authority.strip_suffix(port_suffix) else {
return key.to_string();
};
format!("//{authority}{path}")
}
pub(crate) fn lookup_by_uri_prefix<'a, V>(
map: &'a BTreeMap<String, V>,
key: &str,
) -> Option<&'a V> {
if let Some(v) = map.get(key) {
return Some(v);
}
let trimmed = key.trim_end_matches('/');
if !trimmed.is_empty()
&& trimmed != key
&& let Some(v) = map.get(trimmed)
{
return Some(v);
}
let mut cursor = trimmed;
while let Some(idx) = cursor.rfind('/') {
cursor = &cursor[..idx];
if cursor.len() <= 2 {
break;
}
let with_slash = format!("{cursor}/");
if let Some(v) = map.get(&with_slash) {
return Some(v);
}
if let Some(v) = map.get(cursor) {
return Some(v);
}
}
None
}
pub fn normalize_registry_url_pub(url: &str) -> String {
normalize_registry_url(url)
}
pub fn registry_uri_key_pub(url: &str) -> String {
registry_uri_key(url)
}
fn normalize_registry_url(url: &str) -> String {
let url = url.trim();
if url.ends_with('/') {
url.to_string()
} else {
format!("{url}/")
}
}
fn home_dir() -> Option<PathBuf> {
aube_util::env::home_dir()
}
fn pnpm_global_auth_ini_path(home: &Path, xdg_config_home: Option<&Path>) -> PathBuf {
let config_root = xdg_config_home
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
config_root.join("pnpm").join("auth.ini")
}
fn non_empty(s: String) -> Option<String> {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
fn pem_value(s: String) -> String {
s.replace("\\n", "\n")
}
fn env_any(names: &[&str]) -> Option<String> {
for n in names {
if let Ok(v) = std::env::var(n) {
let trimmed = v.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
pub(crate) fn run_token_helper(command: &str) -> Option<String> {
let output = match std::process::Command::new(command).output() {
Ok(o) => o,
Err(e) => {
tracing::warn!("tokenHelper {command:?} could not be spawned: {e}");
return None;
}
};
if !output.status.success() {
tracing::warn!("tokenHelper {command:?} exited with {}", output.status);
return None;
}
let token = String::from_utf8(output.stdout).ok()?;
non_empty(token.lines().next().unwrap_or_default().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_npmrc_strips_utf8_bom() {
let dir = tempfile::tempdir().unwrap();
let rc = dir.path().join(".npmrc");
std::fs::write(&rc, "\u{feff}registry=https://r.example.com\n").unwrap();
let entries = parse_npmrc(&rc).unwrap();
assert_eq!(
entries,
vec![("registry".to_string(), "https://r.example.com".to_string())]
);
}
#[test]
fn scoped_registry_lookup_is_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"@MyOrg:registry=https://myorg.example.com/\n",
)
.unwrap();
let cfg = NpmConfig::load_isolated(dir.path());
assert_eq!(cfg.registry_for("@myorg/pkg"), "https://myorg.example.com/");
}
#[test]
fn test_parse_npmrc_basic() {
let dir = tempfile::tempdir().unwrap();
let rc = dir.path().join(".npmrc");
std::fs::write(
&rc,
"registry=https://registry.example.com\n_authToken=secret123\n",
)
.unwrap();
let entries = parse_npmrc(&rc).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0],
(
"registry".to_string(),
"https://registry.example.com".to_string()
)
);
assert_eq!(
entries[1],
("_authToken".to_string(), "secret123".to_string())
);
}
#[test]
fn test_parse_npmrc_comments_and_blanks() {
let dir = tempfile::tempdir().unwrap();
let rc = dir.path().join(".npmrc");
std::fs::write(
&rc,
"# comment\n\n; another comment\nregistry=https://r.com\n",
)
.unwrap();
let entries = parse_npmrc(&rc).unwrap();
assert_eq!(entries.len(), 1);
}
#[test]
fn test_substitute_env() {
unsafe { std::env::set_var("AUBE_TEST_TOKEN_CFG", "mytoken") };
assert_eq!(substitute_env("${AUBE_TEST_TOKEN_CFG}"), "mytoken");
assert_eq!(
substitute_env("prefix-${AUBE_TEST_TOKEN_CFG}-suffix"),
"prefix-mytoken-suffix"
);
assert_eq!(substitute_env("no-vars-here"), "no-vars-here");
unsafe { std::env::remove_var("AUBE_TEST_TOKEN_CFG") };
}
#[test]
fn test_substitute_env_missing_var() {
assert_eq!(substitute_env("${AUBE_DEFINITELY_NOT_SET}"), "");
}
#[test]
fn parse_npmrc_strips_surrounding_quotes() {
let dir = tempfile::tempdir().unwrap();
let rc = dir.path().join(".npmrc");
std::fs::write(
&rc,
"//artifactory.example.com/api/npm/virtual-npm/:_auth=\"token==\"\n\
//registry.example.com/:_authToken='single-quoted'\n\
registry=\"https://r.example.com/\"\n\
unmatched=\"only-leading\n\
plain=value\n",
)
.unwrap();
let entries = parse_npmrc(&rc).unwrap();
assert_eq!(
entries,
vec![
(
"//artifactory.example.com/api/npm/virtual-npm/:_auth".to_string(),
"token==".to_string()
),
(
"//registry.example.com/:_authToken".to_string(),
"single-quoted".to_string()
),
("registry".to_string(), "https://r.example.com/".to_string()),
("unmatched".to_string(), "\"only-leading".to_string()),
("plain".to_string(), "value".to_string()),
]
);
}
#[test]
fn test_package_scope() {
assert_eq!(package_scope("@myorg/pkg"), Some("@myorg"));
assert_eq!(package_scope("lodash"), None);
assert_eq!(package_scope("@types/node"), Some("@types"));
}
#[test]
fn test_registry_uri_key() {
assert_eq!(
registry_uri_key("https://registry.example.com/"),
"//registry.example.com/"
);
assert_eq!(
registry_uri_key("http://localhost:4873/"),
"//localhost:4873/"
);
}
#[test]
fn test_registry_uri_key_strips_default_port() {
assert_eq!(
registry_uri_key("https://registry.example.com:443/"),
"//registry.example.com/"
);
assert_eq!(
registry_uri_key("http://registry.example.com:80/artifactory/npm/"),
"//registry.example.com/artifactory/npm/"
);
assert_eq!(
registry_uri_key("https://registry.example.com:8443/"),
"//registry.example.com:8443/"
);
}
#[test]
fn test_registry_uri_key_only_strips_matching_default_port() {
assert_eq!(registry_uri_key("https://host:80/x/"), "//host:80/x/",);
assert_eq!(registry_uri_key("http://host:443/x/"), "//host:443/x/",);
}
#[test]
fn test_lookup_by_uri_prefix_longest_match() {
let mut map: BTreeMap<String, &'static str> = BTreeMap::new();
map.insert("//host/artifactory/npm/".to_string(), "scoped-token");
map.insert("//host/".to_string(), "root-token");
assert_eq!(
lookup_by_uri_prefix(&map, "//host/artifactory/npm/lodash/-/lodash-4.17.21.tgz"),
Some(&"scoped-token"),
);
assert_eq!(
lookup_by_uri_prefix(&map, "//host/other/pkg.tgz"),
Some(&"root-token"),
);
assert_eq!(lookup_by_uri_prefix(&map, "//other/foo"), None);
}
#[test]
fn auth_token_resolves_for_path_scoped_registry_with_default_port() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"registry=https://registry.example.com/artifactory/npm/\n\
//registry.example.com/artifactory/npm/:_authToken=scoped-secret\n",
)
.unwrap();
let config = NpmConfig::load_isolated(dir.path());
assert_eq!(
config.auth_token_for(
"https://registry.example.com:443/artifactory/npm/lodash/-/lodash-4.17.21.tgz"
),
Some("scoped-secret"),
);
assert_eq!(
config.auth_token_for(
"https://registry.example.com/artifactory/npm/lodash/-/lodash-4.17.21.tgz"
),
Some("scoped-secret"),
);
}
#[test]
fn npmrc_key_with_default_port_is_normalized_on_ingest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"//registry.example.com:443/:_authToken=via-443\n",
)
.unwrap();
let config = NpmConfig::load_isolated(dir.path());
assert_eq!(
config.auth_token_for("https://registry.example.com/"),
Some("via-443"),
);
}
#[test]
fn test_normalize_registry_url() {
assert_eq!(normalize_registry_url("https://r.com"), "https://r.com/");
assert_eq!(normalize_registry_url("https://r.com/"), "https://r.com/");
}
#[test]
fn test_config_load_project_npmrc() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"registry=https://custom.registry.com\n\
@myorg:registry=https://myorg.registry.com\n\
//myorg.registry.com/:_authToken=org-secret\n\
//custom.registry.com/:_authToken=custom-secret\n",
)
.unwrap();
let config = NpmConfig::load_isolated(dir.path());
assert_eq!(config.registry, "https://custom.registry.com/");
assert_eq!(
config.registry_for("@myorg/pkg"),
"https://myorg.registry.com/"
);
assert_eq!(
config.registry_for("lodash"),
"https://custom.registry.com/"
);
assert_eq!(
config.auth_token_for("https://myorg.registry.com/"),
Some("org-secret")
);
assert_eq!(
config.auth_token_for("https://custom.registry.com/"),
Some("custom-secret")
);
}
#[test]
fn split_username_password_auth_resolves_to_basic_header_payload() {
let dir = tempfile::tempdir().unwrap();
let encoded_password = base64::engine::general_purpose::STANDARD.encode("s3cr3t");
std::fs::write(
dir.path().join(".npmrc"),
format!(
"//registry.example.com/:username=alice\n\
//registry.example.com/:_password={encoded_password}\n"
),
)
.unwrap();
let config = NpmConfig::load_isolated(dir.path());
let expected = base64::engine::general_purpose::STANDARD.encode("alice:s3cr3t");
assert_eq!(
config.basic_auth_for("https://registry.example.com/"),
Some(expected),
);
}
#[test]
fn token_helper_from_project_npmrc_is_refused_kebab_case() {
let project = tempfile::tempdir().unwrap();
std::fs::write(
project.path().join(".npmrc"),
"//registry.example.com/:token-helper=/tmp/evil.sh\n",
)
.unwrap();
let home = tempfile::tempdir().unwrap();
let mut config = NpmConfig::default();
config.apply_tagged(load_npmrc_entries_tagged_with_home(
Some(home.path()),
None,
project.path(),
None,
));
assert_eq!(
config.token_helper_for("https://registry.example.com/"),
None,
"project-scope token-helper (kebab-case) must be refused"
);
}
#[test]
fn token_helper_from_project_npmrc_is_refused() {
let project = tempfile::tempdir().unwrap();
std::fs::write(
project.path().join(".npmrc"),
"//registry.example.com/:tokenHelper=/tmp/evil.sh\n",
)
.unwrap();
let home = tempfile::tempdir().unwrap();
let mut config = NpmConfig::default();
config.apply_tagged(load_npmrc_entries_tagged_with_home(
Some(home.path()),
None,
project.path(),
None,
));
assert_eq!(
config.token_helper_for("https://registry.example.com/"),
None,
"project-scope tokenHelper must be refused"
);
}
#[test]
fn token_helper_from_user_npmrc_is_accepted() {
let home = tempfile::tempdir().unwrap();
let helper_path = if cfg!(windows) {
"C:\\opt\\aube\\helper.exe"
} else {
"/opt/aube/helper"
};
std::fs::write(
home.path().join(".npmrc"),
format!("//registry.example.com/:tokenHelper={helper_path}\n"),
)
.unwrap();
let project = tempfile::tempdir().unwrap();
let mut config = NpmConfig::default();
config.apply_tagged(load_npmrc_entries_tagged_with_home(
Some(home.path()),
None,
project.path(),
None,
));
assert_eq!(
config.token_helper_for("https://registry.example.com/"),
Some(helper_path)
);
}
#[test]
fn token_helper_from_npmrc_auth_file_is_refused() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let auth = project.path().join("auth.rc");
std::fs::write(&auth, "//registry.example.com/:tokenHelper=/tmp/evil.sh\n").unwrap();
std::fs::write(
project.path().join(".npmrc"),
format!(
"npmrc-auth-file={}\n",
auth.to_string_lossy().replace('\\', "/")
),
)
.unwrap();
let mut config = NpmConfig::default();
config.apply_tagged(load_npmrc_entries_tagged_with_home(
Some(home.path()),
None,
project.path(),
None,
));
assert_eq!(
config.token_helper_for("https://registry.example.com/"),
None,
"tokenHelper from an auth file reachable via project `.npmrc` must be refused"
);
}
#[test]
fn sanitize_token_helper_accepts_absolute_path() {
assert_eq!(
sanitize_token_helper("/usr/local/bin/aws-npm-helper"),
Some("/usr/local/bin/aws-npm-helper".to_string())
);
assert_eq!(
sanitize_token_helper("C:\\Program.Files\\auth.exe"),
Some("C:\\Program.Files\\auth.exe".to_string())
);
assert_eq!(
sanitize_token_helper("C:/tools/auth.exe"),
Some("C:/tools/auth.exe".to_string())
);
assert_eq!(
sanitize_token_helper("\\\\server\\share\\auth.exe"),
Some("\\\\server\\share\\auth.exe".to_string())
);
}
#[test]
fn sanitize_token_helper_rejects_relative_path() {
assert!(sanitize_token_helper("aws-helper").is_none());
assert!(sanitize_token_helper("./aws-helper").is_none());
assert!(sanitize_token_helper("bin/aws-helper").is_none());
}
#[test]
fn sanitize_token_helper_rejects_shell_metacharacters() {
for v in [
"/bin/helper;rm",
"/bin/helper|rm",
"/bin/helper&rm",
"/bin/helper`rm`",
"/bin/helper$(rm)",
"/bin/helper>log",
"/bin/helper<log",
"/bin/helper*glob",
"/bin/helper?glob",
"/bin/helper\"evil",
"/bin/helper'evil",
] {
assert!(sanitize_token_helper(v).is_none(), "should reject {v:?}");
}
}
#[test]
fn sanitize_token_helper_rejects_whitespace() {
assert!(sanitize_token_helper("/bin/helper --flag").is_none());
assert!(sanitize_token_helper("/bin/helper\targ").is_none());
assert!(sanitize_token_helper("/bin/helper\nevil").is_none());
}
#[test]
fn sanitize_token_helper_rejects_empty_and_nul() {
assert!(sanitize_token_helper("").is_none());
assert!(sanitize_token_helper(" ").is_none());
assert!(sanitize_token_helper("/bin/helper\0evil").is_none());
}
#[test]
fn sanitize_token_helper_rejects_env_substitution_markers() {
assert!(sanitize_token_helper("/bin/helper-${EVIL}").is_none());
assert!(sanitize_token_helper("/bin/$EVIL").is_none());
}
#[test]
fn per_registry_tls_config_is_parsed() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"//registry.example.com/:ca=-----BEGIN CERTIFICATE-----\\nca\\n-----END CERTIFICATE-----\n\
//registry.example.com/:cafile=corp-ca.pem\n\
//registry.example.com/:cert=-----BEGIN CERTIFICATE-----\\nclient\\n-----END CERTIFICATE-----\n\
//registry.example.com/:key=-----BEGIN PRIVATE KEY-----\\nkey\\n-----END PRIVATE KEY-----\n",
)
.unwrap();
let config = NpmConfig::load_isolated(dir.path());
let tls = &config
.registry_config_for("https://registry.example.com/")
.expect("registry config")
.tls;
assert_eq!(tls.ca.len(), 1);
assert!(tls.ca[0].contains("\nca\n"));
assert!(!tls.ca[0].contains("\\n"));
assert_eq!(tls.cafile.as_deref(), Some(Path::new("corp-ca.pem")));
assert!(tls.cert.as_deref().unwrap().contains("\nclient\n"));
assert!(tls.key.as_deref().unwrap().contains("\nkey\n"));
}
#[test]
fn test_config_global_auth_token() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".npmrc"), "_authToken=global-token\n").unwrap();
let config = NpmConfig::load_isolated(dir.path());
assert_eq!(
config.auth_token_for("https://registry.npmjs.org/"),
Some("global-token")
);
}
#[test]
fn test_config_defaults() {
let dir = tempfile::tempdir().unwrap();
let config = NpmConfig::load_isolated(dir.path());
assert_eq!(config.registry, "https://registry.npmjs.org/");
assert!(
config
.auth_token_for("https://registry.npmjs.org/")
.is_none()
);
}
#[test]
fn test_config_scoped_registry_without_auth() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"@private:registry=https://private.registry.com\n",
)
.unwrap();
let config = NpmConfig::load_isolated(dir.path());
assert_eq!(
config.registry_for("@private/my-lib"),
"https://private.registry.com/"
);
assert!(
config
.auth_token_for("https://private.registry.com/")
.is_none()
);
}
#[test]
fn test_http_proxy_inherits_https_proxy() {
let mut config = NpmConfig {
https_proxy: Some("http://corp.proxy:8080".to_string()),
..Default::default()
};
config.apply_proxy_env();
assert_eq!(
config.http_proxy.as_deref(),
Some("http://corp.proxy:8080"),
"http_proxy must inherit https_proxy"
);
}
#[test]
fn test_npmrc_proxy_key_feeds_https_proxy() {
let mut config = NpmConfig {
npmrc_proxy: Some("http://legacy:3128".to_string()),
..Default::default()
};
config.apply_proxy_env();
assert_eq!(
config.https_proxy.as_deref(),
Some("http://legacy:3128"),
"legacy `proxy=` key must resolve into https_proxy"
);
assert_eq!(
config.http_proxy.as_deref(),
Some("http://legacy:3128"),
"http_proxy then inherits the resolved https_proxy"
);
}
#[test]
fn test_explicit_https_proxy_wins_over_npmrc_proxy() {
let mut config = NpmConfig {
https_proxy: Some("http://explicit:1".to_string()),
npmrc_proxy: Some("http://fallback:2".to_string()),
..Default::default()
};
config.apply_proxy_env();
assert_eq!(config.https_proxy.as_deref(), Some("http://explicit:1"));
}
#[test]
fn test_default_strict_ssl_is_true() {
let c = NpmConfig::default();
assert!(c.strict_ssl);
}
#[test]
fn test_parses_proxy_and_ssl_settings() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"https-proxy=http://proxy.example.com:8080\n\
proxy=http://plain.example.com:3128\n\
noproxy=localhost,.internal\n\
strict-ssl=false\n\
local-address=127.0.0.1\n\
maxsockets=12\n",
)
.unwrap();
let home = tempfile::tempdir().unwrap();
let mut config = NpmConfig {
registry: "https://registry.npmjs.org/".to_string(),
strict_ssl: true,
..Default::default()
};
config.apply(load_npmrc_entries_with_home(
Some(home.path()),
None,
dir.path(),
None,
));
assert_eq!(
config.https_proxy.as_deref(),
Some("http://proxy.example.com:8080")
);
assert_eq!(
config.npmrc_proxy.as_deref(),
Some("http://plain.example.com:3128")
);
assert!(config.http_proxy.is_none());
assert_eq!(config.no_proxy.as_deref(), Some("localhost,.internal"));
assert!(!config.strict_ssl);
assert_eq!(
config.local_address,
Some("127.0.0.1".parse::<std::net::IpAddr>().unwrap())
);
assert_eq!(config.max_sockets, Some(12));
}
#[test]
fn test_strict_ssl_default_true() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".npmrc"), "").unwrap();
let mut config = NpmConfig {
strict_ssl: true,
..Default::default()
};
config.apply(load_npmrc_entries_with_home(
Some(home.path()),
None,
dir.path(),
None,
));
assert!(config.strict_ssl);
}
#[test]
fn test_camel_case_proxy_aliases() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"httpsProxy=http://a\nhttpProxy=http://b\nnoProxy=foo\nstrictSsl=false\nlocalAddress=::1\n",
)
.unwrap();
let home = tempfile::tempdir().unwrap();
let mut config = NpmConfig {
strict_ssl: true,
..Default::default()
};
config.apply(load_npmrc_entries_with_home(
Some(home.path()),
None,
dir.path(),
None,
));
assert_eq!(config.https_proxy.as_deref(), Some("http://a"));
assert_eq!(config.http_proxy.as_deref(), Some("http://b"));
assert_eq!(config.no_proxy.as_deref(), Some("foo"));
assert!(!config.strict_ssl);
assert_eq!(
config.local_address,
Some("::1".parse::<std::net::IpAddr>().unwrap())
);
}
#[test]
fn test_invalid_proxy_values_dropped() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"local-address=not-an-ip\nmaxsockets=zero\nstrict-ssl=perhaps\n",
)
.unwrap();
let home = tempfile::tempdir().unwrap();
let mut config = NpmConfig {
strict_ssl: true,
..Default::default()
};
config.apply(load_npmrc_entries_with_home(
Some(home.path()),
None,
dir.path(),
None,
));
assert!(config.local_address.is_none());
assert!(config.max_sockets.is_none());
assert!(config.strict_ssl);
}
#[test]
fn test_load_npmrc_entries_orders_user_before_project() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
std::fs::write(
home_dir.path().join(".npmrc"),
"auto-install-peers=true\nfoo=user-only\n",
)
.unwrap();
std::fs::write(
proj_dir.path().join(".npmrc"),
"auto-install-peers=false\nbar=project-only\n",
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
assert!(entries.iter().any(|(k, v)| k == "foo" && v == "user-only"));
assert!(
entries
.iter()
.any(|(k, v)| k == "bar" && v == "project-only")
);
let positions: Vec<_> = entries
.iter()
.filter(|(k, _)| k == "auto-install-peers")
.map(|(_, v)| v.as_str())
.collect();
assert_eq!(
positions.len(),
2,
"expected both user and project entries for shared key: {entries:?}"
);
assert_eq!(
positions[0], "true",
"user entry must come first (precedence is last-write-wins downstream)"
);
assert_eq!(
positions[1], "false",
"project entry must come second so it overrides the user entry"
);
}
#[test]
fn pnpm_global_auth_ini_loads_and_overrides_user_rc() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
std::fs::write(
home_dir.path().join(".npmrc"),
"//registry.example.com/:_authToken=stale-npmrc\n",
)
.unwrap();
let auth_ini = home_dir.path().join(".config/pnpm/auth.ini");
std::fs::create_dir_all(auth_ini.parent().unwrap()).unwrap();
std::fs::write(
&auth_ini,
"//registry.example.com/:_authToken=fresh-auth-ini\n\
//other.example.com/:_authToken=other-token\n",
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
let mut cfg = NpmConfig::default();
cfg.apply(entries);
assert_eq!(
cfg.auth_token_for("https://registry.example.com/"),
Some("fresh-auth-ini"),
"auth.ini token should override stale ~/.npmrc token",
);
assert_eq!(
cfg.auth_token_for("https://other.example.com/"),
Some("other-token"),
"additional auth.ini entries should be picked up",
);
}
#[test]
fn pnpm_global_auth_ini_honors_xdg_config_home_override() {
let home_dir = tempfile::tempdir().unwrap();
let xdg_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
let auth_ini = xdg_dir.path().join("pnpm/auth.ini");
std::fs::create_dir_all(auth_ini.parent().unwrap()).unwrap();
std::fs::write(&auth_ini, "//registry.example.com/:_authToken=xdg-token\n").unwrap();
let decoy = home_dir.path().join(".config/pnpm/auth.ini");
std::fs::create_dir_all(decoy.parent().unwrap()).unwrap();
std::fs::write(&decoy, "//registry.example.com/:_authToken=decoy\n").unwrap();
let entries = load_npmrc_entries_with_home(
Some(home_dir.path()),
Some(xdg_dir.path()),
proj_dir.path(),
None,
);
let mut cfg = NpmConfig::default();
cfg.apply(entries);
assert_eq!(
cfg.auth_token_for("https://registry.example.com/"),
Some("xdg-token"),
);
}
#[test]
fn pnpm_global_auth_ini_loses_to_project_npmrc() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
let auth_ini = home_dir.path().join(".config/pnpm/auth.ini");
std::fs::create_dir_all(auth_ini.parent().unwrap()).unwrap();
std::fs::write(
&auth_ini,
"//registry.example.com/:_authToken=global-auth-ini\n",
)
.unwrap();
std::fs::write(
proj_dir.path().join(".npmrc"),
"//registry.example.com/:_authToken=project-pin\n",
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
let mut cfg = NpmConfig::default();
cfg.apply(entries);
assert_eq!(
cfg.auth_token_for("https://registry.example.com/"),
Some("project-pin"),
);
}
#[test]
fn npmrc_auth_file_overrides_user_token() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
let auth_file = proj_dir.path().join("auth.npmrc");
std::fs::write(
home_dir.path().join(".npmrc"),
"//registry.example.com/:_authToken=stale-user-token\n",
)
.unwrap();
std::fs::write(
&auth_file,
"//registry.example.com/:_authToken=fresh-from-auth-file\n",
)
.unwrap();
std::fs::write(
proj_dir.path().join(".npmrc"),
format!("npmrc-auth-file={}\n", auth_file.display()),
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
let mut cfg = NpmConfig::default();
cfg.apply(entries);
assert_eq!(
cfg.auth_token_for("https://registry.example.com/"),
Some("fresh-from-auth-file"),
);
}
#[test]
fn npmrc_auth_file_resolves_relative_to_project_root() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(proj_dir.path().join("secrets")).unwrap();
std::fs::write(
proj_dir.path().join("secrets/npm"),
"//registry.example.com/:_authToken=relative-path-token\n",
)
.unwrap();
std::fs::write(
proj_dir.path().join(".npmrc"),
"npmrc-auth-file=secrets/npm\n",
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
assert!(
entries
.iter()
.any(|(k, v)| k == "//registry.example.com/:_authToken"
&& v == "relative-path-token"),
"auth file entries missing — got {entries:?}",
);
}
#[test]
fn npmrc_auth_file_camel_case_alias_works() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
let auth_file = proj_dir.path().join("auth.npmrc");
std::fs::write(
&auth_file,
"//registry.example.com/:_authToken=camel-token\n",
)
.unwrap();
std::fs::write(
proj_dir.path().join(".npmrc"),
format!("npmrcAuthFile={}\n", auth_file.display()),
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
assert!(
entries
.iter()
.any(|(k, v)| k == "//registry.example.com/:_authToken" && v == "camel-token"),
"camelCase alias did not load auth file — got {entries:?}",
);
}
#[test]
fn npmrc_auth_file_expands_tilde_against_home() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home_dir.path().join("secrets")).unwrap();
std::fs::write(
home_dir.path().join("secrets/npm"),
"//registry.example.com/:_authToken=tilde-token\n",
)
.unwrap();
std::fs::write(
proj_dir.path().join(".npmrc"),
"npmrc-auth-file=~/secrets/npm\n",
)
.unwrap();
let entries =
load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
assert!(
entries
.iter()
.any(|(k, v)| k == "//registry.example.com/:_authToken" && v == "tilde-token"),
"tilde expansion failed — got {entries:?}",
);
}
#[test]
fn userconfig_override_replaces_default_user_npmrc() {
let home_dir = tempfile::tempdir().unwrap();
let proj_dir = tempfile::tempdir().unwrap();
let override_dir = tempfile::tempdir().unwrap();
let override_rc = override_dir.path().join("npmrc");
std::fs::write(
home_dir.path().join(".npmrc"),
"registry=https://decoy.example/\n",
)
.unwrap();
std::fs::write(&override_rc, "registry=https://override.example/\n").unwrap();
let entries = load_npmrc_entries_with_home(
Some(home_dir.path()),
None,
proj_dir.path(),
Some(&override_rc),
);
assert!(
entries
.iter()
.any(|(k, v)| k == "registry" && v == "https://override.example/"),
"override file was not loaded — got {entries:?}",
);
assert!(
!entries.iter().any(|(_, v)| v == "https://decoy.example/"),
"default ~/.npmrc must be skipped when override is set — got {entries:?}",
);
}
#[test]
fn expand_userconfig_path_handles_tilde_absolute_and_empty() {
let home = PathBuf::from("/fake/home");
assert_eq!(
expand_userconfig_path("~/config/npm/npmrc", Some(&home)),
Some(PathBuf::from("/fake/home/config/npm/npmrc"))
);
assert_eq!(
expand_userconfig_path("~", Some(&home)),
Some(PathBuf::from("/fake/home"))
);
assert_eq!(
expand_userconfig_path("/etc/npmrc", Some(&home)),
Some(PathBuf::from("/etc/npmrc"))
);
assert_eq!(expand_userconfig_path("~/x", None), None);
assert_eq!(expand_userconfig_path("", Some(&home)), None);
assert_eq!(expand_userconfig_path(" ", Some(&home)), None);
}
#[test]
fn userconfig_override_from_env_prefers_screaming_casing() {
let home = PathBuf::from("/h");
let upper = vec![(
"NPM_CONFIG_USERCONFIG".to_string(),
"/tmp/upper-rc".to_string(),
)];
assert_eq!(
userconfig_override_from_env(&upper, Some(&home)),
Some(PathBuf::from("/tmp/upper-rc"))
);
let lower = vec![(
"npm_config_userconfig".to_string(),
"/tmp/lower-rc".to_string(),
)];
assert_eq!(
userconfig_override_from_env(&lower, Some(&home)),
Some(PathBuf::from("/tmp/lower-rc"))
);
let upper_first = vec![
(
"NPM_CONFIG_USERCONFIG".to_string(),
"/tmp/upper".to_string(),
),
(
"npm_config_userconfig".to_string(),
"/tmp/lower".to_string(),
),
];
assert_eq!(
userconfig_override_from_env(&upper_first, Some(&home)),
Some(PathBuf::from("/tmp/upper")),
);
let lower_first = vec![
(
"npm_config_userconfig".to_string(),
"/tmp/lower".to_string(),
),
(
"NPM_CONFIG_USERCONFIG".to_string(),
"/tmp/upper".to_string(),
),
];
assert_eq!(
userconfig_override_from_env(&lower_first, Some(&home)),
Some(PathBuf::from("/tmp/upper")),
"SCREAMING form must win regardless of slice position",
);
let none_case = vec![("HOME".to_string(), "/h".to_string())];
assert_eq!(userconfig_override_from_env(&none_case, Some(&home)), None);
}
#[test]
fn load_with_env_honors_npm_config_userconfig() {
let proj_dir = tempfile::tempdir().unwrap();
let override_dir = tempfile::tempdir().unwrap();
let override_rc = override_dir.path().join("custom-npmrc");
std::fs::write(
&override_rc,
"//userconfig-test.example/:_authToken=from-userconfig-file\n",
)
.unwrap();
let env = vec![(
"NPM_CONFIG_USERCONFIG".to_string(),
override_rc.display().to_string(),
)];
let config = NpmConfig::load_with_env(proj_dir.path(), &env);
assert_eq!(
config.auth_token_for("https://userconfig-test.example/"),
Some("from-userconfig-file"),
);
}
#[test]
fn fetch_policy_default_matches_settings_toml_declared_defaults() {
let p = FetchPolicy::default();
assert_eq!(p.timeout_ms, 300_000);
assert_eq!(p.retries, 2);
assert_eq!(p.retry_factor, 10);
assert_eq!(p.retry_min_timeout_ms, 10_000);
assert_eq!(p.retry_max_timeout_ms, 60_000);
}
#[test]
fn fetch_policy_backoff_sequence_matches_make_fetch_happen() {
let p = FetchPolicy::default();
assert_eq!(
p.backoff_for_attempt(1),
std::time::Duration::from_millis(10_000)
);
assert_eq!(
p.backoff_for_attempt(2),
std::time::Duration::from_millis(60_000)
);
assert_eq!(
p.backoff_for_attempt(3),
std::time::Duration::from_millis(60_000)
);
}
#[test]
fn fetch_policy_backoff_clamps_on_huge_factor() {
let p = FetchPolicy {
timeout_ms: 60_000,
retries: 5,
retry_factor: u32::MAX,
retry_min_timeout_ms: 100,
retry_max_timeout_ms: 5_000,
..FetchPolicy::default()
};
assert_eq!(
p.backoff_for_attempt(1),
std::time::Duration::from_millis(100),
"first attempt is the min (no multiplier applied yet)",
);
assert_eq!(
p.backoff_for_attempt(2),
std::time::Duration::from_millis(5_000),
);
assert_eq!(
p.backoff_for_attempt(10),
std::time::Duration::from_millis(5_000),
"deep retries still clamp; no overflow panic",
);
}
#[test]
fn fetch_policy_from_ctx_reads_npmrc_overrides() {
let entries = vec![
("fetch-timeout".to_string(), "1234".to_string()),
("fetch-retries".to_string(), "5".to_string()),
("fetch-retry-factor".to_string(), "3".to_string()),
("fetch-retry-mintimeout".to_string(), "250".to_string()),
("fetch-retry-maxtimeout".to_string(), "9_999".to_string()),
];
let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
std::collections::BTreeMap::new();
let ctx = aube_settings::ResolveCtx::files_only(&entries, &ws);
let p = FetchPolicy::from_ctx(&ctx);
assert_eq!(p.timeout_ms, 1234);
assert_eq!(p.retries, 5);
assert_eq!(p.retry_factor, 3);
assert_eq!(p.retry_min_timeout_ms, 250);
assert_eq!(p.retry_max_timeout_ms, 60_000);
}
#[test]
fn fetch_policy_from_ctx_reads_warn_timeout_and_min_speed() {
let entries = vec![
("fetchWarnTimeoutMs".to_string(), "500".to_string()),
("fetchMinSpeedKiBps".to_string(), "123".to_string()),
];
let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
std::collections::BTreeMap::new();
let ctx = aube_settings::ResolveCtx::files_only(&entries, &ws);
let p = FetchPolicy::from_ctx(&ctx);
assert_eq!(p.warn_timeout_ms, 500);
assert_eq!(p.min_speed_kibps, 123);
}
#[test]
fn fetch_policy_default_includes_observability_thresholds() {
let p = FetchPolicy::default();
assert_eq!(p.warn_timeout_ms, 10_000);
assert_eq!(p.min_speed_kibps, 50);
}
#[test]
fn translate_npm_config_env_maps_default_registry() {
assert_eq!(
translate_npm_config_env("NPM_CONFIG_REGISTRY", "https://r.example/"),
Some(("registry".to_string(), "https://r.example/".to_string()))
);
assert_eq!(
translate_npm_config_env("npm_config_registry", "https://r.example/"),
Some(("registry".to_string(), "https://r.example/".to_string()))
);
assert_eq!(translate_npm_config_env("HOME", "/tmp"), None);
}
#[test]
fn translate_npm_config_env_maps_proxy_and_tls_knobs() {
let cases = [
("NPM_CONFIG_HTTPS_PROXY", "http://p:8", "https-proxy"),
("NPM_CONFIG_HTTP_PROXY", "http://p:9", "http-proxy"),
("NPM_CONFIG_PROXY", "http://p:0", "proxy"),
("NPM_CONFIG_NOPROXY", "localhost,.internal", "noproxy"),
("NPM_CONFIG_STRICT_SSL", "false", "strict-ssl"),
("NPM_CONFIG_LOCAL_ADDRESS", "127.0.0.1", "local-address"),
("NPM_CONFIG_MAXSOCKETS", "16", "maxsockets"),
];
for (name, value, expected_key) in cases {
assert_eq!(
translate_npm_config_env(name, value),
Some((expected_key.to_string(), value.to_string())),
"mapping failed for {name}"
);
}
}
#[test]
fn translate_npm_config_env_maps_scoped_registry() {
assert_eq!(
translate_npm_config_env("NPM_CONFIG_@MYORG:REGISTRY", "https://r.mycorp/"),
Some((
"@myorg:registry".to_string(),
"https://r.mycorp/".to_string()
))
);
assert_eq!(
translate_npm_config_env("npm_config_@myorg:registry", "https://r.mycorp/"),
Some((
"@myorg:registry".to_string(),
"https://r.mycorp/".to_string()
))
);
}
#[test]
fn translate_npm_config_env_passes_uri_auth_through_verbatim() {
assert_eq!(
translate_npm_config_env(
"NPM_CONFIG_//registry.example.com/:_authToken",
"secret-token"
),
Some((
"//registry.example.com/:_authToken".to_string(),
"secret-token".to_string()
))
);
}
#[test]
fn load_with_env_npm_config_registry_overrides_project_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".npmrc"),
"registry=https://file.registry.example/\n",
)
.unwrap();
let env = vec![(
"NPM_CONFIG_REGISTRY".to_string(),
"https://env.registry.example/".to_string(),
)];
let config = NpmConfig::load_with_env(dir.path(), &env);
assert_eq!(config.registry, "https://env.registry.example/");
}
#[test]
fn env_registry_overrides_project_npmrc() {
let mut config = NpmConfig {
registry: "https://registry.npmjs.org/".to_string(),
..Default::default()
};
config.apply(vec![(
"registry".to_string(),
"https://file.registry/".to_string(),
)]);
assert_eq!(config.registry, "https://file.registry/");
let env = translate_npm_config_env("NPM_CONFIG_REGISTRY", "https://env.registry/")
.map(|e| vec![e])
.unwrap_or_default();
config.apply(env);
assert_eq!(
config.registry, "https://env.registry/",
"env var must override file-based registry"
);
}
#[test]
fn fetch_policy_clamps_giant_retry_counts_into_u32() {
let entries = vec![("fetch-retries".to_string(), "99999999999999".to_string())];
let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
std::collections::BTreeMap::new();
let ctx = aube_settings::ResolveCtx::files_only(&entries, &ws);
let p = FetchPolicy::from_ctx(&ctx);
assert_eq!(p.retries, u32::MAX);
}
}