diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 69c3cbfd18..7a7f2267bd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -31,6 +31,17 @@ static YARN_STEP_TIMING_RE: LazyLock = static VITEST_TIMING_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r"(?m)(Duration\s+) \((?:[a-z]+ \d+%(?:, )?)+\)").unwrap() }); +// Vitest conditionally prints this advisory based on measured transform time. +// Remove the complete known block, not arbitrary Transform lines or failures. +static VITEST_TRANSFORM_CACHE_HINT_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(concat!( + r"(?m)\n[ \t]*Transform [^\n]*transforming modules took · \d+% of tracked time, re-done on every run\n", + r"[ \t]*persist transforms across runs with fsModuleCache: true\n", + r"(?:[ \t]*on CI this only helps when the cache directory is persisted between runs\n)?", + r"[ \t]*learn more: https://vitest\.dev/guide/improving-performance#caching-between-reruns(?:\n|$)", + )) + .unwrap() +}); // Only v-prefixed versions are masked: tool and runtime banners all print // that form (`vite v7.3.2`, `vp v0.2.2`, `Node.js v24.18.0`) and churn on // every dep bump, while bare semver literals (`app-1.0.0.tgz`, @@ -529,6 +540,7 @@ pub fn redact_output( output = YARN_ELAPSED_RE.replace_all(&output, "${1}").into_owned(); output = DURATION_RE.replace_all(&output, "").into_owned(); output = VITEST_TIMING_RE.replace_all(&output, "$1 ()").into_owned(); + output = VITEST_TRANSFORM_CACHE_HINT_RE.replace_all(&output, "").into_owned(); // Redact semver-shaped versions (bundled tool versions, Node versions). output = VERSION_RE.replace_all(&output, "").into_owned(); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index 2ef9a82104..a4d77239fa 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -78,6 +78,50 @@ fn masks_vitest_v5_timing_but_preserves_coverage_percentages() { ); } +#[test] +fn omits_optional_vitest_transform_cache_hint() { + let summary = " Tests 1 passed (1)\n Duration 3.4s (transform 84%, tests 16%)\n"; + for project in ["", "[unit] "] { + for ci_hint in [ + "", + " on CI this only helps when the cache directory is persisted between runs\n", + ] { + let hint = format!( + "\n Transform {project}transforming modules took 2.8s · 84% of tracked time, re-done on every run\n\ + {}persist transforms across runs with fsModuleCache: true\n\ + {ci_hint}{}learn more: https://vitest.dev/guide/improving-performance#caching-between-reruns\n", + " ", " " + ); + let expected = redact_output(summary.to_owned(), &[], true); + assert_eq!(redact_output(format!("{summary}{hint}"), &[], true), expected); + assert_eq!( + redact_output(format!("{summary}{}", hint.trim_end_matches('\n')), &[], true), + expected + ); + assert_eq!( + redact_output(format!("{summary}{hint}").replace('\n', "\r\n"), &[], true), + expected + ); + assert_eq!( + redact_output(format!("{summary}{hint}\nnext command\n"), &[], true), + format!("{expected}\nnext command\n") + ); + } + } +} + +#[test] +fn preserves_transform_errors_and_incomplete_cache_hints() { + for input in [ + "Transform failed: cannot load module\n", + "\n Transform transforming modules took 2.8s · 84% of tracked time, re-done on every run\n", + "persist transforms across runs with fsModuleCache: true\n", + "learn more: https://example.com/improving-performance\n", + ] { + assert_eq!(redact_output(input.to_owned(), &[], true), input.replace("2.8s", "")); + } +} + #[test] fn masks_vitest_api_port_but_preserves_other_localhost_urls() { let input = "API started at http://localhost:63316/\nBrowser runner started at http://localhost:63317/__vitest_test__/?sessionId=keep-session\nhttp://localhost:9229/\n"; diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index dda5fe65c1..dd2204c44b 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -5,7 +5,7 @@ use std::{path::Path, process::ExitStatus}; -use vp_pm_cli::{PackageManagerType, resolve_package_manager_version}; +use vp_pm_cli::{PackageManagerType, resolve_package_manager_version_for_cwd}; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -62,7 +62,8 @@ async fn protected_package_manager( } let config = config::load_config().await?; if let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? { - let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + let version = + resolve_package_manager_version_for_cwd(cwd, kind, &selector).await?.to_string(); push_unique_version(&mut protected, version); } Ok(protected) diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index 38c32f774c..162854fa1c 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -10,7 +10,7 @@ use std::process::ExitStatus; use vp_js_runtime::NodeProvider; -use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vp_pm_cli::{download_package_manager_for_cwd, resolve_package_manager_version_for_cwd}; use vp_shared::{PrependOptions, ToolPathEnv, env_vars}; use vt_path::AbsolutePath; @@ -153,7 +153,8 @@ async fn execute_with_version( let mut system_package_manager = None; let selected_package_manager = if let Some(package_manager) = package_manager { let (kind, selector, hash) = parse_package_manager_spec_with_hash(package_manager)?; - let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + let version = + resolve_package_manager_version_for_cwd(cwd, kind, &selector).await?.to_string(); Some((kind, version, hash)) } else { let selected = package_manager_resolution::resolve_current_spec(cwd).await?; @@ -206,7 +207,7 @@ async fn execute_with_version( Some(format!("{kind}@{system_version}")) } else { let (install_dir, _, _) = - download_package_manager(kind, &version, hash.as_deref()).await?; + download_package_manager_for_cwd(cwd, kind, &version, hash.as_deref()).await?; path_prefixes .insert(0, (install_dir.join("bin").into_path_buf(), kind.bin_names().to_vec())); Some(format!("{kind}@{version}")) diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index af7d29b69e..e0b91eef7b 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -1,6 +1,6 @@ use std::process::ExitStatus; -use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vp_pm_cli::{download_package_manager_for_cwd, resolve_package_manager_version_for_cwd}; use vt_path::AbsolutePathBuf; use super::{ @@ -64,7 +64,7 @@ pub(crate) async fn install( if scope.includes_package_managers() { let requested = if let Some((kind, selector, hash)) = specs.package_manager { - let version = resolve_package_manager_version(kind, &selector).await?; + let version = resolve_package_manager_version_for_cwd(&cwd, kind, &selector).await?; Some((kind, version, hash)) } else if let EnvScope::PackageManager(kind) = scope { let resolution = package_manager::resolve_current_or_fallback_for(&cwd, kind).await?; @@ -84,7 +84,7 @@ pub(crate) async fn install( }; if let Some((kind, version, hash)) = requested { vp_shared::output::print_stdout_line(format_args!("Installing {kind} v{version}...")); - download_package_manager(kind, &version, hash.as_deref()).await?; + download_package_manager_for_cwd(&cwd, kind, &version, hash.as_deref()).await?; vp_shared::output::print_stdout_line(format_args!("Installed {kind} v{version}")); } } diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index fd27d29d26..0a3aa3949f 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -6,7 +6,7 @@ use console::style; use futures::future::try_join_all; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; -use vp_pm_cli::{fetch_package_manager_versions, resolve_package_manager_version}; +use vp_pm_cli::{fetch_package_manager_versions_for_cwd, resolve_package_manager_version_for_cwd}; use vt_path::AbsolutePathBuf; use super::{ @@ -78,11 +78,15 @@ pub async fn execute( Ok(None) } }; + let cwd_ref = &cwd; let package_manager_future = try_join_all(package_manager_types.iter().copied().map(|kind| async move { - fetch_package_manager_versions(kind).await.map(|versions| (kind, versions)).map_err( - |error| Error::Other(format!("failed to fetch {kind} versions: {error}").into()), - ) + fetch_package_manager_versions_for_cwd(cwd_ref, kind) + .await + .map(|versions| (kind, versions)) + .map_err(|error| { + Error::Other(format!("failed to fetch {kind} versions: {error}").into()) + }) })); let (node_versions, package_manager_versions) = futures::join!(node_future, package_manager_future); @@ -118,7 +122,7 @@ pub async fn execute( else { continue; }; - let version = resolve_package_manager_version(kind, &selector).await?; + let version = resolve_package_manager_version_for_cwd(&cwd, kind, &selector).await?; default_package_manager_versions.insert(kind.to_string(), version.to_string()); } } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 876ddb46b4..e59cf4a9bf 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -190,7 +190,8 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result package_manager::resolve_current_for(&cwd, None).await?, }; if let Some(resolution) = resolution { - let (install_dir, _, _) = vp_pm_cli::download_package_manager( + let (install_dir, _, _) = vp_pm_cli::download_package_manager_for_cwd( + &cwd, resolution.package_manager_type, &resolution.version, resolution.hash.as_deref(), diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index ae7d858c2b..96aa878118 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -1,6 +1,6 @@ use vp_pm_cli::{ EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, - resolve_environment_package_manager_spec, resolve_package_manager_version, + resolve_environment_package_manager_spec, resolve_package_manager_version_for_cwd, }; use vt_path::AbsolutePath; @@ -36,7 +36,7 @@ pub(crate) async fn resolve_shim_for( .as_ref() .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), )?; - let mut resolution = resolve_selection(resolution, Some(expected)).await?; + let mut resolution = resolve_selection(cwd, resolution, Some(expected)).await?; if override_spec.is_some() && let Some(resolution) = &mut resolution { @@ -74,7 +74,7 @@ pub(crate) async fn resolve_current_for( expected: Option, ) -> Result, Error> { let resolution = resolve_current_spec(cwd).await?; - resolve_selection(resolution, expected).await + resolve_selection(cwd, resolution, expected).await } pub(crate) async fn resolve_current_or_fallback_for( @@ -85,7 +85,7 @@ pub(crate) async fn resolve_current_or_fallback_for( return Ok(resolution); } - registry_fallback_for(package_manager).await + registry_fallback_for(cwd, package_manager).await } pub(crate) async fn resolve_current_spec( @@ -115,6 +115,7 @@ pub(crate) type PackageManagerSpec = (PackageManagerType, String, Option /// Both entry points select a family before looking up its default version. async fn resolve_selection( + cwd: &AbsolutePath, resolution: Option, expected: Option, ) -> Result, Error> { @@ -123,6 +124,7 @@ async fn resolve_selection( let config = config::load_config().await?; let default = kind.map(|kind| configured_default_for(&config, kind)).transpose()?.flatten(); resolve_environment_package_manager( + cwd, resolution, default.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), expected, @@ -148,7 +150,7 @@ pub(crate) async fn resolve_from_files_for( expected: Option, ) -> Result, Error> { let resolution = resolve_environment_package_manager_spec(cwd, None)?; - resolve_selection(resolution, expected).await + resolve_selection(cwd, resolution, expected).await } pub(crate) async fn resolve_from_files_or_fallback_for( @@ -159,15 +161,16 @@ pub(crate) async fn resolve_from_files_or_fallback_for( return Ok(resolution); } - registry_fallback_for(package_manager).await + registry_fallback_for(cwd, package_manager).await } async fn registry_fallback_for( + cwd: &AbsolutePath, package_manager: PackageManagerType, ) -> Result { Ok(EnvironmentPackageManagerResolution { package_manager_type: package_manager, - version: resolve_package_manager_version(package_manager, "latest").await?, + version: resolve_package_manager_version_for_cwd(cwd, package_manager, "latest").await?, hash: None, source: "registry fallback".into(), source_path: None, diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 8afed249a7..f5998f7d14 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -11,8 +11,8 @@ use std::{io::Write, process::ExitStatus}; use vp_js_runtime::{NodeProvider, VersionSource, resolve_node_version}; use vp_pm_cli::{ - PackageManagerType, download_package_manager, resolve_package_manager_from_package_json, - resolve_package_manager_version, + PackageManagerType, download_package_manager_for_cwd, + resolve_package_manager_from_package_json, resolve_package_manager_version_for_cwd, }; use vp_shared::output; use vt_path::AbsolutePathBuf; @@ -845,7 +845,7 @@ async fn pin_package_manager( if matches!(target, Some(PinTarget::NodeVersion | PinTarget::Nvmrc)) { return Err(Error::Other("Node.js file targets cannot pin a package manager".into())); } - let resolved = resolve_package_manager_version(package_manager, version).await?; + let resolved = resolve_package_manager_version_for_cwd(cwd, package_manager, version).await?; package_manager::warn_if_target_differs(cwd, package_manager).await; let package_json_path = cwd.join(PACKAGE_JSON_FILE); let content = tokio::fs::read_to_string(&package_json_path).await?; @@ -912,7 +912,9 @@ async fn pin_package_manager( } if no_install { output::note("Package manager will be downloaded on first use."); - } else if let Err(error) = download_package_manager(package_manager, &resolved, hash).await { + } else if let Err(error) = + download_package_manager_for_cwd(cwd, package_manager, &resolved, hash).await + { output::warn(&format!("Failed to download {package_manager} {resolved}: {error}")); } Ok(ExitStatus::default()) diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 6200611fa9..7f3d31e381 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -10,7 +10,9 @@ use std::process::ExitStatus; -use vp_pm_cli::{PackageManagerType, download_package_manager, resolve_package_manager_version}; +use vp_pm_cli::{ + PackageManagerType, download_package_manager_for_cwd, resolve_package_manager_version_for_cwd, +}; use vt_path::AbsolutePathBuf; use super::{ @@ -147,7 +149,8 @@ pub async fn execute( let package_manager = if scope.includes_package_managers() { if let Some((kind, selector, hash)) = specs.package_manager { - let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + let version = + resolve_package_manager_version_for_cwd(&cwd, kind, &selector).await?.to_string(); package_manager::warn_if_target_differs(&cwd, kind).await; Some((kind, version, selector, hash)) } else if let EnvScope::PackageManager(kind) = scope { @@ -233,7 +236,7 @@ pub async fn execute( } if !no_install { - ensure_components_installed(&node, &package_manager).await?; + ensure_components_installed(&cwd, &node, &package_manager).await?; } if has_eval_wrapper() { @@ -295,6 +298,7 @@ pub async fn execute( } async fn ensure_components_installed( + cwd: &AbsolutePathBuf, node: &Option<(String, String)>, package_manager: &Option<(PackageManagerType, String, String, Option)>, ) -> Result<(), Error> { @@ -318,7 +322,7 @@ async fn ensure_components_installed( } } if let Some((kind, version, _, hash)) = package_manager { - download_package_manager(*kind, version, hash.as_deref()).await?; + download_package_manager_for_cwd(cwd, *kind, version, hash.as_deref()).await?; } Ok(()) } diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 9bc8407cd2..025974127d 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -12,7 +12,7 @@ use chrono::Local; use console::style; use vp_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_version, + resolve_package_manager_version_for_cwd, }; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -170,7 +170,9 @@ async fn execute_package_manager_tool( ), None if expected_type == PackageManagerType::Npm => return Ok(None), None => ( - resolve_package_manager_version(expected_type, "latest").await?.to_string(), + resolve_package_manager_version_for_cwd(cwd, expected_type, "latest") + .await? + .to_string(), "registry fallback".into(), ), }; diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 2ab91e9339..d2e5b743d2 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -153,7 +153,8 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu } } if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { - let (install_dir, _, _) = vp_pm_cli::download_package_manager( + let (install_dir, _, _) = vp_pm_cli::download_package_manager_for_cwd( + project_path, package_manager.package_manager_type, &package_manager.version, package_manager.hash.as_deref(), diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 28ebfb5cc3..2ccd406c16 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -1,42 +1,937 @@ +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; -/// Get the configured NPM registry URL. -#[must_use] -pub fn npm_registry() -> String { - EnvConfig::get().npm_registry.clone() +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, } -/// 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 filename = name.split('/').next_back().unwrap_or(name); - vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") +enum NpmAuth<'a> { + Token(&'a str), + Encoded(&'a str), + UsernamePassword { username: &'a str, password: Vec }, } -#[must_use] -pub(crate) fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> vt_str::Str { - let registry = npm_registry(); - vt_str::format!("{registry}/{name}/{version_or_tag}") +impl NpmConfig { + pub(crate) fn load() -> Self { + vt_path::current_dir() + .ok() + .map_or_else(|| Self::load_for_project(None), |cwd| Self::load_for_cwd(&cwd)) + } + + pub(crate) fn load_for_cwd(cwd: &AbsolutePath) -> Self { + let project_root = find_workspace_root(cwd).map_or_else( + |_| cwd.as_path().to_path_buf(), + |(root, _)| root.path.as_path().to_path_buf(), + ); + // Workspace root locates project npmrc; the caller's cwd resolves relative file paths. + Self::load_for_project_with_cwd(Some(project_root), Some(cwd)) + } + + fn load_for_project(project_root: Option) -> Self { + Self::load_for_project_with_cwd(project_root, None) + } + + fn load_for_project_with_cwd( + project_root: Option, + cwd: Option<&AbsolutePath>, + ) -> Self { + let mut project_values = HashMap::new(); + if let Some(root) = project_root { + load_npmrc(root.join(".npmrc"), &mut project_values); + } + let user_config = env_value("userconfig") + .map(|path| expand_value(&path)) + .or_else(|| project_values.get("userconfig").cloned()) + .map(|path| config_path(&path, cwd)) + .unwrap_or_else(|| EnvConfig::get().user_home.join(".npmrc").into_path_buf()); + let mut user_values = HashMap::new(); + load_npmrc(user_config, &mut user_values); + + // Higher-priority layers select config files; merge their values from low to high. + // Default global paths require npm, so only explicitly selected files are loaded. + let global_config = env_value("globalconfig").map(|path| expand_value(&path)); + let mut values = HashMap::new(); + if let Some(path) = global_config + .as_deref() + .or_else(|| project_values.get("globalconfig").map(String::as_str)) + .or_else(|| user_values.get("globalconfig").map(String::as_str)) + .filter(|path| !path.is_empty()) + { + load_npmrc(config_path(path, cwd), &mut values); + } + values.extend(user_values); + values.extend(project_values); + + // npm_config_* is the highest-precedence npm config source available to vp. + for (key, value) in npm_config_env() { + let raw_key = &key["npm_config_".len()..]; + if value.is_empty() { + continue; + } + // npm preserves registry-scoped ("nerf-darted") keys verbatim. + let key = if raw_key.starts_with("//") { + normalize_key(&expand_value(raw_key)) + } else { + raw_key.cow_replace('_', "-").cow_to_ascii_lowercase().into_owned() + }; + values.insert(key, expand_value(&value)); + } + // Keep the existing EnvConfig precedence when both spellings are set. + if let Some(registry) = env_value("registry") { + values.insert("registry".to_string(), expand_value(®istry)); + } + Self { values } + } + + 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())) + .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 { + 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, + } + } + + /// A same-origin redirect may reach a more specific authenticated path. + /// Such responses must not use the registry-wide latest-version cache. + pub(crate) fn has_auth_for_origin(&self, url: &str) -> bool { + let Ok(url) = Url::parse(url) else { return false }; + let Some(host) = url.host_str() else { return false }; + let authority = url + .port() + .map_or_else(|| host.to_string(), |port| vt_str::format!("{host}:{port}").to_string()); + let prefix = vt_str::format!("//{}", authority.cow_to_ascii_lowercase()); + self.values.iter().any(|(key, value)| { + key.strip_prefix(prefix.as_str()).is_some_and(|rest| { + (rest.starts_with('/') || rest.starts_with(':')) + && [":_authtoken", ":_auth", ":username", ":_password"] + .iter() + .any(|suffix| rest.ends_with(suffix)) + && !value.is_empty() + }) + }) + } + + 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()); + // Walk the original pathname like npm-registry-fetch. Removing empty + // segments or adding a trailing slash would broaden the credential scope. + let mut prefix = + vt_str::format!("//{}{}", authority.cow_to_ascii_lowercase(), url.path()).to_string(); + while prefix.len() > 2 { + if let Some(token) = self.values.get(vt_str::format!("{prefix}:_authtoken").as_str()) + && !token.is_empty() + { + return Some(NpmAuth::Token(token)); + } + if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) + && !auth.is_empty() + { + 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()); + if let (Some(username), Some(password)) = (username, password) + && !username.is_empty() + && !password.is_empty() + && let Ok(decoded) = base64_simd::STANDARD + .decode_to_vec(password) + .or_else(|_| base64_simd::STANDARD_NO_PAD.decode_to_vec(password)) + { + return Some(NpmAuth::UsernamePassword { username, password: decoded }); + } + // Remove either the final slash or the final non-slash segment. + // This checks both directory and non-directory keys without changing + // the request's path or collapsing repeated slashes. + if prefix.ends_with('/') { + prefix.pop(); + } else if let Some(slash) = prefix.rfind('/') { + prefix.truncate(slash + 1); + } + } + None + } +} + +fn env_value(name: &str) -> Option { + let lower = vt_str::format!("npm_config_{name}"); + let upper = vt_str::format!("NPM_CONFIG_{}", name.cow_to_ascii_uppercase()); + [lower.as_str(), upper.as_str()] + .into_iter() + .find_map(|key| env::var(key).ok().filter(|value| !value.is_empty())) + .or_else(|| { + 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)) + }) +} + +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() +} + +// Config sources expand values once; preserve escaped variables in file paths. +fn config_path(path: &str, cwd: Option<&AbsolutePath>) -> PathBuf { + let relative = path + .strip_prefix("~/") + .or_else(|| if cfg!(windows) { path.strip_prefix("~\\") } else { None }); + relative.map_or_else( + || { + let path = PathBuf::from(path); + if let Some(cwd) = cwd.filter(|_| path.is_relative()) { + return cwd.as_path().join(path); + } + path + }, + |relative| EnvConfig::get().user_home.join(relative).into_path_buf(), + ) } -/// Get the metadata url of a npm package (lists all published versions) +// INI quoting is handled by parse_npmrc_value; expansion must preserve literal quotes. +fn expand_value(raw: &str) -> String { + let value = raw.trim(); + let mut expanded = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find("${") { + let Some(end) = rest[start + 2..].find('}') else { + break; + }; + let expression = &rest[start + 2..start + 2 + end]; + let (name, empty_if_missing) = + expression.strip_suffix('?').map_or((expression, false), |name| (name, true)); + if name.is_empty() || name.contains(['$', '{', '?']) { + expanded.push_str(&rest[..start + 2]); + rest = &rest[start + 2..]; + continue; + } + let slashes = rest[..start].bytes().rev().take_while(|byte| *byte == b'\\').count(); + expanded.push_str(&rest[..start - slashes]); + for _ in 0..slashes / 2 { + expanded.push('\\'); + } + if slashes % 2 == 1 { + expanded.push_str(&rest[start..start + 3 + end]); + } else { + 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.trim_start_matches('\u{feff}').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 }; + // Expand before normalizing so case-sensitive environment names survive. + let key = normalize_key(&expand_value(&parse_npmrc_value(key))); + if !key.is_empty() { + values.insert(key, expand_value(&parse_npmrc_value(value))); + } + } +} + +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(); + } + + 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 NPM registry URL. #[must_use] -pub(crate) fn get_npm_package_metadata_url(name: &str) -> vt_str::Str { - let registry = npm_registry(); - vt_str::format!("{registry}/{name}") +pub fn npm_registry() -> String { + EnvConfig::get().npm_registry.clone() } #[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() + } + + // Keep developer registry overrides and npmrc files out of fixture-based tests. + fn with_isolated_npm_config(f: impl FnOnce() -> R) -> R { + let directory = TempDir::new().unwrap(); + let empty_config = directory.path().join("empty.npmrc"); + fs::write(&empty_config, "").unwrap(); + EnvConfig::with_vars( + [ + (env_vars::NPM_CONFIG_REGISTRY, None), + (env_vars::NPM_CONFIG_REGISTRY_UPPER, None), + ("npm_config_userconfig", Some(empty_config.as_os_str())), + ("NPM_CONFIG_USERCONFIG", Some(empty_config.as_os_str())), + ("npm_config_globalconfig", Some(empty_config.as_os_str())), + ("NPM_CONFIG_GLOBALCONFIG", Some(empty_config.as_os_str())), + ], + |_| f(), + ) + } + + #[test] + fn project_userconfig_selects_user_and_global_files() { + with_isolated_npm_config(|| { + let home = TempDir::new().unwrap(); + fs::write(home.path().join(".npmrc"), "//registry.example/:_authToken=DEFAULT_USER\n") + .unwrap(); + fs::write(home.path().join("user.npmrc"), "globalconfig=./global.npmrc\n").unwrap(); + let project = project_with_npmrc(""); + fs::write(project.path().join("pnpm-workspace.yaml"), "packages:\n - packages/*\n") + .unwrap(); + let child = project.path().join("packages/child"); + fs::create_dir_all(&child).unwrap(); + fs::write(child.join("package.json"), "{}").unwrap(); + fs::write(child.join("user.npmrc"), "globalconfig=./global.npmrc\n").unwrap(); + fs::write(child.join("global.npmrc"), "//registry.example/:_authToken=GLOBAL\n") + .unwrap(); + fs::write(child.join("env-user.npmrc"), "//registry.example/:_authToken=ENV_USER\n") + .unwrap(); + let selected_user = "${TEST_CONFIG_HOME}/user.npmrc"; + for (project_selector, selector, expected) in [ + (selected_user, None, Some("Bearer GLOBAL")), + ("./user.npmrc", None, Some("Bearer GLOBAL")), + (selected_user, Some("./env-user.npmrc"), Some("Bearer ENV_USER")), + (selected_user, Some(""), Some("Bearer GLOBAL")), + (selected_user, Some("./missing.npmrc"), None), + ("./missing.npmrc", None, None), + ("", None, None), + ] { + fs::write( + project.path().join(".npmrc"), + vt_str::format!( + "userconfig={project_selector}\nregistry=https://registry.example/\n" + ) + .as_str(), + ) + .unwrap(); + EnvConfig::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + ("USERPROFILE", Some(home.path().as_os_str())), + ("TEST_CONFIG_HOME", Some(home.path().as_os_str())), + ("npm_config_userconfig", selector.map(std::ffi::OsStr::new)), + ("NPM_CONFIG_USERCONFIG", selector.map(std::ffi::OsStr::new)), + ("npm_config_globalconfig", None), + ("NPM_CONFIG_GLOBALCONFIG", None), + ], + |_| { + let config = NpmConfig::load_for_cwd(AbsolutePath::new(&child).unwrap()); + assert_eq!(config.registry_for_package("pnpm"), "https://registry.example"); + let url = config.package_version_url("pnpm", "latest"); + let request = config + .apply_auth(http_client().get(url.as_str()), &url) + .build() + .unwrap(); + assert_eq!( + request + .headers() + .get(reqwest::header::AUTHORIZATION) + .map(|v| v.to_str().unwrap()), + expected, + "userconfig={selector:?}" + ); + }, + ); + } + }); + } + + #[test] + fn global_file_selection_prefers_environment_then_project_then_user() { + with_isolated_npm_config(|| { + let project = project_with_npmrc(""); + let user = project.path().join("user.npmrc"); + fs::write(&user, "globalconfig=./user-global.npmrc\n").unwrap(); + for name in ["user", "project", "env"] { + fs::write( + project.path().join(vt_str::format!("{name}-global.npmrc").as_str()), + vt_str::format!( + "registry=https://{name}.example/\n//registry.example/:_authToken={name}\n" + ) + .as_str(), + ) + .unwrap(); + } + for (project_selector, env_selector, expected) in [ + (None, None, Some("user")), + (Some("./project-global.npmrc"), None, Some("project")), + (Some("./project-global.npmrc"), Some("./env-global.npmrc"), Some("env")), + (Some("./project-global.npmrc"), Some(""), Some("project")), + (Some("./project-global.npmrc"), Some("./missing.npmrc"), None), + (Some("./missing.npmrc"), None, None), + (Some(""), None, None), + ] { + fs::write( + project.path().join(".npmrc"), + project_selector.map_or_else(String::new, |path| { + vt_str::format!("globalconfig={path}\n").to_string() + }), + ) + .unwrap(); + EnvConfig::with_vars( + [ + ("npm_config_userconfig", Some(user.as_os_str())), + ("NPM_CONFIG_USERCONFIG", Some(user.as_os_str())), + ("npm_config_globalconfig", env_selector.map(std::ffi::OsStr::new)), + ("NPM_CONFIG_GLOBALCONFIG", env_selector.map(std::ffi::OsStr::new)), + ], + |_| { + let config = + NpmConfig::load_for_cwd(AbsolutePath::new(project.path()).unwrap()); + let registry = expected.map_or_else( + || DEFAULT_NPM_REGISTRY.to_string(), + |name| vt_str::format!("https://{name}.example").to_string(), + ); + assert_eq!(config.registry_for_package("pnpm"), registry); + let url = "https://registry.example/pkg"; + let request = + config.apply_auth(http_client().get(url), url).build().unwrap(); + let token = + expected.map(|name| vt_str::format!("Bearer {name}").to_string()); + assert_eq!( + request + .headers() + .get(reqwest::header::AUTHORIZATION) + .map(|v| v.to_str().unwrap()), + token.as_deref() + ); + }, + ); + } + }); + } + + #[test] + fn relative_config_paths_use_caller_cwd_without_changing_workspace_root() { + with_isolated_npm_config(|| { + let project = project_with_npmrc("registry=https://target.example/\n"); + fs::write(project.path().join("pnpm-workspace.yaml"), "packages:\n - packages/*\n") + .unwrap(); + let child = project.path().join("packages/child"); + fs::create_dir_all(&child).unwrap(); + fs::write(child.join("package.json"), "{}").unwrap(); + fs::write(child.join("user.npmrc"), "globalconfig=./global.npmrc\n").unwrap(); + for (directory, token) in [(child.as_path(), "TARGET"), (project.path(), "WRONG")] { + fs::write( + directory.join("global.npmrc"), + vt_str::format!("//target.example/:_authToken={token}\n").as_str(), + ) + .unwrap(); + } + fs::write(project.path().join("user.npmrc"), "//target.example/:_authToken=WRONG\n") + .unwrap(); + let user = child.join("user.npmrc"); + let cwd = AbsolutePath::new(&child).unwrap(); + assert_ne!(vt_path::current_dir().unwrap().as_path(), child); + for (user_selector, global_selector) in [ + (std::ffi::OsStr::new("./user.npmrc"), None), + (user.as_os_str(), Some(std::ffi::OsStr::new("./global.npmrc"))), + ] { + EnvConfig::with_vars( + [ + ("npm_config_userconfig", Some(user_selector)), + ("NPM_CONFIG_USERCONFIG", Some(user_selector)), + ("npm_config_globalconfig", global_selector), + ("NPM_CONFIG_GLOBALCONFIG", global_selector), + ], + |_| { + let config = NpmConfig::load_for_cwd(cwd); + assert_eq!(config.registry_for_package("pnpm"), "https://target.example"); + let url = config.package_version_url("pnpm", "latest"); + let request = config + .apply_auth(http_client().get(url.as_str()), &url) + .build() + .unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + "Bearer TARGET" + ); + }, + ); + } + }); + } + + #[test] + fn loads_global_config_selected_by_user_npmrc() { + with_isolated_npm_config(|| { + let home = TempDir::new().unwrap(); + let project = project_with_npmrc("registry=https://registry.example/\n"); + for (selector, filename) in [ + ("${TEST_CONFIG_HOME}/global.npmrc", "global.npmrc"), + ("~/global.npmrc", "global.npmrc"), + (r"${TEST_CONFIG_HOME}/\${TEST_GLOBAL_NAME}.npmrc", "${TEST_GLOBAL_NAME}.npmrc"), + ] { + fs::write( + home.path().join(".npmrc"), + vt_str::format!("globalconfig={selector}\n").as_str(), + ) + .unwrap(); + fs::write( + home.path().join(filename), + "registry=https://global.example/\n//registry.example/:_authToken=GLOBAL\n", + ) + .unwrap(); + EnvConfig::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + ("USERPROFILE", Some(home.path().as_os_str())), + ("TEST_CONFIG_HOME", Some(home.path().as_os_str())), + ("TEST_GLOBAL_NAME", Some(std::ffi::OsStr::new("missing"))), + ("npm_config_userconfig", None), + ("NPM_CONFIG_USERCONFIG", None), + ("npm_config_globalconfig", None), + ("NPM_CONFIG_GLOBALCONFIG", None), + ], + |_| { + let config = + NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package("pnpm"), "https://registry.example"); + let url = config.package_version_url("pnpm", "latest"); + let request = config + .apply_auth(http_client().get(url.as_str()), &url) + .build() + .unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + "Bearer GLOBAL", + "{selector}" + ); + }, + ); + } + }); + } + + #[test] + fn selected_global_config_stays_below_user_project_and_environment() { + with_isolated_npm_config(|| { + let files = TempDir::new().unwrap(); + let global = files.path().join("global.npmrc"); + let user = files.path().join("user.npmrc"); + fs::write( + &global, + "registry=https://global.example/\n//registry.example/:_authToken=GLOBAL\n", + ) + .unwrap(); + fs::write(&user, vt_str::format!("globalconfig={}\nregistry=https://user.example/\n//registry.example/:_authToken=USER\n", global.display()).as_str()).unwrap(); + let project = project_with_npmrc( + "registry=https://project.example/\n//registry.example/:_authToken=PROJECT\n", + ); + EnvConfig::with_vars( + [ + ("npm_config_userconfig", Some(user.as_os_str())), + ("NPM_CONFIG_USERCONFIG", Some(user.as_os_str())), + ("npm_config_globalconfig", None), + ("NPM_CONFIG_GLOBALCONFIG", None), + ], + |_| { + for (root, registry, token) in [ + (None, "https://user.example", "USER"), + (Some(project.path().to_path_buf()), "https://project.example", "PROJECT"), + ] { + let config = NpmConfig::load_for_project(root); + assert_eq!(config.registry_for_package("pnpm"), registry); + let url = "https://registry.example/pkg"; + let request = + config.apply_auth(http_client().get(url), url).build().unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + vt_str::format!("Bearer {token}").as_str() + ); + } + EnvConfig::with_vars( + [ + (env_vars::NPM_CONFIG_REGISTRY, "https://env.example"), + ("npm_config_//registry.example/:_authToken", "ENV"), + ], + |_| { + let config = + NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package("pnpm"), "https://env.example"); + let url = "https://registry.example/pkg"; + let request = + config.apply_auth(http_client().get(url), url).build().unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + "Bearer ENV" + ); + }, + ); + }, + ); + }); + } + + #[test] + fn environment_globalconfig_selects_file_before_user_setting() { + with_isolated_npm_config(|| { + let files = TempDir::new().unwrap(); + let user_global = files.path().join("user-global.npmrc"); + let env_global = files.path().join("env-global.npmrc"); + let user = files.path().join("user.npmrc"); + fs::write(&user_global, "//registry.example/:_authToken=USER_GLOBAL\n").unwrap(); + fs::write(&env_global, "//registry.example/:_authToken=ENV_GLOBAL\n").unwrap(); + fs::write(&user, vt_str::format!("globalconfig={}\n", user_global.display()).as_str()) + .unwrap(); + for (selector, expected) in [ + ("${TEST_CONFIG_HOME}/env-global.npmrc", Some("Bearer ENV_GLOBAL")), + ("${TEST_CONFIG_HOME}/missing.npmrc", None), + ("", Some("Bearer USER_GLOBAL")), + ] { + EnvConfig::with_vars( + [ + ("npm_config_userconfig", user.as_os_str()), + ("NPM_CONFIG_USERCONFIG", user.as_os_str()), + ("TEST_CONFIG_HOME", files.path().as_os_str()), + ("npm_config_globalconfig", std::ffi::OsStr::new(selector)), + ("NPM_CONFIG_GLOBALCONFIG", std::ffi::OsStr::new(selector)), + ], + |_| { + let config = NpmConfig::load_for_project(None); + let url = "https://registry.example/pkg"; + let request = + config.apply_auth(http_client().get(url), url).build().unwrap(); + assert_eq!( + request + .headers() + .get(reqwest::header::AUTHORIZATION) + .map(|value| value.to_str().unwrap()), + expected, + "{selector}" + ); + }, + ); + } + }); + } + + #[test] + fn expands_user_and_global_config_paths() { + with_isolated_npm_config(|| { + let home = TempDir::new().unwrap(); + fs::write(home.path().join("user.npmrc"), "//registry.example/:_authToken=USER\n") + .unwrap(); + fs::write(home.path().join("global.npmrc"), "registry=https://registry.example/\n") + .unwrap(); + let paths = [ + ("~/user.npmrc", "${TEST_CONFIG_HOME}/global.npmrc"), + ("${TEST_CONFIG_HOME}/user.npmrc", "~/global.npmrc"), + #[cfg(windows)] + ("~\\user.npmrc", "~\\global.npmrc"), + ]; + for (user_config, global_config) in paths { + EnvConfig::with_vars( + [ + ("HOME", home.path().to_str().unwrap()), + ("USERPROFILE", home.path().to_str().unwrap()), + ("TEST_CONFIG_HOME", home.path().to_str().unwrap()), + ("npm_config_userconfig", user_config), + ("NPM_CONFIG_USERCONFIG", user_config), + ("npm_config_globalconfig", global_config), + ("NPM_CONFIG_GLOBALCONFIG", global_config), + ], + |_| { + let config = NpmConfig::load_for_project(None); + assert_eq!(config.registry_for_package("pnpm"), "https://registry.example"); + let url = config.package_version_url("pnpm", "latest"); + let request = config + .apply_auth(http_client().get(url.as_str()), &url) + .build() + .unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + "Bearer USER" + ); + }, + ); + } + }); + } + + #[test] + fn reads_bom_prefixed_registry_and_credentials() { + with_isolated_npm_config(|| { + for contents in [ + "\u{feff}registry=https://registry.example/\n//registry.example/:_authToken=TOKEN\n", + "\u{feff}//registry.example/:_authToken=TOKEN\nregistry=https://registry.example/\n", + ] { + let project = project_with_npmrc(contents); + EnvConfig::with_vars(std::iter::empty::<(&str, &str)>(), |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package("pnpm"), "https://registry.example"); + let url = config.package_version_url("pnpm", "latest"); + let request = + config.apply_auth(http_client().get(url.as_str()), &url).build().unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer TOKEN"); + }); + } + }); + } + + #[test] + fn expands_environment_credential_keys_and_values() { + with_isolated_npm_config(|| { + for (key, value) in [ + ("npm_config_//${TEST_AUTH_HOST}/Team/:_authToken", "TOKEN"), + ("npm_config_//registry.example/Team/:_authToken", "${TEST_AUTH_TOKEN}"), + ("npm_config_//${TEST_AUTH_HOST}/Team/:_authToken", "${TEST_AUTH_TOKEN}"), + ] { + EnvConfig::with_vars( + [ + ("TEST_AUTH_HOST", "Registry.Example"), + ("TEST_AUTH_TOKEN", "TOKEN"), + (key, value), + ], + |_| { + let config = NpmConfig::load_for_project(None); + let url = "https://registry.example/Team/pkg"; + let request = + config.apply_auth(http_client().get(url), url).build().unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + "Bearer TOKEN", + "{key}={value}" + ); + let other = "https://registry.example/team/pkg"; + assert!( + !config + .apply_auth(http_client().get(other), other) + .build() + .unwrap() + .headers() + .contains_key(reqwest::header::AUTHORIZATION) + ); + }, + ); + } + }); + } + + #[test] + fn preserves_escaped_variables_and_expands_even_backslashes() { + with_isolated_npm_config(|| { + EnvConfig::with_vars( + [ + ("TEST_EXPANSION", Some("TOKEN")), + ("TEST_MISSING", None), + // These process environment names must not make invalid npm expressions expand. + ("TEST?EXPANSION", Some("TOKEN")), + ("TEST{EXPANSION", Some("TOKEN")), + ("$TEST_EXPANSION", Some("TOKEN")), + ], + |_| { + for (input, expected) in [ + (r"${TEST_EXPANSION}", "TOKEN"), + (r"\${TEST_EXPANSION}", "${TEST_EXPANSION}"), + (r"\\${TEST_EXPANSION}", r"\TOKEN"), + (r"\\\${TEST_EXPANSION}", r"\${TEST_EXPANSION}"), + (r"${TEST_MISSING}", "${TEST_MISSING}"), + (r"${TEST_MISSING?}", ""), + (r"\${TEST_MISSING?}", "${TEST_MISSING?}"), + (r"${TEST_EXPANSION", "${TEST_EXPANSION"), + (r"${}", "${}"), + (r"${?}", "${?}"), + (r"${TEST?EXPANSION}", "${TEST?EXPANSION}"), + (r"${TEST{EXPANSION}", "${TEST{EXPANSION}"), + (r"${$TEST_EXPANSION}", "${$TEST_EXPANSION}"), + ] { + assert_eq!(expand_value(input), expected, "{input}"); + } + let project = project_with_npmrc( + "//registry.example/:_authToken=literal\\${TEST_EXPANSION}\n", + ); + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + let url = "https://registry.example/pkg"; + let request = config.apply_auth(http_client().get(url), url).build().unwrap(); + assert_eq!( + request.headers()[reqwest::header::AUTHORIZATION], + "Bearer literal${TEST_EXPANSION}" + ); + }, + ); + }); + } + + #[test] + fn preserves_literal_quotes_in_basic_username() { + with_isolated_npm_config(|| { + let project = project_with_npmrc( + "//registry.example/:username=\"\\\"user\\\"\"\n//registry.example/:_password=cw==\n", + ); + EnvConfig::with_vars(std::iter::empty::<(&str, &str)>(), |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + let url = "https://registry.example/pkg"; + let request = config.apply_auth(http_client().get(url), url).build().unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Basic InVzZXIiOnM="); + }); + }); + } + + #[test] + fn accepts_padded_and_unpadded_basic_passwords() { + for password in ["cw", "cw==", "c2U", "c2U=", "c2Vj"] { + let config = NpmConfig { + values: HashMap::from([ + ("//registry.example/:username".to_string(), "user".to_string()), + ("//registry.example/:_password".to_string(), password.to_string()), + ]), + }; + let url = "https://registry.example/pkg"; + let request = config.apply_auth(http_client().get(url), url).build().unwrap(); + let expected = match password { + "cw" | "cw==" => "Basic dXNlcjpz", + "c2U" | "c2U=" => "Basic dXNlcjpzZQ==", + _ => "Basic dXNlcjpzZWM=", + }; + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], expected); + } + let config = NpmConfig { + values: HashMap::from([ + ("//registry.example/:username".to_string(), "user".to_string()), + ("//registry.example/:_password".to_string(), "%%%".to_string()), + ]), + }; + let url = "https://registry.example/pkg"; + assert!( + !config + .apply_auth(http_client().get(url), url) + .build() + .unwrap() + .headers() + .contains_key(reqwest::header::AUTHORIZATION) + ); + } + #[test] fn test_npm_registry_default() { - vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { assert_eq!(npm_registry(), "https://registry.npmjs.org"); }); } @@ -53,15 +948,363 @@ mod tests { #[test] fn test_npm_tgz_url() { - vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + let config = NpmConfig { values: HashMap::new() }; + assert_eq!( + config.package_tgz_url("vite", "7.1.3"), + "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz" + ); + assert_eq!( + config.package_tgz_url("@vitejs/release-scripts", "1.6.0"), + "https://registry.npmjs.org/@vitejs/release-scripts/-/release-scripts-1.6.0.tgz" + ); + } + + #[test] + fn reads_project_registry_and_scoped_registry() { + let project = project_with_npmrc( + "registry=https://default.example/\n@yarnpkg:registry=https://yarn.example/\n", + ); + 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] + 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 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 { + 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"); + 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") + }); + } + + #[cfg(unix)] + #[test] + fn lowercase_registry_environment_variable_takes_precedence() { + EnvConfig::with_vars( + [ + (env_vars::NPM_CONFIG_REGISTRY, "https://lower.example"), + (env_vars::NPM_CONFIG_REGISTRY_UPPER, "https://upper.example"), + ], + |_| { + let config = NpmConfig::load_for_project(None); + assert_eq!(config.registry_for_package(""), "https://lower.example"); + }, + ); + } + + #[test] + 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 expands_registry_and_credential_keys_before_normalization() { + let project = project_with_npmrc( + "registry=https://${TEST_REGISTRY_HOST}/Team/\n//${TEST_REGISTRY_HOST}/Team/:_authToken=${TEST_NPM_TOKEN}\n", + ); + EnvConfig::with_vars( + [ + ("TEST_REGISTRY_HOST", Some("Registry.Example")), + ("TEST_NPM_TOKEN", Some("TEAM")), + (env_vars::NPM_CONFIG_REGISTRY, None), + (env_vars::NPM_CONFIG_REGISTRY_UPPER, None), + ], + |_| { + let config = NpmConfig::load_for_project(Some(project.path().to_path_buf())); + assert_eq!(config.registry_for_package("pnpm"), "https://Registry.Example/Team"); + let url = config.package_version_url("pnpm", "latest"); + let request = + config.apply_auth(http_client().get(url.as_str()), &url).build().unwrap(); + assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer TEAM"); + let other_url = "https://registry.example/team/pnpm/latest"; + let other = + config.apply_auth(http_client().get(other_url), other_url).build().unwrap(); + assert!(!other.headers().contains_key(reqwest::header::AUTHORIZATION)); + }, + ); + } + + #[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", + ); + // Keep other tests' registry overrides from replacing the .npmrc value. + EnvConfig::with_vars( + [ + (env_vars::NPM_CONFIG_REGISTRY, None::<&str>), + (env_vars::NPM_CONFIG_REGISTRY_UPPER, None::<&str>), + ], + |_| { + 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 { + 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() { + with_isolated_npm_config(|| { + 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!( + request.headers()[reqwest::header::AUTHORIZATION], + vt_str::format!("Bearer {token}").as_str() + ); + } + }); + } + + #[test] + fn registry_auth_preserves_exact_path_boundaries() { + let client = http_client(); + for credentials in [ + vec![("_authtoken", "SECRET".to_string())], + vec![("_auth", base64_simd::STANDARD.encode_to_string("user:secret"))], + vec![ + ("username", "user".to_string()), + ("_password", base64_simd::STANDARD.encode_to_string("secret")), + ], + ] { + let config = NpmConfig { + values: credentials + .into_iter() + .map(|(key, value)| { + ( + vt_str::format!("//registry.example/team/private/:{key}").to_string(), + value, + ) + }) + .collect(), + }; + for (path, authenticated) in [ + ("/team/private/pkg", true), + ("/team/private/", true), + ("/team/private//pkg", true), + ("/team//private/pkg", false), + ("//team/private/pkg", false), + ("/team/private", false), + ("/team/private-other/pkg", false), + ] { + let url = vt_str::format!("https://registry.example{path}"); + let request = config.apply_auth(client.get(url.as_str()), &url).build().unwrap(); + assert_eq!( + request.headers().contains_key(reqwest::header::AUTHORIZATION), + authenticated, + "unexpected authentication for {path}" + ); + assert_eq!(request.url().path(), path); + } + } + } + + #[test] + fn registry_auth_matches_credentials_with_repeated_slashes() { + let config = NpmConfig { + values: HashMap::from([ + ("//registry.example/team//private/:_authtoken".to_string(), "EXACT".to_string()), + ("//registry.example/team/:_authtoken".to_string(), "PARENT".to_string()), + ]), + }; + let client = http_client(); + for (path, token) in [ + ("/team//private/pkg", "EXACT"), + ("/team/private/pkg", "PARENT"), + ("/team//private", "PARENT"), + ] { + let url = vt_str::format!("https://registry.example{path}"); + let request = config.apply_auth(client.get(url.as_str()), &url).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() { + with_isolated_npm_config(|| { + 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/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 7ea6a97ad8..225be29721 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -9,7 +9,8 @@ use vt_path::AbsolutePath; use crate::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerType, - cli::PackageManagerCommand, download_package_manager, error::Error, resolution::run_resolution, + cli::PackageManagerCommand, download_package_manager_for_cwd, error::Error, + resolution::run_resolution, }; #[derive(Debug)] @@ -68,7 +69,7 @@ async fn dispatch_with_manager( .await? } ManagerSource::Environment(package_manager) => { - build_selected_package_manager(package_manager).await? + build_selected_package_manager(cwd, package_manager).await? } ManagerSource::Resolved(manager) => manager, }; @@ -80,9 +81,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 381be3c2b3..7d58d66fc1 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -28,10 +28,11 @@ pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, - 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, + download_package_manager_for_cwd, ensure_package_manager_bin, fetch_package_manager_versions, + fetch_package_manager_versions_for_cwd, 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, resolve_package_manager_version_for_cwd, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 655332bf7d..d716a73c35 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -12,6 +12,7 @@ use std::{ 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; @@ -22,8 +23,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, }; @@ -244,10 +245,17 @@ impl PackageManagerBuilder { return resolve_npm_from_path(&self.cwd).await; } + let npm_config = NpmConfig::load_for_cwd(&self.cwd); + // 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( + Some(&self.cwd), + package_manager_type, + &version_or_req, + hash.as_deref(), + &npm_config, + ) + .await?; Ok(PackageManager { client: package_manager_type, @@ -573,6 +581,7 @@ fn environment_package_manager_default( /// operations such as `vp env install` and package-manager shims. When `expected` is set, a /// different selected family falls back to the matching configured default before registry lookup. pub async fn resolve_environment_package_manager( + cwd: &AbsolutePath, resolution: Option, default_spec: Option<(PackageManagerType, &str, Option<&str>)>, expected: Option, @@ -593,9 +602,14 @@ 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( + Some(cwd), + resolution.package_manager_type, + &resolution.version, + &npm_config, + ) + .await?; Ok(Some(resolution)) } @@ -916,12 +930,17 @@ 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())) + // The legacy namespace contains files named after package managers. + // Keep it untouched: those versions have no registry provenance. + .join("package_manager_latest_v2") + .join(package_manager_type.to_string()) + .join(registry_key)) } fn read_latest_version_cache(path: &AbsolutePath) -> Option<(Str, bool)> { @@ -943,24 +962,42 @@ 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 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, +) -> 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 { - Ok(package_json) => { - let _ = write_latest_version_cache(&cache_path, &package_json.version); - Ok(package_json.version) + 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_origin(&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()); + } + 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) }; @@ -977,6 +1014,36 @@ 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( + None, + package_manager_type, + version, + &NpmConfig::load(), + ) + .await +} + +/// Resolve a version using npm configuration from the workspace containing `cwd`. +pub async fn resolve_package_manager_version_for_cwd( + cwd: &AbsolutePath, + package_manager_type: PackageManagerType, + version: &str, +) -> Result { + resolve_package_manager_version_with_config( + Some(cwd), + package_manager_type, + version, + &NpmConfig::load_for_cwd(cwd), + ) + .await +} + +async fn resolve_package_manager_version_with_config( + cwd: Option<&AbsolutePath>, + package_manager_type: PackageManagerType, + version: &str, + npm_config: &NpmConfig, ) -> Result { if version.is_empty() { return Err(Error::InvalidArgument( @@ -986,19 +1053,22 @@ pub async fn resolve_package_manager_version( .into(), )); } - match version { "default" => match package_manager_type { PackageManagerType::Npm => { - Ok(resolve_npm_from_path(&vt_path::current_dir()?).await?.version) + let cwd = match cwd { + Some(cwd) => cwd.to_absolute_path_buf(), + None => vt_path::current_dir()?, + }; + Ok(resolve_npm_from_path(&cwd).await?.version) } PackageManagerType::Pnpm | PackageManagerType::Yarn | PackageManagerType::Bun => { - get_latest_version(package_manager_type).await + get_latest_version_with_config(package_manager_type, npm_config).await } }, - "latest" => get_latest_version(package_manager_type).await, + "latest" => get_latest_version_with_config(package_manager_type, npm_config).await, _ if Version::parse(version).is_ok() => Ok(version.into()), - _ => resolve_package_manager_range(package_manager_type, version).await, + _ => resolve_package_manager_range(package_manager_type, version, npm_config).await, } } @@ -1016,10 +1086,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() @@ -1031,9 +1105,26 @@ async fn fetch_registry_versions(package_name: &str) -> Result Result, Error> { - let mut versions = fetch_registry_versions(&package_manager_type.to_string()).await?; + fetch_package_manager_versions_with_config(package_manager_type, &NpmConfig::load()).await +} + +/// Fetch published versions using npm configuration from the workspace containing `cwd`. +pub async fn fetch_package_manager_versions_for_cwd( + cwd: &AbsolutePath, + package_manager_type: PackageManagerType, +) -> Result, Error> { + fetch_package_manager_versions_with_config(package_manager_type, &NpmConfig::load_for_cwd(cwd)) + .await +} + +async fn fetch_package_manager_versions_with_config( + package_manager_type: PackageManagerType, + npm_config: &NpmConfig, +) -> Result, Error> { + 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(); @@ -1060,12 +1151,13 @@ async fn resolve_latest_satisfying_version( package_manager_type: PackageManagerType, range: &js_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 @@ -1086,7 +1178,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(), } }) } @@ -1138,6 +1230,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 = js_semver::Range::parse(version_req).map_err(|_| { Error::InvalidArgument( @@ -1156,10 +1249,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. @@ -1169,9 +1262,51 @@ pub async fn download_package_manager( version_or_latest: &str, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let installed = - download_package_manager_inner(package_manager_type, version_or_latest, expected_hash) - .await?; + download_package_manager_with_config( + None, + package_manager_type, + version_or_latest, + expected_hash, + &NpmConfig::load(), + ) + .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 cwd = cwd.as_ref(); + let npm_config = NpmConfig::load_for_cwd(cwd); + download_package_manager_with_config( + Some(cwd), + package_manager_type, + version_or_latest, + expected_hash, + &npm_config, + ) + .await +} + +async fn download_package_manager_with_config( + cwd: Option<&AbsolutePath>, + package_manager_type: PackageManagerType, + version_or_latest: &str, + expected_hash: Option<&str>, + npm_config: &NpmConfig, +) -> Result<(AbsolutePathBuf, Str, Str), Error> { + let installed = download_package_manager_inner( + cwd, + package_manager_type, + version_or_latest, + expected_hash, + npm_config, + ) + .await?; #[cfg(windows)] ensure_native_windows_bins(package_manager_type, &installed.0).await?; Ok(installed) @@ -1223,11 +1358,19 @@ async fn ensure_native_windows_bins( } async fn download_package_manager_inner( + cwd: Option<&AbsolutePath>, 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(package_manager_type, version_or_latest).await?; + let version = resolve_package_manager_version_with_config( + cwd, + 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 @@ -1259,7 +1402,7 @@ async fn download_package_manager_inner( // 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 @@ -1267,10 +1410,11 @@ async fn download_package_manager_inner( // 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); @@ -1301,12 +1445,13 @@ async fn download_package_manager_inner( // A Yarn 2+ `packageManager` hash covers only the CLI. The rest of the archive // is not authenticated by that hash, 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| { @@ -1503,6 +1648,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(); @@ -1520,24 +1666,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| { @@ -1653,10 +1801,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 @@ -1686,6 +1837,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()?; @@ -1704,15 +1856,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))?; @@ -1720,24 +1873,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| { @@ -1944,6 +2099,30 @@ mod tests { tempdir().expect("Failed to create temp directory") } + #[cfg(unix)] + #[tokio::test(flavor = "current_thread")] + async fn npm_default_uses_target_cwd_for_path_resolution() { + use std::os::unix::fs::PermissionsExt; + + let project = create_temp_dir(); + let cwd = AbsolutePathBuf::new(project.path().to_path_buf()).unwrap(); + assert_ne!(cwd, vt_path::current_dir().unwrap()); + let bin_dir = cwd.join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let npm = bin_dir.join("npm"); + fs::write(&npm, "#!/bin/sh\nprintf '9.8.7\\n'\n").unwrap(); + fs::set_permissions(&npm, fs::Permissions::from_mode(0o755)).unwrap(); + + EnvConfig::with_vars_async([("PATH", "./bin")], |_| async { + let version = + resolve_package_manager_version_for_cwd(&cwd, PackageManagerType::Npm, "default") + .await + .unwrap(); + assert_eq!(version, "9.8.7"); + }) + .await; + } + #[test] fn bin_names_only_include_executables_in_the_installation() { for kind in [ @@ -2065,6 +2244,7 @@ mod tests { ) .unwrap(); let resolution = resolve_environment_package_manager( + &cwd, selected, Some((PackageManagerType::Bun, "1.2.0", None)), None, @@ -2087,6 +2267,7 @@ mod tests { let selected = resolve_environment_package_manager_spec(&cwd, None).unwrap(); let resolution = resolve_environment_package_manager( + &cwd, selected, Some((PackageManagerType::Bun, "1.2.0", None)), None, @@ -2108,6 +2289,7 @@ mod tests { let selected = resolve_environment_package_manager_spec(&cwd, None).unwrap(); let resolution = resolve_environment_package_manager( + &cwd, selected, Some((PackageManagerType::Pnpm, "10.18.0", None)), Some(PackageManagerType::Pnpm), @@ -2121,6 +2303,91 @@ 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(EnvironmentPackageManagerResolution { + package_manager_type: PackageManagerType::Pnpm, + version: "latest".into(), + hash: None, + source: "session".into(), + source_path: None, + project_root: None, + }), + None, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(resolution.version, "10.0.0"); + metadata.assert(); + }) + .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(); @@ -2227,7 +2494,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) @@ -2264,6 +2532,194 @@ mod tests { .await; } + #[tokio::test(flavor = "current_thread")] + async fn latest_version_cache_handles_legacy_cache_files() { + use httpmock::prelude::*; + + let server = MockServer::start(); + let mut request = server.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(200).json_body(serde_json::json!({ "version": "10.2.0" })); + }); + let config = + NpmConfig { values: HashMap::from([("registry".to_string(), server.base_url())]) }; + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.path().as_os_str())], |_| async { + // The legacy cache has no registry provenance and must not be reused. + let legacy_path = + EnvConfig::get().dirs.cache.join("package_manager_latest").join("pnpm"); + fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); + fs::write(&legacy_path, "10.1.0").unwrap(); + for _ in 0..2 { + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &config) + .await + .unwrap(), + "10.2.0" + ); + } + request.assert_hits(1); + assert_eq!(fs::read_to_string(&legacy_path).unwrap(), "10.1.0"); + + let cache_path = + latest_version_cache_path(PackageManagerType::Pnpm, &server.base_url()).unwrap(); + fs::File::options() + .write(true) + .open(&cache_path) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(UNIX_EPOCH)) + .unwrap(); + request.delete(); + assert_eq!( + get_latest_version_with_config(PackageManagerType::Pnpm, &config).await.unwrap(), + "10.2.0" + ); + }) + .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; + } + + #[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; + } + + #[tokio::test(flavor = "current_thread")] + async fn redirected_authenticated_latest_versions_are_not_cached() { + use httpmock::prelude::*; + + let registry = MockServer::start(); + let redirect = registry.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(302).header("location", "/private/pnpm/latest"); + }); + let first = registry.mock(|when, then| { + when.method(GET).path("/private/pnpm/latest").header("authorization", "Bearer FIRST"); + then.status(200).json_body(serde_json::json!({ "version": "10.1.0" })); + }); + let second = registry.mock(|when, then| { + when.method(GET).path("/private/pnpm/latest").header("authorization", "Bearer SECOND"); + then.status(200).json_body(serde_json::json!({ "version": "10.2.0" })); + }); + let registry_url = registry.base_url(); + let authority = registry_url.strip_prefix("http:").unwrap(); + let config = |token: &str| NpmConfig { + values: HashMap::from([ + ("registry".to_string(), registry_url.clone()), + (vt_str::format!("{authority}/private/:_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" + ); + redirect.assert_hits(2); + first.assert_hits(1); + second.assert_hits(1); + }) + .await; + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { @@ -3991,7 +4447,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); @@ -4350,7 +4808,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 50f111c0da..362a080e36 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -16,11 +16,14 @@ use tokio::{fs, io::AsyncWriteExt}; use vp_error::Error; use vp_shared::progress::Progress; +use crate::config::NpmConfig; + /// HTTP client with built-in retry support #[derive(Clone)] pub struct HttpClient { max_times: usize, min_delay: u64, + npm_config: Option, } impl Default for HttpClient { @@ -30,6 +33,8 @@ impl Default for HttpClient { } impl HttpClient { + const MAX_NPM_REDIRECTS: usize = 10; + /// Create a new HTTP client with default settings (3 retries, 500ms min delay) #[must_use] pub const fn new() -> Self { @@ -44,7 +49,88 @@ impl HttpClient { /// * `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 } + 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: 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, + } + } + + fn request_client(&self) -> Result<&'static reqwest::Client, Error> { + if self.npm_config.is_some() { + Ok(vp_shared::shared_http_client_without_redirects()?) + } else { + Ok(vp_shared::shared_http_client()?) + } + } + + async fn send_get( + &self, + client: &reqwest::Client, + url: &str, + accept: Option<&str>, + timeout: Option, + ) -> Result { + let mut url = url.to_string(); + let deadline = timeout.map(|timeout| std::time::Instant::now() + timeout); + let mut allow_auth = true; + for redirects in 0..=Self::MAX_NPM_REDIRECTS { + let mut request = client.get(&url); + if let Some(accept) = accept { + request = request.header(reqwest::header::ACCEPT, accept); + } + if let Some(deadline) = deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "package registry request timed out", + ))); + } + request = request.timeout(remaining); + } + let request = if allow_auth { self.apply_auth(request, &url) } else { request }; + let response = request.send().await?; + if self.npm_config.is_some() + && matches!( + response.status(), + StatusCode::MOVED_PERMANENTLY + | StatusCode::FOUND + | StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::PERMANENT_REDIRECT + ) + && let Some(location) = response.headers().get(reqwest::header::LOCATION) + { + if redirects == Self::MAX_NPM_REDIRECTS { + return Err(Error::InvalidArgument( + "too many package registry redirects".into(), + )); + } + let location = location.to_str().map_err(|_| { + Error::InvalidArgument("invalid package registry redirect location".into()) + })?; + let next_url = response.url().join(location).map_err(|_| { + Error::InvalidArgument("invalid package registry redirect location".into()) + })?; + // Match reqwest's existing behavior: credentials removed on a + // cross-origin redirect must not return later in the chain. + if next_url.origin() != response.url().origin() { + allow_auth = false; + } + url = next_url.to_string(); + continue; + } + return Ok(response.error_for_status()?); + } + unreachable!("the redirect limit returns before the loop can end") } /// Get raw bytes from a URL @@ -166,13 +252,10 @@ impl HttpClient { ) -> Result { tracing::debug!("Fetching JSON from: {} (accept: {:?})", url, accept); - let client = vp_shared::shared_http_client()?; + let client = self.request_client()?; (|| async { - let mut request = client.get(url); - if let Some(accept) = accept { - request = request.header(reqwest::header::ACCEPT, accept); - } - let response = request.send().await?.error_for_status()?; + let timeout = self.npm_config.as_ref().map(|_| vp_shared::request_timeout()); + let response = self.send_get(client, url, accept, timeout).await?; Ok::(response.json::().await?) }) .retry( @@ -210,7 +293,7 @@ impl HttpClient { let target_path = target_path.as_ref(); tracing::debug!("Downloading {} to {:?}", url, target_path); - let client = vp_shared::shared_http_client()?; + let client = self.request_client()?; // Progress bar (only in TTY and not in CI). Built once and reused across // retry attempts; its position is reset at the start of every attempt so @@ -227,7 +310,7 @@ 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.send_get(client, url, None, Some(timeout)).await?; if let Some(ref pb) = progress { let pb = pb.bar(); pb.set_position(0); @@ -383,12 +466,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() @@ -412,7 +515,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() @@ -434,6 +545,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) { @@ -446,7 +558,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 { @@ -795,6 +907,290 @@ 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: Some(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 npm_auth_is_rechecked_on_same_origin_redirects() { + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut requests = Vec::new(); + for _ in 0..8 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + loop { + let mut buffer = [0u8; 1024]; + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0, "request ended before its headers"); + bytes.extend_from_slice(&buffer[..count]); + if bytes.ends_with(b"\r\n\r\n") + || bytes.windows(4).any(|part| part == b"\r\n\r\n") + { + break; + } + } + let request = String::from_utf8(bytes).unwrap(); + let path = request.split_whitespace().nth(1).unwrap().to_string(); + let has_auth = request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer SECRET")); + let response = match path.as_str() { + "/private/metadata" => { + "HTTP/1.1 302 Found\r\nLocation: /public/metadata\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + } + "/public/metadata" => { + "HTTP/1.1 200 OK\r\nContent-Length: 14\r\nConnection: close\r\n\r\n{\"value\":true}" + } + "/private/package.tgz" => { + "HTTP/1.1 302 Found\r\nLocation: /public/package.tgz\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + } + "/public/package.tgz" => { + "HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\narchive" + } + "/team/private/metadata" => { + "HTTP/1.1 302 Found\r\nLocation: /team//private/metadata\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + } + "/team//private/metadata" => { + "HTTP/1.1 200 OK\r\nContent-Length: 14\r\nConnection: close\r\n\r\n{\"value\":true}" + } + "/team/private/package.tgz" => { + "HTTP/1.1 302 Found\r\nLocation: /team/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + } + "/team/private" => { + "HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\narchive" + } + _ => panic!("unexpected request path: {path}"), + }; + socket.write_all(response.as_bytes()).await.unwrap(); + requests.push((path, has_auth)); + } + requests + }); + + let client = HttpClient::with_npm_config( + 0, + 0, + NpmConfig { + values: std::collections::HashMap::from([ + ( + vt_str::format!("//{addr}/private/:_authtoken").to_string(), + "SECRET".to_string(), + ), + ( + vt_str::format!("//{addr}/team/private/:_authtoken").to_string(), + "SECRET".to_string(), + ), + ]), + }, + ); + let metadata: serde_json::Value = + client.get_json(&vt_str::format!("http://{addr}/private/metadata")).await.unwrap(); + let target = TempDir::new().unwrap(); + let archive = target.path().join("package.tgz"); + client + .download_file(&vt_str::format!("http://{addr}/private/package.tgz"), &archive, None) + .await + .unwrap(); + + assert_eq!(metadata, serde_json::json!({ "value": true })); + assert_eq!(fs::read(archive).unwrap(), b"archive"); + let metadata: serde_json::Value = + client.get_json(&vt_str::format!("http://{addr}/team/private/metadata")).await.unwrap(); + client + .download_file( + &vt_str::format!("http://{addr}/team/private/package.tgz"), + &target.path().join("boundary.tgz"), + None, + ) + .await + .unwrap(); + assert_eq!(metadata, serde_json::json!({ "value": true })); + assert_eq!(fs::read(target.path().join("boundary.tgz")).unwrap(), b"archive"); + let requests = + tokio::time::timeout(Duration::from_secs(10), server).await.unwrap().unwrap(); + assert_eq!( + requests, + [ + ("/private/metadata".to_string(), true), + ("/public/metadata".to_string(), false), + ("/private/package.tgz".to_string(), true), + ("/public/package.tgz".to_string(), false), + ("/team/private/metadata".to_string(), true), + ("/team//private/metadata".to_string(), false), + ("/team/private/package.tgz".to_string(), true), + ("/team/private".to_string(), false), + ] + ); + } + + #[tokio::test] + async fn npm_auth_is_preserved_on_matching_redirects() { + let registry = MockServer::start(); + let redirect = registry.mock(|when, then| { + when.method(GET).path("/private/first").header("authorization", "Bearer SECRET"); + then.status(302).header("location", "/private/second"); + }); + let destination = registry.mock(|when, then| { + when.method(GET).path("/private/second").header("authorization", "Bearer SECRET"); + then.status(200).json_body(serde_json::json!({ "value": true })); + }); + let registry_url = registry.base_url(); + let client = HttpClient::with_npm_config( + 0, + 0, + NpmConfig { + values: std::collections::HashMap::from([( + vt_str::format!( + "{}/private/:_authtoken", + registry_url.trim_start_matches("http:") + ) + .to_string(), + "SECRET".to_string(), + )]), + }, + ); + + let result: serde_json::Value = + client.get_json(&vt_str::format!("{registry_url}/private/first")).await.unwrap(); + assert_eq!(result, serde_json::json!({ "value": true })); + redirect.assert_hits(1); + destination.assert_hits(1); + } + + #[tokio::test] + async fn npm_tarball_download_follows_external_redirect() { + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + let registry = MockServer::start(); + let cdn = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let cdn_addr = cdn.local_addr().unwrap(); + let cdn_request = tokio::spawn(async move { + let (mut socket, _) = cdn.accept().await.unwrap(); + let mut bytes = Vec::new(); + loop { + let mut buffer = [0u8; 1024]; + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0, "CDN request ended before its headers"); + bytes.extend_from_slice(&buffer[..count]); + if bytes.windows(4).any(|part| part == b"\r\n\r\n") { + break; + } + } + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\narchive", + ) + .await + .unwrap(); + String::from_utf8(bytes).unwrap() + }); + let redirect = registry.mock(|when, then| { + when.method(GET).path("/private/package.tgz").header("authorization", "Bearer SECRET"); + then.status(302) + .header("location", vt_str::format!("http://{cdn_addr}/package.tgz").as_str()); + }); + let registry_url = registry.base_url(); + let client = HttpClient::with_npm_config( + 0, + 0, + NpmConfig { + values: std::collections::HashMap::from([ + ( + vt_str::format!( + "{}/private/:_authtoken", + registry_url.trim_start_matches("http:") + ) + .to_string(), + "SECRET".to_string(), + ), + ( + vt_str::format!("//{cdn_addr}/:_authtoken").to_string(), + "CDN_SECRET".to_string(), + ), + ]), + }, + ); + let target = TempDir::new().unwrap(); + let archive = target.path().join("package.tgz"); + client + .download_file(&vt_str::format!("{registry_url}/private/package.tgz"), &archive, None) + .await + .unwrap(); + + redirect.assert_hits(1); + let cdn_request = + tokio::time::timeout(Duration::from_secs(10), cdn_request).await.unwrap().unwrap(); + assert!(cdn_request.starts_with("GET /package.tgz ")); + assert!(!cdn_request.lines().any(|line| { + line.split_once(':').is_some_and(|(name, _)| name.eq_ignore_ascii_case("authorization")) + })); + assert_eq!(fs::read(archive).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(); @@ -1048,6 +1444,8 @@ mod tests { let content = b"Hello, World!"; let mut file = tokio::fs::File::create(&test_file).await.unwrap(); file.write_all(content).await.unwrap(); + // Finish the background write before hashing through another file handle. + file.flush().await.unwrap(); // Calculate the expected SRI (registry `dist.integrity` format) let digest = Sha512::digest(content); diff --git a/crates/vp_shared/src/http.rs b/crates/vp_shared/src/http.rs index fd95e56186..6b9c30faff 100644 --- a/crates/vp_shared/src/http.rs +++ b/crates/vp_shared/src/http.rs @@ -1,11 +1,12 @@ -//! Process-wide shared `reqwest::Client`. +//! Process-wide shared `reqwest::Client` instances. //! -//! Built once, lazily, and reused for every HTTP call vp makes. The single -//! instance lets us configure proxy honoring and custom-CA injection in one -//! place so HTTPS-intercepting tools like Socket Firewall Free (sfw) and -//! corporate MITM proxies work without per-call setup. +//! Built lazily and reused for HTTP calls vp makes. The default client follows +//! redirects; a second client lets package-registry callers recheck credentials +//! on each redirect. Both use the same proxy and custom-CA setup so +//! HTTPS-intercepting tools like Socket Firewall Free (sfw) and corporate MITM +//! proxies work without per-call setup. //! -//! Configuration sources (all read at first call): +//! Configuration sources (read when the pair is first built): //! - `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` — honored automatically by //! reqwest. With the `system-proxy` feature enabled, macOS System Settings //! proxies and Windows registry proxies are also picked up. @@ -28,10 +29,9 @@ //! debug-level trace. A populated system store is used as-is; the bundled //! roots are never merged over it. //! -//! Note: env vars are read exactly once at the first HTTP call. In long-lived +//! Note: both clients read env vars once when first built. In long-lived //! processes (e.g. the NAPI binding embedded in Node), later -//! `process.env.SSL_CERT_FILE = ...` mutations do *not* re-configure the -//! client. +//! `process.env.SSL_CERT_FILE = ...` mutations do *not* re-configure them. use std::{ffi::OsStr, path::Path, sync::OnceLock, time::Duration}; @@ -61,6 +61,12 @@ pub struct HttpClientError { /// silently hang a build. const REQUEST_TIMEOUT: Duration = Duration::from_mins(2); +/// Total time budget for a metadata request, including redirects. +#[must_use] +pub const fn request_timeout() -> Duration { + REQUEST_TIMEOUT +} + /// TCP connect timeout. Distinct from the request timeout above — without /// this, a black-holed proxy can stall every HTTP call for kernel-level /// retries (multiple minutes). @@ -123,11 +129,21 @@ pub fn download_timeout() -> Duration { /// `HTTPS_PROXY`, bad `SSL_CERT_FILE`, …). The outcome is cached, so later /// calls report the same error without retrying. pub fn shared_http_client() -> Result<&'static reqwest::Client, HttpClientError> { - static CLIENT: OnceLock> = OnceLock::new(); - CLIENT.get_or_init(build_client).as_ref().map_err(Clone::clone) + Ok(&shared_clients()?.0) +} + +/// Shared client for callers that must inspect each redirect before sending another request. +pub fn shared_http_client_without_redirects() -> Result<&'static reqwest::Client, HttpClientError> { + Ok(&shared_clients()?.1) +} + +fn shared_clients() -> Result<&'static (reqwest::Client, reqwest::Client), HttpClientError> { + static CLIENTS: OnceLock> = + OnceLock::new(); + CLIENTS.get_or_init(build_clients).as_ref().map_err(Clone::clone) } -fn build_client() -> Result { +fn build_clients() -> Result<(reqwest::Client, reqwest::Client), HttpClientError> { crate::ensure_tls_provider(); let extra_certs = extra_certs_from_env(); @@ -140,9 +156,9 @@ fn build_client() -> Result { ); } - match build_with(extra_certs.clone(), insecure) { - Ok(client) => Ok(client), - Err(err) => build_with_bundled_roots(extra_certs, insecure) + match build_pair(extra_certs.clone(), insecure) { + Ok(clients) => Ok(clients), + Err(err) => build_pair_with_bundled_roots(extra_certs, insecure) .ok_or_else(|| HttpClientError { cause: format_error_chain(&err).into() }), } } @@ -189,9 +205,19 @@ fn extra_certs_from_env() -> Vec { extra_certs } +fn build_pair( + extra_certs: Vec, + insecure: bool, +) -> Result<(reqwest::Client, reqwest::Client), reqwest::Error> { + let default = build_with(extra_certs.clone(), insecure, true)?; + let without_redirects = build_with(extra_certs, insecure, false)?; + Ok((default, without_redirects)) +} + fn build_with( extra_certs: Vec, insecure: bool, + follow_redirects: bool, ) -> Result { let mut builder = reqwest::Client::builder().timeout(REQUEST_TIMEOUT).connect_timeout(CONNECT_TIMEOUT); @@ -201,6 +227,9 @@ fn build_with( if insecure { builder = builder.tls_danger_accept_invalid_certs(true); } + if !follow_redirects { + builder = builder.redirect(reqwest::redirect::Policy::none()); + } builder.build() } @@ -212,26 +241,26 @@ fn build_with( /// `SSL_CERT_FILE`) is still present on the retry, so the caller reports the /// original error. #[cfg(not(target_os = "windows"))] -fn build_with_bundled_roots( +fn build_pair_with_bundled_roots( mut extra_certs: Vec, insecure: bool, -) -> Option { +) -> Option<(reqwest::Client, reqwest::Client)> { extra_certs.extend(bundled_root_certs()); - let client = build_with(extra_certs, insecure).ok()?; + let clients = build_pair(extra_certs, insecure).ok()?; tracing::debug!( "system CA store is empty; using the bundled Mozilla root certificates for TLS \ verification" ); - Some(client) + Some(clients) } /// Windows uses native-tls over SChannel, whose store is never empty; the /// bundled roots stay out of the binary there. #[cfg(target_os = "windows")] -fn build_with_bundled_roots( +fn build_pair_with_bundled_roots( _extra_certs: Vec, _insecure: bool, -) -> Option { +) -> Option<(reqwest::Client, reqwest::Client)> { None } @@ -323,7 +352,7 @@ mod tests { /// The bundled-roots retry must not paper over failures the bundle cannot /// fix: a bad `SSL_CERT_FILE` poisons the retry as well, and the original - /// error surfaces. Exercises `build_client` directly because + /// error surfaces. Exercises `build_clients` directly because /// `shared_http_client` caches its first outcome process-wide. /// /// Non-Windows only, like the fallback itself: native-tls rejects the bad @@ -333,7 +362,7 @@ mod tests { #[test] #[serial_test::serial(env)] fn bundled_roots_fallback_does_not_mask_other_build_failures() { - let result = with_invalid_ssl_cert_file("fallback", build_client); + let result = with_invalid_ssl_cert_file("fallback", build_clients); assert!( result.is_err(), diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index 5afb874d9a..2e77b14aa5 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -30,7 +30,10 @@ pub use dirs::{ pub use documentation::documentation_url; pub use env_config::EnvConfig; pub use error::format_error_chain; -pub use http::{HttpClientError, download_timeout, shared_http_client}; +pub use http::{ + HttpClientError, download_timeout, request_timeout, shared_http_client, + shared_http_client_without_redirects, +}; pub use interactivity::{ is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, is_stdout_terminal, diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index c1b145aec2..b61a32c833 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -157,6 +157,17 @@ Low-priority managed shims live in `/fallback-bin`, where `` is the - **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 diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 7e7b6b9463..84d13fa88a 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3645,6 +3645,7 @@ export declare function documentationUrl(path: string): string; * - `name`: The name of the package manager * - `version`: The version of the package manager * - `expected_hash`: The expected hash of the package manager + * - `cwd`: An absolute directory used to load workspace npm configuration * * ## Returns * @@ -3677,6 +3678,7 @@ export interface DownloadPackageManagerOptions { name: string; version: string; expectedHash?: string; + cwd?: string; } export interface DownloadPackageManagerResult { diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index b9f85614d4..461eb724d4 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -204,7 +204,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(), diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index 6278d5c007..9b0eea2f2a 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -11,6 +11,7 @@ pub struct DownloadPackageManagerOptions { pub name: String, pub version: String, pub expected_hash: Option, + pub cwd: Option, } #[napi(object)] @@ -31,6 +32,7 @@ pub struct DownloadPackageManagerResult { /// - `name`: The name of the package manager /// - `version`: The version of the package manager /// - `expected_hash`: The expected hash of the package manager +/// - `cwd`: An absolute directory used to load workspace npm configuration /// /// ## Returns /// @@ -71,13 +73,24 @@ pub async fn download_package_manager( } }; - let (install_dir, package_name, version) = vp_pm_cli::download_package_manager( - package_manager_type, - &options.version, - options.expected_hash.as_deref(), - ) - .await - .map_err(anyhow::Error::from)?; + let result = if let Some(cwd) = options.cwd { + let cwd = AbsolutePathBuf::new(cwd.into()).ok_or(Error::from_reason("invalid cwd"))?; + vp_pm_cli::download_package_manager_for_cwd( + &cwd, + package_manager_type, + &options.version, + options.expected_hash.as_deref(), + ) + .await + } else { + vp_pm_cli::download_package_manager( + package_manager_type, + &options.version, + options.expected_hash.as_deref(), + ) + .await + }; + let (install_dir, package_name, version) = result.map_err(anyhow::Error::from)?; Ok(DownloadPackageManagerResult { name: options.name, @@ -164,3 +177,21 @@ pub async fn detect_workspace(cwd: String) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn download_package_manager_rejects_relative_cwd() { + let error = download_package_manager(DownloadPackageManagerOptions { + name: "pnpm".to_string(), + version: "latest".to_string(), + expected_hash: None, + cwd: Some("relative/project".to_string()), + }) + .await + .unwrap_err(); + assert_eq!(error.reason, "invalid cwd"); + } +} diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index ee629bacc5..6271fc3ae1 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -692,6 +692,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h packageManagerVersion, options.interactive, shouldSilencePackageManagerInstallLog, + workspaceInfoOptional.rootDir, ); const workspaceInfo: WorkspaceInfo = { ...workspaceInfoOptional, diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 6790e01634..8606f8ff76 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -673,6 +673,7 @@ async function downloadSupportedPackageManager(options: { packageManagerVersion, interactive, true, + rootDir, ); if ( diff --git a/packages/cli/src/utils/__tests__/prompts.spec.ts b/packages/cli/src/utils/__tests__/prompts.spec.ts index 0e4c26c3c9..bb5cd89357 100644 --- a/packages/cli/src/utils/__tests__/prompts.spec.ts +++ b/packages/cli/src/utils/__tests__/prompts.spec.ts @@ -1,7 +1,54 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { downloadPackageManager as downloadPackageManagerBinding } from '../../../binding/index.js'; import { PackageManager } from '../../types/index.ts'; -import { resolveGitInit, shouldIgnoreScriptsForAutoInstall } from '../prompts.ts'; +import { + downloadPackageManager, + resolveGitInit, + shouldIgnoreScriptsForAutoInstall, +} from '../prompts.ts'; + +vi.mock('../../../binding/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + downloadPackageManager: vi.fn(), +})); + +describe('downloadPackageManager', () => { + it('forwards the target workspace directory to the native binding', async () => { + const result = { + name: 'pnpm', + version: '11.24.0', + installDir: '/cache/pnpm', + binPrefix: '/cache/pnpm/bin', + packageName: 'pnpm', + }; + vi.mocked(downloadPackageManagerBinding).mockResolvedValueOnce(result); + expect( + await downloadPackageManager(PackageManager.pnpm, 'latest', false, true, '/target/workspace'), + ).toBe(result); + expect(downloadPackageManagerBinding).toHaveBeenLastCalledWith({ + name: 'pnpm', + version: 'latest', + cwd: '/target/workspace', + }); + }); + + it('keeps existing callers without a target directory compatible', async () => { + vi.mocked(downloadPackageManagerBinding).mockResolvedValueOnce({ + name: 'pnpm', + version: '11.24.0', + installDir: '/cache/pnpm', + binPrefix: '/cache/pnpm/bin', + packageName: 'pnpm', + }); + await downloadPackageManager(PackageManager.pnpm, 'latest', false, true); + expect(downloadPackageManagerBinding).toHaveBeenLastCalledWith({ + name: 'pnpm', + version: 'latest', + cwd: undefined, + }); + }); +}); describe('resolveGitInit', () => { it('never initializes git when adding a package to an existing monorepo', async () => { diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 65212ff0b6..2350798a36 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -80,12 +80,14 @@ export async function downloadPackageManager( version: string, interactive?: boolean, silent = false, + cwd?: string, ) { const spinner = silent ? getSilentSpinner() : getSpinner(interactive); spinner.start(`${packageManager}@${version} installing...`); const downloadResult = await downloadPackageManagerBinding({ name: packageManager, version, + cwd, }); spinner.stop(`${packageManager}@${downloadResult.version} installed`); return downloadResult;