From e7a7088cfdcbd879e3a1e9247da9a32521f2bc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Sun, 6 Sep 2026 20:16:31 +0800 Subject: [PATCH 1/4] fix(pm): support authenticated private registries --- crates/vp_pm_cli/src/config.rs | 335 ++++++++++++++++++++++++++++--- crates/vp_pm_cli/src/request.rs | 68 ++++++- docs/guide/installer-env-vars.md | 11 + 3 files changed, 385 insertions(+), 29 deletions(-) diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 28ebfb5cc3..660f362a93 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -1,67 +1,354 @@ +use std::{collections::HashMap, env, fs, path::PathBuf}; + +use cow_utils::CowUtils; +use reqwest::{RequestBuilder, Url}; use vp_shared::EnvConfig; +use vt_workspace::find_workspace_root; + +const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmjs.org"; + +/// npm configuration used while bootstrapping a package manager. +/// Authentication values stay private and are only applied to matching URLs. +#[derive(Clone)] +pub(crate) struct NpmConfig { + pub(crate) values: HashMap, +} + +impl NpmConfig { + pub(crate) fn load() -> Self { + let project_root = vt_path::current_dir() + .ok() + .and_then(|cwd| find_workspace_root(&cwd).ok()) + .map(|(root, _)| root.path.as_path().to_path_buf()); + Self::load_for_project(project_root) + } + + fn load_for_project(project_root: Option) -> Self { + let mut values = HashMap::new(); + + // A default global npmrc cannot be located reliably before npm exists. + // Honor an explicitly configured one, then layer user and project config. + if let Some(path) = env_value("globalconfig") { + load_npmrc(PathBuf::from(path), &mut values); + } + let user_config = env_value("userconfig") + .map(PathBuf::from) + .unwrap_or_else(|| EnvConfig::get().user_home.join(".npmrc").into_path_buf()); + load_npmrc(user_config, &mut values); + if let Some(root) = project_root { + load_npmrc(root.join(".npmrc"), &mut values); + } + + // npm_config_* is the highest-precedence npm config source available to vp. + for (key, value) in env::vars() { + let Some(raw_key) = + key.strip_prefix("npm_config_").or_else(|| key.strip_prefix("NPM_CONFIG_")) + else { + continue; + }; + if value.is_empty() { + continue; + } + // npm preserves registry-scoped ("nerf-darted") keys verbatim. + let key = if raw_key.starts_with("//") { + normalize_key(raw_key) + } else { + raw_key.cow_replace('_', "-").cow_to_ascii_lowercase().into_owned() + }; + values.insert(key, value); + } + Self { values } + } + + fn registry_for_package(&self, package: &str) -> String { + let scoped = package + .strip_prefix('@') + .and_then(|rest| rest.split_once('/')) + .and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str())); + scoped.or_else(|| self.values.get("registry")).map_or_else( + || DEFAULT_NPM_REGISTRY.to_string(), + |value| value.trim_end_matches('/').to_string(), + ) + } + + pub(crate) fn apply_auth(&self, request: RequestBuilder, url: &str) -> RequestBuilder { + let Ok(url) = Url::parse(url) else { return request }; + let Some(host) = url.host_str() else { return request }; + let authority = url + .port() + .map_or_else(|| host.to_string(), |port| vt_str::format!("{host}:{port}").to_string()); + let segments: Vec<_> = url + .path_segments() + .into_iter() + .flatten() + .filter(|segment| !segment.is_empty()) + .collect(); + + // Match npm-registry-fetch: the most specific URL path wins. + for length in (0..=segments.len()).rev() { + let path = if length == 0 { + "/".to_string() + } else { + vt_str::format!("/{}/", segments[..length].join("/")).to_string() + }; + let prefix = vt_str::format!("//{}{path}", authority.cow_to_ascii_lowercase()); + for prefix in [prefix.as_str(), prefix.trim_end_matches('/')] { + if let Some(token) = + self.values.get(vt_str::format!("{prefix}:_authtoken").as_str()) + { + return request.bearer_auth(token); + } + if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) { + return request.header( + reqwest::header::AUTHORIZATION, + vt_str::format!("Basic {auth}").as_str(), + ); + } + let username = self.values.get(vt_str::format!("{prefix}:username").as_str()); + let password = self.values.get(vt_str::format!("{prefix}:_password").as_str()); + if let (Some(username), Some(password)) = (username, password) + && let Ok(decoded) = base64_simd::STANDARD.decode_to_vec(password) + { + return request + .basic_auth(username, Some(String::from_utf8_lossy(&decoded).as_ref())); + } + } + } + request + } +} + +fn env_value(name: &str) -> Option { + env::vars().find_map(|(key, value)| { + key.strip_prefix("npm_config_") + .or_else(|| key.strip_prefix("NPM_CONFIG_")) + .filter(|key| key.eq_ignore_ascii_case(name)) + .map(|_| value) + }) +} + +fn normalize_key(key: &str) -> String { + let key = key.trim(); + let Some((registry, setting)) = key.rsplit_once(':').filter(|_| key.starts_with("//")) else { + return key.cow_to_ascii_lowercase().into_owned(); + }; + let authority_end = registry[2..].find('/').map_or(registry.len(), |index| index + 2); + vt_str::format!( + "{}{}:{}", + registry[..authority_end].cow_to_ascii_lowercase(), + ®istry[authority_end..], + setting.cow_to_ascii_lowercase() + ) + .to_string() +} + +fn expand_value(raw: &str) -> String { + let mut value = raw.trim(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value = &value[1..value.len() - 1]; + } + + let mut expanded = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find("${") { + expanded.push_str(&rest[..start]); + let Some(end) = rest[start + 2..].find('}') else { + expanded.push_str(&rest[start..]); + return expanded; + }; + let expression = &rest[start + 2..start + 2 + end]; + let (name, empty_if_missing) = + expression.strip_suffix('?').map_or((expression, false), |name| (name, true)); + match env::var(name) { + Ok(value) => expanded.push_str(&value), + Err(_) if !empty_if_missing => expanded.push_str(&rest[start..start + 3 + end]), + Err(_) => {} + } + rest = &rest[start + 3 + end..]; + } + expanded.push_str(rest); + expanded +} + +fn load_npmrc(path: PathBuf, values: &mut HashMap) { + let Ok(contents) = fs::read_to_string(path) else { return }; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { continue }; + let key = normalize_key(key); + if !key.is_empty() { + values.insert(key, expand_value(value)); + } + } +} -/// Get the configured NPM registry URL. +/// Get the configured default NPM registry URL. #[must_use] pub fn npm_registry() -> String { - EnvConfig::get().npm_registry.clone() + NpmConfig::load().registry_for_package("") +} + +fn npm_registry_for_package(name: &str) -> String { + NpmConfig::load().registry_for_package(name) } -/// Get the tgz url of a npm package #[must_use] pub(crate) fn get_npm_package_tgz_url(name: &str, version: &str) -> vt_str::Str { - let registry = npm_registry(); - // convert `@scope/name` to `name` + let registry = npm_registry_for_package(name); let filename = name.split('/').next_back().unwrap_or(name); vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") } #[must_use] pub(crate) fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> vt_str::Str { - let registry = npm_registry(); + let registry = npm_registry_for_package(name); vt_str::format!("{registry}/{name}/{version_or_tag}") } -/// Get the metadata url of a npm package (lists all published versions) #[must_use] pub(crate) fn get_npm_package_metadata_url(name: &str) -> vt_str::Str { - let registry = npm_registry(); + let registry = npm_registry_for_package(name); vt_str::format!("{registry}/{name}") } #[cfg(test)] mod tests { + use tempfile::TempDir; use vp_shared::env_vars; use super::*; + fn project_with_npmrc(contents: &str) -> TempDir { + let project = TempDir::new().unwrap(); + fs::write(project.path().join("package.json"), "{}").unwrap(); + fs::write(project.path().join(".npmrc"), contents).unwrap(); + project + } + + fn http_client() -> reqwest::Client { + vp_shared::ensure_tls_provider(); + reqwest::Client::new() + } + + #[test] + fn reads_project_registry_and_scoped_registry() { + let project = project_with_npmrc( + "registry=https://default.example/\n@yarnpkg:registry=https://yarn.example/\n", + ); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package(""), "https://default.example"); + assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://yarn.example"); + } + #[test] - fn test_npm_registry_default() { - vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { - assert_eq!(npm_registry(), "https://registry.npmjs.org"); + fn environment_registry_overrides_project() { + let project = project_with_npmrc("registry=https://project.example\n"); + EnvConfig::with_vars([(env_vars::NPM_CONFIG_REGISTRY, "https://env.example")], |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package(""), "https://env.example") }); } #[test] - fn test_npm_registry_custom() { - EnvConfig::with_vars( - [(env_vars::NPM_CONFIG_REGISTRY, "https://registry.npmmirror.com")], - |_| { - assert_eq!(npm_registry(), "https://registry.npmmirror.com"); - }, + fn expands_auth_token_and_matches_longest_url_path() { + let project = project_with_npmrc( + "//registry.example/:_authToken=HOST\n//registry.example/team/:_authToken=${TEST_NPM_TOKEN}\n", ); + vp_shared::EnvConfig::with_vars([("TEST_NPM_TOKEN", "TEAM")], |_| { + let request = NpmConfig::load_for_project(Some(project.path().to_path_buf())) + .apply_auth( + http_client().get("https://registry.example/team/pkg"), + "https://registry.example/team/pkg", + ) + .build() + .unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer TEAM"); + }); } #[test] - fn test_npm_tgz_url() { - vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + fn does_not_send_auth_to_another_host() { + let config = NpmConfig { + values: HashMap::from([( + "//registry.example/:_authtoken".to_string(), + "SECRET".to_string(), + )]), + }; + let request = config + .apply_auth(http_client().get("https://other.example/pkg"), "https://other.example/pkg") + .build() + .unwrap(); + assert!(!request.headers().contains_key(reqwest::header::AUTHORIZATION)); + } + + #[test] + fn supports_encoded_and_username_password_basic_auth() { + let encoded = base64_simd::STANDARD.encode_to_string("user:secret"); + let config = NpmConfig { + values: HashMap::from([ + ("//encoded.example/:_auth".to_string(), encoded.clone()), + ("//split.example/:username".to_string(), "user".to_string()), + ( + "//split.example/:_password".to_string(), + base64_simd::STANDARD.encode_to_string("secret"), + ), + ]), + }; + for host in ["encoded.example", "split.example"] { + let url = vt_str::format!("https://{host}/pkg"); + let request = + config.apply_auth(http_client().get(url.as_str()), url.as_str()).build().unwrap(); assert_eq!( - get_npm_package_tgz_url("vite", "7.1.3"), - "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz" + request.headers()[reqwest::header::AUTHORIZATION], + vt_str::format!("Basic {encoded}").as_str() ); + } + } + + #[test] + fn accepts_auth_paths_with_or_without_a_trailing_slash() { + let project = project_with_npmrc( + "//registry.example/team:_authToken=NO_SLASH\n//registry.example/other/:_authToken=SLASH\n", + ); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + for (path, token) in [("team/pkg", "NO_SLASH"), ("other/pkg", "SLASH")] { + let url = vt_str::format!("https://registry.example/{path}"); + let request = + config.apply_auth(http_client().get(url.as_str()), url.as_str()).build().unwrap(); assert_eq!( - get_npm_package_tgz_url("@vitejs/release-scripts", "1.6.0"), - "https://registry.npmjs.org/@vitejs/release-scripts/-/release-scripts-1.6.0.tgz" + request.headers()[reqwest::header::AUTHORIZATION], + vt_str::format!("Bearer {token}").as_str() ); - }); + } + } + + #[test] + fn registry_auth_paths_remain_case_sensitive() { + let project = project_with_npmrc("//registry.example/Team/:_authToken=SECRET\n"); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + + let matching = config + .apply_auth( + http_client().get("https://registry.example/Team/pkg"), + "https://registry.example/Team/pkg", + ) + .build() + .unwrap(); + assert_eq!(matching.headers()[reqwest::header::AUTHORIZATION], "Bearer SECRET"); + + let different_case = config + .apply_auth( + http_client().get("https://registry.example/team/pkg"), + "https://registry.example/team/pkg", + ) + .build() + .unwrap(); + assert!(!different_case.headers().contains_key(reqwest::header::AUTHORIZATION)); } } diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index f4c29bc835..0793a07f1a 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -15,11 +15,14 @@ use tar::Archive; use tokio::{fs, io::AsyncWriteExt}; use vp_error::Error; +use crate::config::NpmConfig; + /// HTTP client with built-in retry support #[derive(Clone)] pub struct HttpClient { max_times: usize, min_delay: u64, + npm_config: NpmConfig, } impl Default for HttpClient { @@ -31,7 +34,7 @@ impl Default for HttpClient { impl HttpClient { /// Create a new HTTP client with default settings (3 retries, 500ms min delay) #[must_use] - pub const fn new() -> Self { + pub fn new() -> Self { Self::with_config(3, 500) } @@ -42,8 +45,8 @@ impl HttpClient { /// * `max_times` - Maximum number of retry attempts /// * `min_delay` - Minimum delay in milliseconds for exponential backoff #[must_use] - pub(crate) const fn with_config(max_times: usize, min_delay: u64) -> Self { - Self { max_times, min_delay } + pub(crate) fn with_config(max_times: usize, min_delay: u64) -> Self { + Self { max_times, min_delay, npm_config: NpmConfig::load() } } /// Get raw bytes from a URL @@ -64,7 +67,12 @@ impl HttpClient { // Read the body inside the retry so a mid-body connection drop gets // retried instead of failing outright, like `download_file`. let bytes = (|| async { - let response = client.get(url).send().await?.error_for_status()?; + let response = self + .npm_config + .apply_auth(client.get(url), url) + .send() + .await? + .error_for_status()?; Ok::<_, Error>(response.bytes().await?) }) .retry( @@ -126,6 +134,7 @@ impl HttpClient { if let Some(accept) = accept { request = request.header(reqwest::header::ACCEPT, accept); } + request = self.npm_config.apply_auth(request, url); let response = request.send().await?.error_for_status()?; Ok::(response.json::().await?) }) @@ -199,7 +208,12 @@ impl HttpClient { // a slow-but-steady transfer must be allowed to finish. let timeout = vp_shared::download_timeout(); let result = (|| async { - let response = client.get(url).timeout(timeout).send().await?.error_for_status()?; + let response = self + .npm_config + .apply_auth(client.get(url).timeout(timeout), url) + .send() + .await? + .error_for_status()?; if let Some(ref pb) = progress { pb.set_position(0); if let Some(size) = response.content_length() { @@ -772,6 +786,50 @@ mod tests { assert_eq!(package_info.description, "A test package"); } + #[tokio::test] + async fn npm_auth_is_sent_on_the_first_registry_requests() { + let server = MockServer::start(); + let registry_url = server.base_url(); + let registry_key = registry_url.trim_start_matches("http:"); + let client = HttpClient { + max_times: 0, + min_delay: 0, + npm_config: NpmConfig { + values: std::collections::HashMap::from([( + vt_str::format!("{registry_key}/:_authtoken").to_string(), + "SECRET".to_string(), + )]), + }, + }; + + let authenticated = server.mock(|when, then| { + when.method(GET).path("/package").header("authorization", "Bearer SECRET"); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ "value": true })); + }); + let authenticated_download = server.mock(|when, then| { + when.method(GET).path("/package.tgz").header("authorization", "Bearer SECRET"); + then.status(200).body("archive"); + }); + let result: serde_json::Value = + client.get_json(&vt_str::format!("{}/package", server.base_url())).await.unwrap(); + let target = TempDir::new().unwrap(); + client + .download_file( + &vt_str::format!("{}/package.tgz", server.base_url()), + target.path().join("package.tgz"), + None, + ) + .await + .unwrap(); + + assert_eq!(result, serde_json::json!({ "value": true })); + authenticated.assert_hits(1); + authenticated_download.assert_hits(1); + assert_eq!(fs::read(target.path().join("package.tgz")).unwrap(), b"archive"); + } + #[tokio::test] async fn test_http_client_download_file() { let server = MockServer::start(); diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index 802af64636..49ee9c2228 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -74,6 +74,17 @@ These variables control the installer scripts and the standalone Windows install - **Purpose**: Custom npm registry URL - **Default**: `https://registry.npmjs.org` - **CLI equivalent**: `--registry` +- **Managed package managers**: When Vite+ downloads a pinned npm, pnpm, + Yarn, or Bun version, it also reads the workspace-root and user `.npmrc`. + Package-scoped registries and registry-scoped `_authToken`, `_auth`, or + `username`/`_password` credentials are honored. Keep secrets in environment + variables and reference them from `.npmrc`, for example: + + ```ini + registry=https://npm.corp.example/repository/npm/ + //npm.corp.example/repository/npm/:_authToken=${NPM_TOKEN} + ``` + - **Example**: ```bash curl -fsSL https://vite.plus | NPM_CONFIG_REGISTRY=https://registry.npmmirror.com bash From 27d52479736300e50b6eb456c9009ede5a38b408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Mon, 7 Sep 2026 23:31:21 +0800 Subject: [PATCH 2/4] test(pm): serialize registry environment access --- crates/vp_pm_cli/src/config.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 660f362a93..bf8c0c0e37 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -241,9 +241,11 @@ mod tests { let project = project_with_npmrc( "registry=https://default.example/\n@yarnpkg:registry=https://yarn.example/\n", ); - let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); - assert_eq!(config.registry_for_package(""), "https://default.example"); - assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://yarn.example"); + EnvConfig::with_vars(std::iter::empty::<(&'static str, &'static str)>(), |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package(""), "https://default.example"); + assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://yarn.example"); + }); } #[test] From 4223621fb5d99c8c96e1bff0bc451fad21f29611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Sat, 12 Sep 2026 20:51:27 +0800 Subject: [PATCH 3/4] fix(pm): harden private registry configuration --- crates/vp_pm_cli/src/config.rs | 248 ++++++++++++++++++----- crates/vp_pm_cli/src/package_manager.rs | 257 +++++++++++++++++++----- crates/vp_pm_cli/src/request.rs | 37 +++- 3 files changed, 443 insertions(+), 99 deletions(-) diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index bf8c0c0e37..cc50f73f01 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -1,8 +1,9 @@ -use std::{collections::HashMap, env, fs, path::PathBuf}; +use std::{collections::HashMap, env, ffi::OsString, fs, path::PathBuf}; use cow_utils::CowUtils; use reqwest::{RequestBuilder, Url}; use vp_shared::EnvConfig; +use vt_path::AbsolutePath; use vt_workspace::find_workspace_root; const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmjs.org"; @@ -16,11 +17,20 @@ pub(crate) struct NpmConfig { impl NpmConfig { pub(crate) fn load() -> Self { - let project_root = vt_path::current_dir() + vt_path::current_dir() .ok() - .and_then(|cwd| find_workspace_root(&cwd).ok()) - .map(|(root, _)| root.path.as_path().to_path_buf()); - Self::load_for_project(project_root) + .map_or_else(|| Self::load_for_project(None), |cwd| Self::load_for_cwd(&cwd)) + } + + pub(crate) fn load_for_cwd(cwd: &AbsolutePath) -> Self { + find_workspace_root(cwd).map_or_else( + |_| Self::load_for_project(None), + |(root, _)| Self::load_for_project_root(&root.path), + ) + } + + pub(crate) fn load_for_project_root(project_root: &AbsolutePath) -> Self { + Self::load_for_project(Some(project_root.as_path().to_path_buf())) } fn load_for_project(project_root: Option) -> Self { @@ -40,12 +50,8 @@ impl NpmConfig { } // npm_config_* is the highest-precedence npm config source available to vp. - for (key, value) in env::vars() { - let Some(raw_key) = - key.strip_prefix("npm_config_").or_else(|| key.strip_prefix("NPM_CONFIG_")) - else { - continue; - }; + for (key, value) in npm_config_env() { + let raw_key = &key["npm_config_".len()..]; if value.is_empty() { continue; } @@ -60,15 +66,34 @@ impl NpmConfig { Self { values } } - fn registry_for_package(&self, package: &str) -> String { + pub(crate) fn registry_for_package(&self, package: &str) -> String { let scoped = package .strip_prefix('@') .and_then(|rest| rest.split_once('/')) - .and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str())); - scoped.or_else(|| self.values.get("registry")).map_or_else( - || DEFAULT_NPM_REGISTRY.to_string(), - |value| value.trim_end_matches('/').to_string(), - ) + .and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str())) + .filter(|value| !value.is_empty()); + scoped + .or_else(|| self.values.get("registry").filter(|value| !value.is_empty())) + .map_or_else( + || DEFAULT_NPM_REGISTRY.to_string(), + |value| value.trim_end_matches('/').to_string(), + ) + } + + pub(crate) fn package_tgz_url(&self, name: &str, version: &str) -> vt_str::Str { + let registry = self.registry_for_package(name); + let filename = name.split('/').next_back().unwrap_or(name); + vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") + } + + pub(crate) fn package_version_url(&self, name: &str, version_or_tag: &str) -> vt_str::Str { + let registry = self.registry_for_package(name); + vt_str::format!("{registry}/{name}/{version_or_tag}") + } + + pub(crate) fn package_metadata_url(&self, name: &str) -> vt_str::Str { + let registry = self.registry_for_package(name); + vt_str::format!("{registry}/{name}") } pub(crate) fn apply_auth(&self, request: RequestBuilder, url: &str) -> RequestBuilder { @@ -95,10 +120,13 @@ impl NpmConfig { for prefix in [prefix.as_str(), prefix.trim_end_matches('/')] { if let Some(token) = self.values.get(vt_str::format!("{prefix}:_authtoken").as_str()) + && !token.is_empty() { return request.bearer_auth(token); } - if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) { + if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) + && !auth.is_empty() + { return request.header( reqwest::header::AUTHORIZATION, vt_str::format!("Basic {auth}").as_str(), @@ -107,6 +135,8 @@ impl NpmConfig { let username = self.values.get(vt_str::format!("{prefix}:username").as_str()); let password = self.values.get(vt_str::format!("{prefix}:_password").as_str()); if let (Some(username), Some(password)) = (username, password) + && !username.is_empty() + && !password.is_empty() && let Ok(decoded) = base64_simd::STANDARD.decode_to_vec(password) { return request @@ -119,11 +149,25 @@ impl NpmConfig { } fn env_value(name: &str) -> Option { - env::vars().find_map(|(key, value)| { - key.strip_prefix("npm_config_") - .or_else(|| key.strip_prefix("NPM_CONFIG_")) - .filter(|key| key.eq_ignore_ascii_case(name)) - .map(|_| value) + npm_config_env().find_map(|(key, value)| { + key["npm_config_".len()..] + .eq_ignore_ascii_case(name) + .then_some(value) + .filter(|value| !value.is_empty()) + }) +} + +fn npm_config_env() -> impl Iterator { + npm_config_env_from(env::vars_os()) +} + +fn npm_config_env_from( + vars: impl Iterator, +) -> impl Iterator { + vars.filter_map(|(key, value)| { + let key = key.into_string().ok()?; + let value = value.into_string().ok()?; + key.get(.."npm_config_".len())?.eq_ignore_ascii_case("npm_config_").then_some((key, value)) }) } @@ -183,38 +227,51 @@ fn load_npmrc(path: PathBuf, values: &mut HashMap) { let Some((key, value)) = line.split_once('=') else { continue }; let key = normalize_key(key); if !key.is_empty() { - values.insert(key, expand_value(value)); + values.insert(key, expand_value(&parse_npmrc_value(value))); } } } -/// Get the configured default NPM registry URL. -#[must_use] -pub fn npm_registry() -> String { - NpmConfig::load().registry_for_package("") -} - -fn npm_registry_for_package(name: &str) -> String { - NpmConfig::load().registry_for_package(name) -} - -#[must_use] -pub(crate) fn get_npm_package_tgz_url(name: &str, version: &str) -> vt_str::Str { - let registry = npm_registry_for_package(name); - let filename = name.split('/').next_back().unwrap_or(name); - vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") -} +fn parse_npmrc_value(value: &str) -> String { + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + return serde_json::from_str(value) + .unwrap_or_else(|_| value[1..value.len() - 1].to_string()); + } + if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { + return value[1..value.len() - 1].to_string(); + } -#[must_use] -pub(crate) fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> vt_str::Str { - let registry = npm_registry_for_package(name); - vt_str::format!("{registry}/{name}/{version_or_tag}") + let mut parsed = String::with_capacity(value.len()); + let mut escaped = false; + for character in value.chars() { + if escaped { + if !matches!(character, '\\' | '#' | ';') { + parsed.push('\\'); + } + parsed.push(character); + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if matches!(character, '#' | ';') { + break; + } + parsed.push(character); + } + if escaped { + parsed.push('\\'); + } + parsed.trim_end().to_string() } +/// Get the configured default NPM registry URL. #[must_use] -pub(crate) fn get_npm_package_metadata_url(name: &str) -> vt_str::Str { - let registry = npm_registry_for_package(name); - vt_str::format!("{registry}/{name}") +pub fn npm_registry() -> String { + NpmConfig::load().registry_for_package("") } #[cfg(test)] @@ -248,6 +305,68 @@ mod tests { }); } + #[test] + fn loads_registry_from_caller_provided_workspace() { + let project = project_with_npmrc("registry=https://target.example\n"); + let cwd = AbsolutePath::new(project.path()).unwrap(); + EnvConfig::with_vars(std::iter::empty::<(&str, &str)>(), |_| { + let config = NpmConfig::load_for_cwd(cwd); + assert_eq!(config.registry_for_package("pnpm"), "https://target.example"); + }); + } + + #[test] + fn empty_registry_values_fall_back() { + let config = NpmConfig { + values: HashMap::from([ + ("@yarnpkg:registry".to_string(), String::new()), + ("registry".to_string(), "https://default.example/".to_string()), + ]), + }; + assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://default.example"); + + let config = NpmConfig { values: HashMap::from([("registry".to_string(), String::new())]) }; + assert_eq!(config.registry_for_package("pnpm"), DEFAULT_NPM_REGISTRY); + } + + #[test] + fn empty_userconfig_environment_value_is_ignored() { + EnvConfig::with_vars([("NPM_CONFIG_USERCONFIG", "")], |_| { + assert_eq!(env_value("userconfig"), None); + }); + } + + #[test] + fn npm_config_environment_prefix_is_case_insensitive() { + let values = npm_config_env_from( + [(OsString::from("Npm_Config_Registry"), OsString::from("https://example.test"))] + .into_iter(), + ) + .collect::>(); + assert_eq!( + values, + vec![("Npm_Config_Registry".to_string(), "https://example.test".to_string())] + ); + } + + #[cfg(unix)] + #[test] + fn non_unicode_environment_entries_are_skipped() { + use std::os::unix::ffi::OsStringExt; + + let values = npm_config_env_from( + [ + (OsString::from_vec(vec![0xff]), OsString::from("ignored")), + (OsString::from("NPM_CONFIG_REGISTRY"), OsString::from_vec(vec![0xff])), + (OsString::from("NPM_CONFIG_REGISTRY"), OsString::from("https://example.test")), + ] + .into_iter(), + ) + .collect::>(); + assert_eq!(values.len(), 1); + assert_eq!(values[0].1, "https://example.test"); + } + #[test] fn environment_registry_overrides_project() { let project = project_with_npmrc("registry=https://project.example\n"); @@ -274,6 +393,41 @@ mod tests { }); } + #[test] + fn empty_credentials_fall_back_to_parent_auth_path() { + let config = NpmConfig { + values: HashMap::from([ + ("//registry.example/team/:_authtoken".to_string(), String::new()), + ("//registry.example/:_authtoken".to_string(), "HOST".to_string()), + ]), + }; + let request = config + .apply_auth( + http_client().get("https://registry.example/team/pkg"), + "https://registry.example/team/pkg", + ) + .build() + .unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer HOST"); + } + + #[test] + fn parses_inline_comments_and_escapes_in_npmrc_values() { + let project = project_with_npmrc( + "registry=https://registry.example/ ; mirror\n\ + //registry.example/:_authToken=SECRET # CI\n\ + quoted=\"value # retained\"\n\ + fragment=https://example.test/\\#retained\n\ + semicolon=left\\;right ; removed\n", + ); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package("pnpm"), "https://registry.example"); + assert_eq!(config.values["//registry.example/:_authtoken"], "SECRET"); + assert_eq!(config.values["quoted"], "value # retained"); + assert_eq!(config.values["fragment"], "https://example.test/#retained"); + assert_eq!(config.values["semicolon"], "left;right"); + } + #[test] fn does_not_send_auth_to_another_host() { let config = NpmConfig { diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 2b3ac95e9e..c83a087dbd 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -18,6 +18,7 @@ use crossterm::{ }; use semver::Version; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::fs::remove_dir_all; use vp_error::Error; use vp_shared::OnFail; @@ -28,8 +29,8 @@ use vt_workspace::find_package_root; use vt_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; use crate::{ - config::{get_npm_package_metadata_url, get_npm_package_tgz_url, get_npm_package_version_url}, - request::{HttpClient, download_and_extract_tgz_with_hash, verify_file_hash}, + config::NpmConfig, + request::{HttpClient, download_and_extract_tgz_with_hash_and_config, verify_file_hash}, shim, }; @@ -230,13 +231,18 @@ impl PackageManagerBuilder { /// Detect the package manager from the current working directory. pub async fn build(&self) -> Result { let (workspace_root, _) = find_workspace_root(&self.cwd)?; + let npm_config = NpmConfig::load_for_project_root(&workspace_root.path); let (package_manager_type, version_or_req, hash, _) = get_package_manager_type_and_version(&workspace_root, self.client_override)?; // only download the package manager if it's not already downloaded - let (install_dir, _package_name, version) = - download_package_manager(package_manager_type, &version_or_req, hash.as_deref()) - .await?; + let (install_dir, _package_name, version) = download_package_manager_with_config( + package_manager_type, + &version_or_req, + hash.as_deref(), + &npm_config, + ) + .await?; Ok(PackageManager { client: package_manager_type, @@ -560,6 +566,7 @@ pub async fn resolve_environment_package_manager( default_spec: Option<(PackageManagerType, &str, Option<&str>)>, expected: Option, ) -> Result, Error> { + let cwd = cwd.as_ref(); let mut resolution = resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?; if let Some(expected) = expected @@ -572,9 +579,13 @@ pub async fn resolve_environment_package_manager( let Some(mut resolution) = resolution else { return Ok(None); }; - resolution.version = - resolve_package_manager_version(resolution.package_manager_type, &resolution.version) - .await?; + let npm_config = NpmConfig::load_for_cwd(cwd); + resolution.version = resolve_package_manager_version_with_config( + resolution.package_manager_type, + &resolution.version, + &npm_config, + ) + .await?; Ok(Some(resolution)) } @@ -888,12 +899,15 @@ const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(3600); fn latest_version_cache_path( package_manager_type: PackageManagerType, + registry: &str, ) -> io::Result { + let registry_key = hex::encode(Sha256::digest(registry.as_bytes())); Ok(vp_shared::EnvConfig::get() .dirs .cache .join("package_manager_latest") - .join(package_manager_type.to_string())) + .join(package_manager_type.to_string()) + .join(registry_key)) } fn read_latest_version_cache(path: &AbsolutePath) -> Option<(Str, bool)> { @@ -915,21 +929,27 @@ fn write_latest_version_cache(path: &AbsolutePath, version: &str) -> io::Result< fs::write(path, version) } -async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { - let cache_path = latest_version_cache_path(package_manager_type)?; - let cached = read_latest_version_cache(&cache_path); - if let Some((version, true)) = &cached { - return Ok(version.clone()); - } - +async fn get_latest_version_with_config( + package_manager_type: PackageManagerType, + npm_config: &NpmConfig, +) -> Result { let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name "@yarnpkg/cli-dist".to_string() } else { package_manager_type.to_string() }; - let url = get_npm_package_version_url(&package_name, "latest"); - match HttpClient::new().get_json::(&url).await { + let registry = npm_config.registry_for_package(&package_name); + let cache_path = latest_version_cache_path(package_manager_type, ®istry)?; + let cached = read_latest_version_cache(&cache_path); + if let Some((version, true)) = &cached { + return Ok(version.clone()); + } + let url = npm_config.package_version_url(&package_name, "latest"); + match HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json::(&url) + .await + { Ok(package_json) => { let _ = write_latest_version_cache(&cache_path, &package_json.version); Ok(package_json.version) @@ -949,13 +969,22 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< pub async fn resolve_package_manager_version( package_manager_type: PackageManagerType, version: &str, +) -> Result { + resolve_package_manager_version_with_config(package_manager_type, version, &NpmConfig::load()) + .await +} + +async fn resolve_package_manager_version_with_config( + package_manager_type: PackageManagerType, + version: &str, + npm_config: &NpmConfig, ) -> Result { if version == "latest" { - get_latest_version(package_manager_type).await + get_latest_version_with_config(package_manager_type, npm_config).await } else if Version::parse(version).is_ok() { Ok(version.into()) } else { - resolve_package_manager_range(package_manager_type, version).await + resolve_package_manager_range(package_manager_type, version, npm_config).await } } @@ -973,10 +1002,14 @@ struct RegistryPackument { /// smaller than the full packument (KBs instead of MBs for popular packages). const NPM_ABBREVIATED_METADATA_ACCEPT: &str = "application/vnd.npm.install-v1+json"; -async fn fetch_registry_versions(package_name: &str) -> Result, Error> { - let url = get_npm_package_metadata_url(package_name); - let packument: RegistryPackument = - HttpClient::new().get_json_with_accept(&url, NPM_ABBREVIATED_METADATA_ACCEPT).await?; +async fn fetch_registry_versions( + package_name: &str, + npm_config: &NpmConfig, +) -> Result, Error> { + let url = npm_config.package_metadata_url(package_name); + let packument: RegistryPackument = HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json_with_accept(&url, NPM_ABBREVIATED_METADATA_ACCEPT) + .await?; Ok(packument .versions .keys() @@ -988,9 +1021,11 @@ async fn fetch_registry_versions(package_name: &str) -> Result Result, Error> { - let mut versions = fetch_registry_versions(&package_manager_type.to_string()).await?; + let npm_config = NpmConfig::load(); + let mut versions = + fetch_registry_versions(&package_manager_type.to_string(), &npm_config).await?; if matches!(package_manager_type, PackageManagerType::Yarn) { - versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist", &npm_config).await?); } versions.sort(); versions.dedup(); @@ -1017,12 +1052,13 @@ async fn resolve_latest_satisfying_version( package_manager_type: PackageManagerType, range: &node_semver::Range, version_req: &str, + npm_config: &NpmConfig, ) -> Result { let package_name = package_manager_type.to_string(); - let mut versions = fetch_registry_versions(&package_name).await?; + let mut versions = fetch_registry_versions(&package_name, npm_config).await?; // yarn >= 2.0.0 is published as `@yarnpkg/cli-dist`; merge both version lists if matches!(package_manager_type, PackageManagerType::Yarn) { - versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist", npm_config).await?); } let best = versions @@ -1043,7 +1079,7 @@ async fn resolve_latest_satisfying_version( Error::PackageManagerVersionNotFound { name: package_name.clone().into(), version: version_req.into(), - url: get_npm_package_metadata_url(&package_name).into(), + url: npm_config.package_metadata_url(&package_name).into(), } }) } @@ -1095,6 +1131,7 @@ fn find_cached_package_manager_version( async fn resolve_package_manager_range( package_manager_type: PackageManagerType, version_req: &str, + npm_config: &NpmConfig, ) -> Result { let range = node_semver::Range::parse(version_req).map_err(|_| { Error::InvalidArgument( @@ -1113,10 +1150,10 @@ async fn resolve_package_manager_range( // `*` (any version) resolves to the registry's latest stable if version_req == "*" { - return get_latest_version(package_manager_type).await; + return get_latest_version_with_config(package_manager_type, npm_config).await; } - resolve_latest_satisfying_version(package_manager_type, &range, version_req).await + resolve_latest_satisfying_version(package_manager_type, &range, version_req, npm_config).await } /// Download the package manager and extract it to the vite-plus home directory. @@ -1126,7 +1163,27 @@ pub async fn download_package_manager( version_or_latest: &str, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; + download_package_manager_with_config( + package_manager_type, + version_or_latest, + expected_hash, + &NpmConfig::load(), + ) + .await +} + +async fn download_package_manager_with_config( + package_manager_type: PackageManagerType, + version_or_latest: &str, + expected_hash: Option<&str>, + npm_config: &NpmConfig, +) -> Result<(AbsolutePathBuf, Str, Str), Error> { + let version = resolve_package_manager_version_with_config( + package_manager_type, + version_or_latest, + npm_config, + ) + .await?; // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into @@ -1159,7 +1216,7 @@ pub async fn download_package_manager( // not the platform-specific binary, so we don't pass it through; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, data_dir).await; + return download_bun_package_manager(&version, data_dir, npm_config).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -1167,10 +1224,11 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, data_dir, expected_hash).await; + return download_pnpm_native_package_manager(&version, data_dir, expected_hash, npm_config) + .await; } - let tgz_url = get_npm_package_tgz_url(&package_name, &version); + let tgz_url = npm_config.package_tgz_url(&package_name, &version); // /package_manager/pnpm/10.0.0 let target_dir = data_dir.join("package_manager").join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); @@ -1201,12 +1259,13 @@ pub async fn download_package_manager( // A Corepack Yarn 2+ pin covers only the CLI. The rest of the archive stays // unauthenticated, so vp never writes it to disk. let archive_file = is_modern_yarn.then(|| PathBuf::from(format!("package/{YARN_CLI_ENTRY}"))); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &tgz_url, &target_dir_tmp, archive_file.as_deref(), expected_hash, Some(&download_message), + npm_config, ) .await .map_err(|err| { @@ -1408,6 +1467,7 @@ fn bun_requires_baseline() -> bool { async fn download_bun_package_manager( version: &Str, home_dir: &AbsolutePath, + npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); @@ -1425,24 +1485,26 @@ async fn download_bun_package_manager( let platform_package_name = get_bun_platform_package_name()?; // The declared hash never covers the platform tarball, so verify it // against the registry's `dist.integrity` for the platform package. - let platform_hash = fetch_platform_integrity("bun", &platform_package_name, version).await?; + let platform_hash = + fetch_platform_integrity("bun", &platform_package_name, version, npm_config).await?; let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; // Download the platform-specific package directly - let platform_tgz_url = get_npm_package_tgz_url(&platform_package_name, version); + let platform_tgz_url = npm_config.package_tgz_url(&platform_package_name, version); // Keep the TempDir guard alive so a failure path cleans up the temp dir. let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading bun v{version}..."); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &platform_tgz_url, &target_dir_tmp, None, platform_hash.as_deref(), Some(&download_message), + npm_config, ) .await .map_err(|err| { @@ -1558,10 +1620,13 @@ async fn fetch_platform_integrity( bin_name: &str, platform_package_name: &str, version: &Str, + npm_config: &NpmConfig, ) -> Result, Error> { - let metadata_url = get_npm_package_version_url(platform_package_name, version); - let metadata: RegistryVersionMetadata = - HttpClient::new().get_json(&metadata_url).await.map_err(|err| { + let metadata_url = npm_config.package_version_url(platform_package_name, version); + let metadata: RegistryVersionMetadata = HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json(&metadata_url) + .await + .map_err(|err| { if let Error::Reqwest(e) = &err && let Some(status) = e.status() && status == reqwest::StatusCode::NOT_FOUND @@ -1591,6 +1656,7 @@ async fn download_pnpm_native_package_manager( version: &Str, home_dir: &AbsolutePath, expected_hash: Option<&str>, + npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; @@ -1609,15 +1675,16 @@ async fn download_pnpm_native_package_manager( // platform package: verify it against the artifact it names so a bad pin // still fails, matching pnpm <= 11. if let Some(expected_hash) = expected_hash { - let main_tgz_url = get_npm_package_tgz_url("pnpm", version); + let main_tgz_url = npm_config.package_tgz_url("pnpm", version); let verify_dir = tempfile::tempdir()?; let verify_message = format!("Verifying pnpm v{version}..."); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &main_tgz_url, verify_dir.path(), None, Some(expected_hash), Some(&verify_message), + npm_config, ) .await .map_err(|error| name_hashed_artifact(error, PackageManagerType::Pnpm, version))?; @@ -1625,24 +1692,26 @@ async fn download_pnpm_native_package_manager( // The declared hash never covers the platform tarball, so verify it // against the registry's `dist.integrity` for the platform package. - let platform_hash = fetch_platform_integrity("pnpm", platform_package_name, version).await?; + let platform_hash = + fetch_platform_integrity("pnpm", platform_package_name, version, npm_config).await?; let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; // Download the platform-specific package directly - let platform_tgz_url = get_npm_package_tgz_url(platform_package_name, version); + let platform_tgz_url = npm_config.package_tgz_url(platform_package_name, version); // Keep the TempDir guard alive so a failure path cleans up the temp dir. let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading pnpm v{version}..."); - download_and_extract_tgz_with_hash( + download_and_extract_tgz_with_hash_and_config( &platform_tgz_url, &target_dir_tmp, None, platform_hash.as_deref(), Some(&download_message), + npm_config, ) .await .map_err(|err| { @@ -2237,6 +2306,44 @@ mod tests { assert_eq!(resolution.source, "default"); } + #[tokio::test] + async fn environment_resolution_uses_target_workspace_registry_and_auth() { + use httpmock::prelude::*; + + let project = create_temp_dir(); + let cwd = AbsolutePathBuf::new(project.path().to_path_buf()).unwrap(); + create_package_json(&cwd, "{}"); + + let server = MockServer::start(); + let registry = server.base_url(); + let authority = registry.strip_prefix("http:").unwrap(); + fs::write( + cwd.join(".npmrc"), + format!("registry={registry}\n{authority}/:_authToken=SECRET\n"), + ) + .unwrap(); + let metadata = server.mock(|when, then| { + when.method(GET).path("/pnpm/latest").header("authorization", "Bearer SECRET"); + then.status(200).json_body(serde_json::json!({ "version": "10.0.0" })); + }); + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + let resolution = resolve_environment_package_manager( + &cwd, + Some((PackageManagerType::Pnpm, "latest", None)), + None, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(resolution.version, "10.0.0"); + metadata.assert(); + }) + .await; + } + #[test] fn environment_spec_keeps_declared_version_range() { let temp_dir = create_temp_dir(); @@ -2344,7 +2451,8 @@ mod tests { ); first.assert_hits(1); - let cache_path = latest_version_cache_path(PackageManagerType::Bun).unwrap(); + let cache_path = + latest_version_cache_path(PackageManagerType::Bun, ®istry).unwrap(); let expire_cache = || { fs::File::options() .write(true) @@ -2381,6 +2489,53 @@ mod tests { .await; } + #[tokio::test(flavor = "current_thread")] + async fn latest_version_cache_is_isolated_by_registry() { + use httpmock::prelude::*; + + let first_registry = MockServer::start(); + let second_registry = MockServer::start(); + let first_request = first_registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(200).json_body(serde_json::json!({ "version": "10.1.0" })); + }); + let second_request = second_registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(200).json_body(serde_json::json!({ "version": "10.2.0" })); + }); + let first_config = NpmConfig { + values: HashMap::from([("registry".to_string(), first_registry.base_url())]), + }; + let second_config = NpmConfig { + values: HashMap::from([("registry".to_string(), second_registry.base_url())]), + }; + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &first_config) + .await + .unwrap(), + "10.1.0" + ); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &second_config) + .await + .unwrap(), + "10.2.0" + ); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &first_config) + .await + .unwrap(), + "10.1.0" + ); + first_request.assert_hits(1); + second_request.assert_hits(1); + }) + .await; + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { @@ -4173,7 +4328,9 @@ mod tests { vp_shared::EnvConfig::with_vars_async( [(env_vars::VP_HOME, vp_home.as_os_str())], |_| async move { - let result = get_latest_version(PackageManagerType::Yarn).await; + let result = + get_latest_version_with_config(PackageManagerType::Yarn, &NpmConfig::load()) + .await; assert!(result.is_ok()); let version = result.unwrap(); // println!("version: {:?}", version); @@ -4532,7 +4689,7 @@ mod tests { .join("bun/bin/bun.native"); fs::write(&native_bin, "existing bun").unwrap(); - download_bun_package_manager(&version, &vp_home).await.unwrap(); + download_bun_package_manager(&version, &vp_home, &NpmConfig::load()).await.unwrap(); assert_eq!(fs::read_to_string(native_bin).unwrap(), "existing bun"); }, diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index 0793a07f1a..6d54877dde 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -49,6 +49,10 @@ impl HttpClient { Self { max_times, min_delay, npm_config: NpmConfig::load() } } + pub(crate) fn with_npm_config(max_times: usize, min_delay: u64, npm_config: NpmConfig) -> Self { + Self { max_times, min_delay, npm_config } + } + /// Get raw bytes from a URL /// /// # Arguments @@ -374,12 +378,32 @@ fn extract_tgz_file( /// # Returns /// * `Ok(())` - If the tgz file is downloaded, verified (if hash provided) and extracted successfully. /// * `Err(e)` - If the tgz file is not downloaded, verified or extracted successfully. +#[cfg(test)] pub(crate) async fn download_and_extract_tgz_with_hash( url: &str, target_dir: impl AsRef, archive_file: Option<&Path>, expected_hash: Option<&str>, message: Option<&str>, +) -> Result<(), Error> { + download_and_extract_tgz_with_hash_and_config( + url, + target_dir, + archive_file, + expected_hash, + message, + &NpmConfig::load(), + ) + .await +} + +pub(crate) async fn download_and_extract_tgz_with_hash_and_config( + url: &str, + target_dir: impl AsRef, + archive_file: Option<&Path>, + expected_hash: Option<&str>, + message: Option<&str>, + npm_config: &NpmConfig, ) -> Result<(), Error> { if let Some(archive_file) = archive_file && (archive_file.as_os_str().is_empty() @@ -403,7 +427,15 @@ pub(crate) async fn download_and_extract_tgz_with_hash( // and propagate unchanged so the caller in `package_manager.rs` can map a // 404 to `PackageManagerVersionNotFound`. (|| async { - download_and_extract_tgz_once(url, &target_dir, archive_file, expected_hash, message).await + download_and_extract_tgz_once( + url, + &target_dir, + archive_file, + expected_hash, + message, + npm_config, + ) + .await }) .retry( ExponentialBuilder::default() @@ -425,6 +457,7 @@ async fn download_and_extract_tgz_once( archive_file: Option<&Path>, expected_hash: Option<&str>, message: Option<&str>, + npm_config: &NpmConfig, ) -> Result<(), Error> { // Reset target directory so a partial prior attempt can't interfere. if fs::try_exists(target_dir).await.unwrap_or(false) { @@ -437,7 +470,7 @@ async fn download_and_extract_tgz_once( // letting `download_file` retry here too would nest two retry layers and // multiply attempts (up to N×M downloads) for a persistent failure. let tgz_file = target_dir.join("package.tgz"); - let client = HttpClient::with_config(0, 0); + let client = HttpClient::with_npm_config(0, 0, npm_config.clone()); client.download_file(url, &tgz_file, message).await?; if let Some(archive_file) = archive_file { From f309ace66e96b23a0b19849dc12fa4527dbc6c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Mon, 14 Sep 2026 04:42:38 +0800 Subject: [PATCH 4/4] fix(pm): complete private registry auth flow --- crates/vp_pm_cli/src/config.rs | 51 +++++++-- crates/vp_pm_cli/src/dispatch.rs | 10 +- crates/vp_pm_cli/src/lib.rs | 2 +- crates/vp_pm_cli/src/package_manager.rs | 134 ++++++++++++++++++++++-- crates/vp_pm_cli/src/request.rs | 42 +++++--- packages/cli/binding/src/cli/mod.rs | 3 +- 6 files changed, 204 insertions(+), 38 deletions(-) diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index cc50f73f01..dbced0f834 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -15,6 +15,12 @@ pub(crate) struct NpmConfig { pub(crate) values: HashMap, } +enum NpmAuth<'a> { + Token(&'a str), + Encoded(&'a str), + UsernamePassword { username: &'a str, password: Vec }, +} + impl NpmConfig { pub(crate) fn load() -> Self { vt_path::current_dir() @@ -24,7 +30,7 @@ impl NpmConfig { pub(crate) fn load_for_cwd(cwd: &AbsolutePath) -> Self { find_workspace_root(cwd).map_or_else( - |_| Self::load_for_project(None), + |_| Self::load_for_project_root(cwd), |(root, _)| Self::load_for_project_root(&root.path), ) } @@ -97,8 +103,24 @@ impl NpmConfig { } pub(crate) fn apply_auth(&self, request: RequestBuilder, url: &str) -> RequestBuilder { - let Ok(url) = Url::parse(url) else { return request }; - let Some(host) = url.host_str() else { return request }; + match self.auth_for_url(url) { + Some(NpmAuth::Token(token)) => request.bearer_auth(token), + Some(NpmAuth::Encoded(auth)) => request + .header(reqwest::header::AUTHORIZATION, vt_str::format!("Basic {auth}").as_str()), + Some(NpmAuth::UsernamePassword { username, password }) => { + request.basic_auth(username, Some(String::from_utf8_lossy(&password).as_ref())) + } + None => request, + } + } + + pub(crate) fn has_auth_for_url(&self, url: &str) -> bool { + self.auth_for_url(url).is_some() + } + + fn auth_for_url(&self, url: &str) -> Option> { + let Ok(url) = Url::parse(url) else { return None }; + let host = url.host_str()?; let authority = url .port() .map_or_else(|| host.to_string(), |port| vt_str::format!("{host}:{port}").to_string()); @@ -122,15 +144,12 @@ impl NpmConfig { self.values.get(vt_str::format!("{prefix}:_authtoken").as_str()) && !token.is_empty() { - return request.bearer_auth(token); + return Some(NpmAuth::Token(token)); } if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) && !auth.is_empty() { - return request.header( - reqwest::header::AUTHORIZATION, - vt_str::format!("Basic {auth}").as_str(), - ); + return Some(NpmAuth::Encoded(auth)); } let username = self.values.get(vt_str::format!("{prefix}:username").as_str()); let password = self.values.get(vt_str::format!("{prefix}:_password").as_str()); @@ -139,12 +158,11 @@ impl NpmConfig { && !password.is_empty() && let Ok(decoded) = base64_simd::STANDARD.decode_to_vec(password) { - return request - .basic_auth(username, Some(String::from_utf8_lossy(&decoded).as_ref())); + return Some(NpmAuth::UsernamePassword { username, password: decoded }); } } } - request + None } } @@ -315,6 +333,17 @@ mod tests { }); } + #[test] + fn loads_registry_from_caller_directory_without_a_package() { + let directory = TempDir::new().unwrap(); + fs::write(directory.path().join(".npmrc"), "registry=https://target.example\n").unwrap(); + let cwd = AbsolutePath::new(directory.path()).unwrap(); + EnvConfig::with_vars(std::iter::empty::<(&str, &str)>(), |_| { + let config = NpmConfig::load_for_cwd(cwd); + assert_eq!(config.registry_for_package("pnpm"), "https://target.example"); + }); + } + #[test] fn empty_registry_values_fall_back() { let config = NpmConfig { diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index be89973c4e..1b15561e60 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -10,7 +10,7 @@ use vt_path::AbsolutePath; use crate::{ EnvironmentPackageManagerResolution, PackageManager, cli::PackageManagerCommand, - download_package_manager, + download_package_manager_for_cwd, error::Error, helpers::build_package_manager_or_npm_default, resolution::{DlxArgs, run_resolution}, @@ -69,7 +69,7 @@ async fn dispatch_with_manager( let manager = match source { ManagerSource::Detect => return dispatch_dlx(cwd, args, render_diagnostics).await, ManagerSource::Environment(package_manager) => { - build_selected_package_manager(package_manager).await? + build_selected_package_manager(cwd, package_manager).await? } ManagerSource::Resolved(manager) => manager, }; @@ -83,7 +83,7 @@ async fn dispatch_with_manager( let manager = match source { ManagerSource::Detect => build_package_manager_or_npm_default(cwd).await?, ManagerSource::Environment(package_manager) => { - build_selected_package_manager(package_manager).await? + build_selected_package_manager(cwd, package_manager).await? } ManagerSource::Resolved(manager) => manager, }; @@ -95,9 +95,11 @@ async fn dispatch_with_manager( } async fn build_selected_package_manager( + cwd: &AbsolutePath, package_manager: &EnvironmentPackageManagerResolution, ) -> Result { - let (install_dir, _, version) = download_package_manager( + let (install_dir, _, version) = download_package_manager_for_cwd( + cwd, package_manager.package_manager_type, &package_manager.version, package_manager.hash.as_deref(), diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index fe0ec60df7..6dd7c52996 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -27,7 +27,7 @@ pub use error::Error; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, - ensure_package_manager_bin, fetch_package_manager_versions, + download_package_manager_for_cwd, ensure_package_manager_bin, fetch_package_manager_versions, get_package_manager_type_and_version, package_manager_bin_path, package_manager_install_dir, resolve_environment_package_manager, resolve_environment_package_manager_spec, resolve_package_manager_from_package_json, resolve_package_manager_version, diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index c83a087dbd..0cca1c8563 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -929,6 +929,13 @@ fn write_latest_version_cache(path: &AbsolutePath, version: &str) -> io::Result< fs::write(path, version) } +async fn fetch_latest_version(url: &str, npm_config: &NpmConfig) -> Result { + HttpClient::with_npm_config(3, 500, npm_config.clone()) + .get_json::(url) + .await + .map(|package_json| package_json.version) +} + async fn get_latest_version_with_config( package_manager_type: PackageManagerType, npm_config: &NpmConfig, @@ -940,19 +947,24 @@ async fn get_latest_version_with_config( package_manager_type.to_string() }; let registry = npm_config.registry_for_package(&package_name); + let url = npm_config.package_version_url(&package_name, "latest"); + + // A persisted response authenticated as one user must not be reused by + // another user of the same registry URL. Avoid storing credential-derived + // cache keys and query authenticated registries directly instead. + if npm_config.has_auth_for_url(&url) { + return fetch_latest_version(&url, npm_config).await; + } + let cache_path = latest_version_cache_path(package_manager_type, ®istry)?; let cached = read_latest_version_cache(&cache_path); if let Some((version, true)) = &cached { return Ok(version.clone()); } - let url = npm_config.package_version_url(&package_name, "latest"); - match HttpClient::with_npm_config(3, 500, npm_config.clone()) - .get_json::(&url) - .await - { - Ok(package_json) => { - let _ = write_latest_version_cache(&cache_path, &package_json.version); - Ok(package_json.version) + match fetch_latest_version(&url, npm_config).await { + Ok(version) => { + let _ = write_latest_version_cache(&cache_path, &version); + Ok(version) } Err(error) => { let Some((version, _)) = cached else { return Err(error) }; @@ -1172,6 +1184,24 @@ pub async fn download_package_manager( .await } +/// Download a package manager using npm configuration from the workspace that +/// contains `cwd`, rather than from the process working directory. +pub async fn download_package_manager_for_cwd( + cwd: impl AsRef, + package_manager_type: PackageManagerType, + version_or_latest: &str, + expected_hash: Option<&str>, +) -> Result<(AbsolutePathBuf, Str, Str), Error> { + let npm_config = NpmConfig::load_for_cwd(cwd.as_ref()); + download_package_manager_with_config( + package_manager_type, + version_or_latest, + expected_hash, + &npm_config, + ) + .await +} + async fn download_package_manager_with_config( package_manager_type: PackageManagerType, version_or_latest: &str, @@ -2344,6 +2374,46 @@ mod tests { .await; } + #[tokio::test] + async fn package_manager_download_uses_target_workspace_registry_and_auth() { + use httpmock::prelude::*; + + let project = create_temp_dir(); + let cwd = AbsolutePathBuf::new(project.path().to_path_buf()).unwrap(); + create_package_json(&cwd, "{}"); + + let server = MockServer::start(); + let registry = server.base_url(); + let authority = registry.strip_prefix("http:").unwrap(); + fs::write( + cwd.join(".npmrc"), + format!("registry={registry}\n{authority}/:_authToken=SECRET\n"), + ) + .unwrap(); + let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; + let tarball = server.mock(|when, then| { + when.method(GET) + .path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz") + .header("authorization", "Bearer SECRET"); + then.status(200) + .header("content-type", "application/octet-stream") + .body(create_yarn_package_tgz(yarn_js, None)); + }); + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + let (install_dir, package_name, version) = + download_package_manager_for_cwd(&cwd, PackageManagerType::Yarn, "4.17.1", None) + .await + .unwrap(); + assert_eq!(package_name, "@yarnpkg/cli-dist"); + assert_eq!(version, "4.17.1"); + assert!(install_dir.join("bin/yarn").as_path().is_file()); + tarball.assert_hits(1); + }) + .await; + } + #[test] fn environment_spec_keeps_declared_version_range() { let temp_dir = create_temp_dir(); @@ -2536,6 +2606,54 @@ mod tests { .await; } + #[tokio::test(flavor = "current_thread")] + async fn authenticated_latest_versions_are_not_shared_through_the_cache() { + use httpmock::prelude::*; + + let registry = MockServer::start(); + let registry_url = registry.base_url(); + let authority = registry_url.strip_prefix("http:").unwrap(); + let first_request = registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest").header("authorization", "Bearer FIRST"); + then.status(200).json_body(serde_json::json!({ "version": "10.1.0" })); + }); + let second_request = registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest").header("authorization", "Bearer SECOND"); + then.status(200).json_body(serde_json::json!({ "version": "10.2.0" })); + }); + let config = |token: &str| NpmConfig { + values: HashMap::from([ + ("registry".to_string(), registry_url.clone()), + (vt_str::format!("{authority}/:_authtoken").to_string(), token.to_string()), + ]), + }; + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &config("FIRST")) + .await + .unwrap(), + "10.1.0" + ); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &config("SECOND")) + .await + .unwrap(), + "10.2.0" + ); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &config("FIRST")) + .await + .unwrap(), + "10.1.0" + ); + first_request.assert_hits(2); + second_request.assert_hits(1); + }) + .await; + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index 6d54877dde..827fc6178d 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -22,7 +22,7 @@ use crate::config::NpmConfig; pub struct HttpClient { max_times: usize, min_delay: u64, - npm_config: NpmConfig, + npm_config: Option, } impl Default for HttpClient { @@ -46,11 +46,18 @@ impl HttpClient { /// * `min_delay` - Minimum delay in milliseconds for exponential backoff #[must_use] pub(crate) fn with_config(max_times: usize, min_delay: u64) -> Self { - Self { max_times, min_delay, npm_config: NpmConfig::load() } + Self { max_times, min_delay, npm_config: None } } pub(crate) fn with_npm_config(max_times: usize, min_delay: u64, npm_config: NpmConfig) -> Self { - Self { max_times, min_delay, npm_config } + Self { max_times, min_delay, npm_config: Some(npm_config) } + } + + fn apply_auth(&self, request: reqwest::RequestBuilder, url: &str) -> reqwest::RequestBuilder { + match &self.npm_config { + Some(config) => config.apply_auth(request, url), + None => request, + } } /// Get raw bytes from a URL @@ -71,12 +78,8 @@ impl HttpClient { // Read the body inside the retry so a mid-body connection drop gets // retried instead of failing outright, like `download_file`. let bytes = (|| async { - let response = self - .npm_config - .apply_auth(client.get(url), url) - .send() - .await? - .error_for_status()?; + let response = + self.apply_auth(client.get(url), url).send().await?.error_for_status()?; Ok::<_, Error>(response.bytes().await?) }) .retry( @@ -138,7 +141,7 @@ impl HttpClient { if let Some(accept) = accept { request = request.header(reqwest::header::ACCEPT, accept); } - request = self.npm_config.apply_auth(request, url); + request = self.apply_auth(request, url); let response = request.send().await?.error_for_status()?; Ok::(response.json::().await?) }) @@ -213,7 +216,6 @@ impl HttpClient { let timeout = vp_shared::download_timeout(); let result = (|| async { let response = self - .npm_config .apply_auth(client.get(url).timeout(timeout), url) .send() .await? @@ -827,12 +829,12 @@ mod tests { let client = HttpClient { max_times: 0, min_delay: 0, - npm_config: NpmConfig { + npm_config: Some(NpmConfig { values: std::collections::HashMap::from([( vt_str::format!("{registry_key}/:_authtoken").to_string(), "SECRET".to_string(), )]), - }, + }), }; let authenticated = server.mock(|when, then| { @@ -863,6 +865,20 @@ mod tests { assert_eq!(fs::read(target.path().join("package.tgz")).unwrap(), b"archive"); } + #[test] + fn default_http_client_does_not_apply_npm_auth() { + vp_shared::ensure_tls_provider(); + let client = HttpClient::new(); + let request = client + .apply_auth( + reqwest::Client::new().get("https://registry.example/package"), + "https://registry.example/package", + ) + .build() + .unwrap(); + assert!(!request.headers().contains_key(reqwest::header::AUTHORIZATION)); + } + #[tokio::test] async fn test_http_client_download_file() { let server = MockServer::start(); diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index a5966bad5b..162058adde 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -213,7 +213,8 @@ async fn envs_with_explicit_package_manager_path( return Ok(envs); }; - let (install_dir, _, _) = match vp_pm_cli::download_package_manager( + let (install_dir, _, _) = match vp_pm_cli::download_package_manager_for_cwd( + cwd, resolution.package_manager_type, &resolution.version, resolution.hash.as_deref(),