From 705f33c5d99d53cf27ddf71d4a9510139d023e22 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Mon, 9 Nov 2026 10:46:33 +0800 Subject: [PATCH 1/2] fix(pm): require a dist-tag version to be valid semver A dist-tag lookup (`latest`, `latest-N`, ...) resolves to a version string the registry returns. The installer requires that value to be strict semver, but the fresh dist-tag fetch returned it unchecked. Validate the reply in `fetch_dist_tag_version` -- the single function every dist-tag consumer passes through -- and reject anything that is not strict semver, parsing the raw value with the same `Version::parse` the installer uses so a value that is semver only after trimming is rejected and no surrounding whitespace survives. Co-Authored-By: arkmarta Co-Authored-By: Claude Opus 4.8 --- crates/vp_pm_cli/src/package_manager.rs | 50 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index b01a6c5aa2..f04cc13ce6 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -1066,7 +1066,22 @@ async fn fetch_dist_tag_version( let client = HttpClient::with_npm_config(3, 500, npm_config.clone()); for url in urls { match client.get_json::(url).await { - Ok(package_json) => return Ok(package_json.version), + Ok(package_json) => { + let version = package_json.version; + // The registry controls this reply, and a dist tag must resolve + // to an exact version. Require strict semver on the raw value, + // as the installer does, so the returned version has no + // surrounding whitespace or other characters. + if Version::parse(&version).is_err() { + return Err(Error::InvalidArgument( + format!( + "registry returned an invalid {package_manager_type} version {version:?} for dist-tag {tag:?}" + ) + .into(), + )); + } + return Ok(version); + } Err(error) if is_not_found(&error) => {} Err(error) => return Err(error), } @@ -2929,6 +2944,39 @@ mod tests { .await; } + #[tokio::test(flavor = "current_thread")] + async fn dist_tag_resolution_rejects_a_non_semver_version() { + use httpmock::prelude::*; + + // A raw value that is not an exact version, including one that is semver + // only after trimming, must be rejected rather than returned unchanged. + for payload in ["1.0.0 not a version", "\n1.0.0", " 1.0.0", "1.0.0\n", "1.0.0 "] + { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/pnpm/latest"); + then.status(200).json_body(serde_json::json!({ "version": payload })); + }); + let registry = server.base_url(); + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(®istry)), + ], + |_| async { + let result = + resolve_package_manager_version(PackageManagerType::Pnpm, "latest").await; + assert!( + matches!(result, Err(Error::InvalidArgument(_))), + "a dist-tag reply {payload:?} that is not strict semver must be rejected: {result:?}" + ); + }, + ) + .await; + } + } + #[tokio::test(flavor = "current_thread")] async fn yarn_dist_tags_fall_back_to_the_classic_package() { use httpmock::prelude::*; From 2986b3803475bf8ca32cf36fb6973845f474f5b5 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Mon, 9 Nov 2026 11:04:35 +0800 Subject: [PATCH 2/2] style(pm): keep the dist-tag test loop on one line Co-Authored-By: Claude Opus 4.8 --- crates/vp_pm_cli/src/package_manager.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index f04cc13ce6..28f6e7ecd0 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -2950,8 +2950,7 @@ mod tests { // A raw value that is not an exact version, including one that is semver // only after trimming, must be rejected rather than returned unchanged. - for payload in ["1.0.0 not a version", "\n1.0.0", " 1.0.0", "1.0.0\n", "1.0.0 "] - { + for payload in ["1.0.0 not a version", "\n1.0.0", " 1.0.0", "1.0.0\n", "1.0.0 "] { let server = MockServer::start(); server.mock(|when, then| { when.method(GET).path("/pnpm/latest");