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
This commit is contained in:
A
2026-06-25 17:38:16 +02:00
committed by GitHub
parent 32d4d3f1f0
commit b0304429b0
3 changed files with 309 additions and 0 deletions

View File

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

View File

@@ -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`
/// (<https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/registry-access/commands/src/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<String> {
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> {
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 `<registry>-/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<String> {
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::<WhoamiResponse>()
.await
.into_diagnostic()
.wrap_err("parsing the whoami response")?;
drop(client);
Ok(body.username)
}

View File

@@ -0,0 +1,185 @@
//! `pacquet whoami` resolves the default registry's auth token from config
//! and prints the username returned by `GET <registry>-/whoami`.
//!
//! Ports the upstream whoami tests
//! (<https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/registry-access/commands/test/whoami.ts>):
//! 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, &registry, 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, &registry, 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, &registry, 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, &registry, 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));
}