mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-27 09:25:24 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
498
pacquet/crates/cli/src/cli_args/self_update.rs
Normal file
498
pacquet/crates/cli/src/cli_args/self_update.rs
Normal 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, ¤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<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, ®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<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(),
|
||||
}));
|
||||
}
|
||||
312
pacquet/crates/cli/src/cli_args/self_update/install_pnpm.rs
Normal file
312
pacquet/crates/cli/src/cli_args/self_update/install_pnpm.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
27
pacquet/crates/cli/src/cli_args/self_update/tests.rs
Normal file
27
pacquet/crates/cli/src/cli_args/self_update/tests.rs
Normal 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"));
|
||||
}
|
||||
557
pacquet/crates/cli/src/cli_args/self_update/verify_engine.rs
Normal file
557
pacquet/crates/cli/src/cli_args/self_update/verify_engine.rs
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user