From 891f54bd39a1815b331062ecf13e3371216bd5a2 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Sat, 1 Aug 2026 17:29:08 +0900 Subject: [PATCH 1/8] fix(install): support pnpm v12 native binary distribution pnpm >= 12 is a native binary: the npm package only ships placeholder bins that a preinstall script replaces from the platform-specific @pnpm/exe.* packages. Lifecycle scripts never run for managed package manager installs, so no bin/pnpm.cjs exists and exec of bin/pnpm failed with ENOENT (and the completeness check re-downloaded on every run). Download the @pnpm/exe.{os}-{arch} package directly for pnpm >= 12, place the binary at bin/pnpm.native, and create native shims, mirroring the bun flow. The pnpx shim injects dlx explicitly because shims do not preserve the launch name the binary self-detects for alias behavior. Fixes #2276 --- .../fixtures/command_add_pnpm12/package.json | 5 + .../command_add_pnpm12/snapshots.toml | 18 ++ .../snapshots/command_add_pnpm12.md | 185 +++++++++++++++ .../fixtures/shim_pnpm12_native/package.json | 6 + .../shim_pnpm12_native/snapshots.toml | 12 + .../snapshots/shim_pnpm12_native.md | 36 +++ crates/vite_install/src/package_manager.rs | 223 ++++++++++++++++++ crates/vite_install/src/shim.rs | 73 +++++- 8 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots/command_add_pnpm12.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/package.json new file mode 100644 index 0000000000..e8c0a3a146 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-add-pnpm12", + "version": "1.0.0", + "packageManager": "pnpm@12.0.0-beta.0" +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots.toml new file mode 100644 index 0000000000..089c4a1db7 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots.toml @@ -0,0 +1,18 @@ +[[case]] +name = "command_add_pnpm12" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "add", "--help"], comment = "should show help", continue-on-failure = true }, + { argv = ["vp", "add"], comment = "should error because no packages specified", continue-on-failure = true }, + { argv = ["vp", "add", "testnpm2", "-D", "--", "--loglevel=verbose", "--verbose"], comment = "should add package as dev dependencies" }, + { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, + { argv = ["vp", "add", "testnpm2", "test-vite-plus-install", "--allow-build=test-vite-plus-install"], comment = "should add packages to dependencies" }, + { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, + { argv = ["vp", "install", "test-vite-plus-package@1.0.0", "--save-peer"], comment = "should install package alias for add" }, + { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, + { argv = ["vp", "add", "test-vite-plus-package-optional", "-O"], comment = "should add package as optional dependencies" }, + { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, + { argv = ["vp", "add", "test-vite-plus-package-optional", "--", "--loglevel=warn"], comment = "support pass through arguments" }, + { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots/command_add_pnpm12.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots/command_add_pnpm12.md new file mode 100644 index 0000000000..8987b5ea25 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_add_pnpm12/snapshots/command_add_pnpm12.md @@ -0,0 +1,185 @@ +# command_add_pnpm12 + +## `vp add --help` + +should show help + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp add [OPTIONS] ... [-- ...] + +Add packages to dependencies + +Arguments: + ... Packages to add + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + -P, --save-prod Save to `dependencies` (default) + -D, --save-dev Save to `devDependencies` + --save-peer Save to `peerDependencies` and `devDependencies` + -O, --save-optional Save to `optionalDependencies` + -E, --save-exact Save exact version rather than semver range + --save-catalog-name Save the new dependency to the specified catalog name + --save-catalog Save the new dependency to the default catalog + --allow-build A list of package names allowed to run postinstall + --filter Filter packages in monorepo (can be used multiple times) + -w, --workspace-root Add to workspace root + --workspace Only add if package exists in workspace (pnpm-specific) + -g, --global Install globally + --node Node.js version to use for global installation (only with -g) + --concurrency Number of global package installs to run in parallel (only with -g) + -h, --help Print help + +Documentation: https://viteplus.dev/guide/install +``` + +## `vp add` + +should error because no packages specified + +**Exit code:** 2 + +``` +error: the following required arguments were not provided: + ... + +Usage: vp add ... [-- ...] + +For more information, try '--help'. +``` + +## `vp add testnpm2 -D -- --loglevel=verbose --verbose` + +should add package as dev dependencies + +**Exit code:** 2 + +``` +error: unexpected argument '--loglevel' found + + tip: to pass '--loglevel' as a value, use '-- --loglevel' + +Usage: pnpm add --save-dev ... + +For more information, try '--help'. +``` + +*(skipped 1 step(s) to the next line boundary: step failed)* + +## `vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install` + +should add packages to dependencies + +``` + +dependencies: + test-vite-plus-install 1.0.0 + testnpm2 1.0.1 + +Done in using pnpm +``` + +## `vpt print-file package.json` + +``` +{ + "name": "command-add-pnpm12", + "version": "1.0.0", + "packageManager": "pnpm@12.0.0-beta.0", + "dependencies": { + "test-vite-plus-install": "^1.0.0", + "testnpm2": "^1.0.1" + } +} +``` + +## `vp install test-vite-plus-package@1.0.0 --save-peer` + +should install package alias for add + +``` +VITE+ - The Unified Toolchain for the Web + +โœ“ Lockfile passes supply-chain policies (verified ago) + +devDependencies: + test-vite-plus-package 1.0.0 + +Done in using pnpm +``` + +## `vpt print-file package.json` + +``` +{ + "name": "command-add-pnpm12", + "version": "1.0.0", + "packageManager": "pnpm@12.0.0-beta.0", + "dependencies": { + "test-vite-plus-install": "^1.0.0", + "testnpm2": "^1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "peerDependencies": { + "test-vite-plus-package": "1.0.0" + } +} +``` + +## `vp add test-vite-plus-package-optional -O` + +should add package as optional dependencies + +``` +โœ“ Lockfile passes supply-chain policies (verified ago) + +optionalDependencies: + test-vite-plus-package-optional 1.0.0 + +Done in using pnpm +``` + +## `vpt print-file package.json` + +``` +{ + "name": "command-add-pnpm12", + "version": "1.0.0", + "packageManager": "pnpm@12.0.0-beta.0", + "dependencies": { + "test-vite-plus-install": "^1.0.0", + "testnpm2": "^1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "peerDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "^1.0.0" + } +} +``` + +## `vp add test-vite-plus-package-optional -- --loglevel=warn` + +support pass through arguments + +**Exit code:** 2 + +``` +error: unexpected argument '--loglevel' found + + tip: to pass '--loglevel' as a value, use '-- --loglevel' + +Usage: pnpm add [OPTIONS] ... + +For more information, try '--help'. +``` + +*(skipped 1 step(s) to the next line boundary: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/package.json new file mode 100644 index 0000000000..ccaecd6863 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/package.json @@ -0,0 +1,6 @@ +{ + "name": "shim-pnpm12-native", + "version": "1.0.0", + "private": true, + "packageManager": "pnpm@12.0.0-beta.0" +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml new file mode 100644 index 0000000000..b3564634ce --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "shim_pnpm12_native" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +comment = "pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim runs it directly and the pnpx shim injects the dlx subcommand." +steps = [ + { argv = ["vp", "install", "-g", "pnpm"], comment = "Expose the pnpm/pnpx shims", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", snapshot = false, continue-on-failure = true }, + { argv = ["pnpm", "--version"], comment = "pnpm shim downloads the native binary and resolves the pinned packageManager version (12.0.0-beta.0)", continue-on-failure = true }, + { argv = ["pnpx", "--silent", "cowsay", "hello"], comment = "pnpx shim injects dlx so the native binary runs the package", continue-on-failure = true }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md new file mode 100644 index 0000000000..da9b803c9a --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md @@ -0,0 +1,36 @@ +# shim_pnpm12_native + +pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim runs it directly and the pnpx shim injects the dlx subcommand. + +## `vp install -g pnpm` + +Expose the pnpm/pnpx shims + + +## `vp env exec node --version` + +Ensure Node.js is installed first + + +## `pnpm --version` + +pnpm shim downloads the native binary and resolves the pinned packageManager version (12.0.0-beta.0) + +``` +12.0.0-beta.0 +``` + +## `pnpx --silent cowsay hello` + +pnpx shim injects dlx so the native binary runs the package + +``` + _______ +< hello > + ------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || || +``` diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index 092caa389d..b7a86cc82a 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -905,6 +905,12 @@ pub async fn download_package_manager( return download_bun_package_manager(&version, &home_dir).await; } + // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package + // directly (the main package only ships preinstall-replaced placeholders). + if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { + return download_pnpm_native_package_manager(&version, &home_dir).await; + } + let tgz_url = get_npm_package_tgz_url(&package_name, &version); // $VP_HOME/package_manager/pnpm/10.0.0 let target_dir = home_dir.join("package_manager").join(&bin_name).join(&version); @@ -1115,6 +1121,147 @@ async fn download_bun_package_manager( Ok((install_dir, package_name, version.clone())) } +/// Platform-specific `@pnpm/exe.{os}-{arch}` package name for pnpm >= 12. +fn get_pnpm_platform_package_name() -> Result<&'static str, Error> { + let name = match (env::consts::OS, env::consts::ARCH) { + ("macos", "aarch64") => "@pnpm/exe.darwin-arm64", + ("macos", "x86_64") => "@pnpm/exe.darwin-x64", + #[cfg(target_env = "musl")] + ("linux", "aarch64") => "@pnpm/exe.linux-arm64-musl", + #[cfg(not(target_env = "musl"))] + ("linux", "aarch64") => "@pnpm/exe.linux-arm64", + #[cfg(target_env = "musl")] + ("linux", "x86_64") => "@pnpm/exe.linux-x64-musl", + #[cfg(not(target_env = "musl"))] + ("linux", "x86_64") => "@pnpm/exe.linux-x64", + ("windows", "x86_64") => "@pnpm/exe.win32-x64", + ("windows", "aarch64") => "@pnpm/exe.win32-arm64", + (os, arch) => { + return Err(Error::UnsupportedPackageManager( + format!("pnpm >= 12 (unsupported platform: {os}-{arch})").into(), + )); + } + }; + Ok(name) +} + +/// Download pnpm >= 12 (native binary) via its platform-specific npm package. +/// +/// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` +async fn download_pnpm_native_package_manager( + version: &Str, + home_dir: &AbsolutePath, +) -> Result<(AbsolutePathBuf, Str, Str), Error> { + let package_name: Str = "pnpm".into(); + let platform_package_name = get_pnpm_platform_package_name()?; + + // $VP_HOME/package_manager/pnpm/{version} + let target_dir = home_dir.join("package_manager").join("pnpm").join(version.as_str()); + let install_dir = target_dir.join("pnpm"); + + // If shims already exist, return early (same completeness check as the cache + // and the tgz download path) + if is_package_manager_install_complete(&install_dir, "pnpm")? { + return Ok((install_dir, package_name, version.clone())); + } + + let parent_dir = target_dir.parent().unwrap(); + tokio::fs::create_dir_all(parent_dir).await?; + + // Download the platform-specific package directly + let platform_tgz_url = get_npm_package_tgz_url(platform_package_name, version); + // Keep the TempDir guard alive so a failure path cleans up the temp dir. + let tmp_dir = tempfile::tempdir_in(parent_dir)?; + let target_dir_tmp = tmp_dir.path().to_path_buf(); + + download_and_extract_tgz_with_hash(&platform_tgz_url, &target_dir_tmp, None).await.map_err( + |err| { + if let Error::Reqwest(e) = &err + && let Some(status) = e.status() + && status == reqwest::StatusCode::NOT_FOUND + { + Error::PackageManagerVersionNotFound { + name: "pnpm".into(), + version: version.clone(), + url: platform_tgz_url.into(), + } + } else { + err + } + }, + )?; + + // Create the expected directory structure: pnpm/bin/ + let tmp_bin_dir = target_dir_tmp.join("pnpm").join("bin"); + tokio::fs::create_dir_all(&tmp_bin_dir).await?; + + // The platform package extracts to `package/` with the native binary at its root + let package_dir = target_dir_tmp.join("package"); + let native_bin_src = + if cfg!(windows) { package_dir.join("pnpm.exe") } else { package_dir.join("pnpm") }; + + // Move native binary to bin/pnpm.native + let native_bin_dest = if cfg!(windows) { + tmp_bin_dir.join("pnpm.native.exe") + } else { + tmp_bin_dir.join("pnpm.native") + }; + tokio::fs::rename(&native_bin_src, &native_bin_dest).await?; + + // Set executable permission on the native binary + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&native_bin_dest, fs::Permissions::from_mode(0o755)).await?; + } + + // Clean up the extracted package directory + remove_dir_all_force(&package_dir).await?; + + // Acquire lock for atomic rename + let lock_path = parent_dir.join(format!("{version}.lock")); + tracing::debug!("Acquire lock file: {:?}", lock_path); + let lock_file = open_lock_file(lock_path.as_path())?; + lock_file.lock()?; + tracing::debug!("Lock acquired: {:?}", lock_path); + + if is_package_manager_install_complete(&install_dir, "pnpm")? { + tracing::debug!("pnpm install already complete after lock acquisition, skip rename"); + return Ok((install_dir, package_name, version.clone())); + } + + // Rename temp dir to final location + tracing::debug!("Rename {:?} to {:?}", target_dir_tmp, target_dir); + remove_dir_all_force(&target_dir).await?; + tokio::fs::rename(&target_dir_tmp, &target_dir).await?; + + // Create native binary shims + tracing::debug!("Create native shim files for pnpm"); + create_pnpm_native_shim_files(&install_dir.join("bin")).await?; + + Ok((install_dir, package_name, version.clone())) +} + +/// Create shims for pnpm's native binary (pnpm >= 12): `pnpm` execs it +/// directly, `pnpx` injects `dlx` (shims don't preserve the launch name). +async fn create_pnpm_native_shim_files(bin_prefix: &AbsolutePath) -> Result<(), Error> { + let native_bin = if cfg!(windows) { + bin_prefix.join("pnpm.native.exe") + } else { + bin_prefix.join("pnpm.native") + }; + if !is_exists_file(&native_bin)? { + return Err(Error::CannotFindBinaryPath( + "pnpm native binary not found. Expected bin/pnpm.native".into(), + )); + } + + shim::write_native_shims(&native_bin, &bin_prefix.join("pnpm")).await?; + shim::write_native_shims_with_args(&native_bin, &bin_prefix.join("pnpx"), &["dlx"]).await?; + + Ok(()) +} + /// Remove the directory and all its contents. /// Ignore the error if the directory is not found. async fn remove_dir_all_force(path: impl AsRef) -> Result<(), std::io::Error> { @@ -3183,6 +3330,28 @@ mod tests { remove_dir_all_force(target_dir).await.unwrap(); } + #[tokio::test] + async fn test_download_package_manager_pnpm_v12_native() { + let result = + download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; + assert!(result.is_ok(), "{result:?}"); + let (target_dir, package_name, version) = result.unwrap(); + // native binary plus pnpm/pnpx shims, no JS entrypoint + let native_name = if cfg!(windows) { "bin/pnpm.native.exe" } else { "bin/pnpm.native" }; + assert!(is_exists_file(target_dir.join(native_name)).unwrap()); + assert!(is_exists_file(target_dir.join("bin/pnpm")).unwrap()); + assert!(is_exists_file(target_dir.join("bin/pnpm.cmd")).unwrap()); + assert!(is_exists_file(target_dir.join("bin/pnpx")).unwrap()); + assert_eq!(package_name, "pnpm"); + assert_eq!(version, "12.0.0-beta.0"); + + // again should hit the completeness fast-path and skip download + let result = + download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; + assert!(result.is_ok(), "{result:?}"); + remove_dir_all_force(target_dir).await.unwrap(); + } + #[tokio::test] async fn test_get_latest_version() { let result = get_latest_version(PackageManagerType::Yarn).await; @@ -3505,6 +3674,60 @@ mod tests { "On musl targets, package name should end with -musl, got: {name}" ); } + + #[test] + fn test_get_pnpm_platform_package_name() { + let result = get_pnpm_platform_package_name(); + assert!(result.is_ok(), "Should return a platform package name"); + let name = result.unwrap(); + assert!( + name.starts_with("@pnpm/exe."), + "Package name should start with @pnpm/exe., got: {name}" + ); + #[cfg(target_env = "musl")] + assert!( + name.ends_with("-musl"), + "On musl targets, package name should end with -musl, got: {name}" + ); + } + + #[tokio::test] + #[cfg(not(windows))] + async fn test_create_pnpm_native_shim_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_prefix = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + tokio::fs::create_dir_all(&bin_prefix).await.unwrap(); + tokio::fs::write(bin_prefix.join("pnpm.native"), "fake binary").await.unwrap(); + + create_pnpm_native_shim_files(&bin_prefix).await.unwrap(); + + // pnpm shim execs the native binary as-is + let pnpm_shim = tokio::fs::read_to_string(bin_prefix.join("pnpm")).await.unwrap(); + assert!(pnpm_shim.contains("exec \"$basedir/pnpm.native\" \"$@\""), "{pnpm_shim}"); + + // pnpx shim injects the dlx subcommand + let pnpx_shim = tokio::fs::read_to_string(bin_prefix.join("pnpx")).await.unwrap(); + assert!(pnpx_shim.contains("exec \"$basedir/pnpm.native\" dlx \"$@\""), "{pnpx_shim}"); + + // completeness check accepts the install (bin/pnpm exists) + assert!( + is_package_manager_install_complete( + &AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(), + "pnpm" + ) + .unwrap() + ); + } + + #[tokio::test] + async fn test_create_pnpm_native_shim_files_missing_binary() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_prefix = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + tokio::fs::create_dir_all(&bin_prefix).await.unwrap(); + + let result = create_pnpm_native_shim_files(&bin_prefix).await; + assert!(result.is_err(), "should error when bin/pnpm.native is missing"); + } /// Note: The true ERROR_SHARING_VIOLATION occurs when *multiple processes* /// attempt to lock the file concurrently on Windows (e.g. during parallel MSBuild tasks). /// Standard cargo tests run in a single process, which the Windows OS allows to bypass diff --git a/crates/vite_install/src/shim.rs b/crates/vite_install/src/shim.rs index 6ab46d43d2..57eb42bc9c 100644 --- a/crates/vite_install/src/shim.rs +++ b/crates/vite_install/src/shim.rs @@ -11,6 +11,16 @@ use vite_error::Error; pub async fn write_native_shims( source_file: impl AsRef, to_bin: impl AsRef, +) -> Result<(), Error> { + write_native_shims_with_args(source_file, to_bin, &[]).await +} + +/// Like [`write_native_shims`], but injects fixed leading arguments +/// (e.g. a `pnpx` shim execing `pnpm.native dlx "$@"`). +pub async fn write_native_shims_with_args( + source_file: impl AsRef, + to_bin: impl AsRef, + args: &[&str], ) -> Result<(), Error> { let to_bin = to_bin.as_ref(); let parent = to_bin @@ -23,9 +33,9 @@ pub async fn write_native_shims( .to_str() .ok_or_else(|| Error::CannotFindBinaryPath("shim path is not valid UTF-8".into()))?; - write(to_bin, native_sh_shim(relative_file)).await?; - write(to_bin.with_extension("cmd"), native_cmd_shim(relative_file)).await?; - write(to_bin.with_extension("ps1"), native_pwsh_shim(relative_file)).await?; + write(to_bin, native_sh_shim(relative_file, args)).await?; + write(to_bin.with_extension("cmd"), native_cmd_shim(relative_file, args)).await?; + write(to_bin.with_extension("ps1"), native_pwsh_shim(relative_file, args)).await?; // set executable permission for unix #[cfg(unix)] @@ -38,8 +48,13 @@ pub async fn write_native_shims( Ok(()) } +/// Render injected args as `"arg1 arg2 "` (trailing space), or `""` when empty. +fn format_injected_args(args: &[&str]) -> String { + if args.is_empty() { String::new() } else { format!("{} ", args.join(" ")) } +} + /// Unix shell shim for native binaries. -pub fn native_sh_shim(relative_file: &str) -> String { +pub fn native_sh_shim(relative_file: &str, args: &[&str]) -> String { formatdoc! { r#" #!/bin/sh @@ -53,25 +68,27 @@ pub fn native_sh_shim(relative_file: &str) -> String { ;; esac - exec "$basedir/{relative_file}" "$@" - "# + exec "$basedir/{relative_file}" {injected_args}"$@" + "#, + injected_args = format_injected_args(args) } } /// Windows Command Prompt shim for native binaries. -pub fn native_cmd_shim(relative_file: &str) -> String { +pub fn native_cmd_shim(relative_file: &str, args: &[&str]) -> String { formatdoc! { r#" @SETLOCAL - @"%~dp0\{relative_file}" %* + @"%~dp0\{relative_file}" {injected_args}%* "#, - relative_file = relative_file.replace('/', "\\") + relative_file = relative_file.replace('/', "\\"), + injected_args = format_injected_args(args) } .replace('\n', "\r\n") } /// `PowerShell` shim for native binaries. -pub fn native_pwsh_shim(relative_file: &str) -> String { +pub fn native_pwsh_shim(relative_file: &str, args: &[&str]) -> String { formatdoc! { r#" #!/usr/bin/env pwsh @@ -80,13 +97,14 @@ pub fn native_pwsh_shim(relative_file: &str) -> String { $ret=0 # Support pipeline input if ($MyInvocation.ExpectingInput) {{ - $input | & "$basedir/{relative_file}" $args + $input | & "$basedir/{relative_file}" {injected_args}$args }} else {{ - & "$basedir/{relative_file}" $args + & "$basedir/{relative_file}" {injected_args}$args }} $ret=$LASTEXITCODE exit $ret - "# + "#, + injected_args = format_injected_args(args) } } @@ -206,6 +224,35 @@ mod tests { shim.replace(' ', "ยท") } + #[test] + fn test_native_shims_without_args() { + let sh = native_sh_shim("bun.native", &[]); + assert!(sh.contains("exec \"$basedir/bun.native\" \"$@\""), "{}", format_shim(&sh)); + + let cmd = native_cmd_shim("bun.native", &[]); + assert!(cmd.contains("@\"%~dp0\\bun.native\" %*"), "{}", format_shim(&cmd)); + + let ps1 = native_pwsh_shim("bun.native", &[]); + assert!(ps1.contains("& \"$basedir/bun.native\" $args"), "{}", format_shim(&ps1)); + } + + #[test] + fn test_native_shims_with_injected_args() { + let sh = native_sh_shim("pnpm.native", &["dlx"]); + assert!(sh.contains("exec \"$basedir/pnpm.native\" dlx \"$@\""), "{}", format_shim(&sh)); + + let cmd = native_cmd_shim("pnpm.native", &["dlx"]); + assert!(cmd.contains("@\"%~dp0\\pnpm.native\" dlx %*"), "{}", format_shim(&cmd)); + + let ps1 = native_pwsh_shim("pnpm.native", &["dlx"]); + assert!( + ps1.contains("$input | & \"$basedir/pnpm.native\" dlx $args"), + "{}", + format_shim(&ps1) + ); + assert!(ps1.contains(" & \"$basedir/pnpm.native\" dlx $args"), "{}", format_shim(&ps1)); + } + #[test] fn test_sh_shim() { let shim = sh_shim("pnpm.js"); From c8e25e06455a795e482b05014ef0277e15f23f61 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Sat, 1 Aug 2026 19:02:22 +0900 Subject: [PATCH 2/8] fix(install): verify declared packageManager hash for pnpm >= 12 The hash names the main pnpm tarball, not the platform package: verify it against the artifact it describes before the native download, so a bad pin fails the same way it does for pnpm <= 11. --- crates/vite_install/src/package_manager.rs | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index b7a86cc82a..cde0d00bfc 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -908,7 +908,7 @@ pub async fn download_package_manager( // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package // directly (the main package only ships preinstall-replaced placeholders). if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, &home_dir).await; + return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; } let tgz_url = get_npm_package_tgz_url(&package_name, &version); @@ -1151,6 +1151,7 @@ fn get_pnpm_platform_package_name() -> Result<&'static str, Error> { async fn download_pnpm_native_package_manager( version: &Str, home_dir: &AbsolutePath, + expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; @@ -1165,6 +1166,16 @@ async fn download_pnpm_native_package_manager( return Ok((install_dir, package_name, version.clone())); } + // A `packageManager` hash describes the main `pnpm` tarball, not the + // platform package: verify it against the artifact it names so a bad pin + // still fails, matching pnpm <= 11. + if let Some(expected_hash) = expected_hash { + let main_tgz_url = get_npm_package_tgz_url("pnpm", version); + let verify_dir = tempfile::tempdir()?; + download_and_extract_tgz_with_hash(&main_tgz_url, verify_dir.path(), Some(expected_hash)) + .await?; + } + let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; @@ -3352,6 +3363,33 @@ mod tests { remove_dir_all_force(target_dir).await.unwrap(); } + #[tokio::test] + async fn test_download_package_manager_pnpm_v12_hash_verification() { + // Distinct version from the layout test so the completeness fast-path + // of a concurrent test never skips the verification under test; clear + // any leftover install for the same reason. + let version = "12.0.0-beta.1"; + let install_dir = package_manager_install_dir(PackageManagerType::Pnpm, version).unwrap(); + remove_dir_all_force(install_dir.parent().unwrap()).await.unwrap(); + + // A wrong declared hash must fail (the hash names the main pnpm tarball) + let wrong_hash = "sha512.0000000000000000000000000000000000000000000000000000000000000000\ + 0000000000000000000000000000000000000000000000000000000000000000"; + let result = + download_package_manager(PackageManagerType::Pnpm, version, Some(wrong_hash)).await; + assert!(matches!(result, Err(Error::HashMismatch { .. })), "{result:?}"); + + // The correct hash of the main pnpm tarball installs end-to-end + let correct_hash = "sha512.6398a9d604341739854276620377eb8e15d538091170629307da1ed40b2d\ + 1161478378681e76f7142f7aec4d20ab9e4b8f72d2e38ae340976329eb54ef325e95"; + let result = + download_package_manager(PackageManagerType::Pnpm, version, Some(correct_hash)).await; + assert!(result.is_ok(), "{result:?}"); + let (target_dir, _, _) = result.unwrap(); + assert!(is_exists_file(target_dir.join("bin/pnpm")).unwrap()); + remove_dir_all_force(target_dir).await.unwrap(); + } + #[tokio::test] async fn test_get_latest_version() { let result = get_latest_version(PackageManagerType::Yarn).await; From 228e3fb67445bbd60b774e18ee20715f9cf7b565 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Sat, 1 Aug 2026 19:15:42 +0900 Subject: [PATCH 3/8] test(install): drop pnpm v12 hash integration test The hash path reuses the same download_and_extract_tgz_with_hash / verify_file_hash mechanism the pnpm <= 11 flow uses, which is already covered by the mock-server tests in request.rs; neither flow needs a registry-hitting integration test for it. --- crates/vite_install/src/package_manager.rs | 27 ---------------------- 1 file changed, 27 deletions(-) diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index cde0d00bfc..4fef089711 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -3363,33 +3363,6 @@ mod tests { remove_dir_all_force(target_dir).await.unwrap(); } - #[tokio::test] - async fn test_download_package_manager_pnpm_v12_hash_verification() { - // Distinct version from the layout test so the completeness fast-path - // of a concurrent test never skips the verification under test; clear - // any leftover install for the same reason. - let version = "12.0.0-beta.1"; - let install_dir = package_manager_install_dir(PackageManagerType::Pnpm, version).unwrap(); - remove_dir_all_force(install_dir.parent().unwrap()).await.unwrap(); - - // A wrong declared hash must fail (the hash names the main pnpm tarball) - let wrong_hash = "sha512.0000000000000000000000000000000000000000000000000000000000000000\ - 0000000000000000000000000000000000000000000000000000000000000000"; - let result = - download_package_manager(PackageManagerType::Pnpm, version, Some(wrong_hash)).await; - assert!(matches!(result, Err(Error::HashMismatch { .. })), "{result:?}"); - - // The correct hash of the main pnpm tarball installs end-to-end - let correct_hash = "sha512.6398a9d604341739854276620377eb8e15d538091170629307da1ed40b2d\ - 1161478378681e76f7142f7aec4d20ab9e4b8f72d2e38ae340976329eb54ef325e95"; - let result = - download_package_manager(PackageManagerType::Pnpm, version, Some(correct_hash)).await; - assert!(result.is_ok(), "{result:?}"); - let (target_dir, _, _) = result.unwrap(); - assert!(is_exists_file(target_dir.join("bin/pnpm")).unwrap()); - remove_dir_all_force(target_dir).await.unwrap(); - } - #[tokio::test] async fn test_get_latest_version() { let result = get_latest_version(PackageManagerType::Yarn).await; From 98062166c44aa0fd1c6e39fdfae79955f8d1d8cf Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Sat, 1 Aug 2026 19:22:30 +0900 Subject: [PATCH 4/8] docs(install): note the pnpm >= 12 platform tarball integrity limitation --- crates/vite_install/src/package_manager.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index 4fef089711..c5ee96e74f 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -907,6 +907,8 @@ pub async fn download_package_manager( // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package // directly (the main package only ships preinstall-replaced placeholders). + // A declared hash names the main tarball and is verified against it; the + // platform tarball itself is not integrity-pinned (same as bun above). if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; } From 76664d98b9024d7621d58ba92b6805af33d77522 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 3 Aug 2026 00:11:22 +0900 Subject: [PATCH 5/8] feat(install): verify pnpm v12 platform tarball against dist.integrity The @pnpm/exe.* platform tarball was downloaded without any hash check because the declared packageManager hash only names the main pnpm package. Fetch the platform package's registry version metadata and verify the tarball against its dist.integrity (SRI), converted to the algo.hex format verify_file_hash already understands. Registries that omit the field keep the previous unverified behavior. --- Cargo.lock | 1 + crates/vite_install/Cargo.toml | 1 + crates/vite_install/src/package_manager.rs | 85 ++++++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee6c6d1688..bd5280b41c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8466,6 +8466,7 @@ name = "vite_install" version = "0.0.0" dependencies = [ "backon", + "base64-simd", "crossterm", "flate2", "futures-util", diff --git a/crates/vite_install/Cargo.toml b/crates/vite_install/Cargo.toml index c43462435c..5735679ce5 100644 --- a/crates/vite_install/Cargo.toml +++ b/crates/vite_install/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [dependencies] backon = { workspace = true } +base64-simd = { workspace = true } crossterm = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index c5ee96e74f..6e25ef08f7 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -908,7 +908,7 @@ pub async fn download_package_manager( // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package // directly (the main package only ships preinstall-replaced placeholders). // A declared hash names the main tarball and is verified against it; the - // platform tarball itself is not integrity-pinned (same as bun above). + // 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; } @@ -1147,6 +1147,28 @@ fn get_pnpm_platform_package_name() -> Result<&'static str, Error> { Ok(name) } +/// Registry version metadata subset: only the tarball integrity is needed. +#[derive(Deserialize)] +struct RegistryVersionMetadata { + #[serde(default)] + dist: RegistryDist, +} + +#[derive(Deserialize, Default)] +struct RegistryDist { + #[serde(default)] + integrity: Option, +} + +/// Convert an npm SRI string (`sha512-{base64}`) into the `{algo}.{hex}` +/// format that `download_and_extract_tgz_with_hash` verifies. +fn sri_to_expected_hash(integrity: &str) -> Option { + // SRI allows several space-separated hashes; npm registries serve one. + let (algorithm, b64) = integrity.split_whitespace().next()?.split_once('-')?; + let bytes = base64_simd::STANDARD.decode_to_vec(b64).ok()?; + Some(format!("{algorithm}.{}", hex::encode(bytes))) +} + /// Download pnpm >= 12 (native binary) via its platform-specific npm package. /// /// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` @@ -1178,17 +1200,11 @@ async fn download_pnpm_native_package_manager( .await?; } - let parent_dir = target_dir.parent().unwrap(); - tokio::fs::create_dir_all(parent_dir).await?; - - // Download the platform-specific package directly - let platform_tgz_url = get_npm_package_tgz_url(platform_package_name, version); - // Keep the TempDir guard alive so a failure path cleans up the temp dir. - let tmp_dir = tempfile::tempdir_in(parent_dir)?; - let target_dir_tmp = tmp_dir.path().to_path_buf(); - - download_and_extract_tgz_with_hash(&platform_tgz_url, &target_dir_tmp, None).await.map_err( - |err| { + // The declared hash never covers the platform tarball, so verify it + // against the registry's `dist.integrity` for the platform package. + let metadata_url = get_npm_package_version_url(platform_package_name, version); + let metadata: RegistryVersionMetadata = + HttpClient::new().get_json(&metadata_url).await.map_err(|err| { if let Error::Reqwest(e) = &err && let Some(status) = e.status() && status == reqwest::StatusCode::NOT_FOUND @@ -1196,13 +1212,43 @@ async fn download_pnpm_native_package_manager( Error::PackageManagerVersionNotFound { name: "pnpm".into(), version: version.clone(), - url: platform_tgz_url.into(), + url: metadata_url.as_str().into(), } } else { err } - }, - )?; + })?; + let platform_hash = metadata.dist.integrity.as_deref().and_then(sri_to_expected_hash); + + let parent_dir = target_dir.parent().unwrap(); + tokio::fs::create_dir_all(parent_dir).await?; + + // Download the platform-specific package directly + let platform_tgz_url = get_npm_package_tgz_url(platform_package_name, version); + // Keep the TempDir guard alive so a failure path cleans up the temp dir. + let tmp_dir = tempfile::tempdir_in(parent_dir)?; + let target_dir_tmp = tmp_dir.path().to_path_buf(); + + download_and_extract_tgz_with_hash( + &platform_tgz_url, + &target_dir_tmp, + platform_hash.as_deref(), + ) + .await + .map_err(|err| { + if let Error::Reqwest(e) = &err + && let Some(status) = e.status() + && status == reqwest::StatusCode::NOT_FOUND + { + Error::PackageManagerVersionNotFound { + name: "pnpm".into(), + version: version.clone(), + url: platform_tgz_url.into(), + } + } else { + err + } + })?; // Create the expected directory structure: pnpm/bin/ let tmp_bin_dir = target_dir_tmp.join("pnpm").join("bin"); @@ -3343,6 +3389,15 @@ mod tests { remove_dir_all_force(target_dir).await.unwrap(); } + #[test] + fn test_sri_to_expected_hash() { + use sha2::{Digest, Sha512}; + let digest = Sha512::digest(b"Hello, World!"); + let sri = format!("sha512-{}", base64_simd::STANDARD.encode_to_string(digest)); + assert_eq!(sri_to_expected_hash(&sri).unwrap(), format!("sha512.{}", hex::encode(digest))); + assert_eq!(sri_to_expected_hash("garbage"), None); + } + #[tokio::test] async fn test_download_package_manager_pnpm_v12_native() { let result = From 4b3913c3a836d6980bed53baf7585ce70b4e9bcf Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 3 Aug 2026 00:31:27 +0900 Subject: [PATCH 6/8] refactor(install): verify SRI integrity without a decode-encode roundtrip Teach verify_file_hash the registry dist.integrity SRI format (algorithm-base64) alongside the declared algorithm.hex format, comparing the digest in the encoding the expected hash uses. The pnpm 12 flow now passes dist.integrity straight through instead of converting it to hex first, dropping sri_to_expected_hash. --- crates/vite_install/src/package_manager.rs | 56 +++++---------- crates/vite_install/src/request.rs | 80 +++++++++++++++------- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index 6e25ef08f7..f38b26e2f2 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -1160,15 +1160,6 @@ struct RegistryDist { integrity: Option, } -/// Convert an npm SRI string (`sha512-{base64}`) into the `{algo}.{hex}` -/// format that `download_and_extract_tgz_with_hash` verifies. -fn sri_to_expected_hash(integrity: &str) -> Option { - // SRI allows several space-separated hashes; npm registries serve one. - let (algorithm, b64) = integrity.split_whitespace().next()?.split_once('-')?; - let bytes = base64_simd::STANDARD.decode_to_vec(b64).ok()?; - Some(format!("{algorithm}.{}", hex::encode(bytes))) -} - /// Download pnpm >= 12 (native binary) via its platform-specific npm package. /// /// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` @@ -1218,7 +1209,9 @@ async fn download_pnpm_native_package_manager( err } })?; - let platform_hash = metadata.dist.integrity.as_deref().and_then(sri_to_expected_hash); + // SRI allows several space-separated hashes; npm registries serve one. + let platform_hash = + metadata.dist.integrity.as_deref().and_then(|sri| sri.split_whitespace().next()); let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; @@ -1229,26 +1222,22 @@ async fn download_pnpm_native_package_manager( let tmp_dir = tempfile::tempdir_in(parent_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); - download_and_extract_tgz_with_hash( - &platform_tgz_url, - &target_dir_tmp, - platform_hash.as_deref(), - ) - .await - .map_err(|err| { - if let Error::Reqwest(e) = &err - && let Some(status) = e.status() - && status == reqwest::StatusCode::NOT_FOUND - { - Error::PackageManagerVersionNotFound { - name: "pnpm".into(), - version: version.clone(), - url: platform_tgz_url.into(), + download_and_extract_tgz_with_hash(&platform_tgz_url, &target_dir_tmp, platform_hash) + .await + .map_err(|err| { + if let Error::Reqwest(e) = &err + && let Some(status) = e.status() + && status == reqwest::StatusCode::NOT_FOUND + { + Error::PackageManagerVersionNotFound { + name: "pnpm".into(), + version: version.clone(), + url: platform_tgz_url.into(), + } + } else { + err } - } else { - err - } - })?; + })?; // Create the expected directory structure: pnpm/bin/ let tmp_bin_dir = target_dir_tmp.join("pnpm").join("bin"); @@ -3389,15 +3378,6 @@ mod tests { remove_dir_all_force(target_dir).await.unwrap(); } - #[test] - fn test_sri_to_expected_hash() { - use sha2::{Digest, Sha512}; - let digest = Sha512::digest(b"Hello, World!"); - let sri = format!("sha512-{}", base64_simd::STANDARD.encode_to_string(digest)); - assert_eq!(sri_to_expected_hash(&sri).unwrap(), format!("sha512.{}", hex::encode(digest))); - assert_eq!(sri_to_expected_hash("garbage"), None); - } - #[tokio::test] async fn test_download_package_manager_pnpm_v12_native() { let result = diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index fb5e3027e3..bdecdb0eba 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -236,7 +236,7 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu /// # Arguments /// * `url` - The URL of the tgz file to download. /// * `target_dir` - The directory to extract the tgz file to. -/// * `expected_hash` - Optional expected hash in format "algorithm.hash" (e.g., "sha512.abcd1234...") +/// * `expected_hash` - Optional expected hash, "algorithm.hex" or SRI "algorithm-base64" (see [`verify_file_hash`]) /// /// # Returns /// * `Ok(())` - If the tgz file is downloaded, verified (if hash provided) and extracted successfully. @@ -335,27 +335,21 @@ fn is_retryable_download_error(err: &Error) -> bool { } } -/// Computes the hash of the given content using the specified digest algorithm. -/// -/// # Type Parameters -/// * `D` - A type that implements the [`Digest`] trait, such as `Sha256`, `Sha512`, etc. -/// -/// # Arguments -/// * `content` - The byte slice to hash. -/// -/// # Returns -/// A hex-encoded string representing the computed digest. -fn compute_hash(content: &[u8]) -> String { +/// Computes the digest of the given content using the specified algorithm. +fn compute_digest(content: &[u8]) -> Vec { let mut hasher = D::new(); hasher.update(content); - hex::encode(hasher.finalize()) + hasher.finalize().to_vec() } /// Verify the hash of a file against an expected hash. /// /// # Arguments /// * `file_path` - Path to the file to verify -/// * `expected_hash` - Expected hash in format "algorithm.hash" (e.g., "sha512.abcd1234...") +/// * `expected_hash` - Expected hash, either "algorithm.hex" (e.g., +/// "sha512.abcd1234...", the `packageManager` declaration format) or SRI +/// "algorithm-base64" (e.g., "sha512-q83v...", the registry `dist.integrity` +/// format) /// /// # Returns /// * `Ok(())` - If the file hash matches the expected hash @@ -367,26 +361,35 @@ pub async fn verify_file_hash( let file_path = file_path.as_ref(); let content = fs::read(file_path).await?; - // Parse the hash format (e.g., "sha512.abcd1234..." or "sha256.abcd1234...") - let (algorithm, expected_hex) = if let Some((algo, hash)) = expected_hash.split_once('.') { - (algo, hash) + // "algorithm.hex" carries the hash in hex, SRI "algorithm-base64" in + // base64; hex never contains '-' and base64 never contains '.', so the + // separator alone identifies the format. + let (algorithm, expected, separator) = if let Some((algo, hash)) = expected_hash.split_once('.') + { + (algo, hash, '.') + } else if let Some((algo, hash)) = expected_hash.split_once('-') { + (algo, hash, '-') } else { return Err(Error::InvalidHashFormat(expected_hash.into())); }; - // Calculate the actual hash based on the algorithm - let actual_hex = match algorithm { - "sha512" => compute_hash::(&content), - "sha256" => compute_hash::(&content), - "sha224" => compute_hash::(&content), - "sha1" => compute_hash::(&content), + 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())), }; + let actual = if separator == '-' { + base64_simd::STANDARD.encode_to_string(&digest) + } else { + hex::encode(&digest) + }; - if actual_hex != expected_hex { + if actual != expected { return Err(Error::HashMismatch { expected: expected_hash.into(), - actual: format!("{algorithm}.{actual_hex}").into(), + actual: format!("{algorithm}{separator}{actual}").into(), }); } @@ -823,6 +826,33 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_verify_file_hash_sri() { + use sha2::{Digest, Sha512}; + use tokio::io::AsyncWriteExt; + + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.txt"); + + // Write test content + let content = b"Hello, World!"; + let mut file = tokio::fs::File::create(&test_file).await.unwrap(); + file.write_all(content).await.unwrap(); + + // Calculate the expected SRI (registry `dist.integrity` format) + let digest = Sha512::digest(content); + let expected_sri = format!("sha512-{}", base64_simd::STANDARD.encode_to_string(digest)); + + // Test successful verification + let result = verify_file_hash(&test_file, &expected_sri).await; + assert!(result.is_ok(), "{result:?}"); + + // Test failed verification + let wrong_sri = format!("sha512-{}", base64_simd::STANDARD.encode_to_string([0u8; 64])); + let result = verify_file_hash(&test_file, &wrong_sri).await; + assert!(matches!(result, Err(Error::HashMismatch { .. })), "{result:?}"); + } + #[tokio::test] #[ignore] // Flaky on musl/Alpine โ€” temp file race condition async fn test_verify_file_hash_sha224() { From 11715354e87731b1d99956fcb2b3dd7554605852 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 3 Aug 2026 09:35:04 +0900 Subject: [PATCH 7/8] fix(install): accept SHA-384 SRI integrity --- crates/vite_install/src/request.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index bdecdb0eba..efd31c8051 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -6,7 +6,7 @@ use futures_util::stream::StreamExt; use reqwest::{Response, StatusCode}; use serde::de::DeserializeOwned; use sha1::Sha1; -use sha2::{Digest, Sha224, Sha256, Sha512}; +use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; use tar::Archive; use tokio::{fs, io::AsyncWriteExt}; use vite_error::Error; @@ -375,6 +375,7 @@ pub async fn verify_file_hash( let digest = match algorithm { "sha512" => compute_digest::(&content), + "sha384" => compute_digest::(&content), "sha256" => compute_digest::(&content), "sha224" => compute_digest::(&content), "sha1" => compute_digest::(&content), @@ -828,7 +829,7 @@ mod tests { #[tokio::test] async fn test_verify_file_hash_sri() { - use sha2::{Digest, Sha512}; + use sha2::{Digest, Sha384, Sha512}; use tokio::io::AsyncWriteExt; let temp_dir = TempDir::new().unwrap(); @@ -839,6 +840,12 @@ mod tests { let mut file = tokio::fs::File::create(&test_file).await.unwrap(); file.write_all(content).await.unwrap(); + // SHA-384 is a standard SRI algorithm accepted in registry metadata. + let digest = Sha384::digest(content); + let expected_sri = format!("sha384-{}", base64_simd::STANDARD.encode_to_string(digest)); + let result = verify_file_hash(&test_file, &expected_sri).await; + assert!(result.is_ok(), "{result:?}"); + // Calculate the expected SRI (registry `dist.integrity` format) let digest = Sha512::digest(content); let expected_sri = format!("sha512-{}", base64_simd::STANDARD.encode_to_string(digest)); From c1e92aebf4e1359555cb9e2d0a60c656cce75254 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 3 Aug 2026 11:56:37 +0900 Subject: [PATCH 8/8] Revert "fix(install): accept SHA-384 SRI integrity" This reverts commit 11715354e87731b1d99956fcb2b3dd7554605852. --- crates/vite_install/src/request.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index efd31c8051..bdecdb0eba 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -6,7 +6,7 @@ use futures_util::stream::StreamExt; use reqwest::{Response, StatusCode}; use serde::de::DeserializeOwned; use sha1::Sha1; -use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; +use sha2::{Digest, Sha224, Sha256, Sha512}; use tar::Archive; use tokio::{fs, io::AsyncWriteExt}; use vite_error::Error; @@ -375,7 +375,6 @@ pub async fn verify_file_hash( let digest = match algorithm { "sha512" => compute_digest::(&content), - "sha384" => compute_digest::(&content), "sha256" => compute_digest::(&content), "sha224" => compute_digest::(&content), "sha1" => compute_digest::(&content), @@ -829,7 +828,7 @@ mod tests { #[tokio::test] async fn test_verify_file_hash_sri() { - use sha2::{Digest, Sha384, Sha512}; + use sha2::{Digest, Sha512}; use tokio::io::AsyncWriteExt; let temp_dir = TempDir::new().unwrap(); @@ -840,12 +839,6 @@ mod tests { let mut file = tokio::fs::File::create(&test_file).await.unwrap(); file.write_all(content).await.unwrap(); - // SHA-384 is a standard SRI algorithm accepted in registry metadata. - let digest = Sha384::digest(content); - let expected_sri = format!("sha384-{}", base64_simd::STANDARD.encode_to_string(digest)); - let result = verify_file_hash(&test_file, &expected_sri).await; - assert!(result.is_ok(), "{result:?}"); - // Calculate the expected SRI (registry `dist.integrity` format) let digest = Sha512::digest(content); let expected_sri = format!("sha512-{}", base64_simd::STANDARD.encode_to_string(digest));