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 +``` 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;