From 9a4a399830baa6bea531804d5d4139ffeddec90d Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 20 Oct 2026 10:23:05 +0900 Subject: [PATCH 1/2] fix(pm): verify Bun request pins on fresh installs Verify a supplied pin against the main bun tarball before downloading the platform package. Preserve platform integrity verification and leave cached install pin checks for a separate change. Cover matching and mismatched pins, unpinned installs, and platform hash mismatches with isolated mock registry tests. --- crates/vp_pm_cli/src/package_manager.rs | 134 ++++++++++++++++++++++-- rfcs/package-manager-detection.md | 14 +-- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 43b78bdc93..23bc0929bf 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -1453,12 +1453,9 @@ async fn download_package_manager_inner( let data_dir = &config.dirs.data; let bin_name = package_manager_type.to_string(); - // For bun, use platform-specific download flow. - // The hash from `packageManager` field belongs to the main `bun` npm package, - // not the platform-specific binary, so we don't pass it through; the - // platform tarball is verified against the registry's `dist.integrity`. + // Bun pins cover the main tarball, so verify its platform binary separately. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, data_dir, npm_config).await; + return download_bun_package_manager(&version, data_dir, expected_hash, npm_config).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -1704,6 +1701,7 @@ fn bun_requires_baseline() -> bool { async fn download_bun_package_manager( version: &Str, home_dir: &AbsolutePath, + expected_hash: Option<&str>, npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); @@ -1718,6 +1716,23 @@ async fn download_bun_package_manager( return Ok((install_dir, package_name, version.clone())); } + // A declared pin covers the main bun tarball, not the platform binary. + if let Some(expected_hash) = expected_hash { + let main_tgz_url = npm_config.package_tgz_url("bun", version); + let verify_dir = tempfile::tempdir()?; + let verify_message = format!("Verifying bun v{version}..."); + download_and_extract_tgz_with_hash_and_config( + &main_tgz_url, + verify_dir.path(), + None, + Some(expected_hash), + Some(&verify_message), + npm_config, + ) + .await + .map_err(|error| name_hashed_artifact(error, PackageManagerType::Bun, version))?; + } + // Temporary by design: select a CPU-compatible package for new downloads without replacing existing installs. let platform_package_name = get_bun_platform_package_name()?; // The declared hash never covers the platform tarball, so verify it @@ -5009,6 +5024,111 @@ mod tests { assert_eq!(with_bun_baseline("@oven/bun-linux-x64", false), "@oven/bun-linux-x64"); } + #[tokio::test] + async fn test_download_bun_package_manager_verifies_hashes() { + use httpmock::prelude::*; + use sha2::Sha512; + + let create_tgz = |path: &str, content: &[u8]| { + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + archive.append_data(&mut header, path, content).unwrap(); + archive.into_inner().unwrap().finish().unwrap() + }; + let main_tgz = create_tgz("package/package.json", br#"{"name":"bun","version":"1.2.0"}"#); + let binary = b"mock bun executable"; + let binary_path = if cfg!(windows) { "package/bin/bun.exe" } else { "package/bin/bun" }; + let platform_tgz = create_tgz(binary_path, binary); + let main_hash = format!("sha512.{}", hex::encode(Sha512::digest(&main_tgz))); + let wrong_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); + let platform_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&platform_tgz)) + ); + let wrong_integrity = + format!("sha512-{}", base64_simd::STANDARD.encode_to_string(Sha512::digest(b"other"))); + let platform_package = get_bun_platform_package_name().unwrap(); + let platform_basename = platform_package.strip_prefix("@oven/").unwrap(); + + for (pin, integrity) in [ + (None, &platform_integrity), + (Some(wrong_hash.as_str()), &platform_integrity), + (Some(main_hash.as_str()), &platform_integrity), + (Some(main_hash.as_str()), &wrong_integrity), + ] { + let vp_home = create_temp_dir(); + let server = MockServer::start(); + let main_tarball = server.mock(|when, then| { + when.method(GET).path("/bun/-/bun-1.2.0.tgz"); + then.status(200).body(main_tgz.clone()); + }); + let metadata = server.mock(|when, then| { + when.method(GET).path(format!("/{platform_package}/1.2.0")); + then.status(200).json_body(serde_json::json!({"dist": {"integrity": integrity}})); + }); + let platform_tarball = server.mock(|when, then| { + when.method(GET) + .path(format!("/{platform_package}/-/{platform_basename}-1.2.0.tgz")); + then.status(200).body(platform_tgz.clone()); + }); + + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + ], + |_| async { + let result = + download_package_manager(PackageManagerType::Bun, "1.2.0", pin).await; + if pin == Some(wrong_hash.as_str()) { + let Err(Error::PackageManagerHashMismatch(mismatch)) = result else { + panic!("a mismatched Bun pin must fail: {result:?}"); + }; + assert_eq!(mismatch.name, "bun"); + assert_eq!(mismatch.version, "1.2.0"); + assert_eq!(mismatch.expected, wrong_hash.as_str()); + assert_eq!(mismatch.actual, main_hash.as_str()); + assert_eq!(mismatch.basis, "the npm package tarball"); + assert!(main_tarball.hits() > 0); + metadata.assert_hits(0); + platform_tarball.assert_hits(0); + } else if integrity == &wrong_integrity { + assert!( + matches!(result, Err(Error::HashMismatch { .. })), + "the main pin must not replace platform verification: {result:?}" + ); + main_tarball.assert_hits(1); + metadata.assert_hits(1); + assert!(platform_tarball.hits() > 0); + } else { + let (install_dir, package, version) = result.unwrap(); + assert_eq!(package, "bun"); + assert_eq!(version, "1.2.0"); + let native_bin = + if cfg!(windows) { "bin/bun.native.exe" } else { "bin/bun.native" }; + assert_eq!(fs::read(install_dir.join(native_bin)).unwrap(), binary); + assert!(is_package_manager_install_complete(&install_dir, "bun").unwrap()); + assert!(!install_dir.join("package.json").as_path().exists()); + main_tarball.assert_hits(usize::from(pin.is_some())); + metadata.assert_hits(1); + platform_tarball.assert_hits(1); + return; + } + let install_dir = + package_manager_install_dir(PackageManagerType::Bun, "1.2.0").unwrap(); + assert!( + !install_dir.as_path().exists(), + "a failed verification must not install Bun" + ); + }, + ) + .await; + } + } + #[tokio::test] async fn test_download_bun_package_manager_preserves_existing_install() { let vp_home = shared_vp_home(); @@ -5026,7 +5146,9 @@ mod tests { .join("bun/bin/bun.native"); fs::write(&native_bin, "existing bun").unwrap(); - download_bun_package_manager(&version, &vp_home, &NpmConfig::load()).await.unwrap(); + download_bun_package_manager(&version, &vp_home, None, &NpmConfig::load()) + .await + .unwrap(); assert_eq!(fs::read_to_string(native_bin).unwrap(), "existing bun"); }, diff --git a/rfcs/package-manager-detection.md b/rfcs/package-manager-detection.md index 93deab6e8b..b1820e91f1 100644 --- a/rfcs/package-manager-detection.md +++ b/rfcs/package-manager-detection.md @@ -136,12 +136,14 @@ Projects that require a deterministic declaration can pin it explicitly with `vp 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` | +| 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 | the platform package against the registry `dist.integrity` | + +For a fresh Bun install, Vite+ downloads the main `bun` tarball only when a declared hash needs verification. It still installs the platform-specific binary and verifies that package against the registry's `dist.integrity` when present. Cached Bun installs do not yet recheck declared pins. 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). From 7753954e275f721bb6ad3c80e4a238eaf036c3c2 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 20 Oct 2026 13:54:51 +0900 Subject: [PATCH 2/2] fix(pm): isolate Bun hash test server across cases Keep the mock server alive for all cases so other tests cannot reuse its pooled HTTP connections while the Bun test runtime waits for the environment lock. Reset mocks and request history between cases. --- crates/vp_pm_cli/src/package_manager.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 23bc0929bf..f90b887a5f 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -5052,6 +5052,8 @@ mod tests { format!("sha512-{}", base64_simd::STANDARD.encode_to_string(Sha512::digest(b"other"))); let platform_package = get_bun_platform_package_name().unwrap(); let platform_basename = platform_package.strip_prefix("@oven/").unwrap(); + // Keep other tests from borrowing this server while its connections use this runtime. + let server = MockServer::start(); for (pin, integrity) in [ (None, &platform_integrity), @@ -5060,7 +5062,7 @@ mod tests { (Some(main_hash.as_str()), &wrong_integrity), ] { let vp_home = create_temp_dir(); - let server = MockServer::start(); + server.reset(); let main_tarball = server.mock(|when, then| { when.method(GET).path("/bun/-/bun-1.2.0.tgz"); then.status(200).body(main_tgz.clone());