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..aefcf8c16f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -0,0 +1,44 @@ +[[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 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]] +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", "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 }, +] + +[[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 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]] +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", "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.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..a54c1b3bc6 --- /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 is not in the cache + +``` +/.vite-plus/package_manager/yarn/: missing +``` + +## `vp install` + +A first install accepts the hash that Corepack wrote + +``` +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 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 new file mode 100644 index 0000000000..6c1beb91ed --- /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 replace-file-content package.json b7ad4697 b7ad4698` + +Change the pin to a hash that the cached CLI does not match + + +## `vp install` + +The error names the artifact that the hash covers. vp does not download the CLI again + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +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.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..f897ffa188 --- /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 is not in the cache + +``` +/.vite-plus/package_manager/yarn/: missing +``` + +## `vp run smoke` + +A first vp run accepts the hash and runs 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 wrote the verified Yarn CLI to the cache + +``` +/.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file +``` + +## `vp run smoke` + +A second vp run uses the cached Yarn CLI + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt print yarn hash accepted ⊘ cache disabled +yarn hash accepted +``` 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..e6aed071f0 --- /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 replace-file-content package.json b7ad4697 b7ad4698` + +Change the pin to a hash that the cached CLI does not match + + +## `vp run smoke` + +vp run reports the integrity failure. It does not start the task + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +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_error/src/lib.rs b/crates/vp_error/src/lib.rs index f6f2e4edd7..1a4fbbc7a0 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 does not match the artifact it covers. + /// + /// 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), + #[error("Invalid hash format: {0}")] InvalidHashFormat(Str), @@ -131,3 +138,33 @@ pub enum Error { #[error(transparent)] Anyhow(#[from] anyhow::Error), } + +impl Error { + /// Whether the error says that an artifact failed its integrity check. + /// + /// 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 { .. }) + } +} + +/// Details of a failed `packageManager` integrity check. +/// +/// `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}. Corepack hashes the same artifact." +)] +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 e9a4f02b8b..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,24 +685,14 @@ 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)); - } - } - - 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( @@ -1495,6 +1484,43 @@ 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::PackageManagerHashMismatch { .. })) + ), + "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/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 adc63f752d..08379569cf 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -15,7 +15,7 @@ use crossterm::{ style::{Color, Print, ResetColor, SetForegroundColor}, terminal, }; -use semver::{Version, VersionReq}; +use semver::Version; use serde::{Deserialize, Serialize}; use tokio::fs::remove_dir_all; use vp_error::Error; @@ -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, }; @@ -105,6 +105,41 @@ impl PackageManagerType { (_, Self::Bun) => "bun", } } + + /// 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)) + } + + /// 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) + } +} + +/// 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 +/// 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. 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 } /// Package-manager resolution from an explicit project `packageManager` field. @@ -835,12 +870,11 @@ pub async fn download_package_manager( ) })?; + 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 - 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 +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, + &target_dir, + &install_dir, + expected_hash, + &version, + ) + .await?; return Ok((install_dir, package_name, version)); } @@ -883,9 +925,13 @@ 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 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, &target_dir_tmp, + archive_file.as_deref(), expected_hash, Some(&download_message), ) @@ -902,14 +948,15 @@ pub async fn download_package_manager( url: tgz_url.into(), } } else { - err + name_hashed_artifact(err, package_manager_type, &version) } })?; // 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)?; + + 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,9 +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, + &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?; @@ -944,6 +1008,110 @@ pub async fn download_package_manager( Ok((install_dir, package_name, version)) } +/// Resolve the executable path of a managed package manager. Install that +/// 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: 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, + 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)) +} + +/// 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. +/// +/// 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 +/// 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, +) -> Result<(), Error> { + let Some(expected_hash) = expected_hash else { + return Ok(()); + }; + if !package_manager_type.uses_cli_binary_hash(version) { + 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))?; + // 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 +/// failure. +/// +/// `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, + version: &str, +) -> 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, + })) +} + /// 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. @@ -1029,6 +1197,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), ) @@ -1203,10 +1372,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 @@ -1226,6 +1397,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), ) @@ -1701,6 +1873,7 @@ fn simple_text_prompt() -> Result { mod tests { use std::fs; + use semver::VersionReq; use tempfile::{TempDir, tempdir}; use vp_shared::EnvConfig; @@ -1710,6 +1883,39 @@ mod tests { tempdir().expect("Failed to create temp directory") } + /// Build an `@yarnpkg/cli-dist` style tarball around `yarn_js`. + /// + /// `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(); + 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(); + + 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(); + } + + 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"); } @@ -1872,6 +2078,21 @@ 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")); + 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. 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")); + } + #[test] fn test_resolve_package_manager_from_package_json_npm() { let temp_dir = create_temp_dir(); @@ -3069,19 +3290,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::HashMismatch { expected, actual }) = result { - assert_eq!( - expected, - "sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" - ); - assert_eq!( - actual, - "sha512.ca75da26c00327d26267ce33536e5790f18ebd53266796fbb664d2a4a5116308042dd8ee7003b276a20eace7d3c5561c3577bdd71bcb67071187af124779620a" - ); - } else { - panic!("Expected HashMismatch 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 that Corepack pins. + assert_eq!(mismatch.basis, "the npm package tarball"); + } + other => panic!("Expected PackageManagerHashMismatch error, got {other:?}"), } } @@ -3384,6 +3609,193 @@ 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, 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); + }); + 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); + 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)) + .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(&other_hash)).await; + let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { + 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, "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] + 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(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); + }); + 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", None); + let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; + 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(); + 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..f4c29bc835 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,11 +297,63 @@ 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`], 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, + 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()) +} + /// 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, 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`]) /// @@ -308,16 +363,23 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu 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> { + 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(), + )); + } + let target_dir = target_dir.as_ref().to_path_buf(); - tracing::debug!( - "Start download and extract {} to {:?}, expected hash: {:?}", - url, - target_dir, - expected_hash - ); + 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 @@ -327,7 +389,7 @@ pub(crate) async fn download_and_extract_tgz_with_hash( // and propagate unchanged so the caller in `package_manager.rs` can map a // 404 to `PackageManagerVersionNotFound`. (|| async { - download_and_extract_tgz_with_hash_once(url, &target_dir, expected_hash, message).await + download_and_extract_tgz_once(url, &target_dir, archive_file, expected_hash, message).await }) .retry( ExponentialBuilder::default() @@ -343,9 +405,10 @@ pub(crate) async fn download_and_extract_tgz_with_hash( /// /// 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, + archive_file: Option<&Path>, expected_hash: Option<&str>, message: Option<&str>, ) -> Result<(), Error> { @@ -356,25 +419,44 @@ 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?; - } + if let Some(archive_file) = archive_file { + // 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(); + 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?; + } + } 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 fs::remove_file(&tgz_file).await?; @@ -383,7 +465,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: @@ -403,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 { +/// Compute the digest of a file in chunks. +/// +/// 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(); - 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. @@ -426,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 @@ -441,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. 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), + "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) @@ -733,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()); @@ -765,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, @@ -793,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/docs/guide/install.md b/docs/guide/install.md index 8d1bb42523..df4419d148 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -39,6 +39,13 @@ 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.…`). `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+ 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. ## Usage diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index f5df698019..1954d24daa 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -217,6 +217,11 @@ async fn envs_with_explicit_package_manager_path( .await { Ok(result) => result, + // 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!( ?error, @@ -257,9 +262,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 error.is_integrity_failure() => return Err(error), + Err(error) => { + tracing::debug!(?error, "failed to resolve package manager for task PATH setup"); + } } let session = Session::init(SessionConfig { @@ -441,7 +452,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 +544,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..01aa5bc752 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -108,9 +108,14 @@ 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 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), + 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"); 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)