From f8e5a0de8c3e32dcc2555ff4de9763f0c3d86dfd Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 27 Jun 2026 13:25:48 +0200 Subject: [PATCH] feat(pacquet-cli): port the self-update command (#12693) Ports pnpm's self-update command to the Rust pacquet CLI. Resolves the target version from the trusted package-manager bootstrap registry (honoring minimumReleaseAge / trustPolicy, strict resolution failing closed), guards against corepack, refuses to downgrade on an implicit `latest`, and emits the major-upgrade migration hint. When the project pins pnpm via packageManager / devEngines.packageManager, the pin is updated in place (range-style preserved, integrities persisted). Otherwise the engine is installed into the global packages directory: integrities are resolved into the env lockfile, the engine's npm registry signature is verified against npm's embedded public keys, the host's native platform binary is hard-linked into the wrapper (the preinstall is skipped because the engine installs with scripts disabled), and the bins are linked into the global bin directory with a cache-keyed hash symlink. New modules: self_update/install_pnpm (installPnpm + linkExePlatformBinary) and self_update/verify_engine (verifyPnpmEngineIdentity). config_deps gains resolve_pnpm_version, the policy-aware version probe. --- pacquet/crates/cli/src/cli_args.rs | 1 + .../crates/cli/src/cli_args/cli_command.rs | 3 + pacquet/crates/cli/src/cli_args/dispatch.rs | 1 + .../crates/cli/src/cli_args/dispatch_query.rs | 22 + .../cli/src/cli_args/package_manager.rs | 18 +- .../crates/cli/src/cli_args/self_update.rs | 498 ++++++++++++++++ .../src/cli_args/self_update/install_pnpm.rs | 312 ++++++++++ .../self_update/install_pnpm/tests.rs | 65 ++ .../cli/src/cli_args/self_update/tests.rs | 27 + .../src/cli_args/self_update/verify_engine.rs | 557 ++++++++++++++++++ .../self_update/verify_engine/tests.rs | 114 ++++ pacquet/crates/cli/src/config_deps.rs | 104 ++++ 12 files changed, 1713 insertions(+), 9 deletions(-) create mode 100644 pacquet/crates/cli/src/cli_args/self_update.rs create mode 100644 pacquet/crates/cli/src/cli_args/self_update/install_pnpm.rs create mode 100644 pacquet/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs create mode 100644 pacquet/crates/cli/src/cli_args/self_update/tests.rs create mode 100644 pacquet/crates/cli/src/cli_args/self_update/verify_engine.rs create mode 100644 pacquet/crates/cli/src/cli_args/self_update/verify_engine/tests.rs diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index 3f22794818..95ffd02a03 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -39,6 +39,7 @@ pub mod root; pub mod run; pub mod runtime; pub mod sanitize; +pub mod self_update; pub mod set_script; pub mod stop; pub mod store; diff --git a/pacquet/crates/cli/src/cli_args/cli_command.rs b/pacquet/crates/cli/src/cli_args/cli_command.rs index 5733116b74..1f44f3e0cb 100644 --- a/pacquet/crates/cli/src/cli_args/cli_command.rs +++ b/pacquet/crates/cli/src/cli_args/cli_command.rs @@ -36,6 +36,7 @@ use super::{ root::RootArgs, run::RunArgs, runtime::RuntimeArgs, + self_update::SelfUpdateArgs, set_script::SetScriptArgs, stop::StopArgs, store::StoreCommand, @@ -221,4 +222,6 @@ pub enum CliCommand { /// Opens the documentation of a package in the browser. #[clap(visible_alias = "home")] Docs(DocsArgs), + /// Updates pnpm to the latest version (or the one specified) + SelfUpdate(SelfUpdateArgs), } diff --git a/pacquet/crates/cli/src/cli_args/dispatch.rs b/pacquet/crates/cli/src/cli_args/dispatch.rs index 576b23a406..f010625876 100644 --- a/pacquet/crates/cli/src/cli_args/dispatch.rs +++ b/pacquet/crates/cli/src/cli_args/dispatch.rs @@ -271,6 +271,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result dispatch_install::fetch(ctx, args), CliCommand::Unlink(args) => dispatch_install::unlink(ctx, args), CliCommand::Docs(args) => dispatch_query::docs(ctx, args), + CliCommand::SelfUpdate(args) => dispatch_query::self_update(ctx, args), CliCommand::Completion(_) | CliCommand::CompletionServer(_) => { unreachable!("completion returns before configuration") } diff --git a/pacquet/crates/cli/src/cli_args/dispatch_query.rs b/pacquet/crates/cli/src/cli_args/dispatch_query.rs index c6efa68e65..36e7689bf4 100644 --- a/pacquet/crates/cli/src/cli_args/dispatch_query.rs +++ b/pacquet/crates/cli/src/cli_args/dispatch_query.rs @@ -16,6 +16,7 @@ use super::{ ping::PingArgs, reporter::ReporterType, root::RootArgs, + self_update::SelfUpdateArgs, store::StoreCommand, why::WhyArgs, }; @@ -190,6 +191,27 @@ pub(super) fn docs<'a>(ctx: &RunCtx<'a>, args: DocsArgs) -> miette::Result( + ctx: &RunCtx<'a>, + args: SelfUpdateArgs, +) -> miette::Result> { + // Refuse corepack before loading project config, so a broken `.npmrc` + // / workspace config can't mask the corepack refusal. + super::self_update::reject_if_corepack()?; + let config = (ctx.config)()?; + let dir = ctx.dir; + macro_rules! run_self_update { + ($reporter:ty) => { + Box::pin(args.run::<$reporter>(config, dir)) + }; + } + Ok(match ctx.reporter { + ReporterType::Default | ReporterType::AppendOnly => run_self_update!(DefaultReporter), + ReporterType::Ndjson => run_self_update!(NdjsonReporter), + ReporterType::Silent => run_self_update!(SilentReporter), + }) +} + pub(super) fn store<'a>( ctx: &RunCtx<'a>, command: StoreCommand, diff --git a/pacquet/crates/cli/src/cli_args/package_manager.rs b/pacquet/crates/cli/src/cli_args/package_manager.rs index c54ccf330d..d4c7f711b7 100644 --- a/pacquet/crates/cli/src/cli_args/package_manager.rs +++ b/pacquet/crates/cli/src/cli_args/package_manager.rs @@ -3,11 +3,11 @@ use serde_json::Value; use std::{fs, io::ErrorKind, path::Path}; #[derive(Debug)] -struct WantedPackageManager { - name: String, - version: Option, - from_dev_engines: bool, - on_fail: Option, +pub(crate) struct WantedPackageManager { + pub(crate) name: String, + pub(crate) version: Option, + pub(crate) from_dev_engines: bool, + pub(crate) on_fail: Option, } #[derive(Debug, PartialEq, Eq)] @@ -43,7 +43,7 @@ pub(crate) fn package_manager_to_sync( .map(|version| PackageManagerToSync { specifier: wanted_version.to_string(), version })) } -fn read_manifest_json(path: &Path) -> miette::Result> { +pub(crate) fn read_manifest_json(path: &Path) -> miette::Result> { let content = match fs::read_to_string(path) { Ok(content) => content, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), @@ -52,7 +52,7 @@ fn read_manifest_json(path: &Path) -> miette::Result> { serde_json::from_str(&content).into_diagnostic().map(Some) } -fn wanted_package_manager(manifest: &Value) -> Option { +pub(crate) fn wanted_package_manager(manifest: &Value) -> Option { if let Some(mut pm) = parse_dev_engines_package_manager(manifest) { if pm.version.as_deref().is_some_and(|version| node_semver::Range::parse(version).is_err()) { @@ -129,7 +129,7 @@ pub(crate) fn parse_package_manager(package_manager: &str) -> (String, Option bool { +pub(crate) fn should_persist_package_manager_lockfile(pm: &WantedPackageManager) -> bool { if pm.on_fail.as_deref().unwrap_or("download") == "ignore" { return false; } @@ -153,7 +153,7 @@ fn pnpm_version_from(root_dir: &Path) -> Option { value.get("version").and_then(Value::as_str).map(ToString::to_string) } -fn exact_version(version: &str) -> Option { +pub(crate) fn exact_version(version: &str) -> Option { let parsed = node_semver::Version::parse(version).ok()?; (parsed.to_string() == version).then(|| version.to_string()) } diff --git a/pacquet/crates/cli/src/cli_args/self_update.rs b/pacquet/crates/cli/src/cli_args/self_update.rs new file mode 100644 index 0000000000..858a276b42 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/self_update.rs @@ -0,0 +1,498 @@ +//! `pacquet self-update` — update pnpm to the latest version (or a given one). +//! +//! Ports pnpm's +//! [`self-update` command](https://github.com/pnpm/pnpm/blob/a33eeec9cd/pnpm11/engine/pm/commands/src/self-updater/selfUpdate.ts). +//! +//! The target version is resolved from the trusted package-manager +//! bootstrap registry. When the project pins pnpm via +//! `packageManager` / `devEngines.packageManager`, that pin is updated in +//! place; otherwise the engine is installed into the global packages +//! directory, its native binary linked, its registry signature verified, +//! and its bins linked into the global bin directory. + +mod install_pnpm; +mod verify_engine; + +use clap::Args; +use derive_more::{Display, Error}; +use miette::{Context, Diagnostic, IntoDiagnostic}; +use pacquet_cmd_shim::{Host as CmdShimHost, link_bins_of_packages_with_excludes}; +use pacquet_config::{Config, PACQUET_VERSION}; +use pacquet_fs::force_symlink_dir; +use pacquet_global::{create_global_cache_key, get_hash_link, read_installed_packages}; +use pacquet_lockfile::EnvLockfile; +use pacquet_package_manifest::PackageManifest; +use pacquet_reporter::{LogEvent, LogLevel, PnpmLog, Reporter}; +use pacquet_resolving_npm_resolver::which_version_is_pinned; +use serde_json::Value; +use std::{collections::HashSet, path::Path}; + +use crate::config_deps; + +/// Migration guidance printed once when `self-update` crosses a major +/// boundary. Mirrors pnpm's `MAJOR_UPGRADE_HINTS`; add an entry per +/// future major that ships breaking changes users need to act on. +fn major_upgrade_hint(target_major: u64) -> Option<&'static str> { + match target_major { + 11 => Some( + "pnpm v11 removed or renamed several v10 settings. \ + See https://pnpm.io/11.x/migration for migration instructions.", + ), + _ => None, + } +} + +/// Errors specific to `self-update`. Codes mirror pnpm's `PnpmError` +/// codes (pnpm prefixes `ERR_PNPM_`, so a code already starting with +/// `PNPM_` becomes `ERR_PNPM_PNPM_...`). +#[derive(Debug, Display, Error, Diagnostic)] +pub(crate) enum SelfUpdateError { + #[display("You should update pnpm with corepack")] + #[diagnostic(code(ERR_PNPM_CANT_SELF_UPDATE_IN_COREPACK))] + CantSelfUpdateInCorepack, + + #[display(r#"Cannot find "{specifier}" version of pnpm"#)] + #[diagnostic(code(ERR_PNPM_CANNOT_RESOLVE_PNPM))] + CannotResolvePnpm { specifier: String }, + + #[display( + "Refusing to switch to pnpm v{version}: it violates the configured minimumReleaseAge / trustPolicy" + )] + #[diagnostic(code(ERR_PNPM_PNPM_RELEASE_POLICY_VIOLATION))] + ReleasePolicyViolation { version: String }, + + #[display("{message}")] + #[diagnostic(code(ERR_PNPM_PNPM_ENGINE_IDENTITY_UNVERIFIABLE))] + EngineIdentityUnverifiable { message: String }, + + #[display("{message}")] + #[diagnostic(code(ERR_PNPM_PNPM_ENGINE_IDENTITY_MISMATCH))] + EngineIdentityMismatch { message: String }, + + #[display("Unable to find the global bin directory")] + #[diagnostic( + code(ERR_PNPM_NO_GLOBAL_BIN_DIR), + help( + r#"Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH."# + ) + )] + NoGlobalDir, +} + +#[derive(Debug, Args)] +pub struct SelfUpdateArgs { + /// The version, range, or dist-tag to update to. Defaults to the + /// `latest` dist-tag (which refuses to downgrade). + pub version: Option, +} + +/// Refuse to self-update under corepack (which manages its own updates). +/// Checked in the dispatcher *before* project config is loaded, so a broken +/// `.npmrc` / workspace config can't mask the corepack refusal. Mirrors +/// pnpm's `isExecutedByCorepack` guard. +pub(crate) fn reject_if_corepack() -> miette::Result<()> { + if is_executed_by_corepack() { + return Err(SelfUpdateError::CantSelfUpdateInCorepack.into()); + } + Ok(()) +} + +impl SelfUpdateArgs { + pub async fn run( + self, + config: &'static Config, + dir: &Path, + ) -> miette::Result<()> { + if let Some(message) = + Box::pin(handler::(self.version.as_deref(), config, dir)).await? + { + println!("{message}"); + } + Ok(()) + } +} + +/// The `self-update` flow. Returns the final user-facing message (printed +/// to stdout), or `None` when nothing needs printing. +async fn handler( + params: Option<&str>, + config: &'static Config, + dir: &Path, +) -> miette::Result> { + let prefix = dir.to_string_lossy().into_owned(); + info::(&prefix, "Checking for updates..."); + + // `self-update` (no args) defaults to the `latest` dist-tag but + // refuses to downgrade; `self-update latest` (explicit) bypasses the + // guard so a downgrade can still be forced. + let is_implicit_latest = params.is_none(); + let bare_specifier = params.unwrap_or("latest"); + + let resolved = + Box::pin(config_deps::resolve_pnpm_version(config, bare_specifier)).await?.ok_or_else( + || SelfUpdateError::CannotResolvePnpm { specifier: bare_specifier.to_string() }, + )?; + let target_version = resolved.version; + + // Under strict resolution (`minimumReleaseAge` strict, or + // `trustPolicy='no-downgrade'`), a policy violation must fail closed + // rather than silently switch — mirrors pnpm's `makeResolutionStrict`. + let strict_resolution = (config.resolved_minimum_release_age().is_some() + && config.resolved_minimum_release_age_strict()) + || config.trust_policy == pacquet_config::TrustPolicy::NoDowngrade; + if strict_resolution && resolved.policy_violation { + return Err(SelfUpdateError::ReleasePolicyViolation { version: target_version }.into()); + } + + let manifest_value = super::package_manager::read_manifest_json(&dir.join("package.json"))?; + let wanted = manifest_value.as_ref().and_then(super::package_manager::wanted_package_manager); + + // Migration hint when crossing a major boundary. The pinned version + // (when the project pins pnpm) is the source of truth for "from"; + // otherwise the running binary is. + let previous_version = match &wanted { + // A range pin (`^10.0.0`) has no recoverable major on its own, so + // read the resolved version from the env lockfile to drive the + // hint — otherwise crossing a major would silently skip it. + Some(pm) if pm.name == "pnpm" => { + let lockfile_dir = config.workspace_dir.as_deref().unwrap_or(dir); + read_project_pinned_pnpm_version(lockfile_dir, pm.version.as_deref()) + .filter(|version| version != &target_version) + } + _ if PACQUET_VERSION != target_version => Some(PACQUET_VERSION.to_string()), + _ => None, + }; + if let Some(previous) = &previous_version + && let (Some(previous_major), Ok(target)) = + (coerce_major(previous), node_semver::Version::parse(&target_version)) + && target.major > previous_major + && let Some(hint) = major_upgrade_hint(target.major) + { + warn::(&prefix, hint); + } + + // Project-pin branch: the project pins pnpm, so update the pin in + // place instead of touching the global install. + if let Some(pm) = &wanted + && pm.name == "pnpm" + { + return Box::pin(update_project_pin( + config, + dir, + pm, + &target_version, + bare_specifier, + is_implicit_latest, + )) + .await; + } + + // Global switch. + if target_version == PACQUET_VERSION { + return Ok(Some(format!( + r#"The currently active pnpm v{PACQUET_VERSION} is already "{bare_specifier}" and doesn't need an update"#, + ))); + } + if is_implicit_latest && version_lt(&target_version, PACQUET_VERSION) { + return Ok(Some(format!( + r#"The currently active pnpm v{PACQUET_VERSION} is newer than the "latest" version on the registry (v{target_version}). No update performed. Run "pnpm self-update latest" to downgrade."#, + ))); + } + + info::( + &prefix, + &format!("Switching pnpm from v{PACQUET_VERSION} to v{target_version}..."), + ); + + let env_root = config.global_pkg_dir.clone().ok_or(SelfUpdateError::NoGlobalDir)?; + // Resolve integrities (pnpm + @pnpm/exe + platform binary) into the + // env lockfile so the engine identity can be verified before install. + Box::pin(config_deps::sync_package_manager_dependencies( + config, + &env_root, + bare_specifier, + &target_version, + false, + )) + .await?; + let env = EnvLockfile::read(&env_root) + .map_err(miette::Report::new) + .wrap_err("read the env lockfile")? + .ok_or_else(|| SelfUpdateError::EngineIdentityUnverifiable { + message: format!( + "Cannot verify the identity of pnpm@{target_version}: its integrity metadata is missing from pnpm-lock.yaml.", + ), + })?; + Box::pin(verify_engine::verify_pnpm_engine_identity(&env, &target_version, config)).await?; + + let result = Box::pin(install_pnpm::install_pnpm::( + config, + &target_version, + config.supported_architectures.clone(), + )) + .await?; + + link_into_global_bin(config, &result.install_dir)?; + + if result.already_existed { + return Ok(Some(format!( + "The {bare_specifier} version, v{target_version}, is already present on the system. It was activated by linking it from {}.", + result.install_dir.display(), + ))); + } + Ok(Some(format!("Successfully updated pnpm to v{target_version}"))) +} + +/// Update the project's `packageManager` / `devEngines.packageManager` +/// pin to `target_version`. Mirrors the project-pin branch of pnpm's +/// `self-update` handler. +async fn update_project_pin( + config: &'static Config, + dir: &Path, + pm: &super::package_manager::WantedPackageManager, + target_version: &str, + bare_specifier: &str, + is_implicit_latest: bool, +) -> miette::Result> { + if pm.version.as_deref() == Some(target_version) { + return Ok(Some(format!( + "The current project is already set to use pnpm v{target_version}", + ))); + } + + // Implicit `latest` must not downgrade a project pinned to a newer + // version than the registry's `latest`. The env lockfile lives at the + // workspace root, not necessarily the command's `--dir`. + let lockfile_dir = config.workspace_dir.as_deref().unwrap_or(dir); + if is_implicit_latest + && let Some(current) = read_project_pinned_pnpm_version(lockfile_dir, pm.version.as_deref()) + && version_lt(target_version, ¤t) + { + return Ok(Some(format!( + r#"The current project is set to use pnpm v{current}, which is newer than the "latest" version on the registry (v{target_version}). No update performed. Run "pnpm self-update latest" to downgrade."#, + ))); + } + + let manifest_path = dir.join("package.json"); + let mut manifest = PackageManifest::from_path(manifest_path) + .map_err(miette::Report::new) + .wrap_err("read the project manifest")?; + + let has_dev_engines = manifest + .value() + .get("devEngines") + .and_then(|dev_engines| dev_engines.get("packageManager")) + .is_some(); + + if has_dev_engines { + let legacy_pins_pnpm = manifest + .value() + .get("packageManager") + .and_then(Value::as_str) + .map(super::package_manager::parse_package_manager) + .is_some_and(|(name, version)| name == "pnpm" && version.is_some()); + + let mut changed = false; + if let Some(entry) = dev_engines_pnpm_entry_mut(manifest.value_mut()) { + let current = entry.get("version").and_then(Value::as_str).map(ToString::to_string); + let updated = if legacy_pins_pnpm { + target_version.to_string() + } else { + update_version_constraint(current.as_deref(), target_version) + }; + if current.as_deref() != Some(updated.as_str()) + && let Some(object) = entry.as_object_mut() + { + object.insert("version".to_string(), Value::String(updated)); + changed = true; + } + } + if legacy_pins_pnpm { + let new_legacy = format!("pnpm@{target_version}"); + if manifest.value().get("packageManager").and_then(Value::as_str) != Some(&new_legacy) + && let Some(object) = manifest.value_mut().as_object_mut() + { + object.insert("packageManager".to_string(), Value::String(new_legacy)); + changed = true; + } + } + if changed { + manifest.save().map_err(miette::Report::new).wrap_err("write the project manifest")?; + } + if super::package_manager::should_persist_package_manager_lockfile(&pm_for_persist(pm)) { + let root_dir = config.workspace_dir.clone().unwrap_or_else(|| dir.to_path_buf()); + Box::pin(config_deps::sync_package_manager_dependencies( + config, + &root_dir, + bare_specifier, + target_version, + false, + )) + .await?; + } + } else if let Some(object) = manifest.value_mut().as_object_mut() { + object + .insert("packageManager".to_string(), Value::String(format!("pnpm@{target_version}"))); + manifest.save().map_err(miette::Report::new).wrap_err("write the project manifest")?; + } + + Ok(Some(format!("The current project has been updated to use pnpm v{target_version}"))) +} + +/// The `pnpm` entry of `devEngines.packageManager` (which can be a single +/// object or an array), as a mutable reference. +fn dev_engines_pnpm_entry_mut(manifest: &mut Value) -> Option<&mut Value> { + let package_manager = manifest.get_mut("devEngines")?.get_mut("packageManager")?; + if package_manager.is_array() { + return package_manager + .as_array_mut()? + .iter_mut() + .find(|item| item.get("name").and_then(Value::as_str) == Some("pnpm")); + } + if package_manager.get("name").and_then(Value::as_str) == Some("pnpm") { + return Some(package_manager); + } + None +} + +/// A [`super::package_manager::WantedPackageManager`] flagged as `fromDevEngines` so +/// [`super::package_manager::should_persist_package_manager_lockfile`] decides persistence +/// the same way pnpm's `shouldPersistLockfile` does for a devEngines pin. +fn pm_for_persist( + pm: &super::package_manager::WantedPackageManager, +) -> super::package_manager::WantedPackageManager { + super::package_manager::WantedPackageManager { + name: pm.name.clone(), + version: pm.version.clone(), + from_dev_engines: true, + on_fail: pm.on_fail.clone(), + } +} + +/// Returns the updated `devEngines.packageManager` version constraint. +/// Mirrors pnpm's `updateVersionConstraint`: a constraint that still +/// satisfies the new version is left as-is (the lockfile pins the exact +/// version); otherwise the new version is written with the constraint's +/// pinning style, falling back to a caret range. +fn update_version_constraint(current: Option<&str>, new_version: &str) -> String { + let Some(current) = current else { + return new_version.to_string(); + }; + if range_satisfies(current, new_version) { + return current.to_string(); + } + match which_version_is_pinned(current) { + Some(pinned) => format!("{}{new_version}", pinned.range_prefix()), + None => format!("^{new_version}"), + } +} + +/// The project's currently-pinned pnpm version, used to guard implicit +/// `latest` against downgrading. Prefers the env lockfile's resolved +/// version (accurate for range pins); falls back to the spec's exact +/// version. Mirrors pnpm's `readProjectPinnedPnpmVersion`. +fn read_project_pinned_pnpm_version(lockfile_dir: &Path, spec: Option<&str>) -> Option { + let lockfile_pinned = EnvLockfile::read(lockfile_dir).ok().flatten().and_then(|env| { + env.importers + .get(EnvLockfile::ROOT_IMPORTER_KEY) + .and_then(|importer| importer.package_manager_dependencies.as_ref()) + .and_then(|deps| deps.get("pnpm")) + .map(|dep| dep.version.clone()) + }); + let spec_min = spec.and_then(super::package_manager::exact_version); + match (lockfile_pinned, spec_min) { + (Some(lockfile), Some(spec)) => { + Some(if version_lt(&spec, &lockfile) { lockfile } else { spec }) + } + (lockfile, spec) => lockfile.or(spec), + } +} + +/// Link the installed engine's bins into the global bin directory and +/// record its cache-keyed hash symlink (so `pnpm ls -g` and `store prune` +/// see it). Mirrors the linking pnpm's `self-update` does after install. +fn link_into_global_bin(config: &Config, install_dir: &Path) -> miette::Result<()> { + let global_bin = config.global_bin.clone().ok_or(SelfUpdateError::NoGlobalDir)?; + let global_pkg_dir = config.global_pkg_dir.clone().ok_or(SelfUpdateError::NoGlobalDir)?; + + let pkgs = read_installed_packages(install_dir); + link_bins_of_packages_with_excludes::(&pkgs, &global_bin, &HashSet::new()) + .map_err(miette::Report::new) + .wrap_err("link the updated pnpm bins")?; + + let aliases = vec![install_pnpm::PNPM_PACKAGE_NAME.to_string()]; + let cache_hash = create_global_cache_key(&aliases, ®istries_for_cache_key(config)); + let hash_link = get_hash_link(&global_pkg_dir, &cache_hash); + force_symlink_dir(install_dir, &hash_link) + .into_diagnostic() + .wrap_err("link the global pnpm install directory")?; + Ok(()) +} + +/// Build the registry map (`{ default, ...scoped }`) hashed into the +/// global cache key, from the trusted package-manager bootstrap registries +/// — never the repo-controlled project registries, so a project `.npmrc` +/// can't change the hash-symlink name (which would create duplicate global +/// `pnpm` groups that `find_global_package` resolves non-deterministically). +fn registries_for_cache_key(config: &Config) -> Vec<(String, String)> { + let bootstrap = &config.package_manager_bootstrap; + let mut registries = vec![("default".to_string(), bootstrap.registry.clone())]; + registries.extend(bootstrap.registries.iter().map(|(key, value)| (key.clone(), value.clone()))); + registries +} + +/// `true` when pnpm is running under corepack, which manages its own +/// updates. Mirrors pnpm's `isExecutedByCorepack` (corepack sets +/// `COREPACK_ROOT`). +fn is_executed_by_corepack() -> bool { + std::env::var_os("COREPACK_ROOT").is_some() +} + +fn coerce_major(version: &str) -> Option { + node_semver::Version::parse(version).ok().map(|version| version.major) +} + +fn version_lt(left: &str, right: &str) -> bool { + match (node_semver::Version::parse(left), node_semver::Version::parse(right)) { + (Ok(left), Ok(right)) => left < right, + _ => false, + } +} + +fn range_satisfies(range: &str, version: &str) -> bool { + let (Ok(range), Ok(parsed)) = + (node_semver::Range::parse(range), node_semver::Version::parse(version)) + else { + return false; + }; + if range.satisfies(&parsed) { + return true; + } + // node-semver rejects a prerelease (`1.2.3-beta.1`) even when its base + // version is in range; retry with the base so prerelease self-update + // targets aren't spuriously treated as out of range. + if parsed.pre_release.is_empty() { + return false; + } + let base = format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch); + matches!(node_semver::Version::parse(&base), Ok(base) if range.satisfies(&base)) +} + +#[cfg(test)] +mod tests; + +fn info(prefix: &str, message: &str) { + Reporter::emit(&LogEvent::Pnpm(PnpmLog { + level: LogLevel::Info, + message: message.to_string(), + prefix: prefix.to_string(), + })); +} + +fn warn(prefix: &str, message: &str) { + Reporter::emit(&LogEvent::Pnpm(PnpmLog { + level: LogLevel::Warn, + message: message.to_string(), + prefix: prefix.to_string(), + })); +} diff --git a/pacquet/crates/cli/src/cli_args/self_update/install_pnpm.rs b/pacquet/crates/cli/src/cli_args/self_update/install_pnpm.rs new file mode 100644 index 0000000000..d1f4ccb987 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/self_update/install_pnpm.rs @@ -0,0 +1,312 @@ +//! Install pnpm into the global packages directory for a self-update. +//! +//! Ports pnpm's +//! [`installPnpm`](https://github.com/pnpm/pnpm/blob/a33eeec9cd/pnpm11/engine/pm/commands/src/self-updater/installPnpm.ts): +//! the engine is installed into a fresh directory under the global +//! packages dir (visible to `pnpm ls -g`), the host's native platform +//! binary is linked into the wrapper (replicating the wrapper's +//! preinstall, which is skipped because pnpm installs the engine with +//! scripts disabled), and the caller links the bins + hash symlink. + +use crate::{State, cli_args::add::add_package}; +use miette::{Context, IntoDiagnostic}; +use pacquet_config::{Config, PackageManagerBootstrap}; +use pacquet_global::{clean_orphaned_install_dirs, create_install_dir, find_global_package}; +use pacquet_graph_hasher::{host_arch, host_libc, host_platform}; +use pacquet_package_is_installable::SupportedArchitectures; +use pacquet_package_manifest::DependencyGroup; +use pacquet_registry::PinnedVersion; +use pacquet_reporter::Reporter; +use serde_json::Value; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use super::SelfUpdateError; + +/// From v12 the unscoped `pnpm` package is itself the native engine +/// (equal content to `@pnpm/exe`), so a self-update always converges on +/// installing `pnpm`. Mirrors pnpm's `pnpmPackageNameToInstall` for the +/// v12+ branch. +pub(super) const PNPM_PACKAGE_NAME: &str = "pnpm"; + +pub(super) struct InstallPnpmResult { + pub install_dir: PathBuf, + pub already_existed: bool, +} + +/// Install `pnpm@` into the global packages directory. Returns +/// the install directory and whether the requested version was already +/// present (in which case nothing is downloaded and the caller just +/// relinks it). +pub(super) async fn install_pnpm( + base_config: &'static Config, + version: &str, + supported_architectures: Option, +) -> miette::Result { + let global_pkg_dir = base_config.global_pkg_dir.clone().ok_or(SelfUpdateError::NoGlobalDir)?; + fs::create_dir_all(&global_pkg_dir) + .into_diagnostic() + .wrap_err("create the global packages directory")?; + clean_orphaned_install_dirs(&global_pkg_dir); + + if let Some(existing) = find_global_package(&global_pkg_dir, PNPM_PACKAGE_NAME) + .into_diagnostic() + .wrap_err("scan global packages")? + && installed_version(&existing.install_dir).as_deref() == Some(version) + { + return Ok(InstallPnpmResult { install_dir: existing.install_dir, already_existed: true }); + } + + let install_dir = create_install_dir(&global_pkg_dir) + .into_diagnostic() + .wrap_err("create the global install dir")?; + let outcome = Box::pin(run_install::( + base_config, + &install_dir, + version, + supported_architectures, + )) + .await + .and_then(|()| link_exe_platform_binary(&install_dir, PNPM_PACKAGE_NAME)); + if let Err(err) = outcome { + let _ = fs::remove_dir_all(&install_dir); + return Err(err); + } + Ok(InstallPnpmResult { install_dir, already_existed: false }) +} + +/// The `version` recorded in `/node_modules/pnpm/package.json`, +/// or `None` when the install is absent or unreadable. +fn installed_version(install_dir: &Path) -> Option { + let pkg_json = install_dir.join("node_modules").join(PNPM_PACKAGE_NAME).join("package.json"); + let text = fs::read_to_string(pkg_json).ok()?; + let value: Value = serde_json::from_str(&text).ok()?; + value.get("version").and_then(Value::as_str).map(ToString::to_string) +} + +/// Install `pnpm@` into a fresh group directory, mirroring the +/// global-add group install but with scripts disabled (the native binary +/// is linked manually afterwards) and no build-approval prompt. +async fn run_install( + base_config: &'static Config, + install_dir: &Path, + version: &str, + supported_architectures: Option, +) -> miette::Result<()> { + let mut cfg = base_config.clone(); + // Resolve and fetch the engine bytes through the trusted + // package-manager bootstrap registry/network/auth, never the + // repository-controlled project settings — otherwise a malicious + // project `.npmrc` could redirect the downloaded pnpm bytes to an + // attacker registry. This mirrors how `config_deps` resolves the + // package manager and how the engine signature is verified. + apply_package_manager_bootstrap(&mut cfg, &base_config.package_manager_bootstrap); + cfg.modules_dir = install_dir.join("node_modules"); + cfg.virtual_store_dir = install_dir.join("node_modules").join(".pnpm"); + // The engine install is self-contained, so its virtual store lives + // inside its own install dir, never the shared global one. + cfg.enable_global_virtual_store = false; + cfg.lockfile = true; + cfg.workspace_dir = None; + cfg.supported_architectures = supported_architectures; + // pnpm installs the engine with `ignoreScripts: true` — the wrapper's + // preinstall (which links the platform binary) is replicated by + // `link_exe_platform_binary`, so running it here is both unnecessary + // and a code-execution surface during a privileged self-update. + cfg.ignore_scripts = true; + cfg.dangerously_allow_all_builds = false; + cfg.allow_builds.clear(); + cfg.strict_dep_builds = false; + // Drop repo-controlled resolution-rewrite settings so a project's + // `pnpm-workspace.yaml` can't change the engine's installed dependency + // graph. The top-level engine components are signature-verified, but + // the installed closure must stay the published one. + cfg.overrides = None; + cfg.package_extensions = None; + cfg.catalogs = None; + cfg.patched_dependencies = None; + + let config: &'static Config = Config::leak(cfg); + let manifest_path = install_dir.join("package.json"); + let state = State::init(manifest_path, config, false) + .wrap_err("initialize the self-update install state")?; + add_package::( + state, + &format!("{PNPM_PACKAGE_NAME}@{version}"), + PinnedVersion::Patch, + None, + false, + config.supported_architectures.clone(), + || std::iter::once(DependencyGroup::Prod), + ) + .await +} + +/// Scope-local directory name of the `@pnpm/exe` platform package under +/// the legacy `-` scheme (`macos-arm64`, `win-x86`, +/// `linux-x64`, `linuxstatic-x64`). Mirrors pnpm's `exePlatformPkgDirName`. +pub(super) fn exe_platform_pkg_dir_name(platform: &str, arch: &str, libc: &str) -> String { + let arch = normalized_arch(platform, arch); + let os = match platform { + "darwin" => "macos", + "win32" => "win", + "linux" => { + if libc == "musl" { + "linuxstatic" + } else { + "linux" + } + } + other => other, + }; + format!("{os}-{arch}") +} + +/// Scope-local directory name of the platform package under the +/// `exe.-[-musl]` scheme — the convention pnpm v12 (the +/// Rust port) ships its native binaries under. Mirrors pnpm's +/// `exePlatformPkgDirNameNext`. +pub(super) fn exe_platform_pkg_dir_name_next(platform: &str, arch: &str, libc: &str) -> String { + let arch = normalized_arch(platform, arch); + let libc_suffix = if platform == "linux" && libc == "musl" { "-musl" } else { "" }; + format!("exe.{platform}-{arch}{libc_suffix}") +} + +fn normalized_arch<'a>(platform: &str, arch: &'a str) -> &'a str { + if platform == "win32" && arch == "ia32" { "x86" } else { arch } +} + +#[cfg(test)] +mod tests; + +/// Apply the trusted package-manager bootstrap registry/network/auth onto +/// `cfg`, so the engine install can't be redirected by repo-controlled +/// project settings. Mirrors the routing in +/// [`crate::config_deps`]'s `for_package_manager` context. +fn apply_package_manager_bootstrap(cfg: &mut Config, bootstrap: &PackageManagerBootstrap) { + cfg.registry.clone_from(&bootstrap.registry); + cfg.registries.clone_from(&bootstrap.registries); + cfg.proxy.clone_from(&bootstrap.proxy); + cfg.tls.clone_from(&bootstrap.tls); + cfg.tls_by_uri.clone_from(&bootstrap.tls_by_uri); + cfg.auth_headers = std::sync::Arc::clone(&bootstrap.auth_headers); +} + +/// Link the host's native platform binary (`@pnpm/exe.`) into the +/// wrapper package directory, replicating the wrapper's preinstall step +/// (skipped because the engine is installed with scripts disabled). +/// Mirrors pnpm's `linkExePlatformBinary`. +/// +/// Errors loudly when the wrapper or its platform binary is missing, or +/// when the hard link fails: with scripts disabled, this manual linking is +/// the critical path, so a silent no-op would leave a "successful" +/// self-update with a non-functional `pnpm`. +fn link_exe_platform_binary(install_dir: &Path, wrapper_pkg_name: &str) -> miette::Result<()> { + let mut wrapper_dir = install_dir.join("node_modules"); + for segment in wrapper_pkg_name.split('/') { + wrapper_dir.push(segment); + } + if !wrapper_dir.exists() { + let wrapper_display = wrapper_dir.display(); + return Err(miette::miette!("the installed pnpm wrapper is missing at {wrapper_display}")); + } + let platform = host_platform(); + let arch = host_arch(); + let libc = host_libc(); + let executable = if platform == "win32" { "pnpm.exe" } else { "pnpm" }; + + // Resolve the platform binary by its explicit adjacent path in the + // real virtual store, not via a `node_modules` walk (which a + // repo-controlled store-dir could shadow). `@pnpm/exe`'s parent is + // already `@pnpm`; the unscoped `pnpm` descends into `@pnpm`. + let wrapper_real_dir = fs::canonicalize(&wrapper_dir) + .into_diagnostic() + .wrap_err_with(|| format!("resolve the pnpm wrapper at {}", wrapper_dir.display()))?; + let parent = wrapper_real_dir + .parent() + .ok_or_else(|| miette::miette!("the pnpm wrapper has no parent directory"))?; + let scope_dir = + if wrapper_pkg_name.starts_with('@') { parent.to_path_buf() } else { parent.join("@pnpm") }; + + let candidate_dir_names = [ + exe_platform_pkg_dir_name(platform, arch, libc), + exe_platform_pkg_dir_name_next(platform, arch, libc), + ]; + let src = candidate_dir_names + .iter() + .map(|dir_name| scope_dir.join(dir_name).join(executable)) + .find(|candidate| candidate.exists()) + .ok_or_else(|| { + miette::miette!("no @pnpm/exe.{platform}-{arch} native binary was found for this host") + })?; + let dest = wrapper_dir.join(executable); + force_link(&src, &dest) + .into_diagnostic() + .wrap_err("link the native pnpm binary into the wrapper")?; + + if platform == "win32" { + // Aliases (pn / pnpx / pnx) must be .exe hardlinks of the native + // binary, not .cmd wrappers — cmd-shim's Bash shim mangles a .cmd + // target under MSYS2 / Git Bash. The native binary detects which + // name it was launched as and prepends `dlx` for pnpx / pnx. + for alias in ["pn", "pnpx", "pnx"] { + force_link(&src, &wrapper_dir.join(format!("{alias}.exe"))) + .into_diagnostic() + .wrap_err_with(|| format!("link the {alias} alias into the wrapper"))?; + } + rewrite_windows_bin_field(&wrapper_dir); + } + Ok(()) +} + +/// Hard-link `src` to `dest`, replacing any existing file. Marks the +/// result executable on Unix (a copy/link can lose the bit). +fn force_link(src: &Path, dest: &Path) -> std::io::Result<()> { + match fs::remove_file(dest) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + fs::hard_link(src, dest)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dest, fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +/// Point the Windows wrapper's `bin` field at the `.exe` variants (the +/// npm shim generator reads `bin` at install time). Written via a temp +/// file + rename so the content-addressed, hard-linked `package.json` +/// blob is not mutated in place. Mirrors pnpm's same step. +fn rewrite_windows_bin_field(wrapper_dir: &Path) { + let pkg_json_path = wrapper_dir.join("package.json"); + let Ok(text) = fs::read_to_string(&pkg_json_path) else { + return; + }; + let Ok(mut pkg) = serde_json::from_str::(&text) else { + return; + }; + let Some(bin) = pkg.get_mut("bin").and_then(Value::as_object_mut) else { + return; + }; + for (name, target) in + [("pnpm", "pnpm.exe"), ("pn", "pn.exe"), ("pnpx", "pnpx.exe"), ("pnx", "pnx.exe")] + { + bin.insert(name.to_string(), Value::String(target.to_string())); + } + let Ok(serialized) = serde_json::to_string_pretty(&pkg) else { + return; + }; + let temp_path = pkg_json_path.with_extension("json.pnpm-tmp"); + if fs::write(&temp_path, serialized).is_err() { + let _ = fs::remove_file(&temp_path); + return; + } + if fs::rename(&temp_path, &pkg_json_path).is_err() { + let _ = fs::remove_file(&temp_path); + } +} diff --git a/pacquet/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs b/pacquet/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs new file mode 100644 index 0000000000..5c0dbbb236 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs @@ -0,0 +1,65 @@ +use super::{exe_platform_pkg_dir_name, exe_platform_pkg_dir_name_next, link_exe_platform_binary}; +use pacquet_graph_hasher::{host_arch, host_libc, host_platform}; +use std::fs; + +#[test] +fn legacy_platform_dir_names() { + assert_eq!(exe_platform_pkg_dir_name("darwin", "arm64", "unknown"), "macos-arm64"); + assert_eq!(exe_platform_pkg_dir_name("darwin", "x64", "unknown"), "macos-x64"); + assert_eq!(exe_platform_pkg_dir_name("win32", "x64", "unknown"), "win-x64"); + assert_eq!(exe_platform_pkg_dir_name("win32", "ia32", "unknown"), "win-x86"); + assert_eq!(exe_platform_pkg_dir_name("linux", "x64", "glibc"), "linux-x64"); + assert_eq!(exe_platform_pkg_dir_name("linux", "x64", "musl"), "linuxstatic-x64"); + assert_eq!(exe_platform_pkg_dir_name("linux", "arm64", "musl"), "linuxstatic-arm64"); +} + +#[test] +fn next_platform_dir_names() { + assert_eq!(exe_platform_pkg_dir_name_next("darwin", "arm64", "unknown"), "exe.darwin-arm64"); + assert_eq!(exe_platform_pkg_dir_name_next("win32", "ia32", "unknown"), "exe.win32-x86"); + assert_eq!(exe_platform_pkg_dir_name_next("linux", "x64", "glibc"), "exe.linux-x64"); + assert_eq!(exe_platform_pkg_dir_name_next("linux", "x64", "musl"), "exe.linux-x64-musl"); + assert_eq!(exe_platform_pkg_dir_name_next("linux", "arm64", "musl"), "exe.linux-arm64-musl"); +} + +/// Lay out a fake engine install: the `pnpm` wrapper and, under +/// `@pnpm/`, the native binary the wrapper's preinstall +/// would normally link. +fn fake_engine_install(install_dir: &std::path::Path, with_native_binary: bool) { + let node_modules = install_dir.join("node_modules"); + fs::create_dir_all(node_modules.join("pnpm")).expect("create wrapper dir"); + if with_native_binary { + let platform_dir = + exe_platform_pkg_dir_name_next(host_platform(), host_arch(), host_libc()); + let src_dir = node_modules.join("@pnpm").join(platform_dir); + fs::create_dir_all(&src_dir).expect("create platform dir"); + fs::write(src_dir.join("pnpm"), b"#!/bin/sh\necho pnpm\n").expect("write native binary"); + } +} + +#[cfg(unix)] +#[test] +fn links_the_host_platform_binary_into_the_wrapper() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("tempdir"); + fake_engine_install(temp.path(), true); + + link_exe_platform_binary(temp.path(), "pnpm").expect("linking should succeed"); + + let dest = temp.path().join("node_modules").join("pnpm").join("pnpm"); + assert!(dest.exists(), "the native binary is linked into the wrapper"); + assert_eq!(fs::read(&dest).expect("read linked binary"), b"#!/bin/sh\necho pnpm\n"); + let mode = fs::metadata(&dest).expect("stat linked binary").permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "the linked binary is executable"); +} + +#[test] +fn link_errors_when_the_native_binary_is_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + // Wrapper present, but no `@pnpm/` native binary — linking must + // fail loudly rather than leave a broken "successful" self-update. + fake_engine_install(temp.path(), false); + + assert!(link_exe_platform_binary(temp.path(), "pnpm").is_err()); +} diff --git a/pacquet/crates/cli/src/cli_args/self_update/tests.rs b/pacquet/crates/cli/src/cli_args/self_update/tests.rs new file mode 100644 index 0000000000..6745c71ebe --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/self_update/tests.rs @@ -0,0 +1,27 @@ +use super::{update_version_constraint, version_lt}; + +#[test] +fn version_constraint_preserves_pinning_style() { + // No prior constraint → the exact version. + assert_eq!(update_version_constraint(None, "1.2.3"), "1.2.3"); + // A range that still satisfies the new version is left untouched; the + // lockfile pins the exact version. + assert_eq!(update_version_constraint(Some("^1.0.0"), "1.5.0"), "^1.0.0"); + // A range that no longer satisfies is rewritten in its own style. + assert_eq!(update_version_constraint(Some("^1.0.0"), "2.0.0"), "^2.0.0"); + assert_eq!(update_version_constraint(Some("~1.0.0"), "2.0.0"), "~2.0.0"); + // An exact pin stays exact. + assert_eq!(update_version_constraint(Some("1.0.0"), "2.0.0"), "2.0.0"); + // A complex multi-comparator range falls back to a caret range. + assert_eq!(update_version_constraint(Some(">=1.0.0 <2.0.0"), "3.0.0"), "^3.0.0"); +} + +#[test] +fn version_lt_compares_semver() { + assert!(version_lt("1.0.0", "2.0.0")); + assert!(version_lt("12.0.0-alpha.0", "12.0.0")); + assert!(!version_lt("2.0.0", "1.0.0")); + assert!(!version_lt("1.0.0", "1.0.0")); + // Unparsable input compares as not-less-than (never downgrades). + assert!(!version_lt("not-a-version", "1.0.0")); +} diff --git a/pacquet/crates/cli/src/cli_args/self_update/verify_engine.rs b/pacquet/crates/cli/src/cli_args/self_update/verify_engine.rs new file mode 100644 index 0000000000..e7388bf833 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/self_update/verify_engine.rs @@ -0,0 +1,557 @@ +//! Verify that the pnpm engine about to be installed and executed is the +//! genuinely-published `pnpm`. +//! +//! Ports pnpm's +//! [`verifyPnpmEngineIdentity`](https://github.com/pnpm/pnpm/blob/a33eeec9cd/pnpm11/engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts). +//! +//! The wanted pnpm version comes from the resolved env lockfile, and the +//! project controls the lockfile integrity and the registry the bytes are +//! fetched from — so without this check a cloned repository could make +//! pnpm download and run an arbitrary native binary. The signed message +//! is built from the lockfile integrity and verified against npm's +//! embedded public keys (so a project-controlled registry cannot answer +//! with its own key pair); the signed packument is fetched from the +//! trusted package-manager bootstrap registry, which an npm mirror +//! proxies transparently. +//! +//! Runs only on a genuine download (a store cache miss), so it does not +//! add a network round trip to every command. + +use base64::Engine as _; +use p256::{ + ecdsa::{Signature, VerifyingKey, signature::Verifier}, + pkcs8::DecodePublicKey, +}; +use pacquet_config::Config; +use pacquet_graph_hasher::{host_arch, host_libc, host_platform}; +use pacquet_lockfile::{EnvLockfile, PackageKey, SnapshotDepRef}; +use pacquet_network::{ + NetworkSettings, RetryOpts, ThrottledClient, redact_url_credentials, send_with_retry, +}; +use serde::Deserialize; +use std::time::Duration; + +use super::{ + SelfUpdateError, + install_pnpm::{exe_platform_pkg_dir_name, exe_platform_pkg_dir_name_next}, +}; + +/// npm's public registry signing keys, mirrored from +/// . Ports pnpm's +/// `NPM_SIGNING_KEYS`; `expires` is `None` for a key with no expiry. +const NPM_SIGNING_KEYS: &[NpmSigningKey] = &[ + NpmSigningKey { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==", + expires: Some("2025-01-29T00:00:00.000Z"), + }, + NpmSigningKey { + keyid: "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U", + key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==", + expires: None, + }, +]; + +struct NpmSigningKey<'a> { + keyid: &'a str, + key: &'a str, + expires: Option<&'a str>, +} + +/// A pnpm-engine component whose registry signature must validate over the +/// bytes the lockfile pins. Mirrors pnpm's `InstalledPackageToVerify`. +struct EngineComponent { + name: String, + registry: String, + version: String, + integrity: String, +} + +/// Verify the pnpm engine recorded in `env` against npm's embedded keys. +/// +/// Returns an error when verification detects tampering (an invalid +/// signature), when a component is absent from the registry, when a +/// component carries no integrity metadata, or when the registry is +/// unreachable — failing closed in every case, since the lockfile +/// integrity is project-controlled and not a safe fallback. +pub(super) async fn verify_pnpm_engine_identity( + env: &EnvLockfile, + pnpm_version: &str, + config: &Config, +) -> Result<(), SelfUpdateError> { + let to_verify = collect_engine_components(env, config)?; + if to_verify.is_empty() { + return Err(SelfUpdateError::EngineIdentityUnverifiable { + message: format!( + "Cannot verify the identity of pnpm@{pnpm_version}: its integrity metadata is missing from pnpm-lock.yaml.", + ), + }); + } + + let client = build_client(config)?; + let retry_opts = retry_opts(config); + + let mut failures: Vec = Vec::new(); + for component in &to_verify { + if let Some(failure) = find_signature_failure(component, &client, retry_opts, config).await + { + failures.push(failure); + } + } + if failures.is_empty() { + return Ok(()); + } + failures.sort_by(|left, right| left.label.cmp(&right.label)); + + let only_unreachable = + failures.iter().all(|failure| failure.category == FailureCategory::Unreachable); + let described = failures.iter().map(SignatureFailure::describe).collect::>().join("; "); + let message = format!( + "Refusing to run pnpm@{pnpm_version}: its npm registry signature could not be verified \ + ({described}). The bytes selected by this project's lockfile/registry do not match a \ + published, signed pnpm release.", + ); + if only_unreachable { + Err(SelfUpdateError::EngineIdentityUnverifiable { message }) + } else { + Err(SelfUpdateError::EngineIdentityMismatch { message }) + } +} + +/// Collect the engine components to verify from the env lockfile: `pnpm`, +/// `@pnpm/exe`, and the host's `@pnpm/exe` platform binary (an optional +/// dependency of `@pnpm/exe`). Errors if a present component carries no +/// integrity. Mirrors pnpm's `collectEnginePackagesToVerify`. +fn collect_engine_components( + env: &EnvLockfile, + config: &Config, +) -> Result, SelfUpdateError> { + let mut to_verify = Vec::new(); + let pm_deps = env + .importers + .get(EnvLockfile::ROOT_IMPORTER_KEY) + .and_then(|importer| importer.package_manager_dependencies.as_ref()); + let Some(pm_deps) = pm_deps else { + return Ok(to_verify); + }; + + for name in ["pnpm", "@pnpm/exe"] { + if let Some(dep) = pm_deps.get(name) { + to_verify.push(engine_component(env, config, name, &dep.version)?); + } + } + + // The bytes actually executed are the host's `@pnpm/exe` platform + // binary, listed as an optional dependency of `@pnpm/exe`. Since this + // is the native code self-update will run, a missing snapshot, missing + // optional deps, or no host candidate fails closed rather than letting + // verification pass on `pnpm`/`@pnpm/exe` alone. + if let Some(exe) = pm_deps.get("@pnpm/exe") { + let exe_version = &exe.version; + let snapshot_label = format!("@pnpm/exe@{exe_version}"); + let snapshot_key = snapshot_label.parse::().map_err(|_| { + SelfUpdateError::EngineIdentityUnverifiable { + message: format!( + "Cannot verify the identity of {snapshot_label}: its lockfile snapshot key is invalid.", + ), + } + })?; + let optional_deps = env + .snapshots + .get(&snapshot_key) + .and_then(|snapshot| snapshot.optional_dependencies.as_ref()) + .ok_or_else(|| SelfUpdateError::EngineIdentityUnverifiable { + message: format!( + "Cannot verify the identity of {snapshot_label}: its platform binaries are missing from pnpm-lock.yaml.", + ), + })?; + let platform = host_platform(); + let arch = host_arch(); + let libc = host_libc(); + let candidate_names = [ + format!("@pnpm/{}", exe_platform_pkg_dir_name(platform, arch, libc)), + format!("@pnpm/{}", exe_platform_pkg_dir_name_next(platform, arch, libc)), + ]; + let platform_dep = candidate_names.iter().find_map(|platform_name| { + let key = platform_name.parse().ok()?; + let version = plain_version(optional_deps.get(&key)?)?; + Some((platform_name.clone(), version)) + }); + // The first candidate present in the lockfile is the binary the + // install links and executes. + let Some((platform_name, version)) = platform_dep else { + return Err(SelfUpdateError::EngineIdentityUnverifiable { + message: format!( + "Cannot verify the identity of the @pnpm/exe.{platform}-{arch} native binary: it is missing from pnpm-lock.yaml.", + ), + }); + }; + to_verify.push(engine_component(env, config, &platform_name, &version)?); + } + + Ok(to_verify) +} + +/// Build the [`EngineComponent`] for `name@version`, reading its integrity +/// from the env lockfile's `packages:` map. Mirrors pnpm's +/// `engineComponentToVerify`: a missing integrity fails closed. +fn engine_component( + env: &EnvLockfile, + config: &Config, + name: &str, + version: &str, +) -> Result { + let integrity = format!("{name}@{version}") + .parse::() + .ok() + .and_then(|key| env.packages.get(&key).map(|metadata| metadata.resolution.integrity())) + .flatten() + .map(ToString::to_string); + let Some(integrity) = integrity.filter(|integrity| !integrity.is_empty()) else { + return Err(SelfUpdateError::EngineIdentityUnverifiable { + message: format!( + "Cannot verify the identity of {name}@{version}: its integrity metadata is missing from pnpm-lock.yaml.", + ), + }); + }; + Ok(EngineComponent { + name: name.to_string(), + registry: pick_registry(name, config), + version: version.to_string(), + integrity, + }) +} + +/// The exact version of a plain (non-alias, non-link) snapshot reference. +fn plain_version(reference: &SnapshotDepRef) -> Option { + match reference { + SnapshotDepRef::Plain(ver_peer) => { + // Strip any peer suffix; an `@pnpm/exe` platform optional dep + // is always an exact, peerless version. + Some(ver_peer.to_string().split('(').next().unwrap_or_default().to_string()) + } + SnapshotDepRef::Alias(_) | SnapshotDepRef::Link(_) => None, + } +} + +#[derive(PartialEq, Eq)] +enum FailureCategory { + Invalid, + Absent, + Unreachable, +} + +struct SignatureFailure { + label: String, + reason: String, + category: FailureCategory, +} + +impl SignatureFailure { + fn describe(&self) -> String { + format!("{}: {}", self.label, self.reason) + } +} + +/// Per-component verification, mirroring pnpm's `findSignatureFailure`. +/// Returns `None` when a registry signature validates over the lockfile +/// bytes. +async fn find_signature_failure( + component: &EngineComponent, + client: &ThrottledClient, + retry_opts: RetryOpts, + config: &Config, +) -> Option { + let label = format!("{}@{}", component.name, component.version); + let packument = match fetch_packument(component, client, retry_opts, config).await { + Ok(Some(packument)) => packument, + Ok(None) => { + return Some(SignatureFailure { + reason: format!("{} is not published on {}", component.name, component.registry), + category: FailureCategory::Absent, + label, + }); + } + Err(reason) => { + return Some(SignatureFailure { + reason, + category: FailureCategory::Unreachable, + label, + }); + } + }; + + let Some(version) = packument.versions.get(&component.version) else { + return Some(SignatureFailure { + reason: format!("{label} was not found on {}", component.registry), + category: FailureCategory::Absent, + label, + }); + }; + let raw_signatures = version.dist.as_ref().and_then(|dist| dist.signatures.as_ref()); + let parsed_signatures = match raw_signatures { + None => Vec::new(), + Some(serde_json::Value::Array(elements)) => { + let mut parsed = Vec::with_capacity(elements.len()); + for element in elements { + let Ok(signature) = serde_json::from_value::(element.clone()) + else { + return Some(SignatureFailure { + reason: format!("malformed registry signatures metadata for {label}"), + category: FailureCategory::Absent, + label, + }); + }; + parsed.push(signature); + } + parsed + } + Some(_) => { + return Some(SignatureFailure { + reason: format!("malformed registry signatures metadata for {label}"), + category: FailureCategory::Absent, + label, + }); + } + }; + if parsed_signatures.is_empty() { + return Some(SignatureFailure { + reason: format!("{label} has no registry signature"), + category: FailureCategory::Absent, + label, + }); + } + + let published_at = packument.time.get(&component.version).and_then(serde_json::Value::as_str); + // The message is built from the *lockfile* integrity, so a signature + // only validates when the installed bytes match what the registry + // signed. + if signature_validates(component, &parsed_signatures, published_at) { + None + } else { + Some(SignatureFailure { + reason: "invalid registry signature".to_string(), + category: FailureCategory::Invalid, + label, + }) + } +} + +/// `true` as soon as one signature validates against a trusted, unexpired +/// npm key over `name@version:integrity`. Mirrors pnpm's +/// `verifyPackageSignatures` acceptance rule. +fn signature_validates( + component: &EngineComponent, + signatures: &[PackageSignature], + published_at: Option<&str>, +) -> bool { + signature_validates_against(component, signatures, published_at, NPM_SIGNING_KEYS) +} + +/// [`signature_validates`] against an explicit key set — the trusted +/// [`NPM_SIGNING_KEYS`] in production, a test key in unit tests. +fn signature_validates_against( + component: &EngineComponent, + signatures: &[PackageSignature], + published_at: Option<&str>, + keys: &[NpmSigningKey<'_>], +) -> bool { + let message = format!("{}@{}:{}", component.name, component.version, component.integrity); + let published_time = published_at.and_then(parse_timestamp); + for signature in signatures { + let Some(key) = keys.iter().find(|key| key.keyid == signature.keyid) else { + continue; + }; + let expired = match (key.expires.and_then(parse_timestamp), published_time) { + (Some(expires), Some(published)) => published >= expires, + _ => false, + }; + if expired { + continue; + } + if verify_one(key.key, &message, &signature.sig) { + return true; + } + } + false +} + +/// Verify one base64 ECDSA-P256 signature over `message` against a base64 +/// SPKI public key. Malformed key/signature bytes count as a non-match. +/// Same crypto core as `audit signatures`' `verify_one`. +fn verify_one(public_key_base64: &str, message: &str, signature_base64: &str) -> bool { + let engine = base64::engine::general_purpose::STANDARD; + let Ok(key_der) = engine.decode(public_key_base64) else { + return false; + }; + let Ok(verifying_key) = VerifyingKey::from_public_key_der(&key_der) else { + return false; + }; + let Ok(signature_der) = engine.decode(signature_base64) else { + return false; + }; + let Ok(signature) = Signature::from_der(&signature_der) else { + return false; + }; + verifying_key.verify(message.as_bytes(), &signature).is_ok() +} + +fn parse_timestamp(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value).ok().map(|datetime| datetime.timestamp_millis()) +} + +#[derive(Deserialize)] +struct PackageSignature { + keyid: String, + sig: String, +} + +#[derive(Deserialize)] +struct Dist { + #[serde(default)] + signatures: Option, +} + +#[derive(Deserialize)] +struct PackumentVersion { + #[serde(default)] + dist: Option, +} + +#[derive(Deserialize)] +struct Packument { + #[serde(default)] + time: std::collections::BTreeMap, + #[serde(default)] + versions: std::collections::HashMap, +} + +/// Fetch a component's packument from its (trusted) registry. `Ok(None)` +/// for a 404 (package absent); `Err` for any other failure (treated as +/// `unreachable` by the caller, so verification fails closed). +async fn fetch_packument( + component: &EngineComponent, + client: &ThrottledClient, + retry_opts: RetryOpts, + config: &Config, +) -> Result, String> { + let registry_url = with_trailing_slash(&component.registry); + let packument_url = format!("{registry_url}{}", encode_package_name(&component.name)); + let display_url = redact_url_credentials(&packument_url); + // Resolve auth against the request URL *and* the package name so a + // `@scope:registry`-scoped token applies (plain `for_url` skips the + // scope lookup, breaking bootstrap registries that require it). + let authorization = config + .package_manager_bootstrap + .auth_headers + .for_url_with_package(&packument_url, Some(&component.name)); + + let (_guard, response) = send_with_retry(client, &packument_url, retry_opts, |client| { + let mut request = client.get(&packument_url).header("accept", "application/json"); + if let Some(value) = &authorization { + request = request.header("authorization", value); + } + request + }) + .await + .map_err(|source| format!("{display_url}: {}", redact_url_credentials(&source.to_string())))?; + + let status = response.status().as_u16(); + if status == 404 { + return Ok(None); + } + if status != 200 { + return Err(format!("{display_url} responded with {status}")); + } + // Bound the buffered body so an oversized response from a + // misconfigured/compromised registry can't exhaust memory on this + // trust-critical path. + if let Some(length) = response.content_length() + && length > MAX_PACKUMENT_BYTES + { + return Err(format!("{display_url} returned an oversized packument ({length} bytes)")); + } + let body = response.text().await.map_err(|source| { + format!("{display_url}: {}", redact_url_credentials(&source.to_string())) + })?; + if body.len() as u64 > MAX_PACKUMENT_BYTES { + return Err(format!("{display_url} returned an oversized packument")); + } + serde_json::from_str::(&body) + .map(Some) + .map_err(|err| format!("{display_url} returned invalid JSON: {err}")) +} + +/// Upper bound on a buffered packument response. Generous relative to the +/// pnpm / `@pnpm/exe` packuments (well under a megabyte) while still +/// capping a runaway response. +const MAX_PACKUMENT_BYTES: u64 = 50 * 1024 * 1024; + +/// Route a (possibly scoped) engine component to its registry, using the +/// trusted package-manager bootstrap configuration. Mirrors pnpm's +/// `pickRegistryForPackage`. +fn pick_registry(name: &str, config: &Config) -> String { + let bootstrap = &config.package_manager_bootstrap; + if let Some(scope) = name.strip_prefix('@').and_then(|rest| rest.split('/').next()) + && let Some(registry) = bootstrap.registries.get(&format!("@{scope}")) + { + return registry.clone(); + } + bootstrap.registry.clone() +} + +fn build_client(config: &Config) -> Result { + let bootstrap = &config.package_manager_bootstrap; + ThrottledClient::for_installs( + &bootstrap.proxy, + &bootstrap.tls, + &bootstrap.tls_by_uri, + &NetworkSettings { + network_concurrency: config.network_concurrency, + fetch_timeout: Duration::from_millis(config.fetch_timeout), + user_agent: config.user_agent.clone(), + }, + ) + .map_err(|error| SelfUpdateError::EngineIdentityUnverifiable { + message: format!("could not build the network client to verify the pnpm release: {error}"), + }) +} + +fn retry_opts(config: &Config) -> RetryOpts { + RetryOpts { + retries: config.fetch_retries, + factor: config.fetch_retry_factor, + min_timeout: Duration::from_millis(config.fetch_retry_mintimeout), + max_timeout: Duration::from_millis(config.fetch_retry_maxtimeout), + } +} + +fn with_trailing_slash(registry: &str) -> String { + if registry.ends_with('/') { registry.to_string() } else { format!("{registry}/") } +} + +/// Percent-encode a package name for a packument URL (scoped names keep +/// the leading `@`, the `/` becomes `%2F`). Mirrors pnpm's `toUri`. +fn encode_package_name(name: &str) -> String { + match name.strip_prefix('@') { + Some(rest) => format!("@{}", encode_uri_component(rest)), + None => encode_uri_component(name), + } +} + +fn encode_uri_component(input: &str) -> String { + use std::fmt::Write as _; + const UNRESERVED: &[u8] = b"-_.!~*'()"; + let mut output = String::with_capacity(input.len()); + for &byte in input.as_bytes() { + if byte.is_ascii_alphanumeric() || UNRESERVED.contains(&byte) { + output.push(byte as char); + } else { + write!(output, "%{byte:02X}").expect("writing to a String never fails"); + } + } + output +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/cli/src/cli_args/self_update/verify_engine/tests.rs b/pacquet/crates/cli/src/cli_args/self_update/verify_engine/tests.rs new file mode 100644 index 0000000000..dc550ea634 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/self_update/verify_engine/tests.rs @@ -0,0 +1,114 @@ +use super::{ + EngineComponent, NpmSigningKey, PackageSignature, plain_version, signature_validates_against, + verify_one, +}; +use base64::Engine as _; +use p256::ecdsa::SigningKey; +use pacquet_lockfile::SnapshotDepRef; + +fn signing_key() -> SigningKey { + SigningKey::from_slice(&[0x42; 32]).expect("valid P-256 scalar") +} + +fn public_key_b64(key: &SigningKey) -> String { + use p256::pkcs8::EncodePublicKey; + let der = key.verifying_key().to_public_key_der().expect("encode SPKI"); + base64::engine::general_purpose::STANDARD.encode(der.as_bytes()) +} + +fn sign_b64(key: &SigningKey, message: &str) -> String { + use p256::ecdsa::{Signature, signature::Signer}; + let signature: Signature = key.sign(message.as_bytes()); + base64::engine::general_purpose::STANDARD.encode(signature.to_der().as_bytes()) +} + +fn component() -> EngineComponent { + EngineComponent { + name: "pnpm".to_string(), + registry: "https://registry.example.com/".to_string(), + version: "12.0.0".to_string(), + integrity: "sha512-deadbeef".to_string(), + } +} + +fn signed_message(component: &EngineComponent) -> String { + format!("{}@{}:{}", component.name, component.version, component.integrity) +} + +#[test] +fn verify_one_accepts_only_a_genuine_signature() { + let key = signing_key(); + let pub_b64 = public_key_b64(&key); + let message = "pnpm@12.0.0:sha512-deadbeef"; + let sig = sign_b64(&key, message); + + assert!(verify_one(&pub_b64, message, &sig), "a genuine signature validates"); + // A signature over different bytes must not validate the message. + assert!(!verify_one(&pub_b64, "pnpm@12.0.1:sha512-deadbeef", &sig)); + // Malformed key / signature material is a non-match, not a panic. + assert!(!verify_one("not-base64!!", message, &sig)); + assert!(!verify_one(&pub_b64, message, "not-base64!!")); +} + +#[test] +fn signature_validates_accepts_a_trusted_unexpired_key() { + let key = signing_key(); + let pub_b64 = public_key_b64(&key); + let component = component(); + let keys = [NpmSigningKey { keyid: "SHA256:test", key: &pub_b64, expires: None }]; + let signatures = [PackageSignature { + keyid: "SHA256:test".to_string(), + sig: sign_b64(&key, &signed_message(&component)), + }]; + assert!(signature_validates_against(&component, &signatures, None, &keys)); +} + +#[test] +fn signature_validates_rejects_an_expired_key() { + let key = signing_key(); + let pub_b64 = public_key_b64(&key); + let component = component(); + let keys = [NpmSigningKey { + keyid: "SHA256:test", + key: &pub_b64, + expires: Some("2000-01-01T00:00:00.000Z"), + }]; + let signatures = [PackageSignature { + keyid: "SHA256:test".to_string(), + sig: sign_b64(&key, &signed_message(&component)), + }]; + // Published after the key expired, so even a valid signature is rejected. + assert!(!signature_validates_against( + &component, + &signatures, + Some("2020-01-01T00:00:00.000Z"), + &keys, + )); +} + +#[test] +fn signature_validates_rejects_unknown_keyid_and_empty_signatures() { + let key = signing_key(); + let pub_b64 = public_key_b64(&key); + let component = component(); + let keys = [NpmSigningKey { keyid: "SHA256:test", key: &pub_b64, expires: None }]; + + let unknown = [PackageSignature { + keyid: "SHA256:unknown".to_string(), + sig: sign_b64(&key, &signed_message(&component)), + }]; + assert!(!signature_validates_against(&component, &unknown, None, &keys)); + assert!(!signature_validates_against(&component, &[], None, &keys)); +} + +#[test] +fn plain_version_reads_only_plain_references() { + let plain: SnapshotDepRef = "1.2.3".parse().expect("parse plain ref"); + assert_eq!(plain_version(&plain), Some("1.2.3".to_string())); + + let alias: SnapshotDepRef = "foo@1.2.3".parse().expect("parse alias ref"); + assert_eq!(plain_version(&alias), None); + + let link = SnapshotDepRef::Link("packages/x".to_string()); + assert_eq!(plain_version(&link), None); +} diff --git a/pacquet/crates/cli/src/config_deps.rs b/pacquet/crates/cli/src/config_deps.rs index 5fc00eaac8..d626b5d28b 100644 --- a/pacquet/crates/cli/src/config_deps.rs +++ b/pacquet/crates/cli/src/config_deps.rs @@ -23,6 +23,7 @@ use pacquet_resolving_npm_resolver::{ InMemoryPackageMetaCache, NpmResolver, shared_packument_fetch_locker, shared_picked_manifest_cache, }; +use pacquet_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency}; use pacquet_store_dir::StoreDir; use pacquet_workspace_state::ConfigDependency; use serde_json::Value; @@ -69,6 +70,109 @@ pub async fn sync_package_manager_dependencies( .wrap_err("resolve package manager dependencies") } +/// The version `pnpm self-update` resolved a specifier to, plus whether +/// the pick violated the active maturity/trust policy. Mirrors the +/// `resolution.manifest`-derived values pnpm's self-update reads from +/// [`createResolver`](https://github.com/pnpm/pnpm/blob/a33eeec9cd/pnpm11/engine/pm/commands/src/self-updater/selfUpdate.ts#L83-L116). +pub struct ResolvedPnpm { + pub version: String, + /// `true` when the resolver picked a version despite the maturity + /// (`minimumReleaseAge`) or `trustPolicy` gate. Self-update fails + /// closed on this under strict resolution, mirroring pnpm's + /// `makeResolutionStrict`. + pub policy_violation: bool, +} + +/// Resolve `pnpm@` against the trusted package-manager +/// bootstrap registry (never the repository-controlled project +/// registries), applying the same `minimumReleaseAge` / `trustPolicy` +/// gates the install path uses. Returns `None` when the specifier cannot +/// be resolved. Backs `pacquet self-update`'s "check for updates" probe. +pub async fn resolve_pnpm_version( + config: &Config, + bare_specifier: &str, +) -> Result> { + let context = EnvInstallerContext::for_package_manager(config)?; + + // `minimumReleaseAge` cutoff, computed the same way as the install + // path's `PickPolicy::from_config`. When the age is configured, a + // failure to compute the cutoff fails closed rather than silently + // disabling the maturity gate — self-update is a trust decision. + let published_by = match config.resolved_minimum_release_age() { + Some(minutes) => { + let minutes = i64::try_from(minutes) + .into_diagnostic() + .wrap_err("convert minimumReleaseAge to minutes")?; + let duration = chrono::Duration::try_minutes(minutes) + .ok_or_else(|| miette::miette!("minimumReleaseAge is too large"))?; + Some( + chrono::Utc::now() + .checked_sub_signed(duration) + .ok_or_else(|| miette::miette!("minimumReleaseAge cutoff is out of range"))?, + ) + } + None => None, + }; + let published_by_exclude = config + .minimum_release_age_exclude + .as_deref() + .filter(|patterns| !patterns.is_empty()) + .map(pacquet_config::version_policy::create_package_version_policy) + .transpose() + .into_diagnostic() + .wrap_err("compile the minimum-release-age-exclude policy")?; + let trust_policy = match config.trust_policy { + pacquet_config::TrustPolicy::Off => None, + pacquet_config::TrustPolicy::NoDowngrade => Some(pacquet_config::TrustPolicy::NoDowngrade), + }; + let trust_policy_exclude = config + .trust_policy_exclude + .as_deref() + .filter(|patterns| !patterns.is_empty()) + .map(pacquet_config::version_policy::create_package_version_policy) + .transpose() + .into_diagnostic() + .wrap_err("compile the trust-policy-exclude policy")?; + + let wanted = WantedDependency { + alias: Some("pnpm".to_string()), + bare_specifier: Some(bare_specifier.to_string()), + ..WantedDependency::default() + }; + let opts = ResolveOptions { + default_tag: Some("latest".to_string()), + published_by, + published_by_exclude, + trust_policy, + trust_policy_exclude, + trust_policy_ignore_after: config.trust_policy_ignore_after, + ..ResolveOptions::default() + }; + let result = context + .resolver + .resolve(&wanted, &opts) + .await + .map_err(|error| miette::miette!("{error}")) + .wrap_err_with(|| format!("resolve pnpm@{bare_specifier}"))?; + let Some(result) = result else { + return Ok(None); + }; + let Some(name_ver) = result.name_ver else { + return Ok(None); + }; + // Fail closed if the specifier resolved to something other than `pnpm` + // (e.g. an `npm:other-pkg@x` alias): otherwise the maturity/trust + // policy decision would be made against the wrong package's metadata + // while self-update still installs `pnpm@`. + if name_ver.name.to_string() != "pnpm" { + return Ok(None); + } + Ok(Some(ResolvedPnpm { + version: name_ver.suffix.to_string(), + policy_violation: result.policy_violation.is_some(), + })) +} + /// Add a single config dependency: resolve + install it (merged with any /// already-declared config deps), then write the clean specifier into /// `pnpm-workspace.yaml`'s `configDependencies` block. Backs