#![deny(clippy::print_stderr)]
#![deny(clippy::print_stdout)]
#![deny(clippy::unused_async)]
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use monch::*;
use sys_traits::EnvVar;
use url::Url;
use self::ini::Key;
use self::ini::KeyValueOrSection;
use self::ini::Value;
mod ini;
pub static NPM_DEFAULT_REGISTRY: &str = "https://registry.npmjs.org";
const NPM_DEFAULT_REGISTRY_HOST: &str = "registry.npmjs.org";
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("failed parsing npm registry url for scope '{scope}'")]
UrlScope {
scope: String,
#[source]
source: url::ParseError,
},
#[error("failed parsing npm registry url")]
Url(#[source] url::ParseError),
}
pub type NpmRcParseError = monch::ParseErrorFailureError;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RegistryConfig {
pub auth: Option<String>,
pub auth_token: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub email: Option<String>,
pub certfile: Option<String>,
pub keyfile: Option<String>,
}
impl RegistryConfig {
pub fn has_auth(&self) -> bool {
self.auth_token.is_some()
|| self.auth.is_some()
|| ((self.username.is_some() || self.email.is_some())
&& self.password.is_some())
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum TrustPolicyConfig {
#[default]
Off,
NoDowngrade,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum ReplaceRegistryHost {
#[default]
NpmJs,
Never,
Always,
Hostname(String),
Url(Url),
}
impl ReplaceRegistryHost {
fn parse(value: &str) -> Self {
let value = value.trim();
match value {
"" | "npmjs" => Self::NpmJs,
"never" => Self::Never,
"always" => Self::Always,
_ => match Url::parse(value) {
Ok(url) if url.host_str().is_some() => Self::Url(url),
_ => Self::Hostname(value.to_string()),
},
}
}
pub fn for_npm(sys: &impl EnvVar) -> Option<Self> {
for env_var_name in [
"NPM_CONFIG_REPLACE_REGISTRY_HOST",
"npm_config_replace_registry_host",
] {
if let Ok(value) = sys.env_var(env_var_name) {
return Some(Self::parse(&value));
}
}
None
}
fn matches(&self, tarball_url: &Url) -> Option<Option<&str>> {
match self {
Self::NpmJs => (tarball_url.host_str()
== Some(NPM_DEFAULT_REGISTRY_HOST))
.then_some(None),
Self::Never => None,
Self::Always => Some(None),
Self::Hostname(hostname) => {
(tarball_url.host_str() == Some(hostname.as_str())).then_some(None)
}
Self::Url(url) => {
let host_matches = url.host_str() == tarball_url.host_str();
let match_path = url.path().trim_end_matches('/');
let path_matches = match_path.is_empty()
|| path_has_prefix(tarball_url.path(), match_path);
(host_matches && path_matches)
.then_some((!match_path.is_empty()).then_some(match_path))
}
}
}
fn replace(&self, mut tarball_url: Url, registry_url: &Url) -> Url {
let Some(maybe_match_path) = self.matches(&tarball_url) else {
return tarball_url;
};
let original_url = tarball_url.clone();
if tarball_url.set_scheme(registry_url.scheme()).is_err()
|| tarball_url.set_host(registry_url.host_str()).is_err()
|| tarball_url.set_port(registry_url.port()).is_err()
{
return original_url;
}
let registry_path = registry_url.path().trim_end_matches('/');
let tarball_path = original_url.path();
let replaced_path = if let Some(match_path) = maybe_match_path {
format!("{}{}", registry_path, &tarball_path[match_path.len()..])
} else if !registry_path.is_empty()
&& !path_has_prefix(tarball_path, registry_path)
{
format!("{}{}", registry_path, tarball_path)
} else {
tarball_path.to_string()
};
tarball_url.set_path(&replaced_path);
tarball_url
}
}
fn path_has_prefix(path: &str, prefix: &str) -> bool {
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('/'))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct NpmRc {
pub registry: Option<String>,
pub scope_registries: HashMap<String, String>,
pub registry_configs: HashMap<String, Arc<RegistryConfig>>,
pub replace_registry_host: Option<ReplaceRegistryHost>,
pub min_release_age_days: Option<u64>,
pub trust_policy: TrustPolicyConfig,
pub trust_policy_ignore_after_minutes: Option<u64>,
pub trust_policy_exclude: Vec<String>,
}
impl NpmRc {
pub fn parse(
sys: &impl EnvVar,
input: &str,
) -> Result<Self, NpmRcParseError> {
let kv_or_sections = ini::parse_ini(input)?;
let mut registry = None;
let mut scope_registries: HashMap<String, String> = HashMap::new();
let mut registry_configs: HashMap<String, RegistryConfig> = HashMap::new();
let replace_registry_host_from_env = ReplaceRegistryHost::for_npm(sys);
let mut replace_registry_host = None;
let mut min_release_age_days = min_release_age_days_from_env(sys);
let mut trust_policy = TrustPolicyConfig::default();
let mut trust_policy_ignore_after_minutes: Option<u64> = None;
let mut trust_policy_exclude: Vec<String> = Vec::new();
for kv_or_section in kv_or_sections {
match kv_or_section {
KeyValueOrSection::KeyValue(kv) => {
if let Key::Plain(key) = &kv.key {
if let Some((left, right)) = key.rsplit_once(':') {
if let Some(scope) = left.strip_prefix('@') {
if right == "registry"
&& let Value::String(text) = &kv.value
{
let value = expand_vars(text, sys);
scope_registries.insert(scope.to_string(), value);
}
} else if let Some(host_and_path) = left.strip_prefix("//")
&& let Value::String(text) = &kv.value
{
let value = expand_vars(text, sys);
let config = registry_configs
.entry(host_and_path.to_string())
.or_default();
match right {
"_auth" => {
config.auth = Some(value);
}
"_authToken" => {
config.auth_token = Some(value);
}
"username" => {
config.username = Some(value);
}
"_password" => {
config.password = Some(value);
}
"email" => {
config.email = Some(value);
}
"certfile" => {
config.certfile = Some(value);
}
"keyfile" => {
config.keyfile = Some(value);
}
_ => {}
}
}
} else if key == "registry"
&& let Value::String(text) = &kv.value
{
let value = expand_vars(text, sys);
registry = Some(value);
} else if key == "replace-registry-host"
&& let Value::String(text) = &kv.value
{
let value = expand_vars(text, sys);
replace_registry_host = Some(ReplaceRegistryHost::parse(&value));
} else if key == "min-release-age" {
match &kv.value {
Value::Number(n) if *n >= 0 => {
min_release_age_days = Some(*n as u64);
}
Value::String(text) => {
let value = expand_vars(text, sys);
if let Ok(days) = value.trim().parse::<u64>() {
min_release_age_days = Some(days);
}
}
_ => {}
}
} else if key == "trust-policy"
&& let Value::String(text) = &kv.value
{
let value = expand_vars(text, sys);
trust_policy = match value.trim() {
"no-downgrade" => TrustPolicyConfig::NoDowngrade,
_ => TrustPolicyConfig::Off,
};
} else if key == "trust-policy-ignore-after" {
match &kv.value {
Value::Number(n) if *n >= 0 => {
trust_policy_ignore_after_minutes = Some(*n as u64);
}
Value::String(text) => {
let value = expand_vars(text, sys);
if let Ok(minutes) = value.trim().parse::<u64>() {
trust_policy_ignore_after_minutes = Some(minutes);
}
}
_ => {}
}
}
} else if let Key::Array(key) = &kv.key
&& key == "trust-policy-exclude"
&& let Value::String(text) = &kv.value
{
let value = expand_vars(text, sys);
let value = value.trim();
if !value.is_empty() {
trust_policy_exclude.push(value.to_string());
}
}
}
KeyValueOrSection::Section(_) => {
}
}
}
Ok(NpmRc {
registry,
scope_registries,
registry_configs: registry_configs
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect(),
replace_registry_host: replace_registry_host_from_env
.or(replace_registry_host),
min_release_age_days,
trust_policy,
trust_policy_ignore_after_minutes,
trust_policy_exclude,
})
}
pub fn as_resolved(
&self,
registry_url: &NpmRegistryUrl,
) -> Result<ResolvedNpmRc, ResolveError> {
let mut scopes = HashMap::with_capacity(self.scope_registries.len());
for scope in self.scope_registries.keys() {
let (url, config) = self.registry_url_and_config_for_maybe_scope(
Some(scope.as_str()),
registry_url,
);
let url = Url::parse(&url).map_err(|e| ResolveError::UrlScope {
scope: scope.clone(),
source: e,
})?;
scopes.insert(
scope.clone(),
RegistryConfigWithUrl {
registry_url: url,
config,
},
);
}
let (default_url, default_config) =
self.registry_url_and_config_for_maybe_scope(None, registry_url);
let default_url = Url::parse(&default_url).map_err(ResolveError::Url)?;
Ok(ResolvedNpmRc {
default_config: RegistryConfigWithUrl {
registry_url: default_url,
config: default_config,
},
scopes,
registry_configs: self.registry_configs.clone(),
replace_registry_host: self
.replace_registry_host
.clone()
.unwrap_or_default(),
min_release_age_days: self.min_release_age_days,
trust_policy: self.trust_policy,
trust_policy_ignore_after_minutes: self.trust_policy_ignore_after_minutes,
trust_policy_exclude: self.trust_policy_exclude.clone(),
})
}
fn registry_url_and_config_for_maybe_scope(
&self,
maybe_scope_name: Option<&str>,
registry_url: &NpmRegistryUrl,
) -> (String, Arc<RegistryConfig>) {
let registry_url = maybe_scope_name
.and_then(|scope| self.scope_registries.get(scope).map(|s| s.as_str()))
.unwrap_or_else(|| {
if registry_url.from_env {
registry_url.url.as_str()
} else {
self
.registry
.as_deref()
.unwrap_or(registry_url.url.as_str())
}
});
let original_registry_url = if registry_url.ends_with('/') {
Cow::Borrowed(registry_url)
} else {
Cow::Owned(format!("{}/", registry_url))
};
let Some((_, registry_url)) = original_registry_url
.split_once("//")
.filter(|(_, url)| !url.is_empty())
else {
return (
original_registry_url.into_owned(),
Arc::new(RegistryConfig::default()),
);
};
let mut url: &str = registry_url;
loop {
if let Some(config) = self.registry_configs.get(url) {
return (original_registry_url.into_owned(), config.clone());
}
let Some(next_slash_index) = url[..url.len() - 1].rfind('/') else {
return (
original_registry_url.into_owned(),
Arc::new(RegistryConfig::default()),
);
};
url = &url[..next_slash_index + 1];
}
}
}
pub fn min_release_age_days_from_env(sys: &impl EnvVar) -> Option<u64> {
for env_var_name in
["NPM_CONFIG_MIN_RELEASE_AGE", "npm_config_min_release_age"]
{
if let Ok(value) = sys.env_var(env_var_name)
&& let Ok(days) = value.trim().parse::<u64>()
{
return Some(days);
}
}
None
}
fn get_scope_name(package_name: &str) -> Option<&str> {
let no_at_pkg_name = package_name.strip_prefix('@')?;
no_at_pkg_name.split_once('/').map(|(scope, _)| scope)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryConfigWithUrl {
pub registry_url: Url,
pub config: Arc<RegistryConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedNpmRc {
pub default_config: RegistryConfigWithUrl,
pub scopes: HashMap<String, RegistryConfigWithUrl>,
pub registry_configs: HashMap<String, Arc<RegistryConfig>>,
pub replace_registry_host: ReplaceRegistryHost,
pub min_release_age_days: Option<u64>,
pub trust_policy: TrustPolicyConfig,
pub trust_policy_ignore_after_minutes: Option<u64>,
pub trust_policy_exclude: Vec<String>,
}
impl ResolvedNpmRc {
pub fn get_registry_url(&self, package_name: &str) -> &Url {
let Some(scope_name) = get_scope_name(package_name) else {
return &self.default_config.registry_url;
};
match self.scopes.get(scope_name) {
Some(registry_config) => ®istry_config.registry_url,
None => &self.default_config.registry_url,
}
}
pub fn get_registry_config(
&self,
package_name: &str,
) -> &Arc<RegistryConfig> {
let Some(scope_name) = get_scope_name(package_name) else {
return &self.default_config.config;
};
match self.scopes.get(scope_name) {
Some(registry_config) => ®istry_config.config,
None => &self.default_config.config,
}
}
pub fn get_all_known_registries_urls(&self) -> Vec<Url> {
let mut urls = Vec::with_capacity(1 + self.scopes.len());
urls.push(self.default_config.registry_url.clone());
for scope_config in self.scopes.values() {
urls.push(scope_config.registry_url.clone());
}
urls
}
pub fn replace_tarball_url(
&self,
tarball_url: Url,
package_name: &str,
) -> Url {
self
.replace_registry_host
.replace(tarball_url, self.get_registry_url(package_name))
}
pub fn tarball_config(
&self,
tarball_url: &Url,
) -> Option<&Arc<RegistryConfig>> {
let mut best_match: Option<(usize, &Arc<RegistryConfig>)> = None;
for (config_url, config) in &self.registry_configs {
if let Some(match_len) =
registry_config_match_len(tarball_url, config_url)
&& best_match
.is_none_or(|(current_match_len, _)| match_len > current_match_len)
{
best_match = Some((match_len, config));
}
}
best_match.map(|(_, config)| config)
}
pub fn tarball_config_for_package(
&self,
tarball_url: &Url,
package_name: &str,
) -> Option<&Arc<RegistryConfig>> {
if let Some(config) = self.tarball_config(tarball_url) {
return Some(config);
}
let scope_registry = get_scope_name(package_name)
.and_then(|scope| self.scopes.get(scope))
.unwrap_or(&self.default_config);
let registry_url = &scope_registry.registry_url;
let same_origin = registry_url.scheme() == tarball_url.scheme()
&& registry_url.host_str() == tarball_url.host_str()
&& registry_url.port_or_known_default()
== tarball_url.port_or_known_default();
if same_origin && scope_registry.config.has_auth() {
Some(&scope_registry.config)
} else {
None
}
}
}
fn registry_config_match_len(
tarball_url: &Url,
config_url: &str,
) -> Option<usize> {
let (config_authority, config_path) = config_url
.find('/')
.map(|index| config_url.split_at(index))
.unwrap_or((config_url, ""));
if config_authority.is_empty() || tarball_url.host().is_none() {
return None;
}
let tarball_authority =
&tarball_url[url::Position::BeforeHost..url::Position::AfterPort];
if !config_authority.eq_ignore_ascii_case(tarball_authority) {
return None;
}
let tarball_path = tarball_url.path();
let path_matches = if config_path.is_empty() {
true
} else if config_path.ends_with('/') {
tarball_path.starts_with(config_path)
} else {
tarball_path == config_path
|| tarball_path
.strip_prefix(config_path)
.is_some_and(|rest| rest.starts_with('/'))
};
path_matches.then_some(config_path.len())
}
fn expand_vars(input: &str, sys: &impl EnvVar) -> String {
fn escaped_char(input: &str) -> ParseResult<'_, char> {
preceded(ch('\\'), next_char)(input)
}
fn env_var(input: &str) -> ParseResult<'_, &str> {
let (input, _) = tag("${")(input)?;
let (input, var_name) = take_while_byte(|b| b != b'}')(input)?;
if var_name.chars().any(|c| matches!(c, '$' | '{' | '\\')) {
return ParseError::backtrace();
}
let (input, _) = ch('}')(input)?;
Ok((input, var_name))
}
let (input, results) = many0(or3(
map(escaped_char, |c| c.to_string()),
map(env_var, |var_name| {
if let Ok(var_value) = sys.env_var(var_name) {
var_value
} else {
format!("${{{}}}", var_name)
}
}),
map(next_char, |c| c.to_string()),
))(input)
.unwrap();
assert!(input.is_empty());
results.join("")
}
#[derive(Debug, Clone)]
pub struct NpmRegistryUrl {
pub url: Url,
pub from_env: bool,
}
impl NpmRegistryUrl {
pub fn for_npm(sys: &impl EnvVar) -> Self {
Self::from_env(sys, "NPM_CONFIG_REGISTRY", NPM_DEFAULT_REGISTRY)
}
pub fn for_jsr(sys: &impl EnvVar) -> Self {
Self::from_env(sys, "JSR_NPM_URL", "https://npm.jsr.io")
}
fn from_env(
sys: &impl EnvVar,
env_var_name: &str,
fallback_url: &str,
) -> Self {
fn ensure_trailing_slash(value: &str) -> Cow<'_, str> {
if value.ends_with('/') {
Cow::Borrowed(value)
} else {
Cow::Owned(format!("{}/", value))
}
}
if let Ok(registry_url) = sys.env_var(env_var_name) {
let registry_url = ensure_trailing_slash(®istry_url);
match Url::parse(®istry_url) {
Ok(url) => {
return NpmRegistryUrl {
url,
from_env: true,
};
}
Err(err) => {
log::debug!(
"Invalid {} environment variable: {:#}",
env_var_name,
err,
);
}
}
}
Self {
url: Url::parse(fallback_url).unwrap(),
from_env: false,
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use sys_traits::EnvSetVar;
use sys_traits::impls::InMemorySys;
use super::*;
fn replace_tarball_url(
config: &str,
registry: &str,
tarball: &str,
package_name: &str,
) -> String {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
&format!("registry={registry}\n{config}"),
)
.unwrap()
.as_resolved(&npm_url(NPM_DEFAULT_REGISTRY))
.unwrap();
npm_rc
.replace_tarball_url(Url::parse(tarball).unwrap(), package_name)
.to_string()
}
#[test]
fn test_replace_registry_host_default() {
assert_eq!(
replace_tarball_url(
"",
"https://artifactory.example.com/api/npm/npm-remote/",
"https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz?x=1#fragment",
"@scope/pkg",
),
"https://artifactory.example.com/api/npm/npm-remote/@scope/pkg/-/pkg-1.0.0.tgz?x=1#fragment",
);
assert_eq!(
replace_tarball_url(
"",
"https://artifactory.example.com/api/npm/npm-remote/",
"https://cdn.example.com/pkg-1.0.0.tgz",
"pkg",
),
"https://cdn.example.com/pkg-1.0.0.tgz",
);
}
#[test]
fn test_replace_registry_host_never_and_always() {
let tarball = "https://cdn.example.com/pkg/-/pkg-1.0.0.tgz";
assert_eq!(
replace_tarball_url(
"replace-registry-host=never",
"https://mirror.example.com/npm/",
tarball,
"pkg",
),
tarball,
);
assert_eq!(
replace_tarball_url(
"replace-registry-host=always",
"https://mirror.example.com/npm/",
tarball,
"pkg",
),
"https://mirror.example.com/npm/pkg/-/pkg-1.0.0.tgz",
);
}
#[test]
fn test_replace_registry_host_hostname() {
assert_eq!(
replace_tarball_url(
"replace-registry-host=old.example.com",
"https://mirror.example.com/npm/",
"http://old.example.com/pkg/-/pkg-1.0.0.tgz",
"pkg",
),
"https://mirror.example.com/npm/pkg/-/pkg-1.0.0.tgz",
);
}
#[test]
fn test_replace_registry_host_url_prefix() {
assert_eq!(
replace_tarball_url(
"replace-registry-host=https://old.example.com/api/npm/",
"https://mirror.example.com/npm/",
"https://old.example.com/api/npm/pkg/-/pkg-1.0.0.tgz",
"pkg",
),
"https://mirror.example.com/npm/pkg/-/pkg-1.0.0.tgz",
);
let non_matching =
"https://old.example.com/api/npm-other/pkg/-/pkg-1.0.0.tgz";
assert_eq!(
replace_tarball_url(
"replace-registry-host=https://old.example.com/api/npm/",
"https://mirror.example.com/npm/",
non_matching,
"pkg",
),
non_matching,
);
}
#[test]
fn test_replace_registry_host_does_not_duplicate_registry_path() {
assert_eq!(
replace_tarball_url(
"replace-registry-host=old.example.com",
"https://mirror.example.com/npm/",
"https://old.example.com/npm/pkg/-/pkg-1.0.0.tgz",
"pkg",
),
"https://mirror.example.com/npm/pkg/-/pkg-1.0.0.tgz",
);
}
#[test]
fn test_replace_registry_host_uses_scoped_registry() {
assert_eq!(
replace_tarball_url(
"@scope:registry=https://scope.example.com/npm/",
"https://default.example.com/",
"https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz",
"@scope/pkg",
),
"https://scope.example.com/npm/@scope/pkg/-/pkg-1.0.0.tgz",
);
}
#[test]
fn test_replace_registry_host_env_overrides_npmrc() {
let sys = InMemorySys::default();
sys.env_set_var("NPM_CONFIG_REPLACE_REGISTRY_HOST", "never");
let npm_rc = NpmRc::parse(&sys, "replace-registry-host=always").unwrap();
assert_eq!(
npm_rc.replace_registry_host,
Some(ReplaceRegistryHost::Never)
);
}
#[test]
fn test_parse_basic() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
@myorg:registry=https://example.com/myorg
@another:registry=https://example.com/another
@example:registry=https://example.com/example
@yet_another:registry=https://yet.another.com/
//registry.npmjs.org/:_authToken=MYTOKEN
; would apply to both @myorg and @another
//example.com/:_authToken=MYTOKEN0
//example.com/:_auth=AUTH
//example.com/:username=USERNAME
//example.com/:_password=PASSWORD
//example.com/:email=EMAIL
//example.com/:certfile=CERTFILE
//example.com/:keyfile=KEYFILE
; would apply only to @myorg
//example.com/myorg/:_authToken=MYTOKEN1
; would apply only to @another
//example.com/another/:_authToken=MYTOKEN2
; this should not apply to `@yet_another`, because the URL contains the name of the scope
; and not the URL of the registry root specified above
//yet.another.com/yet_another/:_authToken=MYTOKEN3
registry=https://registry.npmjs.org/
"#,
)
.unwrap();
assert_eq!(
npm_rc,
NpmRc {
registry: Some("https://registry.npmjs.org/".to_string()),
scope_registries: HashMap::from([
("myorg".to_string(), "https://example.com/myorg".to_string()),
(
"another".to_string(),
"https://example.com/another".to_string()
),
(
"example".to_string(),
"https://example.com/example".to_string()
),
(
"yet_another".to_string(),
"https://yet.another.com/".to_string()
),
]),
registry_configs: HashMap::from([
(
"example.com/".to_string(),
Arc::new(RegistryConfig {
auth: Some("AUTH".to_string()),
auth_token: Some("MYTOKEN0".to_string()),
username: Some("USERNAME".to_string()),
password: Some("PASSWORD".to_string()),
email: Some("EMAIL".to_string()),
certfile: Some("CERTFILE".to_string()),
keyfile: Some("KEYFILE".to_string()),
})
),
(
"example.com/another/".to_string(),
Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN2".to_string()),
..Default::default()
})
),
(
"example.com/myorg/".to_string(),
Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN1".to_string()),
..Default::default()
})
),
(
"yet.another.com/yet_another/".to_string(),
Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN3".to_string()),
..Default::default()
})
),
(
"registry.npmjs.org/".to_string(),
Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN".to_string()),
..Default::default()
})
),
]),
replace_registry_host: None,
min_release_age_days: None,
trust_policy: Default::default(),
trust_policy_ignore_after_minutes: None,
trust_policy_exclude: Vec::new(),
}
);
let resolved_npm_rc = npm_rc
.as_resolved(&npm_url("https://deno.land/npm/"))
.unwrap();
assert_eq!(
resolved_npm_rc,
ResolvedNpmRc {
default_config: RegistryConfigWithUrl {
registry_url: Url::parse("https://registry.npmjs.org/").unwrap(),
config: Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN".to_string()),
..Default::default()
}),
},
scopes: HashMap::from([
(
"myorg".to_string(),
RegistryConfigWithUrl {
registry_url: Url::parse("https://example.com/myorg/").unwrap(),
config: Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN1".to_string()),
..Default::default()
})
}
),
(
"another".to_string(),
RegistryConfigWithUrl {
registry_url: Url::parse("https://example.com/another/").unwrap(),
config: Arc::new(RegistryConfig {
auth_token: Some("MYTOKEN2".to_string()),
..Default::default()
})
}
),
(
"example".to_string(),
RegistryConfigWithUrl {
registry_url: Url::parse("https://example.com/example/").unwrap(),
config: Arc::new(RegistryConfig {
auth: Some("AUTH".to_string()),
auth_token: Some("MYTOKEN0".to_string()),
username: Some("USERNAME".to_string()),
password: Some("PASSWORD".to_string()),
email: Some("EMAIL".to_string()),
certfile: Some("CERTFILE".to_string()),
keyfile: Some("KEYFILE".to_string()),
})
}
),
(
"yet_another".to_string(),
RegistryConfigWithUrl {
registry_url: Url::parse("https://yet.another.com/").unwrap(),
config: Default::default()
}
),
]),
registry_configs: npm_rc.registry_configs.clone(),
replace_registry_host: ReplaceRegistryHost::default(),
min_release_age_days: None,
trust_policy: Default::default(),
trust_policy_ignore_after_minutes: None,
trust_policy_exclude: Vec::new(),
}
);
{
let registry_url = resolved_npm_rc.get_registry_url("test");
let config = resolved_npm_rc.get_registry_config("test");
assert_eq!(registry_url.as_str(), "https://registry.npmjs.org/");
assert_eq!(config.auth_token, Some("MYTOKEN".to_string()));
}
{
let registry_url = resolved_npm_rc.get_registry_url("@example/pkg");
let config = resolved_npm_rc.get_registry_config("@example/pkg");
assert_eq!(registry_url.as_str(), "https://example.com/example/");
assert_eq!(config.auth_token, Some("MYTOKEN0".to_string()));
}
{
let registry_url = resolved_npm_rc.get_registry_url("@myorg/pkg");
let config = resolved_npm_rc.get_registry_config("@myorg/pkg");
assert_eq!(registry_url.as_str(), "https://example.com/myorg/");
assert_eq!(config.auth_token, Some("MYTOKEN1".to_string()));
}
{
let registry_url = resolved_npm_rc.get_registry_url("@yet_another/pkg");
let config = resolved_npm_rc.get_registry_config("@yet_another/pkg");
assert_eq!(registry_url.as_str(), "https://yet.another.com/");
assert_eq!(config.auth_token, None);
}
assert_eq!(
resolved_npm_rc.get_registry_url("@deno/test").as_str(),
"https://registry.npmjs.org/"
);
assert_eq!(
resolved_npm_rc
.get_registry_config("@deno/test")
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN"
);
assert_eq!(
resolved_npm_rc.get_registry_url("@myorg/test").as_str(),
"https://example.com/myorg/"
);
assert_eq!(
resolved_npm_rc
.get_registry_config("@myorg/test")
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN1"
);
assert_eq!(
resolved_npm_rc.get_registry_url("@another/test").as_str(),
"https://example.com/another/"
);
assert_eq!(
resolved_npm_rc
.get_registry_config("@another/test")
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN2"
);
assert_eq!(
resolved_npm_rc.get_registry_url("@example/test").as_str(),
"https://example.com/example/"
);
let config = resolved_npm_rc.get_registry_config("@example/test");
assert_eq!(config.auth.as_ref().unwrap(), "AUTH");
assert_eq!(config.auth_token.as_ref().unwrap(), "MYTOKEN0");
assert_eq!(config.username.as_ref().unwrap(), "USERNAME");
assert_eq!(config.password.as_ref().unwrap(), "PASSWORD");
assert_eq!(config.email.as_ref().unwrap(), "EMAIL");
assert_eq!(config.certfile.as_ref().unwrap(), "CERTFILE");
assert_eq!(config.keyfile.as_ref().unwrap(), "KEYFILE");
{
assert_eq!(
resolved_npm_rc
.tarball_config(
&Url::parse("https://example.com/example/chalk.tgz").unwrap(),
)
.unwrap()
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN0"
);
assert_eq!(
resolved_npm_rc
.tarball_config(
&Url::parse("https://example.com/myorg/chalk.tgz").unwrap(),
)
.unwrap()
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN1"
);
assert_eq!(
resolved_npm_rc
.tarball_config(
&Url::parse("https://example.com/another/chalk.tgz").unwrap(),
)
.unwrap()
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN2"
);
assert_eq!(
resolved_npm_rc.tarball_config(
&Url::parse("https://yet.another.com/example/chalk.tgz").unwrap(),
),
None,
);
assert_eq!(
resolved_npm_rc
.tarball_config(
&Url::parse(
"https://yet.another.com/yet_another/example/chalk.tgz"
)
.unwrap(),
)
.unwrap()
.auth_token
.as_ref()
.unwrap(),
"MYTOKEN3"
);
}
}
#[test]
fn test_tarball_config_matches_authority_and_path_boundaries() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
//example.com:_authToken=HOST
//example.com:8443/:_authToken=PORT
//example.com/private:_authToken=PRIVATE
//example.com/private/nested/:_authToken=NESTED
//[::1]:8443/:_authToken=IPV6
"#,
)
.unwrap();
let resolved_npm_rc = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
let auth_token = |url: &str| {
resolved_npm_rc
.tarball_config(&Url::parse(url).unwrap())
.and_then(|config| config.auth_token.as_deref())
};
assert_eq!(auth_token("https://example.com/pkg.tgz"), Some("HOST"));
assert_eq!(auth_token("http://example.com/pkg.tgz"), Some("HOST"));
assert_eq!(auth_token("https://example.com:443/pkg.tgz"), Some("HOST"));
assert_eq!(auth_token("https://example.com.evil/pkg.tgz"), None);
assert_eq!(auth_token("https://example.com:8443/pkg.tgz"), Some("PORT"));
assert_eq!(auth_token("https://example.com:8444/pkg.tgz"), None);
assert_eq!(auth_token("https://example.com:18443/pkg.tgz"), None);
assert_eq!(auth_token("https://[::1]:8443/pkg.tgz"), Some("IPV6"));
assert_eq!(auth_token("https://[::1]:8444/pkg.tgz"), None);
assert_eq!(
auth_token("https://example.com/private/pkg.tgz"),
Some("PRIVATE")
);
assert_eq!(auth_token("https://example.com/private"), Some("PRIVATE"));
assert_eq!(
auth_token("https://example.com/privateevil/pkg.tgz"),
Some("HOST")
);
assert_eq!(
auth_token("https://example.com/private:evil/pkg.tgz"),
Some("HOST")
);
assert_eq!(
auth_token("https://example.com/private/nested/pkg.tgz"),
Some("NESTED")
);
assert_eq!(
auth_token("https://example.com/private/nested"),
Some("PRIVATE")
);
assert_eq!(
auth_token("https://example.com/private/nested/?download=1"),
Some("NESTED")
);
}
#[test]
fn test_parse_env_vars() {
let sys = InMemorySys::default();
sys.env_set_var("VAR_FOUND", "SOME_VALUE");
let npm_rc = NpmRc::parse(
&sys,
r#"
@myorg:registry=${VAR_FOUND}
@another:registry=${VAR_NOT_FOUND}
@a:registry=\${VAR_FOUND}
//registry.npmjs.org/:_authToken=${VAR_FOUND}
registry=${VAR_FOUND}
"#,
)
.unwrap();
assert_eq!(
npm_rc,
NpmRc {
registry: Some("SOME_VALUE".to_string()),
scope_registries: HashMap::from([
("a".to_string(), "${VAR_FOUND}".to_string()),
("myorg".to_string(), "SOME_VALUE".to_string()),
("another".to_string(), "${VAR_NOT_FOUND}".to_string()),
]),
registry_configs: HashMap::from([(
"registry.npmjs.org/".to_string(),
Arc::new(RegistryConfig {
auth_token: Some("SOME_VALUE".to_string()),
..Default::default()
})
),]),
replace_registry_host: None,
min_release_age_days: None,
trust_policy: Default::default(),
trust_policy_ignore_after_minutes: None,
trust_policy_exclude: Vec::new(),
}
)
}
#[test]
fn test_expand_vars() {
let sys = InMemorySys::default();
sys.env_set_var("VAR", "VALUE");
assert_eq!(expand_vars("test${VAR}test", &sys), "testVALUEtest");
let sys = InMemorySys::default();
sys.env_set_var("A", "1");
sys.env_set_var("B", "2");
sys.env_set_var("C", "3");
assert_eq!(expand_vars("${A}${B}${C}", &sys), "123");
let sys = InMemorySys::default();
sys.env_set_var("VAR", "VALUE");
assert_eq!(expand_vars("test\\${VAR}test", &sys), "test${VAR}test");
let sys = InMemorySys::default();
assert_eq!(expand_vars("test${VA$R}test", &sys), "test${VA$R}test");
assert_eq!(expand_vars("test${VA{R}test", &sys), "test${VA{R}test");
}
#[test]
fn test_parse_min_release_age() {
let sys = InMemorySys::default();
let npm_rc = NpmRc::parse(&sys, "min-release-age=30").unwrap();
assert_eq!(npm_rc.min_release_age_days, Some(30));
let resolved = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
assert_eq!(resolved.min_release_age_days, Some(30));
let npm_rc = NpmRc::parse(&sys, "").unwrap();
assert_eq!(npm_rc.min_release_age_days, None);
let npm_rc = NpmRc::parse(&sys, "min-release-age=invalid").unwrap();
assert_eq!(npm_rc.min_release_age_days, None);
sys.env_set_var("MIN_AGE", "7");
let npm_rc = NpmRc::parse(&sys, "min-release-age=${MIN_AGE}").unwrap();
assert_eq!(npm_rc.min_release_age_days, Some(7));
let sys = InMemorySys::default();
sys.env_set_var("NPM_CONFIG_MIN_RELEASE_AGE", "4");
let npm_rc = NpmRc::parse(&sys, "").unwrap();
assert_eq!(npm_rc.min_release_age_days, Some(4));
let npm_rc = NpmRc::parse(&sys, "min-release-age=5").unwrap();
assert_eq!(npm_rc.min_release_age_days, Some(5));
}
#[test]
fn test_parse_trust_policy() {
let sys = InMemorySys::default();
let npm_rc = NpmRc::parse(&sys, "").unwrap();
assert_eq!(npm_rc.trust_policy, TrustPolicyConfig::Off);
let npm_rc = NpmRc::parse(&sys, "trust-policy=no-downgrade").unwrap();
assert_eq!(npm_rc.trust_policy, TrustPolicyConfig::NoDowngrade);
let resolved = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
assert_eq!(resolved.trust_policy, TrustPolicyConfig::NoDowngrade);
let npm_rc = NpmRc::parse(&sys, "trust-policy=bogus").unwrap();
assert_eq!(npm_rc.trust_policy, TrustPolicyConfig::Off);
let npm_rc = NpmRc::parse(
&sys,
"trust-policy=no-downgrade\ntrust-policy-ignore-after=4320",
)
.unwrap();
assert_eq!(npm_rc.trust_policy_ignore_after_minutes, Some(4320));
let resolved = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
assert_eq!(resolved.trust_policy_ignore_after_minutes, Some(4320));
let npm_rc = NpmRc::parse(&sys, "trust-policy-ignore-after=soon").unwrap();
assert_eq!(npm_rc.trust_policy_ignore_after_minutes, None);
let npm_rc = NpmRc::parse(
&sys,
"trust-policy=no-downgrade\ntrust-policy-exclude[]=@scope/pkg\ntrust-policy-exclude[]=other",
)
.unwrap();
assert_eq!(
npm_rc.trust_policy_exclude,
vec!["@scope/pkg".to_string(), "other".to_string()]
);
let resolved = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
assert_eq!(
resolved.trust_policy_exclude,
vec!["@scope/pkg".to_string(), "other".to_string()]
);
let npm_rc = NpmRc::parse(&sys, "").unwrap();
assert!(npm_rc.trust_policy_exclude.is_empty());
}
#[test]
fn test_scope_registry_url_only() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
@example:registry=https://example.com/
"#,
)
.unwrap();
let npm_rc = npm_rc
.as_resolved(&npm_url("https://deno.land/npm/"))
.unwrap();
{
let registry_url = npm_rc.get_registry_url("@example/test");
let config = npm_rc.get_registry_config("@example/test");
assert_eq!(registry_url.as_str(), "https://example.com/");
assert_eq!(config.as_ref(), &RegistryConfig::default());
}
{
let registry_url = npm_rc.get_registry_url("test");
let config = npm_rc.get_registry_config("test");
assert_eq!(registry_url.as_str(), "https://deno.land/npm/");
assert_eq!(config.as_ref(), &Default::default());
}
}
#[test]
fn test_scope_with_auth() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
@example:registry=https://example.com/foo
@example2:registry=https://example2.com/
//example.com/foo/:_authToken=MY_AUTH_TOKEN
; This one is borked - the URL must match registry URL exactly
//example.com2/example/:_authToken=MY_AUTH_TOKEN2
"#,
)
.unwrap();
let npm_rc = npm_rc
.as_resolved(&npm_url("https://deno.land/npm/"))
.unwrap();
{
let registry_url = npm_rc.get_registry_url("@example/test");
let config = npm_rc.get_registry_config("@example/test");
assert_eq!(registry_url.as_str(), "https://example.com/foo/");
assert_eq!(
config.as_ref(),
&RegistryConfig {
auth_token: Some("MY_AUTH_TOKEN".to_string()),
..Default::default()
}
);
}
{
let registry_url = npm_rc.get_registry_url("@example2/test");
let config = npm_rc.get_registry_config("@example2/test");
assert_eq!(registry_url.as_str(), "https://example2.com/");
assert_eq!(config.as_ref(), &Default::default());
}
}
#[test]
fn test_scope_registry_same_as_env_registry() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
@jsr:registry=https://registry.npmjs.org/
"#,
)
.unwrap();
let npm_rc = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
assert!(npm_rc.scopes.contains_key("jsr"));
assert_eq!(
npm_rc.scopes.get("jsr").unwrap().registry_url.as_str(),
"https://registry.npmjs.org/"
);
}
#[test]
fn test_npm_config_registry_overrides_npmrc() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
"registry=http://wrong.registry.example.com/",
)
.unwrap();
let env_registry_url =
Url::parse("http://env.registry.example.com/").unwrap();
let resolved = npm_rc
.as_resolved(&NpmRegistryUrl {
url: env_registry_url,
from_env: true,
})
.unwrap();
assert_eq!(
resolved.default_config.registry_url.as_str(),
"http://env.registry.example.com/"
);
}
#[test]
fn test_npmrc_registry_used_when_no_env_var() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
"registry=http://npmrc.registry.example.com/",
)
.unwrap();
let resolved = npm_rc
.as_resolved(&NpmRegistryUrl {
url: Url::parse("https://registry.npmjs.org/").unwrap(),
from_env: false,
})
.unwrap();
assert_eq!(
resolved.default_config.registry_url.as_str(),
"http://npmrc.registry.example.com/"
);
}
#[test]
fn test_gitlab_instance_level_tarball_auth() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
@myscope:registry=https://gitlab.example.com/api/v4/packages/npm/
//gitlab.example.com/api/v4/packages/npm/:_authToken=GITLABTOKEN
"#,
)
.unwrap();
let resolved_npm_rc = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
let tarball_url = Url::parse(
"https://gitlab.example.com/api/v4/projects/4055/packages/npm/@myscope/pkg/-/@myscope/pkg-1.0.0.tgz",
)
.unwrap();
assert_eq!(resolved_npm_rc.tarball_config(&tarball_url), None);
assert_eq!(
resolved_npm_rc
.tarball_config_for_package(&tarball_url, "@myscope/pkg")
.unwrap()
.auth_token
.as_deref(),
Some("GITLABTOKEN"),
);
let other_host = Url::parse(
"https://evil.example.org/api/v4/projects/4055/packages/npm/@myscope/pkg/-/pkg-1.0.0.tgz",
)
.unwrap();
assert_eq!(
resolved_npm_rc.tarball_config_for_package(&other_host, "@myscope/pkg"),
None,
);
let other_port = Url::parse(
"https://gitlab.example.com:8443/api/v4/projects/4055/packages/npm/@myscope/pkg/-/pkg-1.0.0.tgz",
)
.unwrap();
assert_eq!(
resolved_npm_rc.tarball_config_for_package(&other_port, "@myscope/pkg"),
None,
);
let other_scheme = Url::parse(
"http://gitlab.example.com/api/v4/projects/4055/packages/npm/@myscope/pkg/-/pkg-1.0.0.tgz",
)
.unwrap();
assert_eq!(
resolved_npm_rc.tarball_config_for_package(&other_scheme, "@myscope/pkg"),
None,
);
assert_eq!(
resolved_npm_rc.tarball_config_for_package(&tarball_url, "@other/pkg"),
None,
);
}
#[test]
fn test_tarball_config_for_package_default_scope() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
registry=https://gitlab.example.com/api/v4/packages/npm/
//gitlab.example.com/api/v4/packages/npm/:_authToken=GITLABTOKEN
"#,
)
.unwrap();
let resolved_npm_rc = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
let tarball_url = Url::parse(
"https://gitlab.example.com/api/v4/projects/4055/packages/npm/pkg/-/pkg-1.0.0.tgz",
)
.unwrap();
assert_eq!(resolved_npm_rc.tarball_config(&tarball_url), None);
assert_eq!(
resolved_npm_rc
.tarball_config_for_package(&tarball_url, "pkg")
.unwrap()
.auth_token
.as_deref(),
Some("GITLABTOKEN"),
);
}
#[test]
fn test_tarball_config_for_package_scoped_unconfigured_default() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
registry=https://gitlab.example.com/api/v4/packages/npm/
//gitlab.example.com/api/v4/packages/npm/:_authToken=GITLABTOKEN
"#,
)
.unwrap();
let resolved_npm_rc = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
let tarball_url = Url::parse(
"https://gitlab.example.com/api/v4/projects/4055/packages/npm/@foo/bar/-/bar-1.0.0.tgz",
)
.unwrap();
assert_eq!(resolved_npm_rc.tarball_config(&tarball_url), None);
assert_eq!(
resolved_npm_rc
.tarball_config_for_package(&tarball_url, "@foo/bar")
.unwrap()
.auth_token
.as_deref(),
Some("GITLABTOKEN"),
);
}
#[test]
fn test_has_auth() {
let with = |f: fn(&mut RegistryConfig)| {
let mut config = RegistryConfig::default();
f(&mut config);
config.has_auth()
};
assert!(with(|c| c.auth_token = Some("t".into())));
assert!(with(|c| c.auth = Some("a".into())));
assert!(with(|c| {
c.username = Some("u".into());
c.password = Some("p".into());
}));
assert!(with(|c| {
c.email = Some("e".into());
c.password = Some("p".into());
}));
assert!(!with(|_| {}));
assert!(!with(|c| c.username = Some("u".into())));
assert!(!with(|c| c.password = Some("p".into())));
assert!(!with(|c| c.email = Some("e".into())));
}
#[test]
fn test_tarball_config_for_package_no_auth() {
let npm_rc = NpmRc::parse(
&InMemorySys::default(),
r#"
@myscope:registry=https://gitlab.example.com/api/v4/packages/npm/
"#,
)
.unwrap();
let resolved_npm_rc = npm_rc
.as_resolved(&npm_url("https://registry.npmjs.org/"))
.unwrap();
let tarball_url = Url::parse(
"https://gitlab.example.com/api/v4/projects/4055/packages/npm/@myscope/pkg/-/pkg-1.0.0.tgz",
)
.unwrap();
assert_eq!(
resolved_npm_rc.tarball_config_for_package(&tarball_url, "@myscope/pkg"),
None,
);
}
fn npm_url(url: &str) -> NpmRegistryUrl {
NpmRegistryUrl {
url: Url::parse(url).unwrap(),
from_env: false,
}
}
}