From b0304429b0c666baa8baec65bd4ce2c64fe15b2f Mon Sep 17 00:00:00 2001 From: A Date: Thu, 25 Jun 2026 17:38:16 +0200 Subject: [PATCH] feat(pacquet): port whoami command (#12649) * feat(pacquet): port whoami command * fix(cli): redact registry URL from whoami error context * fix(cli): sanitize whoami username before printing --- pacquet/crates/cli/src/cli_args.rs | 17 ++ pacquet/crates/cli/src/cli_args/whoami.rs | 107 +++++++++++++ pacquet/crates/cli/tests/whoami.rs | 185 ++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 pacquet/crates/cli/src/cli_args/whoami.rs create mode 100644 pacquet/crates/cli/tests/whoami.rs diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index 5c73bfe46a..3d497a4498 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -37,6 +37,7 @@ pub mod supported_architectures; pub mod unlink; pub mod update; pub mod update_interactive; +pub mod whoami; pub mod why; use crate::{State, config_deps, config_overrides::ConfigOverrides}; @@ -188,6 +189,8 @@ pub enum CliCommand { List(ListArgs), /// Shows the packages that depend on `pkg` Why(WhyArgs), + /// Displays your pnpm username. + Whoami, /// Rebuild a package. #[clap(visible_alias = "rb")] Rebuild(RebuildArgs), @@ -508,6 +511,20 @@ impl CliArgs { global::handle_global_remove(config()?, &args.package_names)?; Box::pin(std::future::ready(Ok(()))) } + // `whoami` is a read-only registry query: it resolves the + // default registry's auth header from config and GETs + // `-/whoami`, with no lockfile or install pipeline. It needs + // an async future for the request but no reporter-typed + // fan-out, so it dispatches off `config()` like the other + // read-only commands. + CliCommand::Whoami => { + let cfg: &Config = config()?; + Box::pin(async move { + let username = whoami::whoami(cfg).await?; + println!("{}", sanitize::sanitize(&username)); + Ok(()) + }) + } CliCommand::Remove(args) => { let command_state = state(false)?; match reporter { diff --git a/pacquet/crates/cli/src/cli_args/whoami.rs b/pacquet/crates/cli/src/cli_args/whoami.rs new file mode 100644 index 0000000000..31bad63f07 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/whoami.rs @@ -0,0 +1,107 @@ +use derive_more::{Display, Error}; +use miette::{Context, Diagnostic, IntoDiagnostic}; +use pacquet_config::Config; +use pacquet_network::{NetworkSettings, RetryOpts, ThrottledClient, send_with_retry}; +use serde::Deserialize; +use std::time::Duration; + +/// Errors from `pacquet whoami`. +/// +/// Mirrors the error codes pnpm raises in `whoami.ts` +/// (). +#[derive(Debug, Display, Error, Diagnostic)] +#[non_exhaustive] +pub enum WhoamiError { + #[display("You must be logged in to use whoami")] + #[diagnostic(code(ERR_PNPM_WHOAMI_UNAUTHORIZED))] + Unauthorized, + + #[display("Failed to find the current user: {status} {status_text}")] + #[diagnostic(code(ERR_PNPM_WHOAMI_FAILED))] + Failed { status: u16, status_text: String }, +} + +/// The `GET /-/whoami` response body. The registry returns other fields +/// too; only `username` is read. +#[derive(Debug, Deserialize)] +struct WhoamiResponse { + username: String, +} + +/// `pacquet whoami` — return the username the configured registry +/// associates with the current auth token. +/// +/// Ports `whoami.ts`'s `handler`: resolve the default registry, look up +/// its `Authorization` header, and fail with `ERR_PNPM_WHOAMI_UNAUTHORIZED` +/// when no credentials are configured — before any request is made. +pub async fn whoami(config: &Config) -> miette::Result { + let auth_header = + config.auth_headers.for_url(&config.registry).ok_or(WhoamiError::Unauthorized)?; + let http_client = build_http_client(config)?; + let retry_opts = 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), + }; + fetch_whoami(&config.registry, &http_client, &auth_header, retry_opts).await +} + +/// The network client `whoami` makes its single request through, built +/// from the same proxy / TLS / timeout config as the install client +/// ([`crate::state::State::init`]). +fn build_http_client(config: &Config) -> miette::Result { + ThrottledClient::for_installs( + &config.proxy, + &config.tls, + &config.tls_by_uri, + &NetworkSettings { + network_concurrency: config.network_concurrency, + fetch_timeout: Duration::from_millis(config.fetch_timeout), + user_agent: config.user_agent.clone(), + }, + ) + .into_diagnostic() + .wrap_err("create the network client for whoami") +} + +/// GET `-/whoami` with the resolved `Authorization` header and +/// read `username` from the JSON body. +/// +/// Ports `whoami.ts`'s `fetchWhoami`, erroring with +/// `ERR_PNPM_WHOAMI_FAILED` on any non-success status. `registry_url` is +/// the config registry, which always carries a trailing slash, so +/// concatenating `-/whoami` reproduces pnpm's `new URL('./-/whoami', ...)` +/// join (preserving any registry path prefix). +async fn fetch_whoami( + registry_url: &str, + http_client: &ThrottledClient, + auth_header: &str, + retry_opts: RetryOpts, +) -> miette::Result { + let url = format!("{registry_url}-/whoami"); + // Diagnostic context omits the URL: a registry configured as + // `https://user:password@host/` carries inline credentials (accepted by + // `AuthHeaders`), which must not reach stderr / CI logs. + let (client, response) = send_with_retry(http_client, &url, retry_opts, |client| { + client.get(&url).header("authorization", auth_header) + }) + .await + .into_diagnostic() + .wrap_err("requesting the registry whoami endpoint")?; + if !response.status().is_success() { + let status = response.status(); + return Err(WhoamiError::Failed { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or_default().to_string(), + } + .into()); + } + let body = response + .json::() + .await + .into_diagnostic() + .wrap_err("parsing the whoami response")?; + drop(client); + Ok(body.username) +} diff --git a/pacquet/crates/cli/tests/whoami.rs b/pacquet/crates/cli/tests/whoami.rs new file mode 100644 index 0000000000..df0e56b4e9 --- /dev/null +++ b/pacquet/crates/cli/tests/whoami.rs @@ -0,0 +1,185 @@ +//! `pacquet whoami` resolves the default registry's auth token from config +//! and prints the username returned by `GET -/whoami`. +//! +//! Ports the upstream whoami tests +//! (): +//! the success path, the unauthenticated path, a registry that rejects the +//! request, and preservation of a registry path prefix. Adds a pacquet-only +//! guard that control characters in a registry-provided username are stripped. +//! +//! The registry is a `mockito` server the spawned `pacquet` connects to +//! over loopback. Credentials are supplied through `--npmrc-auth-file`, +//! which replaces the developer's real `~/.npmrc`, so the unauthenticated +//! test can't be fooled by a token already on the machine. + +use assert_cmd::prelude::*; +use command_extra::CommandExtra; +use pacquet_testing_utils::bin::CommandTempCwd; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +fn pacquet_at(workspace: &Path) -> Command { + Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace) +} + +/// The nerf-darted `.npmrc` auth-key prefix (`//host:port/path/`) for a +/// registry URL — its scheme dropped and a single trailing slash kept. +fn nerf(registry: &str) -> String { + let without_scheme = registry + .strip_prefix("http://") + .or_else(|| registry.strip_prefix("https://")) + .unwrap_or(registry); + format!("//{}/", without_scheme.trim_end_matches('/')) +} + +/// Point `pacquet whoami` at `registry`: the project `.npmrc` carries the +/// registry URL and a separate auth file (returned for `--npmrc-auth-file`) +/// carries the token. Passing `None` leaves the user unauthenticated. +fn configure(root: &Path, workspace: &Path, registry: &str, auth_token: Option<&str>) -> PathBuf { + fs::write(workspace.join(".npmrc"), format!("registry={registry}\n")) + .expect("write project .npmrc"); + let auth_file = root.join("auth-npmrc"); + let contents = match auth_token { + Some(token) => format!("{}:_authToken={token}\n", nerf(registry)), + None => String::new(), + }; + fs::write(&auth_file, contents).expect("write auth .npmrc"); + auth_file +} + +fn run_whoami(workspace: &Path, auth_file: &Path) -> std::process::Output { + pacquet_at(workspace) + .with_arg("--npmrc-auth-file") + .with_arg(auth_file) + .with_arg("whoami") + .output() + .expect("spawn pacquet whoami") +} + +#[test] +fn returns_the_current_username() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/whoami") + .match_header("authorization", "Bearer test-token") + .with_status(200) + .with_body(r#"{"username":"alice"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, Some("test-token")); + + let output = run_whoami(&workspace, &auth_file); + + mock.assert(); + assert!( + output.status.success(), + "whoami must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "alice"); + drop((root, server)); +} + +#[test] +fn strips_control_characters_from_the_username() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + // A malicious/compromised registry returns a username carrying an ESC + // (0x1b) and a BEL (0x07); pacquet must not emit them raw to the terminal. + let esc = char::from(0x1b); + let bel = char::from(0x07); + let username = format!("al{esc}[31mice{bel}"); + let body = serde_json::json!({ "username": username }).to_string(); + let mock = server.mock("GET", "/-/whoami").with_status(200).with_body(body).create(); + let auth_file = configure(root.path(), &workspace, ®istry, Some("test-token")); + + let output = run_whoami(&workspace, &auth_file); + + mock.assert(); + assert!( + output.status.success(), + "whoami must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "al[31mice", "control characters must be stripped"); + assert!(!stdout.contains(esc), "ESC must be stripped: {stdout:?}"); + assert!(!stdout.contains(bel), "BEL must be stripped: {stdout:?}"); + drop((root, server)); +} + +#[test] +fn fails_when_not_logged_in() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + // No request is made, so no server is needed; the registry just has to + // resolve to no credentials. + let auth_file = configure(root.path(), &workspace, "http://127.0.0.1:1/", None); + + let output = run_whoami(&workspace, &auth_file); + + assert!( + !output.status.success(), + "an unauthenticated whoami must fail (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("ERR_PNPM_WHOAMI_UNAUTHORIZED") && stderr.contains("You must be logged in"), + "stderr must name the unauthorized diagnostic; got:\n{stderr}", + ); + drop(root); +} + +#[test] +fn fails_when_the_registry_rejects_the_request() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server.mock("GET", "/-/whoami").with_status(401).with_body("{}").create(); + let auth_file = configure(root.path(), &workspace, ®istry, Some("test-token")); + + let output = run_whoami(&workspace, &auth_file); + + mock.assert(); + assert!( + !output.status.success(), + "a rejected whoami must fail (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("ERR_PNPM_WHOAMI_FAILED") + && stderr.contains("Failed to find the current user"), + "stderr must name the failed diagnostic; got:\n{stderr}", + ); + drop((root, server)); +} + +#[test] +fn preserves_a_registry_path_prefix() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/custom-prefix/", server.url()); + let mock = server + .mock("GET", "/custom-prefix/-/whoami") + .with_status(200) + .with_body(r#"{"username":"alice"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, Some("test-token")); + + let output = run_whoami(&workspace, &auth_file); + + mock.assert(); + assert!( + output.status.success(), + "whoami must succeed against a prefixed registry (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "alice"); + drop((root, server)); +}