From f736472eda6c67bcd09f09ff215118252771bd0d Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Thu, 23 Jul 2026 14:11:57 +0800 Subject: [PATCH 01/12] fix(install): verify modern Yarn hashes against the CLI binary Added verification for Yarn binary hash for modern Yarn installations. Updated download logic to handle Yarn 2+ package manager correctly. Signed-off-by: Leslie Lau <1178273431@qq.com> --- crates/vp_pm_cli/src/package_manager.rs | 89 +++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index adc63f752d..7bed83026e 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -28,7 +28,7 @@ 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}, + request::{HttpClient, download_and_extract_tgz_with_hash, verify_file_hash}, shim, }; @@ -835,12 +835,12 @@ pub async fn download_package_manager( ) })?; + let is_modern_yarn = matches!(package_manager_type, PackageManagerType::Yarn) + && VersionReq::parse(">=2.0.0")?.matches(&parsed_version); let mut package_name: Str = package_manager_type.to_string().into(); // handle yarn >= 2.0.0 to use `@yarnpkg/cli-dist` as package name // @see https://github.com/nodejs/corepack/blob/main/config.json#L135 - if matches!(package_manager_type, PackageManagerType::Yarn) - && VersionReq::parse(">=2.0.0")?.matches(&parsed_version) - { + if is_modern_yarn { package_name = "@yarnpkg/cli-dist".into(); } @@ -871,6 +871,9 @@ pub async fn download_package_manager( // If all shims already exist, return the target directory // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) if is_package_manager_install_complete(&install_dir, &bin_name)? { + if is_modern_yarn { + verify_yarn_binary_hash(&install_dir, expected_hash).await?; + } return Ok((install_dir, package_name, version)); } @@ -882,11 +885,12 @@ pub async fn download_package_manager( let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); + let archive_hash = if is_modern_yarn { None } else { expected_hash }; let download_message = format!("Downloading {package_manager_type} v{version}..."); download_and_extract_tgz_with_hash( &tgz_url, &target_dir_tmp, - expected_hash, + archive_hash, Some(&download_message), ) .await @@ -908,8 +912,13 @@ pub async fn download_package_manager( // Normalize the package root to $target_dir_tmp/{bin_name}. Most npm // tarballs use `package/`, but the directory name is not guaranteed. - tracing::debug!("Rename package dir to {}", bin_name); let extracted_package_dir = find_extracted_package_dir(&target_dir_tmp)?; + + if is_modern_yarn { + verify_yarn_binary_hash(&extracted_package_dir, expected_hash).await?; + } + + tracing::debug!("Rename package dir to {}", bin_name); tokio::fs::rename(&extracted_package_dir, &target_dir_tmp.join(&bin_name)).await?; // Use a file-based lock to ensure atomicity of remove + rename operations @@ -929,6 +938,9 @@ pub async fn download_package_manager( // the install is all-or-nothing) if is_package_manager_install_complete(&install_dir, &bin_name)? { tracing::debug!("install already complete after lock acquisition, skip rename"); + if is_modern_yarn { + verify_yarn_binary_hash(&install_dir, expected_hash).await?; + } return Ok((install_dir, package_name, version)); } @@ -944,6 +956,17 @@ pub async fn download_package_manager( Ok((install_dir, package_name, version)) } +/// Corepack hashes the extracted Yarn 2+ CLI instead of the npm tarball. +async fn verify_yarn_binary_hash( + package_dir: impl AsRef, + expected_hash: Option<&str>, +) -> Result<(), Error> { + if let Some(expected_hash) = expected_hash { + verify_file_hash(package_dir.as_ref().join("bin/yarn.js"), expected_hash).await?; + } + Ok(()) +} + /// Open a lock file without truncating it. This is required on Windows /// where `File::create` implies truncation, which is forbidden when another /// process holds an exclusive lock on the file. @@ -1710,6 +1733,25 @@ mod tests { tempdir().expect("Failed to create temp directory") } + fn create_yarn_package_tgz(yarn_js: &[u8]) -> Vec { + let mut tar_builder = tar::Builder::new(Vec::new()); + let mut header = tar::Header::new_gnu(); + header.set_size(yarn_js.len() as u64); + header.set_mode(0o755); + tar_builder + .append_data(&mut header, "package/bin/yarn.js", std::io::Cursor::new(yarn_js)) + .unwrap(); + + let tar_data = tar_builder.into_inner().unwrap(); + let mut gz_data = Vec::new(); + { + let mut encoder = + flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default()); + std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap(); + } + gz_data + } + fn create_package_json(dir: &AbsolutePath, content: &str) { fs::write(dir.join("package.json"), content).expect("Failed to write package.json"); } @@ -3384,6 +3426,41 @@ mod tests { remove_dir_all_force(target_dir).await.unwrap(); } + #[tokio::test] + async fn test_download_modern_yarn_verifies_corepack_binary_hash() { + use httpmock::prelude::*; + use sha2::{Digest, Sha512}; + + let vp_home = create_temp_dir(); + let server = MockServer::start(); + let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; + let mock_tgz = create_yarn_package_tgz(yarn_js); + let mock = server.mock(|when, then| { + when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); + then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); + }); + let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); + + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: server.base_url().into(), + vite_plus_home: Some(vp_home.path().to_path_buf()), + ..EnvConfig::for_test() + }); + + let (install_dir, _, _) = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await + .expect("Corepack's Yarn binary hash should be accepted"); + assert_eq!(mock.hits(), 1); + + fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); + let result = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await; + assert!(matches!(result, Err(Error::HashMismatch { .. }))); + assert_eq!(mock.hits(), 1, "cached installs should be verified without downloading"); + } + #[tokio::test] async fn test_get_latest_version() { let result = get_latest_version(PackageManagerType::Yarn).await; From 30dec746bfe6c765334ee484193db4ed9184c5a2 Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Sun, 9 Aug 2026 22:52:26 +0800 Subject: [PATCH 02/12] test(install): cover Corepack Yarn hash snapshots --- .../install_yarn_corepack_hash/package.json | 8 ++++ .../install_yarn_corepack_hash/snapshots.toml | 22 ++++++++++ .../snapshots/install_yarn_corepack_hash.md | 39 ++++++++++++++++ .../snapshots/run_yarn_corepack_hash.md | 44 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/package.json new file mode 100644 index 0000000000..f2a4b4843b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/package.json @@ -0,0 +1,8 @@ +{ + "name": "install-yarn-corepack-hash", + "private": true, + "scripts": { + "smoke": "vpt print yarn hash accepted" + }, + "packageManager": "yarn@4.17.1+sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml new file mode 100644 index 0000000000..338ca68b03 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -0,0 +1,22 @@ +[[case]] +name = "install_yarn_corepack_hash" +vp = "global" +env = { YARN_ENABLE_TELEMETRY = "0" } +steps = [ + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 starts uncached" }, + { argv = ["vp", "install"], comment = "A cold install accepts the hash written by Corepack", timeout = 120000 }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "The verified Yarn CLI binary is cached" }, +] + +[[case]] +name = "run_yarn_corepack_hash" +vp = "global" +env = { YARN_ENABLE_TELEMETRY = "0" } +steps = [ + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 starts uncached" }, + { argv = ["vp", "run", "smoke"], comment = "A cold vp run accepts the hash and executes the task", timeout = 120000 }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "vp run finalized the verified Yarn cache" }, + { argv = ["vp", "run", "smoke"], comment = "A warm vp run reuses the cached Yarn binary" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md new file mode 100644 index 0000000000..ff7fb611f5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md @@ -0,0 +1,39 @@ +# install_yarn_corepack_hash + +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` + +Ensure the Corepack-pinned Yarn version is not cached + + +## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1 --assert missing` + +Yarn 4.17.1 starts uncached + +``` +/.vite-plus/package_manager/yarn/: missing +``` + +## `vp install` + +A cold install accepts the hash written by Corepack + +``` +VITE+ - The Unified Toolchain for the Web + +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in +``` + +## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js --assert file` + +The verified Yarn CLI binary is cached + +``` +/.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md new file mode 100644 index 0000000000..11284b5481 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md @@ -0,0 +1,44 @@ +# run_yarn_corepack_hash + +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` + +Ensure the Corepack-pinned Yarn version is not cached + + +## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1 --assert missing` + +Yarn 4.17.1 starts uncached + +``` +/.vite-plus/package_manager/yarn/: missing +``` + +## `vp run smoke` + +A cold vp run accepts the hash and executes the task + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt print yarn hash accepted ⊘ cache disabled +yarn hash accepted +``` + +## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js --assert file` + +vp run finalized the verified Yarn cache + +``` +/.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file +``` + +## `vp run smoke` + +A warm vp run reuses the cached Yarn binary + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt print yarn hash accepted ⊘ cache disabled +yarn hash accepted +``` From 474af7f16227b914f88b229b2918e7470441acaa Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Tue, 11 Aug 2026 08:30:49 +0800 Subject: [PATCH 03/12] fix(install): harden Corepack Yarn verification Signed-off-by: Leslie Lau <1178273431@qq.com> --- crates/vp_global_cli/src/shim/dispatch.rs | 53 +++++- crates/vp_pm_cli/src/package_manager.rs | 187 ++++++++++++++++++++-- crates/vp_pm_cli/src/request.rs | 181 +++++++++++++++++---- 3 files changed, 364 insertions(+), 57 deletions(-) diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index e9a4f02b8b..6638d0da2a 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -687,13 +687,18 @@ async fn resolve_matching_package_manager_tool( let bin_name = expected_type.bin_name_for_tool(tool); - // Fast path: if the managed install already exists, skip download_package_manager - // entirely. The slow path stats three files (`bin`, `.cmd`, `.ps1`) on every - // invocation, which adds up on the shim hot path. - if let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) { - let bin_path = package_manager_bin_path(&install_dir, bin_name); - if bin_path.as_path().exists() { - return Ok(Some(bin_path)); + // Keep the hot path for unpinned installs and archive-hashed package + // managers. A Corepack-style Yarn pin covers the extracted CLI, so it must + // pass through download_package_manager even when the shim already exists; + // that path re-verifies the cached yarn.js before it can be executed. + let must_verify_cached_cli = + resolution.hash.is_some() && expected_type.uses_cli_binary_hash(&resolution.version); + if !must_verify_cached_cli { + if let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) { + let bin_path = package_manager_bin_path(&install_dir, bin_name); + if bin_path.as_path().exists() { + return Ok(Some(bin_path)); + } } } @@ -1495,6 +1500,40 @@ mod tests { } } + #[tokio::test] + #[serial] + async fn test_hash_pinned_modern_yarn_rechecks_cached_cli() { + let temp = TempDir::new().unwrap(); + let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let expected_hash = format!("sha512.{}", "0".repeat(128)); + std::fs::write( + cwd.join("package.json"), + format!(r#"{{"packageManager":"yarn@4.17.1+{expected_hash}"}}"#), + ) + .unwrap(); + + let bin_dir = + vp_home.join("package_manager").join("yarn").join("4.17.1").join("yarn").join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::write(bin_dir.join("yarn"), "shim").unwrap(); + std::fs::write(bin_dir.join("yarn.cmd"), "shim").unwrap(); + std::fs::write(bin_dir.join("yarn.ps1"), "shim").unwrap(); + std::fs::write(bin_dir.join("yarn.js"), "corrupt").unwrap(); + + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + vp_home.as_path(), + )); + + let result = resolve_matching_package_manager_tool(&cwd, "yarn").await; + assert!( + matches!(result, Err(Error::Install(vp_error::Error::HashMismatch { .. }))), + "the global Yarn shim must reject a corrupted pinned cache: {result:?}" + ); + } + #[tokio::test] #[serial] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 7bed83026e..362c5f170d 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -15,7 +15,9 @@ use crossterm::{ style::{Color, Print, ResetColor, SetForegroundColor}, terminal, }; -use semver::{Version, VersionReq}; +use semver::Version; +#[cfg(test)] +use semver::VersionReq; use serde::{Deserialize, Serialize}; use tokio::fs::remove_dir_all; use vp_error::Error; @@ -28,7 +30,10 @@ 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}, + request::{ + HttpClient, download_and_extract_tgz_file_with_hash, download_and_extract_tgz_with_hash, + verify_file_hash, + }, shim, }; @@ -105,6 +110,14 @@ impl PackageManagerType { (_, Self::Bun) => "bun", } } + + /// Whether Corepack integrity pins for this package-manager version cover + /// the extracted CLI binary rather than the npm tarball. + #[must_use] + pub fn uses_cli_binary_hash(self, version: &str) -> bool { + matches!(self, Self::Yarn) + && Version::parse(version).is_ok_and(|version| version >= Version::new(2, 0, 0)) + } } /// Package-manager resolution from an explicit project `packageManager` field. @@ -835,8 +848,7 @@ pub async fn download_package_manager( ) })?; - let is_modern_yarn = matches!(package_manager_type, PackageManagerType::Yarn) - && VersionReq::parse(">=2.0.0")?.matches(&parsed_version); + let is_modern_yarn = package_manager_type.uses_cli_binary_hash(&version); let mut package_name: Str = package_manager_type.to_string().into(); // handle yarn >= 2.0.0 to use `@yarnpkg/cli-dist` as package name // @see https://github.com/nodejs/corepack/blob/main/config.json#L135 @@ -885,16 +897,26 @@ pub async fn download_package_manager( let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); - let archive_hash = if is_modern_yarn { None } else { expected_hash }; let download_message = format!("Downloading {package_manager_type} v{version}..."); - download_and_extract_tgz_with_hash( - &tgz_url, - &target_dir_tmp, - archive_hash, - Some(&download_message), - ) - .await - .map_err(|err| { + let download_result = if is_modern_yarn { + download_and_extract_tgz_file_with_hash( + &tgz_url, + &target_dir_tmp, + "package/bin/yarn.js", + expected_hash, + Some(&download_message), + ) + .await + } else { + download_and_extract_tgz_with_hash( + &tgz_url, + &target_dir_tmp, + expected_hash, + Some(&download_message), + ) + .await + }; + download_result.map_err(|err| { // status 404 means the version is not found, convert to PackageManagerVersionNotFound error if let Error::Reqwest(e) = &err && let Some(status) = e.status() @@ -914,10 +936,6 @@ pub async fn download_package_manager( // tarballs use `package/`, but the directory name is not guaranteed. let extracted_package_dir = find_extracted_package_dir(&target_dir_tmp)?; - if is_modern_yarn { - verify_yarn_binary_hash(&extracted_package_dir, expected_hash).await?; - } - tracing::debug!("Rename package dir to {}", bin_name); tokio::fs::rename(&extracted_package_dir, &target_dir_tmp.join(&bin_name)).await?; @@ -1752,6 +1770,34 @@ mod tests { gz_data } + fn create_yarn_package_tgz_with_symlink(yarn_js: &[u8], link_target: &Path) -> Vec { + let mut tar_builder = tar::Builder::new(Vec::new()); + + let mut header = tar::Header::new_gnu(); + header.set_size(yarn_js.len() as u64); + header.set_mode(0o755); + tar_builder + .append_data(&mut header, "package/bin/yarn.js", std::io::Cursor::new(yarn_js)) + .unwrap(); + + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_link_name(link_target).unwrap(); + header.set_cksum(); + tar_builder.append_data(&mut header, "package/bin/yarn", std::io::empty()).unwrap(); + + let tar_data = tar_builder.into_inner().unwrap(); + let mut gz_data = Vec::new(); + { + let mut encoder = + flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default()); + std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap(); + } + gz_data + } + fn create_package_json(dir: &AbsolutePath, content: &str) { fs::write(dir.join("package.json"), content).expect("Failed to write package.json"); } @@ -1914,6 +1960,16 @@ mod tests { assert_eq!(PackageManagerType::Bun.bin_name_for_tool("bunx"), "bunx"); } + #[test] + fn test_uses_cli_binary_hash_only_for_modern_yarn() { + assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("1.22.22")); + assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("2.0.0-rc.1")); + assert!(PackageManagerType::Yarn.uses_cli_binary_hash("2.0.0")); + assert!(PackageManagerType::Yarn.uses_cli_binary_hash("4.17.1")); + assert!(!PackageManagerType::Pnpm.uses_cli_binary_hash("10.0.0")); + assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("latest")); + } + #[test] fn test_resolve_package_manager_from_package_json_npm() { let temp_dir = create_temp_dir(); @@ -3461,6 +3517,103 @@ mod tests { assert_eq!(mock.hits(), 1, "cached installs should be verified without downloading"); } + #[tokio::test] + async fn test_download_modern_yarn_extracts_only_authenticated_cli() { + use httpmock::prelude::*; + use sha2::{Digest, Sha512}; + + let vp_home = create_temp_dir(); + let victim_dir = create_temp_dir(); + let victim = victim_dir.path().join("victim"); + fs::write(&victim, "original").unwrap(); + + let server = MockServer::start(); + let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; + let mock_tgz = create_yarn_package_tgz_with_symlink(yarn_js, &victim); + server.mock(|when, then| { + when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); + then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); + }); + let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); + + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: server.base_url().into(), + vite_plus_home: Some(vp_home.path().to_path_buf()), + ..EnvConfig::for_test() + }); + + let (install_dir, _, _) = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await + .expect("the authenticated Yarn CLI should install"); + + assert_eq!(fs::read_to_string(&victim).unwrap(), "original"); + assert!( + fs::symlink_metadata(install_dir.join("bin/yarn")).unwrap().file_type().is_file(), + "the generated shim must not reuse an archive-provided symlink" + ); + } + + #[tokio::test] + async fn test_download_modern_yarn_retries_binary_hash_mismatch() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use sha2::{Digest, Sha512}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + let bad_tgz = create_yarn_package_tgz(b"corrupt"); + let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; + let good_tgz = create_yarn_package_tgz(yarn_js); + let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + let server_attempts = Arc::clone(&attempts); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 2048]; + let _ = socket.read(&mut request).await; + + let attempt = server_attempts.fetch_add(1, Ordering::SeqCst); + let body = if attempt == 0 { &bad_tgz } else { &good_tgz }; + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + socket.write_all(headers.as_bytes()).await.unwrap(); + socket.write_all(body).await.unwrap(); + socket.flush().await.unwrap(); + } + }); + + let vp_home = create_temp_dir(); + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: format!("http://{addr}").into(), + vite_plus_home: Some(vp_home.path().to_path_buf()), + ..EnvConfig::for_test() + }); + + let result = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await; + server.abort(); + + assert!(result.is_ok(), "a fresh authenticated response should recover: {result:?}"); + assert_eq!( + attempts.load(Ordering::SeqCst), + 2, + "the bad CLI response should be retried exactly once" + ); + } + #[tokio::test] async fn test_get_latest_version() { let result = get_latest_version(PackageManagerType::Yarn).await; diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index 05f82605b1..b749b8cd46 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -1,4 +1,7 @@ -use std::{path::Path, time::Duration}; +use std::{ + path::{Component, Path}, + time::Duration, +}; use backon::{ExponentialBuilder, Retryable}; use flate2::read::GzDecoder; @@ -294,6 +297,61 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu Ok(()) } +/// Extract exactly one regular file from a tgz archive. +/// +/// Unlike [`extract_tgz`], archive-controlled paths and links are never written. +/// This is used when an integrity pin covers one file inside an otherwise +/// unauthenticated archive. +fn extract_tgz_file( + tgz_file: impl AsRef, + archive_file: impl AsRef, + target_file: impl AsRef, +) -> Result<(), Error> { + let tgz_file = tgz_file.as_ref(); + let archive_file = archive_file.as_ref(); + let target_file = target_file.as_ref(); + tracing::debug!("Extract {:?} from tgz {:?} to {:?}", archive_file, tgz_file, target_file); + + let file = std::fs::File::open(tgz_file)?; + let tar_stream = GzDecoder::new(file); + let mut archive = Archive::new(tar_stream); + + for entry in archive.entries()? { + let mut entry = entry?; + if entry.path()?.as_ref() != archive_file { + continue; + } + if !entry.header().entry_type().is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "package archive CLI entry is not a regular file", + ) + .into()); + } + + if let Some(parent) = target_file.parent() { + std::fs::create_dir_all(parent)?; + } + let mut output = + std::fs::OpenOptions::new().write(true).create_new(true).open(target_file)?; + std::io::copy(&mut entry, &mut output)?; + tracing::debug!("Extract tgz file finished"); + return Ok(()); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "package archive does not contain the expected CLI entry", + ) + .into()) +} + +#[derive(Clone, Copy)] +enum TgzExtraction<'a> { + Archive { expected_hash: Option<&'a str> }, + File { path: &'a Path, expected_hash: Option<&'a str> }, +} + /// Download a tgz file from a URL and extract it to a target directory with optional hash verification. /// /// # Arguments @@ -311,13 +369,52 @@ pub(crate) async fn download_and_extract_tgz_with_hash( expected_hash: Option<&str>, message: Option<&str>, ) -> Result<(), Error> { - let target_dir = target_dir.as_ref().to_path_buf(); - tracing::debug!( - "Start download and extract {} to {:?}, expected hash: {:?}", + download_and_extract_tgz( + url, + target_dir.as_ref(), + TgzExtraction::Archive { expected_hash }, + message, + ) + .await +} + +/// Download a tgz archive, extract only `archive_file`, and verify that file. +/// +/// The selected path must be a safe relative path made entirely of normal path +/// components. All other archive entries are ignored. +pub(crate) async fn download_and_extract_tgz_file_with_hash( + url: &str, + target_dir: impl AsRef, + archive_file: impl AsRef, + expected_hash: Option<&str>, + message: Option<&str>, +) -> Result<(), Error> { + let archive_file = archive_file.as_ref(); + if archive_file.as_os_str().is_empty() + || !archive_file.components().all(|component| matches!(component, Component::Normal(_))) + { + return Err(Error::InvalidArgument( + "archive file path must be a safe relative path".into(), + )); + } + + download_and_extract_tgz( url, - target_dir, - expected_hash - ); + target_dir.as_ref(), + TgzExtraction::File { path: archive_file, expected_hash }, + message, + ) + .await +} + +async fn download_and_extract_tgz( + url: &str, + target_dir: &Path, + extraction: TgzExtraction<'_>, + message: Option<&str>, +) -> Result<(), Error> { + let target_dir = target_dir.to_path_buf(); + tracing::debug!("Start download and extract {} to {:?}", url, target_dir); // This is the single retry layer for the whole download → verify → extract // pipeline: each attempt does one download (no nested retry — see @@ -326,27 +423,25 @@ pub(crate) async fn download_and_extract_tgz_with_hash( // attempt. A 404 (version not found) and permanent config errors fail fast // and propagate unchanged so the caller in `package_manager.rs` can map a // 404 to `PackageManagerVersionNotFound`. - (|| async { - download_and_extract_tgz_with_hash_once(url, &target_dir, expected_hash, message).await - }) - .retry( - ExponentialBuilder::default() - .with_jitter() - .with_min_delay(Duration::from_millis(500)) - .with_max_times(3), - ) - .when(is_retryable_download_error) - .await + (|| async { download_and_extract_tgz_once(url, &target_dir, extraction, message).await }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .when(is_retryable_download_error) + .await } /// A single download → verify → extract attempt. /// /// Starts from clean state by removing and recreating `target_dir`, so a /// partially-extracted or corrupt prior attempt cannot interfere with a retry. -async fn download_and_extract_tgz_with_hash_once( +async fn download_and_extract_tgz_once( url: &str, target_dir: &Path, - expected_hash: Option<&str>, + extraction: TgzExtraction<'_>, message: Option<&str>, ) -> Result<(), Error> { // Reset target directory so a partial prior attempt can't interfere. @@ -356,25 +451,45 @@ async fn download_and_extract_tgz_with_hash_once( fs::create_dir_all(target_dir).await?; // Download the tgz file with a single attempt (no internal retry). The - // pipeline retry in `download_and_extract_tgz_with_hash` owns all retries; + // pipeline retry in `download_and_extract_tgz` owns all retries; // 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); client.download_file(url, &tgz_file, message).await?; - // Verify hash if provided - if let Some(expected_hash) = expected_hash { - verify_file_hash(&tgz_file, expected_hash).await?; - } + match extraction { + TgzExtraction::Archive { expected_hash } => { + if let Some(expected_hash) = expected_hash { + verify_file_hash(&tgz_file, expected_hash).await?; + } + + let tgz_file_for_extract = tgz_file.clone(); + let target_dir_for_extract = target_dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + extract_tgz(&tgz_file_for_extract, &target_dir_for_extract) + }) + .await??; + } + TgzExtraction::File { path, expected_hash } => { + let target_file = target_dir.join(path); + let tgz_file_for_extract = tgz_file.clone(); + let archive_file_for_extract = path.to_path_buf(); + let target_file_for_extract = target_file.clone(); + tokio::task::spawn_blocking(move || { + extract_tgz_file( + &tgz_file_for_extract, + &archive_file_for_extract, + &target_file_for_extract, + ) + }) + .await??; - // Extract the tgz file to the target directory - let tgz_file_for_extract = tgz_file.clone(); - let target_dir_for_extract = target_dir.to_path_buf(); - tokio::task::spawn_blocking(move || { - extract_tgz(&tgz_file_for_extract, &target_dir_for_extract) - }) - .await??; + if let Some(expected_hash) = expected_hash { + verify_file_hash(&target_file, expected_hash).await?; + } + } + } // Remove the temp file fs::remove_file(&tgz_file).await?; @@ -383,7 +498,7 @@ async fn download_and_extract_tgz_with_hash_once( } /// Predicate for the single download → verify → extract retry in -/// [`download_and_extract_tgz_with_hash`]. +/// [`download_and_extract_tgz`]. /// /// Retries transient failures that a fresh re-download can fix; everything else /// fails fast: From 96029483c020325a7d42a2bb7f24154c00f6b21a Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 12:55:02 +0800 Subject: [PATCH 04/12] fix(install): name the artifact a packageManager hash covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Hash mismatch: expected …, got …` named neither the package manager nor the file that was hashed, so #2209 read as a corrupt download instead of two tools hashing different artifacts. Report both, and say which artifact the pin covers. `vp run` and `vp exec` dropped the failure into a debug log and continued without the managed package manager, so the command failed later with "yarn not found" and no hint about the pin. Stop on an integrity failure there, the way `vp install` already does. --- .../install_yarn_corepack_hash/snapshots.toml | 11 ++++ .../install_yarn_corepack_hash_mismatch.md | 29 +++++++++ crates/vp_error/src/lib.rs | 25 ++++++++ crates/vp_global_cli/src/shim/dispatch.rs | 5 +- crates/vp_pm_cli/src/package_manager.rs | 59 +++++++++++++++--- docs/guide/install.md | 2 + packages/cli/binding/src/cli/mod.rs | 60 +++++++++++++++++-- packages/cli/binding/src/exec/workspace.rs | 13 +++- 8 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index 338ca68b03..2a1be3ad32 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -9,6 +9,17 @@ steps = [ { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "The verified Yarn CLI binary is cached" }, ] +[[case]] +name = "install_yarn_corepack_hash_mismatch" +vp = "global" +env = { YARN_ENABLE_TELEMETRY = "0" } +steps = [ + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content the pin does not cover", snapshot = false }, + { argv = ["vp", "install"], comment = "The error names the artifact the hash covers, and no download repairs it", continue-on-failure = true }, +] + [[case]] name = "run_yarn_corepack_hash" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md new file mode 100644 index 0000000000..704d5b40a1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -0,0 +1,29 @@ +# install_yarn_corepack_hash_mismatch + +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` + +Ensure the Corepack-pinned Yarn version is not cached + + +## `vp install` + +Cache the verified Yarn CLI + + +## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` + +Replace the cached CLI with content the pin does not cover + + +## `vp install` + +The error names the artifact the hash covers, and no download repairs it + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js), the artifact Corepack pins. +``` diff --git a/crates/vp_error/src/lib.rs b/crates/vp_error/src/lib.rs index f6f2e4edd7..bf6315153b 100644 --- a/crates/vp_error/src/lib.rs +++ b/crates/vp_error/src/lib.rs @@ -112,6 +112,13 @@ pub enum Error { #[error("Hash mismatch: expected {expected}, got {actual}")] HashMismatch { expected: Str, actual: Str }, + /// A `packageManager` integrity pin did not match the artifact it covers. + /// + /// Boxed so this one rare variant does not widen every `Result<_, Error>` + /// in the CLI (`clippy::result_large_err`). + #[error(transparent)] + PackageManagerHashMismatch(#[from] Box), + #[error("Invalid hash format: {0}")] InvalidHashFormat(Str), @@ -131,3 +138,21 @@ pub enum Error { #[error(transparent)] Anyhow(#[from] anyhow::Error), } + +/// Details of a failed `packageManager` integrity check. +/// +/// `basis` names the hashed artifact. Corepack pins Yarn 2+ from the extracted +/// CLI and every other package manager from the npm tarball, so a bare "hash +/// mismatch" reads like a corrupt download. +#[derive(Error, Debug)] +#[error( + "Hash mismatch for {name}@{version}: expected {expected}, got {actual}\n\ + The `packageManager` hash covers {basis}, the artifact Corepack pins." +)] +pub struct PackageManagerHashMismatch { + pub name: Str, + pub version: Str, + pub expected: Str, + pub actual: Str, + pub basis: Str, +} diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 6638d0da2a..ed73912462 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1529,7 +1529,10 @@ mod tests { let result = resolve_matching_package_manager_tool(&cwd, "yarn").await; assert!( - matches!(result, Err(Error::Install(vp_error::Error::HashMismatch { .. }))), + matches!( + result, + Err(Error::Install(vp_error::Error::PackageManagerHashMismatch { .. })) + ), "the global Yarn shim must reject a corrupted pinned cache: {result:?}" ); } diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 362c5f170d..903132fb75 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -872,7 +872,9 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; + return download_pnpm_native_package_manager(&version, &home_dir, expected_hash) + .await + .map_err(|error| name_hashed_artifact(error, package_manager_type, &version, false)); } let tgz_url = get_npm_package_tgz_url(&package_name, &version); @@ -884,7 +886,7 @@ pub async fn download_package_manager( // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) if is_package_manager_install_complete(&install_dir, &bin_name)? { if is_modern_yarn { - verify_yarn_binary_hash(&install_dir, expected_hash).await?; + verify_yarn_binary_hash(&install_dir, expected_hash, &version).await?; } return Ok((install_dir, package_name, version)); } @@ -928,7 +930,7 @@ pub async fn download_package_manager( url: tgz_url.into(), } } else { - err + name_hashed_artifact(err, package_manager_type, &version, is_modern_yarn) } })?; @@ -957,7 +959,7 @@ pub async fn download_package_manager( if is_package_manager_install_complete(&install_dir, &bin_name)? { tracing::debug!("install already complete after lock acquisition, skip rename"); if is_modern_yarn { - verify_yarn_binary_hash(&install_dir, expected_hash).await?; + verify_yarn_binary_hash(&install_dir, expected_hash, &version).await?; } return Ok((install_dir, package_name, version)); } @@ -978,13 +980,43 @@ pub async fn download_package_manager( async fn verify_yarn_binary_hash( package_dir: impl AsRef, expected_hash: Option<&str>, + version: &str, ) -> Result<(), Error> { if let Some(expected_hash) = expected_hash { - verify_file_hash(package_dir.as_ref().join("bin/yarn.js"), expected_hash).await?; + verify_file_hash(package_dir.as_ref().join("bin/yarn.js"), expected_hash).await.map_err( + |error| name_hashed_artifact(error, PackageManagerType::Yarn, version, true), + )?; } Ok(()) } +/// Name the artifact a `packageManager` hash covers in an integrity failure. +/// +/// `Error::HashMismatch` alone reads like a corrupt download, which sent the +/// reporter of #2209 looking for a network problem instead of a hash basis. +fn name_hashed_artifact( + error: Error, + package_manager_type: PackageManagerType, + version: &str, + is_modern_yarn: bool, +) -> Error { + let Error::HashMismatch { expected, actual } = error else { + return error; + }; + Error::PackageManagerHashMismatch(Box::new(vp_error::PackageManagerHashMismatch { + name: package_manager_type.to_string().into(), + version: version.into(), + expected, + actual, + basis: if is_modern_yarn { + "the extracted Yarn CLI (bin/yarn.js)" + } else { + "the npm package tarball" + } + .into(), + })) +} + /// Open a lock file without truncating it. This is required on Windows /// where `File::create` implies truncation, which is forbidden when another /// process holds an exclusive lock on the file. @@ -3169,7 +3201,11 @@ mod tests { let result = PackageManager::builder(temp_dir_path).build().await; assert!(result.is_err()); // Check if it's the expected error type - if let Err(Error::HashMismatch { expected, actual }) = result { + if let Err(Error::PackageManagerHashMismatch(mismatch)) = result { + let vp_error::PackageManagerHashMismatch { name, version, expected, actual, basis } = + *mismatch; + assert_eq!(name, "yarn"); + assert_eq!(version, "1.22.21"); assert_eq!( expected, "sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" @@ -3178,8 +3214,10 @@ mod tests { actual, "sha512.ca75da26c00327d26267ce33536e5790f18ebd53266796fbb664d2a4a5116308042dd8ee7003b276a20eace7d3c5561c3577bdd71bcb67071187af124779620a" ); + // Yarn Classic ships the CLI in the tarball corepack pins. + assert_eq!(basis, "the npm package tarball"); } else { - panic!("Expected HashMismatch error"); + panic!("Expected PackageManagerHashMismatch error"); } } @@ -3513,7 +3551,12 @@ mod tests { let result = download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) .await; - assert!(matches!(result, Err(Error::HashMismatch { .. }))); + let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { + panic!("a corrupted cached CLI must fail the integrity check: {result:?}"); + }; + let message = error.to_string(); + assert!(message.contains("yarn@4.17.1"), "{message}"); + assert!(message.contains("bin/yarn.js"), "{message}"); assert_eq!(mock.hits(), 1, "cached installs should be verified without downloading"); } diff --git a/docs/guide/install.md b/docs/guide/install.md index 8d1bb42523..24be67132b 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -39,6 +39,8 @@ A range resolves to an already-downloaded satisfying version when possible, othe Vite+ currently downloads the declared package manager (the `onFail: "download"` behavior); the other `onFail` values are accepted but not yet differentiated. +A `packageManager` pin can carry an integrity hash (`yarn@4.17.1+sha512.…`), which `corepack use` writes. Vite+ verifies the artifact Corepack hashes: the extracted CLI binary (`bin/yarn.js`) for Yarn 2 and later, and the npm tarball for npm, pnpm, and Yarn Classic. A Yarn 2+ pin is re-checked against the cached CLI on every command, so a modified cache fails the check instead of running. + The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. ## Usage diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index f5df698019..67e59b902c 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -217,6 +217,7 @@ async fn envs_with_explicit_package_manager_path( .await { Ok(result) => result, + Err(error) if is_package_manager_integrity_failure(&error) => return Err(error), Err(error) => { tracing::debug!( ?error, @@ -229,6 +230,16 @@ async fn envs_with_explicit_package_manager_path( Ok(prepend_to_env_path(&envs, &install_dir.join("bin"))) } +/// Whether an error means the pinned package manager failed its integrity check. +/// +/// Every other reason to miss the managed package manager (no network, an +/// unknown version) leaves the command usable through PATH, so it stays a debug +/// log. An integrity failure does not: dropping it here turns a wrong +/// `packageManager` hash into "command not found" further down. +pub(crate) fn is_package_manager_integrity_failure(error: &Error) -> bool { + matches!(error, Error::PackageManagerHashMismatch(_) | Error::HashMismatch { .. }) +} + /// Execute a vite-task command (run, cache) through Session. async fn execute_vite_task_command( command: vt::Command, @@ -257,9 +268,15 @@ async fn execute_vite_task_command( let mut config_loader = VitePlusConfigLoader::new(resolve_vite_config_fn); // Update PATH to include package manager bin directory BEFORE session init - if let Ok(pm) = vp_pm_cli::PackageManager::builder(&cwd).build().await { - let bin_prefix = pm.get_bin_prefix(); - let _ = prepend_to_path_env(&bin_prefix, PrependOptions::default()); + match vp_pm_cli::PackageManager::builder(&cwd).build().await { + Ok(pm) => { + let bin_prefix = pm.get_bin_prefix(); + let _ = prepend_to_path_env(&bin_prefix, PrependOptions::default()); + } + Err(error) if is_package_manager_integrity_failure(&error) => return Err(error), + Err(error) => { + tracing::debug!(?error, "failed to resolve package manager for task PATH setup"); + } } let session = Session::init(SessionConfig { @@ -441,7 +458,7 @@ mod tests { use vt::config::UserRunConfig; use vt_path::AbsolutePathBuf; - use super::{envs_with_explicit_package_manager_path, prepend_to_env_path}; + use super::{Error, envs_with_explicit_package_manager_path, prepend_to_env_path}; fn envs_with_path(path: &std::ffi::OsStr) -> Arc, Arc>> { Arc::new(FxHashMap::from_iter([(Arc::from(OsStr::new("PATH")), Arc::from(path))])) @@ -533,6 +550,41 @@ mod tests { fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); } + #[tokio::test] + async fn stops_when_the_pinned_package_manager_fails_its_integrity_check() { + let suffix = + SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); + let temp_dir = std::env::temp_dir().join(format!("vite-plus-bad-hash-{suffix}")); + let vp_home = temp_dir.join("vp-home"); + let bin_dir = + vp_home.join("package_manager").join("yarn").join("4.17.1").join("yarn").join("bin"); + fs::create_dir_all(&bin_dir).expect("cached package manager should be created"); + for shim in ["yarn", "yarn.cmd", "yarn.ps1"] { + fs::write(bin_dir.join(shim), "shim").expect("shim should be written"); + } + fs::write(bin_dir.join("yarn.js"), "corrupt").expect("CLI should be written"); + + let expected_hash = format!("sha512.{}", "0".repeat(128)); + fs::write( + temp_dir.join("package.json"), + format!(r#"{{"name":"fixture","packageManager":"yarn@4.17.1+{expected_hash}"}}"#), + ) + .expect("package.json should be written"); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).expect("temp dir should be absolute"); + let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); + let envs = envs_with_path(original_path.as_os_str()); + + let _guard = + vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&vp_home)); + let result = envs_with_explicit_package_manager_path(&cwd, envs).await; + + assert!( + matches!(result, Err(Error::PackageManagerHashMismatch(_))), + "an integrity failure must reach the user instead of a missing command: {result:?}" + ); + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); + } + #[tokio::test] async fn ignores_lockfile_without_explicit_package_manager() { let suffix = diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index 3c65c59e9c..f790476471 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -108,9 +108,16 @@ pub(super) async fn execute_exec_workspace( // Build base PATH: :: let base_path_dirs: Vec = { let mut dirs = Vec::new(); - // Include package manager bin dir - if let Ok(pm) = vp_pm_cli::PackageManager::builder(&*workspace_root.path).build().await { - dirs.push(pm.get_bin_prefix().as_path().to_path_buf()); + // Include package manager bin dir. An integrity failure stops the run: + // see `is_package_manager_integrity_failure`. + match vp_pm_cli::PackageManager::builder(&*workspace_root.path).build().await { + Ok(pm) => dirs.push(pm.get_bin_prefix().as_path().to_path_buf()), + Err(error) if crate::cli::is_package_manager_integrity_failure(&error) => { + return Err(error); + } + Err(error) => { + tracing::debug!(?error, "failed to resolve package manager for exec PATH setup"); + } } // Include workspace root's node_modules/.bin let ws_bin = workspace_root.path.join("node_modules").join(".bin"); From 9a25c8e3f2fb8e733d88c5fc472f113192b47798 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 13:05:26 +0800 Subject: [PATCH 05/12] fix(install): treat every Yarn 2.x prerelease as Berry Corepack matches its `>=2.0.0` Yarn range with `satisfiesWithPrereleases`, which drops the prerelease tag before comparing, so `yarn@4.0.0-rc.53` is a Berry pin there. Comparing full semver sent it to the Yarn Classic package, which never published that version. `corepack use yarn@4.0.0-rc.53` now installs under `vp install`, and the cached bin/yarn.js matches the hash Corepack wrote. --- crates/vp_pm_cli/src/package_manager.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 903132fb75..985d3a57f5 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -113,10 +113,17 @@ impl PackageManagerType { /// Whether Corepack integrity pins for this package-manager version cover /// the extracted CLI binary rather than the npm tarball. + /// + /// Corepack splits Yarn at 2.0.0 and matches that range with + /// `satisfiesWithPrereleases`, which drops the prerelease tag before it + /// compares. Every 2.x prerelease is therefore a Berry version to Corepack, + /// so this compares the major alone; `VersionReq(">=2.0.0")` would exclude + /// `4.0.0-rc.53` and send it to the Yarn Classic package, which never + /// published it. #[must_use] pub fn uses_cli_binary_hash(self, version: &str) -> bool { matches!(self, Self::Yarn) - && Version::parse(version).is_ok_and(|version| version >= Version::new(2, 0, 0)) + && Version::parse(version).is_ok_and(|version| version.major >= 2) } } @@ -1995,11 +2002,16 @@ mod tests { #[test] fn test_uses_cli_binary_hash_only_for_modern_yarn() { assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("1.22.22")); - assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("2.0.0-rc.1")); assert!(PackageManagerType::Yarn.uses_cli_binary_hash("2.0.0")); assert!(PackageManagerType::Yarn.uses_cli_binary_hash("4.17.1")); assert!(!PackageManagerType::Pnpm.uses_cli_binary_hash("10.0.0")); assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("latest")); + + // Corepack drops the prerelease tag before it matches its `>=2.0.0` + // range, so a 2.x prerelease pin is a Berry pin there too. `corepack + // use yarn@4.0.0-rc.53` writes a hash of `bin/yarn.js`. + assert!(PackageManagerType::Yarn.uses_cli_binary_hash("2.0.0-rc.1")); + assert!(PackageManagerType::Yarn.uses_cli_binary_hash("4.0.0-rc.53")); } #[test] From 77e713317d3a29b0cf12ba5ef1869b7be908e71f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 13:36:55 +0800 Subject: [PATCH 06/12] refactor(install): give the cached-CLI check one home The shim had to restate Corepack's Yarn rule to decide whether it could take its fast path, so the same security-relevant condition lived in two crates. `ensure_package_manager_bin` owns it instead, and the shim asks for a bin path without knowing which package manager hashes what. Also from the review pass: - one `download_and_extract_tgz_with_hash` with an optional archive entry, in place of the two-variant extraction enum - `verify_cached_cli_hash` decides whether a pin covers a cached file, so neither early return repeats the test - `name_hashed_artifact` derives the artifact name; the pnpm branch maps at the pin check, so a `dist.integrity` failure keeps its own message - `Error::is_integrity_failure` replaces the predicate the binding exported to its own exec module - `is_yarn_berry` is shared with the Yarn dialect's `is_berry` - `verify_file_hash` streams in 64 KiB chunks on the blocking pool instead of slurping the artifact, which the shim path now reads on every command --- crates/vp_error/src/lib.rs | 13 ++ crates/vp_global_cli/src/shim/dispatch.rs | 26 +-- crates/vp_pm_cli/src/lib.rs | 4 +- crates/vp_pm_cli/src/package_manager.rs | 243 +++++++++++---------- crates/vp_pm_cli/src/request.rs | 182 +++++++-------- crates/vp_pm_cli/src/resolution/dialect.rs | 2 +- packages/cli/binding/src/cli/mod.rs | 18 +- packages/cli/binding/src/exec/workspace.rs | 8 +- 8 files changed, 247 insertions(+), 249 deletions(-) diff --git a/crates/vp_error/src/lib.rs b/crates/vp_error/src/lib.rs index bf6315153b..a52e0fe3b4 100644 --- a/crates/vp_error/src/lib.rs +++ b/crates/vp_error/src/lib.rs @@ -139,6 +139,19 @@ pub enum Error { Anyhow(#[from] anyhow::Error), } +impl Error { + /// Whether this error means a downloaded or cached artifact failed its + /// integrity check. + /// + /// Callers that otherwise fall back when a managed tool is unavailable use + /// this to stop instead: an unverified artifact is the user's to fix, and + /// falling back hides it behind a later, unrelated failure. + #[must_use] + pub const fn is_integrity_failure(&self) -> bool { + matches!(self, Self::PackageManagerHashMismatch(_) | Self::HashMismatch { .. }) + } +} + /// Details of a failed `packageManager` integrity check. /// /// `basis` names the hashed artifact. Corepack pins Yarn 2+ from the extracted diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index ed73912462..0f40b95b65 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -6,8 +6,7 @@ //! 3. Tool execution (core tools and package binaries) use vp_pm_cli::{ - PackageManagerType, download_package_manager, package_manager_bin_path, - package_manager_install_dir, resolve_package_manager_from_package_json, + PackageManagerType, ensure_package_manager_bin, resolve_package_manager_from_package_json, }; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -686,29 +685,14 @@ async fn resolve_matching_package_manager_tool( } let bin_name = expected_type.bin_name_for_tool(tool); - - // Keep the hot path for unpinned installs and archive-hashed package - // managers. A Corepack-style Yarn pin covers the extracted CLI, so it must - // pass through download_package_manager even when the shim already exists; - // that path re-verifies the cached yarn.js before it can be executed. - let must_verify_cached_cli = - resolution.hash.is_some() && expected_type.uses_cli_binary_hash(&resolution.version); - if !must_verify_cached_cli { - if let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) { - let bin_path = package_manager_bin_path(&install_dir, bin_name); - if bin_path.as_path().exists() { - return Ok(Some(bin_path)); - } - } - } - - let (install_dir, _, _) = download_package_manager( - resolution.package_manager_type, + let bin_path = ensure_package_manager_bin( + expected_type, &resolution.version, resolution.hash.as_deref(), + bin_name, ) .await?; - Ok(Some(package_manager_bin_path(&install_dir, bin_name))) + Ok(Some(bin_path)) } async fn prepend_js_child_process_path_env( diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index de091c6f2a..98b025aac8 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -23,8 +23,8 @@ pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; pub use error::Error; pub use package_manager::{ PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, - PackageManagerType, download_package_manager, get_package_manager_type_and_version, - package_manager_bin_path, package_manager_install_dir, + PackageManagerType, download_package_manager, ensure_package_manager_bin, + get_package_manager_type_and_version, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; pub use request::HttpClient; diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 985d3a57f5..5ef2944a5f 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -16,8 +16,6 @@ use crossterm::{ terminal, }; use semver::Version; -#[cfg(test)] -use semver::VersionReq; use serde::{Deserialize, Serialize}; use tokio::fs::remove_dir_all; use vp_error::Error; @@ -30,10 +28,7 @@ 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_file_with_hash, download_and_extract_tgz_with_hash, - verify_file_hash, - }, + request::{HttpClient, download_and_extract_tgz_with_hash, verify_file_hash}, shim, }; @@ -113,20 +108,35 @@ impl PackageManagerType { /// Whether Corepack integrity pins for this package-manager version cover /// the extracted CLI binary rather than the npm tarball. - /// - /// Corepack splits Yarn at 2.0.0 and matches that range with - /// `satisfiesWithPrereleases`, which drops the prerelease tag before it - /// compares. Every 2.x prerelease is therefore a Berry version to Corepack, - /// so this compares the major alone; `VersionReq(">=2.0.0")` would exclude - /// `4.0.0-rc.53` and send it to the Yarn Classic package, which never - /// published it. #[must_use] pub fn uses_cli_binary_hash(self, version: &str) -> bool { - matches!(self, Self::Yarn) - && Version::parse(version).is_ok_and(|version| version.major >= 2) + Version::parse(version).is_ok_and(|version| self.hashes_cli_binary_of(&version)) + } + + /// [`Self::uses_cli_binary_hash`] for a version the caller already parsed. + #[must_use] + pub fn hashes_cli_binary_of(self, version: &Version) -> bool { + matches!(self, Self::Yarn) && is_yarn_berry(version) } } +/// Path of the Yarn CLI inside `@yarnpkg/cli-dist`, relative to the package root. +/// +/// Corepack pins Yarn 2+ by hashing this file, so the download, the cached-CLI +/// check, and the error message must all name the same path. +const YARN_CLI_ENTRY: &str = "bin/yarn.js"; + +/// Whether a Yarn version is Berry (Yarn 2 and later). +/// +/// Corepack splits Yarn at 2.0.0 and matches that range with +/// `satisfiesWithPrereleases`, which drops the prerelease tag before it +/// compares. Every 2.x prerelease is Berry there, so this compares the major +/// alone; `VersionReq(">=2.0.0")` would exclude `4.0.0-rc.53` and send it to +/// the Yarn Classic package, which never published it. +pub(crate) fn is_yarn_berry(version: &Version) -> bool { + version.major >= 2 +} + /// Package-manager resolution from an explicit project `packageManager` field. #[derive(Debug, Clone)] pub struct PackageManagerResolution { @@ -855,7 +865,7 @@ pub async fn download_package_manager( ) })?; - let is_modern_yarn = package_manager_type.uses_cli_binary_hash(&version); + let is_modern_yarn = package_manager_type.hashes_cli_binary_of(&parsed_version); let mut package_name: Str = package_manager_type.to_string().into(); // handle yarn >= 2.0.0 to use `@yarnpkg/cli-dist` as package name // @see https://github.com/nodejs/corepack/blob/main/config.json#L135 @@ -879,9 +889,7 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, &home_dir, expected_hash) - .await - .map_err(|error| name_hashed_artifact(error, package_manager_type, &version, false)); + return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; } let tgz_url = get_npm_package_tgz_url(&package_name, &version); @@ -892,9 +900,7 @@ pub async fn download_package_manager( // If all shims already exist, return the target directory // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) if is_package_manager_install_complete(&install_dir, &bin_name)? { - if is_modern_yarn { - verify_yarn_binary_hash(&install_dir, expected_hash, &version).await?; - } + verify_cached_cli_hash(package_manager_type, &install_dir, expected_hash, &version).await?; return Ok((install_dir, package_name, version)); } @@ -907,25 +913,18 @@ pub async fn download_package_manager( let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading {package_manager_type} v{version}..."); - let download_result = if is_modern_yarn { - download_and_extract_tgz_file_with_hash( - &tgz_url, - &target_dir_tmp, - "package/bin/yarn.js", - expected_hash, - Some(&download_message), - ) - .await - } else { - download_and_extract_tgz_with_hash( - &tgz_url, - &target_dir_tmp, - expected_hash, - Some(&download_message), - ) - .await - }; - download_result.map_err(|err| { + // A Corepack Yarn 2+ pin only covers the CLI, so the rest of that archive + // stays unauthenticated and is never written to disk. + let archive_file = is_modern_yarn.then(|| PathBuf::from(format!("package/{YARN_CLI_ENTRY}"))); + download_and_extract_tgz_with_hash( + &tgz_url, + &target_dir_tmp, + archive_file.as_deref(), + expected_hash, + Some(&download_message), + ) + .await + .map_err(|err| { // status 404 means the version is not found, convert to PackageManagerVersionNotFound error if let Error::Reqwest(e) = &err && let Some(status) = e.status() @@ -937,7 +936,7 @@ pub async fn download_package_manager( url: tgz_url.into(), } } else { - name_hashed_artifact(err, package_manager_type, &version, is_modern_yarn) + name_hashed_artifact(err, package_manager_type, &version) } })?; @@ -965,9 +964,7 @@ pub async fn download_package_manager( // the install is all-or-nothing) if is_package_manager_install_complete(&install_dir, &bin_name)? { tracing::debug!("install already complete after lock acquisition, skip rename"); - if is_modern_yarn { - verify_yarn_binary_hash(&install_dir, expected_hash, &version).await?; - } + verify_cached_cli_hash(package_manager_type, &install_dir, expected_hash, &version).await?; return Ok((install_dir, package_name, version)); } @@ -983,18 +980,56 @@ pub async fn download_package_manager( Ok((install_dir, package_name, version)) } -/// Corepack hashes the extracted Yarn 2+ CLI instead of the npm tarball. -async fn verify_yarn_binary_hash( - package_dir: impl AsRef, +/// Resolve the executable path of a managed package manager, installing it when +/// the cache cannot serve it. +/// +/// Takes the fast path when the shim already exists, except for a pin that +/// covers a file inside the install: [`verify_cached_cli_hash`] has to re-read +/// that file before anything executes it. Keeping that rule here is what lets +/// the shim hot path stay free of package-manager specifics. +pub async fn ensure_package_manager_bin( + package_manager_type: PackageManagerType, + version: &str, + expected_hash: Option<&str>, + bin_name: &str, +) -> Result { + let verifies_cached_cli = + expected_hash.is_some() && package_manager_type.uses_cli_binary_hash(version); + if !verifies_cached_cli + && let Some(install_dir) = package_manager_install_dir(package_manager_type, version) + { + let bin_path = package_manager_bin_path(&install_dir, bin_name); + if bin_path.as_path().exists() { + return Ok(bin_path); + } + } + + let (install_dir, _, _) = + download_package_manager(package_manager_type, version, expected_hash).await?; + Ok(package_manager_bin_path(&install_dir, bin_name)) +} + +/// Re-check a cached CLI against a pin that covers it. +/// +/// Corepack hashes the extracted Yarn 2+ CLI instead of the npm tarball, so +/// that pin can be checked without downloading anything. Every other pin names +/// a tarball vp no longer keeps, and passes here unchecked. +async fn verify_cached_cli_hash( + package_manager_type: PackageManagerType, + install_dir: &AbsolutePath, expected_hash: Option<&str>, version: &str, ) -> Result<(), Error> { - if let Some(expected_hash) = expected_hash { - verify_file_hash(package_dir.as_ref().join("bin/yarn.js"), expected_hash).await.map_err( - |error| name_hashed_artifact(error, PackageManagerType::Yarn, version, true), - )?; + let Some(expected_hash) = expected_hash else { + return Ok(()); + }; + if !package_manager_type.uses_cli_binary_hash(version) { + return Ok(()); } - Ok(()) + + verify_file_hash(install_dir.join(YARN_CLI_ENTRY), expected_hash) + .await + .map_err(|error| name_hashed_artifact(error, package_manager_type, version)) } /// Name the artifact a `packageManager` hash covers in an integrity failure. @@ -1005,22 +1040,21 @@ fn name_hashed_artifact( error: Error, package_manager_type: PackageManagerType, version: &str, - is_modern_yarn: bool, ) -> Error { let Error::HashMismatch { expected, actual } = error else { return error; }; + let basis: Str = if package_manager_type.uses_cli_binary_hash(version) { + vt_str::format!("the extracted Yarn CLI ({YARN_CLI_ENTRY})").into() + } else { + "the npm package tarball".into() + }; Error::PackageManagerHashMismatch(Box::new(vp_error::PackageManagerHashMismatch { name: package_manager_type.to_string().into(), version: version.into(), expected, actual, - basis: if is_modern_yarn { - "the extracted Yarn CLI (bin/yarn.js)" - } else { - "the npm package tarball" - } - .into(), + basis, })) } @@ -1109,6 +1143,7 @@ async fn download_bun_package_manager( download_and_extract_tgz_with_hash( &platform_tgz_url, &target_dir_tmp, + None, platform_hash.as_deref(), Some(&download_message), ) @@ -1283,10 +1318,12 @@ async fn download_pnpm_native_package_manager( download_and_extract_tgz_with_hash( &main_tgz_url, verify_dir.path(), + None, Some(expected_hash), Some(&verify_message), ) - .await?; + .await + .map_err(|error| name_hashed_artifact(error, PackageManagerType::Pnpm, version))?; } // The declared hash never covers the platform tarball, so verify it @@ -1306,6 +1343,7 @@ async fn download_pnpm_native_package_manager( download_and_extract_tgz_with_hash( &platform_tgz_url, &target_dir_tmp, + None, platform_hash.as_deref(), Some(&download_message), ) @@ -1781,6 +1819,7 @@ fn simple_text_prompt() -> Result { mod tests { use std::fs; + use semver::VersionReq; use tempfile::{TempDir, tempdir}; use vp_shared::EnvConfig; @@ -1790,7 +1829,11 @@ mod tests { tempdir().expect("Failed to create temp directory") } - fn create_yarn_package_tgz(yarn_js: &[u8]) -> Vec { + /// Build an `@yarnpkg/cli-dist` style tarball around `yarn_js`. + /// + /// `symlink_target` adds a `package/bin/yarn` symlink, the archive-controlled + /// entry an unauthenticated tarball could use to write outside the install. + fn create_yarn_package_tgz(yarn_js: &[u8], symlink_target: Option<&Path>) -> Vec { let mut tar_builder = tar::Builder::new(Vec::new()); let mut header = tar::Header::new_gnu(); header.set_size(yarn_js.len() as u64); @@ -1799,33 +1842,15 @@ mod tests { .append_data(&mut header, "package/bin/yarn.js", std::io::Cursor::new(yarn_js)) .unwrap(); - let tar_data = tar_builder.into_inner().unwrap(); - let mut gz_data = Vec::new(); - { - let mut encoder = - flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default()); - std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap(); + if let Some(symlink_target) = symlink_target { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_link_name(symlink_target).unwrap(); + header.set_cksum(); + tar_builder.append_data(&mut header, "package/bin/yarn", std::io::empty()).unwrap(); } - gz_data - } - - fn create_yarn_package_tgz_with_symlink(yarn_js: &[u8], link_target: &Path) -> Vec { - let mut tar_builder = tar::Builder::new(Vec::new()); - - let mut header = tar::Header::new_gnu(); - header.set_size(yarn_js.len() as u64); - header.set_mode(0o755); - tar_builder - .append_data(&mut header, "package/bin/yarn.js", std::io::Cursor::new(yarn_js)) - .unwrap(); - - let mut header = tar::Header::new_gnu(); - header.set_entry_type(tar::EntryType::Symlink); - header.set_size(0); - header.set_mode(0o777); - header.set_link_name(link_target).unwrap(); - header.set_cksum(); - tar_builder.append_data(&mut header, "package/bin/yarn", std::io::empty()).unwrap(); let tar_data = tar_builder.into_inner().unwrap(); let mut gz_data = Vec::new(); @@ -3211,25 +3236,23 @@ mod tests { create_package_json(&temp_dir_path, package_content); let result = PackageManager::builder(temp_dir_path).build().await; - assert!(result.is_err()); // Check if it's the expected error type - if let Err(Error::PackageManagerHashMismatch(mismatch)) = result { - let vp_error::PackageManagerHashMismatch { name, version, expected, actual, basis } = - *mismatch; - assert_eq!(name, "yarn"); - assert_eq!(version, "1.22.21"); - assert_eq!( - expected, - "sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" - ); - assert_eq!( - actual, - "sha512.ca75da26c00327d26267ce33536e5790f18ebd53266796fbb664d2a4a5116308042dd8ee7003b276a20eace7d3c5561c3577bdd71bcb67071187af124779620a" - ); - // Yarn Classic ships the CLI in the tarball corepack pins. - assert_eq!(basis, "the npm package tarball"); - } else { - panic!("Expected PackageManagerHashMismatch error"); + match &result { + Err(Error::PackageManagerHashMismatch(mismatch)) => { + assert_eq!(mismatch.name, "yarn"); + assert_eq!(mismatch.version, "1.22.21"); + assert_eq!( + mismatch.expected, + "sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + ); + assert_eq!( + mismatch.actual, + "sha512.ca75da26c00327d26267ce33536e5790f18ebd53266796fbb664d2a4a5116308042dd8ee7003b276a20eace7d3c5561c3577bdd71bcb67071187af124779620a" + ); + // Yarn Classic ships the CLI inside the tarball Corepack pins. + assert_eq!(mismatch.basis, "the npm package tarball"); + } + other => panic!("Expected PackageManagerHashMismatch error, got {other:?}"), } } @@ -3540,7 +3563,7 @@ mod tests { let vp_home = create_temp_dir(); let server = MockServer::start(); let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; - let mock_tgz = create_yarn_package_tgz(yarn_js); + let mock_tgz = create_yarn_package_tgz(yarn_js, None); let mock = server.mock(|when, then| { when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); @@ -3584,7 +3607,7 @@ mod tests { let server = MockServer::start(); let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; - let mock_tgz = create_yarn_package_tgz_with_symlink(yarn_js, &victim); + let mock_tgz = create_yarn_package_tgz(yarn_js, Some(&victim)); server.mock(|when, then| { when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); @@ -3622,9 +3645,9 @@ mod tests { net::TcpListener, }; - let bad_tgz = create_yarn_package_tgz(b"corrupt"); + let bad_tgz = create_yarn_package_tgz(b"corrupt", None); let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; - let good_tgz = create_yarn_package_tgz(yarn_js); + let good_tgz = create_yarn_package_tgz(yarn_js, None); let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index b749b8cd46..3adca5e3c0 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -346,17 +346,14 @@ fn extract_tgz_file( .into()) } -#[derive(Clone, Copy)] -enum TgzExtraction<'a> { - Archive { expected_hash: Option<&'a str> }, - File { path: &'a Path, expected_hash: Option<&'a str> }, -} - /// Download a tgz file from a URL and extract it to a target directory with optional hash verification. /// /// # Arguments /// * `url` - The URL of the tgz file to download. /// * `target_dir` - The directory to extract the tgz file to. +/// * `archive_file` - Optional single entry to extract, given as a relative path +/// of normal components. Every other entry is then ignored and never written, +/// and `expected_hash` covers that one file instead of the tgz. /// * `expected_hash` - Optional expected hash, "algorithm.hex" or SRI "algorithm-base64" (see [`verify_file_hash`]) /// * `message` - Optional message shown above a progress bar while downloading (see [`HttpClient::download_file`]) /// @@ -366,54 +363,22 @@ enum TgzExtraction<'a> { 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( - url, - target_dir.as_ref(), - TgzExtraction::Archive { expected_hash }, - message, - ) - .await -} - -/// Download a tgz archive, extract only `archive_file`, and verify that file. -/// -/// The selected path must be a safe relative path made entirely of normal path -/// components. All other archive entries are ignored. -pub(crate) async fn download_and_extract_tgz_file_with_hash( - url: &str, - target_dir: impl AsRef, - archive_file: impl AsRef, - expected_hash: Option<&str>, - message: Option<&str>, -) -> Result<(), Error> { - let archive_file = archive_file.as_ref(); - if archive_file.as_os_str().is_empty() - || !archive_file.components().all(|component| matches!(component, Component::Normal(_))) + if let Some(archive_file) = archive_file + && (archive_file.as_os_str().is_empty() + || !archive_file + .components() + .all(|component| matches!(component, Component::Normal(_)))) { return Err(Error::InvalidArgument( "archive file path must be a safe relative path".into(), )); } - download_and_extract_tgz( - url, - target_dir.as_ref(), - TgzExtraction::File { path: archive_file, expected_hash }, - message, - ) - .await -} - -async fn download_and_extract_tgz( - url: &str, - target_dir: &Path, - extraction: TgzExtraction<'_>, - message: Option<&str>, -) -> Result<(), Error> { - let target_dir = target_dir.to_path_buf(); + let target_dir = target_dir.as_ref().to_path_buf(); tracing::debug!("Start download and extract {} to {:?}", url, target_dir); // This is the single retry layer for the whole download → verify → extract @@ -423,15 +388,17 @@ async fn download_and_extract_tgz( // attempt. A 404 (version not found) and permanent config errors fail fast // 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, extraction, message).await }) - .retry( - ExponentialBuilder::default() - .with_jitter() - .with_min_delay(Duration::from_millis(500)) - .with_max_times(3), - ) - .when(is_retryable_download_error) - .await + (|| async { + download_and_extract_tgz_once(url, &target_dir, archive_file, expected_hash, message).await + }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .when(is_retryable_download_error) + .await } /// A single download → verify → extract attempt. @@ -441,7 +408,8 @@ async fn download_and_extract_tgz( async fn download_and_extract_tgz_once( url: &str, target_dir: &Path, - extraction: TgzExtraction<'_>, + archive_file: Option<&Path>, + expected_hash: Option<&str>, message: Option<&str>, ) -> Result<(), Error> { // Reset target directory so a partial prior attempt can't interfere. @@ -458,37 +426,36 @@ async fn download_and_extract_tgz_once( let client = HttpClient::with_config(0, 0); client.download_file(url, &tgz_file, message).await?; - match extraction { - TgzExtraction::Archive { expected_hash } => { - if let Some(expected_hash) = expected_hash { - verify_file_hash(&tgz_file, expected_hash).await?; - } + if let Some(archive_file) = archive_file { + // The hash covers one entry, so the archive around it is unauthenticated + // and only that entry may be written. + let target_file = target_dir.join(archive_file); + let tgz_file_for_extract = tgz_file.clone(); + let archive_file_for_extract = archive_file.to_path_buf(); + let target_file_for_extract = target_file.clone(); + tokio::task::spawn_blocking(move || { + extract_tgz_file( + &tgz_file_for_extract, + &archive_file_for_extract, + &target_file_for_extract, + ) + }) + .await??; - let tgz_file_for_extract = tgz_file.clone(); - let target_dir_for_extract = target_dir.to_path_buf(); - tokio::task::spawn_blocking(move || { - extract_tgz(&tgz_file_for_extract, &target_dir_for_extract) - }) - .await??; + if let Some(expected_hash) = expected_hash { + verify_file_hash(&target_file, expected_hash).await?; } - TgzExtraction::File { path, expected_hash } => { - let target_file = target_dir.join(path); - let tgz_file_for_extract = tgz_file.clone(); - let archive_file_for_extract = path.to_path_buf(); - let target_file_for_extract = target_file.clone(); - tokio::task::spawn_blocking(move || { - extract_tgz_file( - &tgz_file_for_extract, - &archive_file_for_extract, - &target_file_for_extract, - ) - }) - .await??; - - if let Some(expected_hash) = expected_hash { - verify_file_hash(&target_file, expected_hash).await?; - } + } else { + if let Some(expected_hash) = expected_hash { + verify_file_hash(&tgz_file, expected_hash).await?; } + + let tgz_file_for_extract = tgz_file.clone(); + let target_dir_for_extract = target_dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + extract_tgz(&tgz_file_for_extract, &target_dir_for_extract) + }) + .await??; } // Remove the temp file @@ -518,11 +485,23 @@ fn is_retryable_download_error(err: &Error) -> bool { } } -/// Computes the digest of the given content using the specified algorithm. -fn compute_digest(content: &[u8]) -> Vec { +/// Computes the digest of a file, reading it in chunks. +/// +/// Streaming keeps peak memory flat where slurping would allocate the whole +/// artifact: `bin/yarn.js` is ~3 MB and is re-hashed on every command that +/// resolves a hash-pinned Yarn. +fn digest_file(file_path: &Path) -> Result, std::io::Error> { + let mut file = std::fs::File::open(file_path)?; let mut hasher = D::new(); - hasher.update(content); - hasher.finalize().to_vec() + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let read = std::io::Read::read(&mut file, &mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().to_vec()) } /// Verify the hash of a file against an expected hash. @@ -541,8 +520,7 @@ pub(crate) async fn verify_file_hash( file_path: impl AsRef, expected_hash: &str, ) -> Result<(), Error> { - let file_path = file_path.as_ref(); - let content = fs::read(file_path).await?; + let file_path = file_path.as_ref().to_path_buf(); // "algorithm.hex" carries the hash in hex, SRI "algorithm-base64" in // base64; hex never contains '-' and base64 never contains '.', so the @@ -556,12 +534,19 @@ pub(crate) async fn verify_file_hash( return Err(Error::InvalidHashFormat(expected_hash.into())); }; - let digest = match algorithm { - "sha512" => compute_digest::(&content), - "sha256" => compute_digest::(&content), - "sha224" => compute_digest::(&content), - "sha1" => compute_digest::(&content), - _ => return Err(Error::UnsupportedHashAlgorithm(algorithm.into())), + // Read and hash on the blocking pool: hashing multi-megabyte artifacts is + // CPU-bound and would otherwise stall a runtime worker thread. + let algorithm_for_digest = algorithm.to_owned(); + let digest = tokio::task::spawn_blocking(move || match algorithm_for_digest.as_str() { + "sha512" => digest_file::(&file_path).map(Some), + "sha256" => digest_file::(&file_path).map(Some), + "sha224" => digest_file::(&file_path).map(Some), + "sha1" => digest_file::(&file_path).map(Some), + _ => Ok(None), + }) + .await??; + let Some(digest) = digest else { + return Err(Error::UnsupportedHashAlgorithm(algorithm.into())); }; let actual = if separator == '-' { base64_simd::STANDARD.encode_to_string(&digest) @@ -848,7 +833,7 @@ mod tests { }); let url = vt_str::format!("{}/test-package.tgz", server.base_url()); - let result = download_and_extract_tgz_with_hash(&url, &target_dir, None, None).await; + let result = download_and_extract_tgz_with_hash(&url, &target_dir, None, None, None).await; assert!(result.is_ok(), "Failed to download and extract: {result:?}"); assert!(target_dir.join("package/bin/yarn").exists()); @@ -880,7 +865,7 @@ mod tests { let target_dir = temp_dir.path().join("extracted"); let url = vt_str::format!("{}/corrupt.tgz", server.base_url()); - let result = download_and_extract_tgz_with_hash(&url, &target_dir, None, None).await; + let result = download_and_extract_tgz_with_hash(&url, &target_dir, None, None, None).await; assert!(result.is_err(), "corrupt archive should fail to extract: {result:?}"); assert!( mock.hits() > 1, @@ -908,7 +893,8 @@ mod tests { 0000000000000000000000000000000000000000000000000000000000000000"; let result = - download_and_extract_tgz_with_hash(&url, &target_dir, Some(wrong_hash), None).await; + download_and_extract_tgz_with_hash(&url, &target_dir, None, Some(wrong_hash), None) + .await; assert!(result.is_err(), "hash mismatch should fail: {result:?}"); assert!( mock.hits() > 1, diff --git a/crates/vp_pm_cli/src/resolution/dialect.rs b/crates/vp_pm_cli/src/resolution/dialect.rs index 27a193dea4..a07e29dec1 100644 --- a/crates/vp_pm_cli/src/resolution/dialect.rs +++ b/crates/vp_pm_cli/src/resolution/dialect.rs @@ -68,6 +68,6 @@ impl PackageManagerDialect for Npm { impl Yarn { pub(crate) fn is_berry(&self) -> bool { - self.version.major >= 2 + crate::package_manager::is_yarn_berry(&self.version) } } diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 67e59b902c..b20f854c0e 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -217,7 +217,11 @@ async fn envs_with_explicit_package_manager_path( .await { Ok(result) => result, - Err(error) if is_package_manager_integrity_failure(&error) => return Err(error), + // Every other reason to miss the managed package manager (no network, + // an unknown version) leaves the command usable through PATH, so it + // stays a debug log. Swallowing an integrity failure would turn a wrong + // `packageManager` hash into "command not found" further down. + Err(error) if error.is_integrity_failure() => return Err(error), Err(error) => { tracing::debug!( ?error, @@ -230,16 +234,6 @@ async fn envs_with_explicit_package_manager_path( Ok(prepend_to_env_path(&envs, &install_dir.join("bin"))) } -/// Whether an error means the pinned package manager failed its integrity check. -/// -/// Every other reason to miss the managed package manager (no network, an -/// unknown version) leaves the command usable through PATH, so it stays a debug -/// log. An integrity failure does not: dropping it here turns a wrong -/// `packageManager` hash into "command not found" further down. -pub(crate) fn is_package_manager_integrity_failure(error: &Error) -> bool { - matches!(error, Error::PackageManagerHashMismatch(_) | Error::HashMismatch { .. }) -} - /// Execute a vite-task command (run, cache) through Session. async fn execute_vite_task_command( command: vt::Command, @@ -273,7 +267,7 @@ async fn execute_vite_task_command( let bin_prefix = pm.get_bin_prefix(); let _ = prepend_to_path_env(&bin_prefix, PrependOptions::default()); } - Err(error) if is_package_manager_integrity_failure(&error) => return Err(error), + Err(error) if error.is_integrity_failure() => return Err(error), Err(error) => { tracing::debug!(?error, "failed to resolve package manager for task PATH setup"); } diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index f790476471..59180ebb60 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -108,13 +108,11 @@ pub(super) async fn execute_exec_workspace( // Build base PATH: :: let base_path_dirs: Vec = { let mut dirs = Vec::new(); - // Include package manager bin dir. An integrity failure stops the run: - // see `is_package_manager_integrity_failure`. + // Include package manager bin dir. An unverified package manager stops + // the run instead of dropping out of PATH. match vp_pm_cli::PackageManager::builder(&*workspace_root.path).build().await { Ok(pm) => dirs.push(pm.get_bin_prefix().as_path().to_path_buf()), - Err(error) if crate::cli::is_package_manager_integrity_failure(&error) => { - return Err(error); - } + Err(error) if error.is_integrity_failure() => return Err(error), Err(error) => { tracing::debug!(?error, "failed to resolve package manager for exec PATH setup"); } From e76df9305c8377d55078d745019ed7ec83ea4600 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 17:09:51 +0800 Subject: [PATCH 07/12] test(install): cover vp run aborting on a failed integrity check The snapshot suite proved `vp install` rejects a tampered cached CLI, but not that `vp run` stops too. That path resolves the package manager through the NAPI binding, which used to log the failure at debug level and run the task without the pinned Yarn. --- .../install_yarn_corepack_hash/snapshots.toml | 11 +++++++ .../run_yarn_corepack_hash_mismatch.md | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index 2a1be3ad32..806b9c1f81 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -31,3 +31,14 @@ steps = [ { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "vp run finalized the verified Yarn cache" }, { argv = ["vp", "run", "smoke"], comment = "A warm vp run reuses the cached Yarn binary" }, ] + +[[case]] +name = "run_yarn_corepack_hash_mismatch" +vp = "global" +env = { YARN_ENABLE_TELEMETRY = "0" } +steps = [ + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content the pin does not cover", snapshot = false }, + { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure and never starts the task", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md new file mode 100644 index 0000000000..f0a8f70982 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md @@ -0,0 +1,29 @@ +# run_yarn_corepack_hash_mismatch + +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` + +Ensure the Corepack-pinned Yarn version is not cached + + +## `vp install` + +Cache the verified Yarn CLI + + +## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` + +Replace the cached CLI with content the pin does not cover + + +## `vp run smoke` + +vp run reports the integrity failure and never starts the task + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js), the artifact Corepack pins. +``` From 97f4b767107e324cca6687b70ed31f2c33c5b80d Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 17:17:39 +0800 Subject: [PATCH 08/12] docs(install): rewrite the new prose in simplified technical english One idea per sentence, active voice with a named actor, and no gerund subjects, across the comments, doc comments, snapshot step comments, the guide paragraph, and the integrity error itself. The error's second line was a sentence fragment ("..., the artifact Corepack pins"), which read as an aside about the artifact rather than a statement about what vp hashed. --- .../install_yarn_corepack_hash/snapshots.toml | 22 +++---- .../snapshots/install_yarn_corepack_hash.md | 6 +- .../install_yarn_corepack_hash_mismatch.md | 6 +- .../snapshots/run_yarn_corepack_hash.md | 8 +-- .../run_yarn_corepack_hash_mismatch.md | 6 +- crates/vp_error/src/lib.rs | 23 ++++--- crates/vp_pm_cli/src/package_manager.rs | 62 ++++++++++--------- crates/vp_pm_cli/src/request.rs | 26 ++++---- docs/guide/install.md | 7 ++- packages/cli/binding/src/cli/mod.rs | 8 +-- packages/cli/binding/src/exec/workspace.rs | 4 +- 11 files changed, 92 insertions(+), 86 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index 806b9c1f81..88232952bc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -4,9 +4,9 @@ vp = "global" env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 starts uncached" }, - { argv = ["vp", "install"], comment = "A cold install accepts the hash written by Corepack", timeout = 120000 }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "The verified Yarn CLI binary is cached" }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 is not in the cache" }, + { argv = ["vp", "install"], comment = "A first install accepts the hash that Corepack wrote", timeout = 120000 }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "The cache holds the Yarn CLI that vp verified" }, ] [[case]] @@ -16,8 +16,8 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content the pin does not cover", snapshot = false }, - { argv = ["vp", "install"], comment = "The error names the artifact the hash covers, and no download repairs it", continue-on-failure = true }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, + { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, ] [[case]] @@ -26,10 +26,10 @@ vp = "global" env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 starts uncached" }, - { argv = ["vp", "run", "smoke"], comment = "A cold vp run accepts the hash and executes the task", timeout = 120000 }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "vp run finalized the verified Yarn cache" }, - { argv = ["vp", "run", "smoke"], comment = "A warm vp run reuses the cached Yarn binary" }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 is not in the cache" }, + { argv = ["vp", "run", "smoke"], comment = "A first vp run accepts the hash and runs the task", timeout = 120000 }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "vp run wrote the verified Yarn CLI to the cache" }, + { argv = ["vp", "run", "smoke"], comment = "A second vp run uses the cached Yarn CLI" }, ] [[case]] @@ -39,6 +39,6 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content the pin does not cover", snapshot = false }, - { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure and never starts the task", continue-on-failure = true }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, + { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure. It does not start the task", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md index ff7fb611f5..a54c1b3bc6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md @@ -7,7 +7,7 @@ Ensure the Corepack-pinned Yarn version is not cached ## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1 --assert missing` -Yarn 4.17.1 starts uncached +Yarn 4.17.1 is not in the cache ``` /.vite-plus/package_manager/yarn/: missing @@ -15,7 +15,7 @@ Yarn 4.17.1 starts uncached ## `vp install` -A cold install accepts the hash written by Corepack +A first install accepts the hash that Corepack wrote ``` VITE+ - The Unified Toolchain for the Web @@ -32,7 +32,7 @@ VITE+ - The Unified Toolchain for the Web ## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js --assert file` -The verified Yarn CLI binary is cached +The cache holds the Yarn CLI that vp verified ``` /.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index 704d5b40a1..ace79623d6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -12,12 +12,12 @@ Cache the verified Yarn CLI ## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` -Replace the cached CLI with content the pin does not cover +Replace the cached CLI with content that the pin does not cover ## `vp install` -The error names the artifact the hash covers, and no download repairs it +The error names the artifact that the hash covers. vp does not download the CLI again **Exit code:** 1 @@ -25,5 +25,5 @@ The error names the artifact the hash covers, and no download repairs it VITE+ - The Unified Toolchain for the Web error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 -The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js), the artifact Corepack pins. +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md index 11284b5481..f897ffa188 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md @@ -7,7 +7,7 @@ Ensure the Corepack-pinned Yarn version is not cached ## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1 --assert missing` -Yarn 4.17.1 starts uncached +Yarn 4.17.1 is not in the cache ``` /.vite-plus/package_manager/yarn/: missing @@ -15,7 +15,7 @@ Yarn 4.17.1 starts uncached ## `vp run smoke` -A cold vp run accepts the hash and executes the task +A first vp run accepts the hash and runs the task ``` VITE+ - The Unified Toolchain for the Web @@ -26,7 +26,7 @@ yarn hash accepted ## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js --assert file` -vp run finalized the verified Yarn cache +vp run wrote the verified Yarn CLI to the cache ``` /.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file @@ -34,7 +34,7 @@ vp run finalized the verified Yarn cache ## `vp run smoke` -A warm vp run reuses the cached Yarn binary +A second vp run uses the cached Yarn CLI ``` VITE+ - The Unified Toolchain for the Web diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md index f0a8f70982..d1b21a7abc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md @@ -12,12 +12,12 @@ Cache the verified Yarn CLI ## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` -Replace the cached CLI with content the pin does not cover +Replace the cached CLI with content that the pin does not cover ## `vp run smoke` -vp run reports the integrity failure and never starts the task +vp run reports the integrity failure. It does not start the task **Exit code:** 1 @@ -25,5 +25,5 @@ vp run reports the integrity failure and never starts the task VITE+ - The Unified Toolchain for the Web error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 -The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js), the artifact Corepack pins. +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_error/src/lib.rs b/crates/vp_error/src/lib.rs index a52e0fe3b4..1a4fbbc7a0 100644 --- a/crates/vp_error/src/lib.rs +++ b/crates/vp_error/src/lib.rs @@ -112,10 +112,10 @@ pub enum Error { #[error("Hash mismatch: expected {expected}, got {actual}")] HashMismatch { expected: Str, actual: Str }, - /// A `packageManager` integrity pin did not match the artifact it covers. + /// A `packageManager` integrity pin does not match the artifact it covers. /// - /// Boxed so this one rare variant does not widen every `Result<_, Error>` - /// in the CLI (`clippy::result_large_err`). + /// This variant boxes its payload. Without the box, it makes every + /// `Result<_, Error>` in the CLI larger (`clippy::result_large_err`). #[error(transparent)] PackageManagerHashMismatch(#[from] Box), @@ -140,12 +140,11 @@ pub enum Error { } impl Error { - /// Whether this error means a downloaded or cached artifact failed its - /// integrity check. + /// Whether the error says that an artifact failed its integrity check. /// - /// Callers that otherwise fall back when a managed tool is unavailable use - /// this to stop instead: an unverified artifact is the user's to fix, and - /// falling back hides it behind a later, unrelated failure. + /// Some callers continue when a managed tool is missing. They must stop for + /// this error. The user must fix an unverified artifact, and a fallback + /// hides the cause behind a later, unrelated failure. #[must_use] pub const fn is_integrity_failure(&self) -> bool { matches!(self, Self::PackageManagerHashMismatch(_) | Self::HashMismatch { .. }) @@ -154,13 +153,13 @@ impl Error { /// Details of a failed `packageManager` integrity check. /// -/// `basis` names the hashed artifact. Corepack pins Yarn 2+ from the extracted -/// CLI and every other package manager from the npm tarball, so a bare "hash -/// mismatch" reads like a corrupt download. +/// `basis` names the artifact that vp hashed. Corepack hashes the extracted CLI +/// for Yarn 2+, and the npm tarball for every other package manager. A message +/// that says only "hash mismatch" reads like a corrupt download. #[derive(Error, Debug)] #[error( "Hash mismatch for {name}@{version}: expected {expected}, got {actual}\n\ - The `packageManager` hash covers {basis}, the artifact Corepack pins." + The `packageManager` hash covers {basis}. Corepack hashes the same artifact." )] pub struct PackageManagerHashMismatch { pub name: Str, diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 5ef2944a5f..f0fa32ae8c 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -106,14 +106,15 @@ impl PackageManagerType { } } - /// Whether Corepack integrity pins for this package-manager version cover - /// the extracted CLI binary rather than the npm tarball. + /// Whether a Corepack pin for this version covers the extracted CLI binary + /// and not the npm tarball. #[must_use] pub fn uses_cli_binary_hash(self, version: &str) -> bool { Version::parse(version).is_ok_and(|version| self.hashes_cli_binary_of(&version)) } - /// [`Self::uses_cli_binary_hash`] for a version the caller already parsed. + /// The same test as [`Self::uses_cli_binary_hash`], for a version that the + /// caller already parsed. #[must_use] pub fn hashes_cli_binary_of(self, version: &Version) -> bool { matches!(self, Self::Yarn) && is_yarn_berry(version) @@ -122,17 +123,17 @@ impl PackageManagerType { /// Path of the Yarn CLI inside `@yarnpkg/cli-dist`, relative to the package root. /// -/// Corepack pins Yarn 2+ by hashing this file, so the download, the cached-CLI -/// check, and the error message must all name the same path. +/// Corepack hashes this file to pin Yarn 2+. Three places must name the same +/// path: the download, the cached-CLI check, and the error message. const YARN_CLI_ENTRY: &str = "bin/yarn.js"; /// Whether a Yarn version is Berry (Yarn 2 and later). /// -/// Corepack splits Yarn at 2.0.0 and matches that range with -/// `satisfiesWithPrereleases`, which drops the prerelease tag before it -/// compares. Every 2.x prerelease is Berry there, so this compares the major -/// alone; `VersionReq(">=2.0.0")` would exclude `4.0.0-rc.53` and send it to -/// the Yarn Classic package, which never published it. +/// Corepack splits Yarn at 2.0.0. It matches that range with +/// `satisfiesWithPrereleases`, which drops the prerelease tag first. Every 2.x +/// prerelease is therefore a Berry version, so this function compares the major +/// number alone. `VersionReq(">=2.0.0")` excludes `4.0.0-rc.53` and sends it to +/// the Yarn Classic package, which never published that version. pub(crate) fn is_yarn_berry(version: &Version) -> bool { version.major >= 2 } @@ -913,8 +914,8 @@ pub async fn download_package_manager( let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading {package_manager_type} v{version}..."); - // A Corepack Yarn 2+ pin only covers the CLI, so the rest of that archive - // stays unauthenticated and is never written to disk. + // A Corepack Yarn 2+ pin covers only the CLI. The rest of the archive stays + // unauthenticated, so vp never writes it to disk. let archive_file = is_modern_yarn.then(|| PathBuf::from(format!("package/{YARN_CLI_ENTRY}"))); download_and_extract_tgz_with_hash( &tgz_url, @@ -980,13 +981,13 @@ pub async fn download_package_manager( Ok((install_dir, package_name, version)) } -/// Resolve the executable path of a managed package manager, installing it when -/// the cache cannot serve it. +/// Resolve the executable path of a managed package manager. Install that +/// package manager when the cache cannot serve it. /// -/// Takes the fast path when the shim already exists, except for a pin that -/// covers a file inside the install: [`verify_cached_cli_hash`] has to re-read -/// that file before anything executes it. Keeping that rule here is what lets -/// the shim hot path stay free of package-manager specifics. +/// This function returns the cached path when the shim already exists. A pin +/// that covers a file inside the install is the exception: +/// [`verify_cached_cli_hash`] must read that file again before vp runs it. The +/// rule stays here, so the shim hot path holds no package-manager specifics. pub async fn ensure_package_manager_bin( package_manager_type: PackageManagerType, version: &str, @@ -1009,11 +1010,11 @@ pub async fn ensure_package_manager_bin( Ok(package_manager_bin_path(&install_dir, bin_name)) } -/// Re-check a cached CLI against a pin that covers it. +/// Verify a cached CLI against a pin that covers it. /// -/// Corepack hashes the extracted Yarn 2+ CLI instead of the npm tarball, so -/// that pin can be checked without downloading anything. Every other pin names -/// a tarball vp no longer keeps, and passes here unchecked. +/// Corepack hashes the extracted Yarn 2+ CLI and not the npm tarball, so vp can +/// verify that pin from the cache. Every other pin names a tarball that vp does +/// not keep, so this function accepts it without a check. async fn verify_cached_cli_hash( package_manager_type: PackageManagerType, install_dir: &AbsolutePath, @@ -1032,10 +1033,11 @@ async fn verify_cached_cli_hash( .map_err(|error| name_hashed_artifact(error, package_manager_type, version)) } -/// Name the artifact a `packageManager` hash covers in an integrity failure. +/// Name the artifact that a `packageManager` hash covers in an integrity +/// failure. /// -/// `Error::HashMismatch` alone reads like a corrupt download, which sent the -/// reporter of #2209 looking for a network problem instead of a hash basis. +/// `Error::HashMismatch` alone reads like a corrupt download. The reporter of +/// #2209 looked for a network problem, not for a different hash basis. fn name_hashed_artifact( error: Error, package_manager_type: PackageManagerType, @@ -1831,8 +1833,8 @@ mod tests { /// Build an `@yarnpkg/cli-dist` style tarball around `yarn_js`. /// - /// `symlink_target` adds a `package/bin/yarn` symlink, the archive-controlled - /// entry an unauthenticated tarball could use to write outside the install. + /// `symlink_target` adds a `package/bin/yarn` symlink. An unauthenticated + /// tarball can use that entry to write outside the install directory. fn create_yarn_package_tgz(yarn_js: &[u8], symlink_target: Option<&Path>) -> Vec { let mut tar_builder = tar::Builder::new(Vec::new()); let mut header = tar::Header::new_gnu(); @@ -2033,8 +2035,8 @@ mod tests { assert!(!PackageManagerType::Yarn.uses_cli_binary_hash("latest")); // Corepack drops the prerelease tag before it matches its `>=2.0.0` - // range, so a 2.x prerelease pin is a Berry pin there too. `corepack - // use yarn@4.0.0-rc.53` writes a hash of `bin/yarn.js`. + // range. A 2.x prerelease pin is therefore a Berry pin there too. + // `corepack use yarn@4.0.0-rc.53` writes a hash of `bin/yarn.js`. assert!(PackageManagerType::Yarn.uses_cli_binary_hash("2.0.0-rc.1")); assert!(PackageManagerType::Yarn.uses_cli_binary_hash("4.0.0-rc.53")); } @@ -3249,7 +3251,7 @@ mod tests { mismatch.actual, "sha512.ca75da26c00327d26267ce33536e5790f18ebd53266796fbb664d2a4a5116308042dd8ee7003b276a20eace7d3c5561c3577bdd71bcb67071187af124779620a" ); - // Yarn Classic ships the CLI inside the tarball Corepack pins. + // Yarn Classic ships the CLI inside the tarball that Corepack pins. assert_eq!(mismatch.basis, "the npm package tarball"); } other => panic!("Expected PackageManagerHashMismatch error, got {other:?}"), diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index 3adca5e3c0..f4c29bc835 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -299,8 +299,8 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu /// Extract exactly one regular file from a tgz archive. /// -/// Unlike [`extract_tgz`], archive-controlled paths and links are never written. -/// This is used when an integrity pin covers one file inside an otherwise +/// Unlike [`extract_tgz`], this function never writes an archive-controlled +/// path or link. Use it when an integrity pin covers one file inside an /// unauthenticated archive. fn extract_tgz_file( tgz_file: impl AsRef, @@ -351,9 +351,9 @@ fn extract_tgz_file( /// # Arguments /// * `url` - The URL of the tgz file to download. /// * `target_dir` - The directory to extract the tgz file to. -/// * `archive_file` - Optional single entry to extract, given as a relative path -/// of normal components. Every other entry is then ignored and never written, -/// and `expected_hash` covers that one file instead of the tgz. +/// * `archive_file` - Optional single entry to extract, as a relative path of +/// normal components. vp ignores every other entry, and `expected_hash` +/// covers that one file instead of the tgz. /// * `expected_hash` - Optional expected hash, "algorithm.hex" or SRI "algorithm-base64" (see [`verify_file_hash`]) /// * `message` - Optional message shown above a progress bar while downloading (see [`HttpClient::download_file`]) /// @@ -427,8 +427,8 @@ async fn download_and_extract_tgz_once( client.download_file(url, &tgz_file, message).await?; if let Some(archive_file) = archive_file { - // The hash covers one entry, so the archive around it is unauthenticated - // and only that entry may be written. + // The hash covers one entry. The rest of the archive is unauthenticated, + // so vp writes only that entry. let target_file = target_dir.join(archive_file); let tgz_file_for_extract = tgz_file.clone(); let archive_file_for_extract = archive_file.to_path_buf(); @@ -485,11 +485,11 @@ fn is_retryable_download_error(err: &Error) -> bool { } } -/// Computes the digest of a file, reading it in chunks. +/// Compute the digest of a file in chunks. /// -/// Streaming keeps peak memory flat where slurping would allocate the whole -/// artifact: `bin/yarn.js` is ~3 MB and is re-hashed on every command that -/// resolves a hash-pinned Yarn. +/// A chunked read keeps peak memory flat, because it never holds the whole +/// artifact. `bin/yarn.js` is about 3 MB, and vp hashes it on every command +/// that resolves a hash-pinned Yarn. fn digest_file(file_path: &Path) -> Result, std::io::Error> { let mut file = std::fs::File::open(file_path)?; let mut hasher = D::new(); @@ -534,8 +534,8 @@ pub(crate) async fn verify_file_hash( return Err(Error::InvalidHashFormat(expected_hash.into())); }; - // Read and hash on the blocking pool: hashing multi-megabyte artifacts is - // CPU-bound and would otherwise stall a runtime worker thread. + // Read and hash on the blocking pool. A hash of a multi-megabyte artifact + // is CPU-bound and stalls a runtime worker thread. let algorithm_for_digest = algorithm.to_owned(); let digest = tokio::task::spawn_blocking(move || match algorithm_for_digest.as_str() { "sha512" => digest_file::(&file_path).map(Some), diff --git a/docs/guide/install.md b/docs/guide/install.md index 24be67132b..bba3543948 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -39,7 +39,12 @@ A range resolves to an already-downloaded satisfying version when possible, othe Vite+ currently downloads the declared package manager (the `onFail: "download"` behavior); the other `onFail` values are accepted but not yet differentiated. -A `packageManager` pin can carry an integrity hash (`yarn@4.17.1+sha512.…`), which `corepack use` writes. Vite+ verifies the artifact Corepack hashes: the extracted CLI binary (`bin/yarn.js`) for Yarn 2 and later, and the npm tarball for npm, pnpm, and Yarn Classic. A Yarn 2+ pin is re-checked against the cached CLI on every command, so a modified cache fails the check instead of running. +A `packageManager` pin can carry an integrity hash (`yarn@4.17.1+sha512.…`). `corepack use` writes that hash. Vite+ hashes the same artifact as Corepack: + +- the extracted CLI binary (`bin/yarn.js`) for Yarn 2 and later +- the npm package tarball for npm, pnpm, and Yarn Classic + +Vite+ verifies a Yarn 2+ pin against the cached CLI on every command. A changed cache fails the check, and the command stops. The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index b20f854c0e..1954d24daa 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -217,10 +217,10 @@ async fn envs_with_explicit_package_manager_path( .await { Ok(result) => result, - // Every other reason to miss the managed package manager (no network, - // an unknown version) leaves the command usable through PATH, so it - // stays a debug log. Swallowing an integrity failure would turn a wrong - // `packageManager` hash into "command not found" further down. + // A missing package manager has other causes, such as no network or an + // unknown version. The command still runs from PATH, so those causes + // stay a debug log. An integrity failure is different. If vp hides it, + // the user sees only "command not found" further down. Err(error) if error.is_integrity_failure() => return Err(error), Err(error) => { tracing::debug!( diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index 59180ebb60..01aa5bc752 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -108,8 +108,8 @@ pub(super) async fn execute_exec_workspace( // Build base PATH: :: let base_path_dirs: Vec = { let mut dirs = Vec::new(); - // Include package manager bin dir. An unverified package manager stops - // the run instead of dropping out of PATH. + // Include the package-manager bin directory. An unverified package + // manager stops the run. vp does not drop it from PATH in silence. match vp_pm_cli::PackageManager::builder(&*workspace_root.path).build().await { Ok(pm) => dirs.push(pm.get_bin_prefix().as_path().to_path_buf()), Err(error) if error.is_integrity_failure() => return Err(error), From fc20a2bf10863f385b4723122f5d34b1cd8307f0 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 17:37:19 +0800 Subject: [PATCH 09/12] perf(install): record the verified pin instead of hashing the CLI again vp hashed the 3 MB `bin/yarn.js` on every command that resolved a hash-pinned Yarn, including every `yarn` shim invocation. It now hashes the CLI once, at install time, and writes the pin it verified next to the install. A later command compares its own pin against that record. A pin that differs from the record still hashes the cached CLI once, so a project that changes its `packageManager` hash cannot pass on a warm cache. An install made by an older vp has no record and is hashed once as well. --- .../install_yarn_corepack_hash/snapshots.toml | 4 +- .../install_yarn_corepack_hash_mismatch.md | 6 +- .../run_yarn_corepack_hash_mismatch.md | 6 +- crates/vp_pm_cli/src/package_manager.rs | 125 ++++++++++++++++-- docs/guide/install.md | 2 +- 5 files changed, 120 insertions(+), 23 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index 88232952bc..aefcf8c16f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -16,7 +16,7 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, + { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, ] @@ -39,6 +39,6 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, + { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure. It does not start the task", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index ace79623d6..6c1beb91ed 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -10,9 +10,9 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI -## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` +## `vpt replace-file-content package.json b7ad4697 b7ad4698` -Replace the cached CLI with content that the pin does not cover +Change the pin to a hash that the cached CLI does not match ## `vp install` @@ -24,6 +24,6 @@ The error names the artifact that the hash covers. vp does not download the CLI ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md index d1b21a7abc..e6aed071f0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md @@ -10,9 +10,9 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI -## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` +## `vpt replace-file-content package.json b7ad4697 b7ad4698` -Replace the cached CLI with content that the pin does not cover +Change the pin to a hash that the cached CLI does not match ## `vp run smoke` @@ -24,6 +24,6 @@ vp run reports the integrity failure. It does not start the task ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index f0fa32ae8c..a2e624ff61 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -121,6 +121,10 @@ impl PackageManagerType { } } +/// Name of the file that records the pin vp verified when it installed a +/// package manager, relative to the version directory. +const VERIFIED_PIN_RECORD: &str = ".verified-pin"; + /// Path of the Yarn CLI inside `@yarnpkg/cli-dist`, relative to the package root. /// /// Corepack hashes this file to pin Yarn 2+. Three places must name the same @@ -901,7 +905,14 @@ pub async fn download_package_manager( // If all shims already exist, return the target directory // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) if is_package_manager_install_complete(&install_dir, &bin_name)? { - verify_cached_cli_hash(package_manager_type, &install_dir, expected_hash, &version).await?; + verify_cached_cli_hash( + package_manager_type, + &target_dir, + &install_dir, + expected_hash, + &version, + ) + .await?; return Ok((install_dir, package_name, version)); } @@ -965,10 +976,26 @@ pub async fn download_package_manager( // the install is all-or-nothing) if is_package_manager_install_complete(&install_dir, &bin_name)? { tracing::debug!("install already complete after lock acquisition, skip rename"); - verify_cached_cli_hash(package_manager_type, &install_dir, expected_hash, &version).await?; + verify_cached_cli_hash( + package_manager_type, + &target_dir, + &install_dir, + expected_hash, + &version, + ) + .await?; return Ok((install_dir, package_name, version)); } + // Record the pin this install was verified against. The record moves into + // place with the install, so a later command can trust the cache after one + // string comparison. + if let Some(expected_hash) = expected_hash + && is_modern_yarn + { + tokio::fs::write(target_dir_tmp.join(VERIFIED_PIN_RECORD), expected_hash).await?; + } + // rename $target_dir_tmp to $target_dir tracing::debug!("Rename {:?} to {:?}", target_dir_tmp, target_dir); remove_dir_all_force(&target_dir).await?; @@ -985,9 +1012,10 @@ pub async fn download_package_manager( /// package manager when the cache cannot serve it. /// /// This function returns the cached path when the shim already exists. A pin -/// that covers a file inside the install is the exception: -/// [`verify_cached_cli_hash`] must read that file again before vp runs it. The -/// rule stays here, so the shim hot path holds no package-manager specifics. +/// that covers a file inside the install is the exception: it goes through +/// [`verify_cached_cli_hash`], which compares the pin against the record vp +/// wrote at install time. The rule stays here, so the shim hot path holds no +/// package-manager specifics. pub async fn ensure_package_manager_bin( package_manager_type: PackageManagerType, version: &str, @@ -1012,11 +1040,19 @@ pub async fn ensure_package_manager_bin( /// Verify a cached CLI against a pin that covers it. /// -/// Corepack hashes the extracted Yarn 2+ CLI and not the npm tarball, so vp can -/// verify that pin from the cache. Every other pin names a tarball that vp does -/// not keep, so this function accepts it without a check. +/// vp hashes the CLI once, when it installs the package manager, and records +/// the pin it verified. A later command compares its own pin against that +/// record, so it never hashes the multi-megabyte CLI again. +/// +/// The record is missing after an install by an older vp, and it differs after +/// the project changes its pin. Both cases hash the CLI once more. +/// +/// Only a Corepack Yarn 2+ pin covers a file that vp keeps. Every other pin +/// names a tarball that vp deletes after it extracts it, so this function +/// accepts those without a check. async fn verify_cached_cli_hash( package_manager_type: PackageManagerType, + target_dir: &AbsolutePath, install_dir: &AbsolutePath, expected_hash: Option<&str>, version: &str, @@ -1028,9 +1064,20 @@ async fn verify_cached_cli_hash( return Ok(()); } + let record_path = target_dir.join(VERIFIED_PIN_RECORD); + if let Ok(recorded_pin) = tokio::fs::read_to_string(&record_path).await + && recorded_pin.trim() == expected_hash + { + tracing::debug!("cached {package_manager_type} matches the recorded pin"); + return Ok(()); + } + verify_file_hash(install_dir.join(YARN_CLI_ENTRY), expected_hash) .await - .map_err(|error| name_hashed_artifact(error, package_manager_type, version)) + .map_err(|error| name_hashed_artifact(error, package_manager_type, version))?; + // Best effort: a read-only cache still verifies, it just hashes every time. + let _ = tokio::fs::write(&record_path, expected_hash).await; + Ok(()) } /// Name the artifact that a `packageManager` hash covers in an integrity @@ -3583,18 +3630,68 @@ mod tests { .await .expect("Corepack's Yarn binary hash should be accepted"); assert_eq!(mock.hits(), 1); + assert_eq!( + fs::read_to_string(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(), + expected_hash, + "the install must record the pin it verified" + ); - fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); + // The same pin on a warm cache reads the record instead of the CLI. + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await + .expect("a recorded pin should be accepted from the cache"); + assert_eq!(mock.hits(), 1, "a cached install must not download again"); + + // A different pin does not match the record, so vp hashes the cached + // CLI and reports the mismatch. + let other_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); let result = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) - .await; + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&other_hash)).await; let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { - panic!("a corrupted cached CLI must fail the integrity check: {result:?}"); + panic!("a pin the cached CLI does not match must fail: {result:?}"); }; let message = error.to_string(); assert!(message.contains("yarn@4.17.1"), "{message}"); assert!(message.contains("bin/yarn.js"), "{message}"); - assert_eq!(mock.hits(), 1, "cached installs should be verified without downloading"); + assert_eq!(mock.hits(), 1, "a cached install must be checked without downloading"); + } + + #[tokio::test] + async fn test_download_modern_yarn_hashes_a_cache_without_a_record() { + use httpmock::prelude::*; + use sha2::{Digest, Sha512}; + + let vp_home = create_temp_dir(); + let server = MockServer::start(); + let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; + let mock_tgz = create_yarn_package_tgz(yarn_js, None); + server.mock(|when, then| { + when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); + then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); + }); + let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); + + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: server.base_url().into(), + vite_plus_home: Some(vp_home.path().to_path_buf()), + ..EnvConfig::for_test() + }); + + let (install_dir, _, _) = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await + .expect("Corepack's Yarn binary hash should be accepted"); + + // An install by an older vp has no record. vp falls back to the CLI. + fs::remove_file(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(); + fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); + let result = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await; + assert!( + matches!(result, Err(Error::PackageManagerHashMismatch { .. })), + "a cache without a record must be hashed: {result:?}" + ); } #[tokio::test] diff --git a/docs/guide/install.md b/docs/guide/install.md index bba3543948..168ba85d36 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -44,7 +44,7 @@ A `packageManager` pin can carry an integrity hash (`yarn@4.17.1+sha512.…`). ` - the extracted CLI binary (`bin/yarn.js`) for Yarn 2 and later - the npm package tarball for npm, pnpm, and Yarn Classic -Vite+ verifies a Yarn 2+ pin against the cached CLI on every command. A changed cache fails the check, and the command stops. +Vite+ hashes the CLI once, when it installs Yarn, and records the pin it verified. A later command compares its own pin against that record. A pin that does not match the record fails the check, and the command stops. The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. From c881a8d2d9d727502d62242eb8a0c370ea27d218 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 19:19:46 +0800 Subject: [PATCH 10/12] fix(install): check the cached CLI status against the record The record held only the pin, so a replaced `bin/yarn.js` passed every later resolution and the shim executed it. The record now holds the size and the modification time of the file vp hashed, and a resolution compares them with one `stat` before it trusts the record. A file that changed sends vp back to the hash, which is the failure the two mismatch snapshot cases now cover again. A replacement that keeps the same size and modification time still passes, the same guarantee Corepack gives its own cache. --- .../install_yarn_corepack_hash/snapshots.toml | 4 +- .../install_yarn_corepack_hash_mismatch.md | 6 +- .../run_yarn_corepack_hash_mismatch.md | 6 +- crates/vp_pm_cli/src/package_manager.rs | 105 ++++++++++++++---- docs/guide/install.md | 2 +- 5 files changed, 93 insertions(+), 30 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index aefcf8c16f..88232952bc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -16,7 +16,7 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, ] @@ -39,6 +39,6 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure. It does not start the task", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index 6c1beb91ed..ace79623d6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -10,9 +10,9 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI -## `vpt replace-file-content package.json b7ad4697 b7ad4698` +## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` -Change the pin to a hash that the cached CLI does not match +Replace the cached CLI with content that the pin does not cover ## `vp install` @@ -24,6 +24,6 @@ The error names the artifact that the hash covers. vp does not download the CLI ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md index e6aed071f0..d1b21a7abc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md @@ -10,9 +10,9 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI -## `vpt replace-file-content package.json b7ad4697 b7ad4698` +## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` -Change the pin to a hash that the cached CLI does not match +Replace the cached CLI with content that the pin does not cover ## `vp run smoke` @@ -24,6 +24,6 @@ vp run reports the integrity failure. It does not start the task ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index a2e624ff61..72ffdb191a 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -987,13 +987,21 @@ pub async fn download_package_manager( return Ok((install_dir, package_name, version)); } - // Record the pin this install was verified against. The record moves into - // place with the install, so a later command can trust the cache after one - // string comparison. + // Record the pin this install was verified against, with the state of the + // file it covers. The record moves into place with the install, so a later + // command can trust the cache after one `stat`. if let Some(expected_hash) = expected_hash && is_modern_yarn { - tokio::fs::write(target_dir_tmp.join(VERIFIED_PIN_RECORD), expected_hash).await?; + let extracted_cli = target_dir_tmp.join(&bin_name).join(YARN_CLI_ENTRY); + let (size, modified_ms) = read_file_state(&extracted_cli).await?; + write_verified_pin( + target_dir_tmp.join(VERIFIED_PIN_RECORD), + expected_hash, + size, + modified_ms, + ) + .await; } // rename $target_dir_tmp to $target_dir @@ -1038,14 +1046,52 @@ pub async fn ensure_package_manager_bin( Ok(package_manager_bin_path(&install_dir, bin_name)) } +/// What vp verified when it installed a package manager. +/// +/// The record holds the pin and the state of the file that the pin covers, so +/// a later command can tell an untouched cache from a changed one. +#[derive(Serialize, Deserialize)] +struct VerifiedPin { + pin: Str, + size: u64, + modified_ms: u64, +} + +/// Read the size and the modification time of a file, in milliseconds. +async fn read_file_state(path: impl AsRef) -> Result<(u64, u64), Error> { + let metadata = tokio::fs::metadata(path.as_ref()).await?; + let modified_ms = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok()) + .unwrap_or(0); + Ok((metadata.len(), modified_ms)) +} + +/// Record the pin that vp verified, next to the install. +async fn write_verified_pin(record_path: impl AsRef, pin: &str, size: u64, modified_ms: u64) { + let record = VerifiedPin { pin: pin.into(), size, modified_ms }; + match serde_json::to_vec(&record) { + // Best effort: a read-only cache still verifies. It hashes every time. + Ok(json) => { + let _ = tokio::fs::write(record_path.as_ref(), json).await; + } + Err(error) => tracing::debug!(?error, "failed to serialize the verified pin"), + } +} + /// Verify a cached CLI against a pin that covers it. /// -/// vp hashes the CLI once, when it installs the package manager, and records -/// the pin it verified. A later command compares its own pin against that -/// record, so it never hashes the multi-megabyte CLI again. +/// vp hashes the CLI when it installs the package manager, and records the pin +/// with the size and the modification time of the file it hashed. A later +/// command reads that record and one `stat`, and hashes the multi-megabyte CLI +/// again only when they disagree. /// -/// The record is missing after an install by an older vp, and it differs after -/// the project changes its pin. Both cases hash the CLI once more. +/// Three cases hash the CLI again: the project changed its pin, an older vp +/// wrote no record, or the file on disk no longer matches the record. A +/// replacement that keeps the same size and modification time defeats the +/// `stat`, which is the same guarantee Corepack gives its own cache. /// /// Only a Corepack Yarn 2+ pin covers a file that vp keeps. Every other pin /// names a tarball that vp deletes after it extracts it, so this function @@ -1064,19 +1110,23 @@ async fn verify_cached_cli_hash( return Ok(()); } + let cli_path = install_dir.join(YARN_CLI_ENTRY); let record_path = target_dir.join(VERIFIED_PIN_RECORD); - if let Ok(recorded_pin) = tokio::fs::read_to_string(&record_path).await - && recorded_pin.trim() == expected_hash + let (size, modified_ms) = read_file_state(&cli_path).await?; + if let Ok(raw_record) = tokio::fs::read_to_string(&record_path).await + && let Ok(record) = serde_json::from_str::(&raw_record) + && record.pin == expected_hash + && record.size == size + && record.modified_ms == modified_ms { - tracing::debug!("cached {package_manager_type} matches the recorded pin"); + tracing::debug!("cached {package_manager_type} still matches the recorded pin"); return Ok(()); } - verify_file_hash(install_dir.join(YARN_CLI_ENTRY), expected_hash) + verify_file_hash(&cli_path, expected_hash) .await .map_err(|error| name_hashed_artifact(error, package_manager_type, version))?; - // Best effort: a read-only cache still verifies, it just hashes every time. - let _ = tokio::fs::write(&record_path, expected_hash).await; + write_verified_pin(&record_path, expected_hash, size, modified_ms).await; Ok(()) } @@ -3630,11 +3680,11 @@ mod tests { .await .expect("Corepack's Yarn binary hash should be accepted"); assert_eq!(mock.hits(), 1); - assert_eq!( - fs::read_to_string(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(), - expected_hash, - "the install must record the pin it verified" - ); + let record_path = install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD); + let record: VerifiedPin = + serde_json::from_str(&fs::read_to_string(&record_path).unwrap()).unwrap(); + assert_eq!(record.pin, expected_hash.as_str(), "the install must record the pin"); + assert_eq!(record.size, u64::try_from(yarn_js.len()).unwrap()); // The same pin on a warm cache reads the record instead of the CLI. download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) @@ -3683,7 +3733,9 @@ mod tests { .expect("Corepack's Yarn binary hash should be accepted"); // An install by an older vp has no record. vp falls back to the CLI. - fs::remove_file(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(); + let record_path = install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD); + let record = fs::read(&record_path).unwrap(); + fs::remove_file(&record_path).unwrap(); fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); let result = download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) @@ -3692,6 +3744,17 @@ mod tests { matches!(result, Err(Error::PackageManagerHashMismatch { .. })), "a cache without a record must be hashed: {result:?}" ); + + // The record from the install no longer describes the file on disk, so + // vp hashes it again rather than trusting the record. + fs::write(&record_path, record).unwrap(); + let result = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await; + assert!( + matches!(result, Err(Error::PackageManagerHashMismatch { .. })), + "a replaced CLI must not pass on its stale record: {result:?}" + ); } #[tokio::test] diff --git a/docs/guide/install.md b/docs/guide/install.md index 168ba85d36..99c0a21b84 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -44,7 +44,7 @@ A `packageManager` pin can carry an integrity hash (`yarn@4.17.1+sha512.…`). ` - the extracted CLI binary (`bin/yarn.js`) for Yarn 2 and later - the npm package tarball for npm, pnpm, and Yarn Classic -Vite+ hashes the CLI once, when it installs Yarn, and records the pin it verified. A later command compares its own pin against that record. A pin that does not match the record fails the check, and the command stops. +Vite+ hashes the CLI once, when it installs Yarn. It records the pin, the file size, and the modification time. A later command reads that record and the file status. Vite+ hashes the CLI again when the pin changed, when the file changed, or when the record is missing. A failed check stops the command. The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. From 0f71670224520176d03fa3804a003ca1beb57068 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 19:31:38 +0800 Subject: [PATCH 11/12] Revert "fix(install): check the cached CLI status against the record" This reverts commit e5f244ceb. The size and modification time did not defend against a writer, because `.verified-pin` sits beside the file it describes: whoever replaces the CLI can rewrite the record in the same step. The check only caught accidental corruption, and it is not worth the description it invited. vp keeps the pin comparison, which Corepack does not do, and documents the trust boundary: write access to `$VP_HOME`. --- .../install_yarn_corepack_hash/snapshots.toml | 4 +- .../install_yarn_corepack_hash_mismatch.md | 6 +- .../run_yarn_corepack_hash_mismatch.md | 6 +- crates/vp_pm_cli/src/package_manager.rs | 110 +++++------------- docs/guide/install.md | 2 +- 5 files changed, 35 insertions(+), 93 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index 88232952bc..aefcf8c16f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -16,7 +16,7 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, + { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, ] @@ -39,6 +39,6 @@ env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "tampered"], comment = "Replace the cached CLI with content that the pin does not cover", snapshot = false }, + { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure. It does not start the task", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index ace79623d6..6c1beb91ed 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -10,9 +10,9 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI -## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` +## `vpt replace-file-content package.json b7ad4697 b7ad4698` -Replace the cached CLI with content that the pin does not cover +Change the pin to a hash that the cached CLI does not match ## `vp install` @@ -24,6 +24,6 @@ The error names the artifact that the hash covers. vp does not download the CLI ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md index d1b21a7abc..e6aed071f0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md @@ -10,9 +10,9 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI -## `vpt write-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js tampered` +## `vpt replace-file-content package.json b7ad4697 b7ad4698` -Replace the cached CLI with content that the pin does not cover +Change the pin to a hash that the cached CLI does not match ## `vp run smoke` @@ -24,6 +24,6 @@ vp run reports the integrity failure. It does not start the task ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697, got sha512.72e0305d3fcfcad84a03e7c1903e912162950491e6d0c7d0e236a04c1800815542cb763c9a251ad01c1c4d72d6aba9e92605e2ed97b463f6b908da58d8cb7870 +error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 72ffdb191a..08379569cf 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -987,21 +987,13 @@ pub async fn download_package_manager( return Ok((install_dir, package_name, version)); } - // Record the pin this install was verified against, with the state of the - // file it covers. The record moves into place with the install, so a later - // command can trust the cache after one `stat`. + // Record the pin this install was verified against. The record moves into + // place with the install, so a later command can trust the cache after one + // string comparison. if let Some(expected_hash) = expected_hash && is_modern_yarn { - let extracted_cli = target_dir_tmp.join(&bin_name).join(YARN_CLI_ENTRY); - let (size, modified_ms) = read_file_state(&extracted_cli).await?; - write_verified_pin( - target_dir_tmp.join(VERIFIED_PIN_RECORD), - expected_hash, - size, - modified_ms, - ) - .await; + tokio::fs::write(target_dir_tmp.join(VERIFIED_PIN_RECORD), expected_hash).await?; } // rename $target_dir_tmp to $target_dir @@ -1046,52 +1038,19 @@ pub async fn ensure_package_manager_bin( Ok(package_manager_bin_path(&install_dir, bin_name)) } -/// What vp verified when it installed a package manager. -/// -/// The record holds the pin and the state of the file that the pin covers, so -/// a later command can tell an untouched cache from a changed one. -#[derive(Serialize, Deserialize)] -struct VerifiedPin { - pin: Str, - size: u64, - modified_ms: u64, -} - -/// Read the size and the modification time of a file, in milliseconds. -async fn read_file_state(path: impl AsRef) -> Result<(u64, u64), Error> { - let metadata = tokio::fs::metadata(path.as_ref()).await?; - let modified_ms = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok()) - .unwrap_or(0); - Ok((metadata.len(), modified_ms)) -} - -/// Record the pin that vp verified, next to the install. -async fn write_verified_pin(record_path: impl AsRef, pin: &str, size: u64, modified_ms: u64) { - let record = VerifiedPin { pin: pin.into(), size, modified_ms }; - match serde_json::to_vec(&record) { - // Best effort: a read-only cache still verifies. It hashes every time. - Ok(json) => { - let _ = tokio::fs::write(record_path.as_ref(), json).await; - } - Err(error) => tracing::debug!(?error, "failed to serialize the verified pin"), - } -} - /// Verify a cached CLI against a pin that covers it. /// -/// vp hashes the CLI when it installs the package manager, and records the pin -/// with the size and the modification time of the file it hashed. A later -/// command reads that record and one `stat`, and hashes the multi-megabyte CLI -/// again only when they disagree. +/// vp hashes the CLI once, when it installs the package manager, and records +/// the pin it verified. A later command compares its own pin against that +/// record, so it never hashes the multi-megabyte CLI again. /// -/// Three cases hash the CLI again: the project changed its pin, an older vp -/// wrote no record, or the file on disk no longer matches the record. A -/// replacement that keeps the same size and modification time defeats the -/// `stat`, which is the same guarantee Corepack gives its own cache. +/// The record is missing after an install by an older vp, and it differs after +/// the project changes its pin. Both cases hash the CLI once more. +/// +/// vp does not detect a CLI that changed on disk after the install, which is +/// the guarantee Corepack gives its own cache. The record sits beside the file +/// it describes, so a writer that can replace one can replace the other. The +/// trust boundary is write access to `$VP_HOME`. /// /// Only a Corepack Yarn 2+ pin covers a file that vp keeps. Every other pin /// names a tarball that vp deletes after it extracts it, so this function @@ -1110,23 +1069,19 @@ async fn verify_cached_cli_hash( return Ok(()); } - let cli_path = install_dir.join(YARN_CLI_ENTRY); let record_path = target_dir.join(VERIFIED_PIN_RECORD); - let (size, modified_ms) = read_file_state(&cli_path).await?; - if let Ok(raw_record) = tokio::fs::read_to_string(&record_path).await - && let Ok(record) = serde_json::from_str::(&raw_record) - && record.pin == expected_hash - && record.size == size - && record.modified_ms == modified_ms + if let Ok(recorded_pin) = tokio::fs::read_to_string(&record_path).await + && recorded_pin.trim() == expected_hash { - tracing::debug!("cached {package_manager_type} still matches the recorded pin"); + tracing::debug!("cached {package_manager_type} matches the recorded pin"); return Ok(()); } - verify_file_hash(&cli_path, expected_hash) + verify_file_hash(install_dir.join(YARN_CLI_ENTRY), expected_hash) .await .map_err(|error| name_hashed_artifact(error, package_manager_type, version))?; - write_verified_pin(&record_path, expected_hash, size, modified_ms).await; + // Best effort: a read-only cache still verifies, it just hashes every time. + let _ = tokio::fs::write(&record_path, expected_hash).await; Ok(()) } @@ -3680,11 +3635,11 @@ mod tests { .await .expect("Corepack's Yarn binary hash should be accepted"); assert_eq!(mock.hits(), 1); - let record_path = install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD); - let record: VerifiedPin = - serde_json::from_str(&fs::read_to_string(&record_path).unwrap()).unwrap(); - assert_eq!(record.pin, expected_hash.as_str(), "the install must record the pin"); - assert_eq!(record.size, u64::try_from(yarn_js.len()).unwrap()); + assert_eq!( + fs::read_to_string(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(), + expected_hash, + "the install must record the pin it verified" + ); // The same pin on a warm cache reads the record instead of the CLI. download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) @@ -3733,9 +3688,7 @@ mod tests { .expect("Corepack's Yarn binary hash should be accepted"); // An install by an older vp has no record. vp falls back to the CLI. - let record_path = install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD); - let record = fs::read(&record_path).unwrap(); - fs::remove_file(&record_path).unwrap(); + fs::remove_file(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(); fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); let result = download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) @@ -3744,17 +3697,6 @@ mod tests { matches!(result, Err(Error::PackageManagerHashMismatch { .. })), "a cache without a record must be hashed: {result:?}" ); - - // The record from the install no longer describes the file on disk, so - // vp hashes it again rather than trusting the record. - fs::write(&record_path, record).unwrap(); - let result = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) - .await; - assert!( - matches!(result, Err(Error::PackageManagerHashMismatch { .. })), - "a replaced CLI must not pass on its stale record: {result:?}" - ); } #[tokio::test] diff --git a/docs/guide/install.md b/docs/guide/install.md index 99c0a21b84..df4419d148 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -44,7 +44,7 @@ A `packageManager` pin can carry an integrity hash (`yarn@4.17.1+sha512.…`). ` - the extracted CLI binary (`bin/yarn.js`) for Yarn 2 and later - the npm package tarball for npm, pnpm, and Yarn Classic -Vite+ hashes the CLI once, when it installs Yarn. It records the pin, the file size, and the modification time. A later command reads that record and the file status. Vite+ hashes the CLI again when the pin changed, when the file changed, or when the record is missing. A failed check stops the command. +Vite+ hashes the CLI once, when it installs Yarn, and records the pin it verified. A later command compares its own pin against that record. A pin that does not match the record fails the check, and the command stops. Corepack keeps the same kind of record for its own cache. The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. From 1143616638e7407274cbc2627da293a2ea0ec6ab Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Aug 2026 19:36:54 +0800 Subject: [PATCH 12/12] docs(rfc): document how Vite+ verifies a packageManager pin `rfcs/package-manager-detection.md` described the `+sha512.` suffix as an optional part of the field format and said nothing about what the hash covers, which is the ambiguity behind #2209. The new section names the hashed artifact per package manager, explains why Yarn 2+ is the exception, and states when Vite+ hashes, what it records, and where the trust boundary sits. --- rfcs/package-manager-detection.md | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/rfcs/package-manager-detection.md b/rfcs/package-manager-detection.md index a524853579..beee04f6c4 100644 --- a/rfcs/package-manager-detection.md +++ b/rfcs/package-manager-detection.md @@ -22,7 +22,7 @@ The highest-priority signal. If the root `package.json` contains a `packageManag - `name` must be one of: `pnpm`, `yarn`, `npm`, `bun` - `semver` must be valid (e.g., `10.19.0`, `4.0.0`) -- Optional hash suffix: `pnpm@10.0.0+sha512.abc123...` +- Optional integrity hash suffix: `pnpm@10.0.0+sha512.abc123...` (see [Integrity Hashes](#integrity-hashes)) **Errors**: @@ -180,9 +180,40 @@ This ensures: **Special cases**: -- **yarn ≥ 2.0.0**: Downloads from `@yarnpkg/cli-dist` instead of `yarn` npm package +- **yarn ≥ 2.0.0**: Downloads from `@yarnpkg/cli-dist` instead of the `yarn` npm package, and extracts only `bin/yarn.js`. Every 2.x prerelease counts as Yarn 2 or later; see [the Yarn 2 boundary](#the-yarn-2-boundary). - **bun**: Downloads platform-specific native binary from `@oven/bun-{os}-{arch}` (including musl variants for Alpine Linux) +## Integrity Hashes + +A `packageManager` field can carry an integrity hash: `yarn@4.17.1+sha512.ccbf…`. `corepack use` writes that suffix. Vite+ hashes the same artifact as Corepack, so one pin works under both tools. + +| Package manager | What the declared hash covers | What Vite+ also verifies | +| ---------------------------- | --------------------------------------------------- | ---------------------------------------------------------- | +| Yarn 2 and later | the extracted CLI, `bin/yarn.js` | — | +| npm, pnpm ≤ 11, Yarn Classic | the npm package tarball | — | +| pnpm ≥ 12 | the main `pnpm` tarball | the platform package against the registry `dist.integrity` | +| bun | the main `bun` tarball, which Vite+ never downloads | the platform package against the registry `dist.integrity` | + +Yarn 2 and later is the exception because Corepack installs Berry from a single file, `repo.yarnpkg.com//packages/yarnpkg-cli/bin/yarn.js`, and hashes that file. Vite+ downloads the `@yarnpkg/cli-dist` tarball instead, so it extracts `bin/yarn.js` and hashes that entry. The bytes are the same; only the basis differs. Vite+ hashed the tarball before, which made a pin written by `corepack use` fail (issue #2209). + +That pin covers one file inside an otherwise unauthenticated archive, so Vite+ writes only that entry to disk. No other archive entry reaches the install directory, and an archive-controlled path or symlink cannot escape it. + +### When Vite+ verifies a pin + +Vite+ hashes the artifact when it downloads it, and records the verified pin beside the install in `/.verified-pin`. A later command compares its own pin against that record: + +- The pins match. The command uses the cache and reads no further. +- The pins differ, or the record is missing. Vite+ hashes the cached CLI once, then rewrites the record. +- The hash disagrees with the pin. The command stops with `Hash mismatch for @`, and the message names the artifact the hash covers. + +Vite+ does not read the CLI again on every command. Corepack gives the same guarantee: it reads its own `.corepack` record and returns. The trust boundary is write access to `$VP_HOME`, which also holds the `vp` binary, the generated shims, and the managed Node.js runtime. + +An integrity failure stops the command that needs the package manager, including `vp run` and `vp exec`. Those commands otherwise continue when the managed package manager is missing, for example with no network or an unknown version. A swallowed integrity failure would surface later as "command not found". + +### The Yarn 2 boundary + +Corepack splits Yarn at 2.0.0 and matches that range with `satisfiesWithPrereleases`, which drops the prerelease tag before it compares. Every 2.x prerelease is therefore a Berry version to Corepack. Vite+ compares the major number alone and agrees: `yarn@4.0.0-rc.53` resolves from `@yarnpkg/cli-dist`. A `>=2.0.0` semver range would exclude that version and send it to the Yarn Classic package, which never published it. + ## Workspace and Monorepo Detection Workspace detection determines `is_monorepo` based on: @@ -226,6 +257,9 @@ Each package manager has specific files that trigger cache invalidation when cha - **File**: `crates/vp_pm_cli/src/package_manager.rs` - **Function**: `get_package_manager_type_and_version()` — priority-ordered detection - **Function**: `prompt_package_manager_selection()` — CI/TTY/interactive fallback +- **Function**: `download_package_manager()` — download, hash, and record the verified pin +- **Function**: `ensure_package_manager_bin()` — resolve the executable, shared with the global shim +- **Function**: `verify_cached_cli_hash()` — compare a pin against the recorded pin - **Enum**: `PackageManagerType` — `Pnpm`, `Yarn`, `Npm`, `Bun` ### TypeScript (CLI integration)