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.
This commit is contained in:
Zoltan Kochan
2026-06-27 13:25:48 +02:00
committed by GitHub
parent 4e18e44e9b
commit f8e5a0de8c
12 changed files with 1713 additions and 9 deletions

View File

@@ -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;

View File

@@ -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),
}

View File

@@ -271,6 +271,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result<CommandFut
CliCommand::Fetch(args) => 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")
}

View File

@@ -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<Comma
Ok(Box::pin(async move { args.run(cfg).await }))
}
pub(super) fn self_update<'a>(
ctx: &RunCtx<'a>,
args: SelfUpdateArgs,
) -> miette::Result<CommandFuture<'a>> {
// 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,

View File

@@ -3,11 +3,11 @@ use serde_json::Value;
use std::{fs, io::ErrorKind, path::Path};
#[derive(Debug)]
struct WantedPackageManager {
name: String,
version: Option<String>,
from_dev_engines: bool,
on_fail: Option<String>,
pub(crate) struct WantedPackageManager {
pub(crate) name: String,
pub(crate) version: Option<String>,
pub(crate) from_dev_engines: bool,
pub(crate) on_fail: Option<String>,
}
#[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<Option<Value>> {
pub(crate) fn read_manifest_json(path: &Path) -> miette::Result<Option<Value>> {
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<Option<Value>> {
serde_json::from_str(&content).into_diagnostic().map(Some)
}
fn wanted_package_manager(manifest: &Value) -> Option<WantedPackageManager> {
pub(crate) fn wanted_package_manager(manifest: &Value) -> Option<WantedPackageManager> {
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<St
)
}
fn should_persist_package_manager_lockfile(pm: &WantedPackageManager) -> 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<String> {
value.get("version").and_then(Value::as_str).map(ToString::to_string)
}
fn exact_version(version: &str) -> Option<String> {
pub(crate) fn exact_version(version: &str) -> Option<String> {
let parsed = node_semver::Version::parse(version).ok()?;
(parsed.to_string() == version).then(|| version.to_string())
}

View File

@@ -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<String>,
}
/// 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<Reporter: self::Reporter + 'static>(
self,
config: &'static Config,
dir: &Path,
) -> miette::Result<()> {
if let Some(message) =
Box::pin(handler::<Reporter>(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<Reporter: self::Reporter + 'static>(
params: Option<&str>,
config: &'static Config,
dir: &Path,
) -> miette::Result<Option<String>> {
let prefix = dir.to_string_lossy().into_owned();
info::<Reporter>(&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::<Reporter>(&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::<Reporter>(
&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::<Reporter>(
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<Option<String>> {
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, &current)
{
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<String> {
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::<CmdShimHost>(&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, &registries_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<u64> {
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<Reporter: self::Reporter>(prefix: &str, message: &str) {
Reporter::emit(&LogEvent::Pnpm(PnpmLog {
level: LogLevel::Info,
message: message.to_string(),
prefix: prefix.to_string(),
}));
}
fn warn<Reporter: self::Reporter>(prefix: &str, message: &str) {
Reporter::emit(&LogEvent::Pnpm(PnpmLog {
level: LogLevel::Warn,
message: message.to_string(),
prefix: prefix.to_string(),
}));
}

View File

@@ -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@<version>` 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<Reporter: self::Reporter + 'static>(
base_config: &'static Config,
version: &str,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<InstallPnpmResult> {
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::<Reporter>(
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 `<install_dir>/node_modules/pnpm/package.json`,
/// or `None` when the install is absent or unreadable.
fn installed_version(install_dir: &Path) -> Option<String> {
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@<version>` 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<Reporter: self::Reporter + 'static>(
base_config: &'static Config,
install_dir: &Path,
version: &str,
supported_architectures: Option<SupportedArchitectures>,
) -> 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::<Reporter, _, _>(
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 `<os>-<arch>` 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.<platform>-<arch>[-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.<target>`) 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::<Value>(&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);
}
}

View File

@@ -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/<host-platform-dir>`, 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/<platform>` 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());
}

View File

@@ -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"));
}

View File

@@ -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
/// <https://registry.npmjs.org/-/npm/v1/keys>. 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<SignatureFailure> = 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::<Vec<_>>().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<Vec<EngineComponent>, 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::<PackageKey>().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<EngineComponent, SelfUpdateError> {
let integrity = format!("{name}@{version}")
.parse::<PackageKey>()
.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<String> {
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<SignatureFailure> {
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::<PackageSignature>(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<i64> {
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<serde_json::Value>,
}
#[derive(Deserialize)]
struct PackumentVersion {
#[serde(default)]
dist: Option<Dist>,
}
#[derive(Deserialize)]
struct Packument {
#[serde(default)]
time: std::collections::BTreeMap<String, serde_json::Value>,
#[serde(default)]
versions: std::collections::HashMap<String, PackumentVersion>,
}
/// 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<Option<Packument>, 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::<Packument>(&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<ThrottledClient, SelfUpdateError> {
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;

View File

@@ -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);
}

View File

@@ -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@<bare_specifier>` 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<Option<ResolvedPnpm>> {
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@<version>`.
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