mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
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:
@@ -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 {
|
||||
|
||||
107
pacquet/crates/cli/src/cli_args/whoami.rs
Normal file
107
pacquet/crates/cli/src/cli_args/whoami.rs
Normal 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)
|
||||
}
|
||||
185
pacquet/crates/cli/tests/whoami.rs
Normal file
185
pacquet/crates/cli/tests/whoami.rs
Normal 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, ®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));
|
||||
}
|
||||
Reference in New Issue
Block a user