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_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/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 092caa389d..f38b26e2f2 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -905,6 +905,14 @@ 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). + // A declared hash names the main tarball and is verified against it; the + // platform tarball is verified against the registry's `dist.integrity`. + if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { + return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; + } + 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 +1123,193 @@ 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) +} + +/// 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, +} + +/// 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, + expected_hash: Option<&str>, +) -> 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())); + } + + // 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?; + } + + // 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 + { + Error::PackageManagerVersionNotFound { + name: "pnpm".into(), + version: version.clone(), + url: metadata_url.as_str().into(), + } + } else { + err + } + })?; + // 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?; + + // 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) + .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 +3378,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 +3722,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/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() { 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");