diff --git a/Cargo.lock b/Cargo.lock index 5fcfb95103..58cb61e85a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1133,6 +1133,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1426,6 +1453,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -2582,6 +2618,15 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -2593,6 +2638,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -2984,6 +3039,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3124,6 +3185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3455,6 +3517,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4146,6 +4219,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "pacquet-network-web-auth" +version = "0.0.1" +dependencies = [ + "crossterm", + "derive_more", + "dialoguer", + "open", + "pacquet-diagnostics", + "pacquet-reporter", + "pretty_assertions", + "qrcode", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "pacquet-pack" version = "0.0.1" @@ -5220,6 +5312,12 @@ dependencies = [ "syn", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.39.4" @@ -6067,6 +6165,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/Cargo.toml b/Cargo.toml index d950a7bee9..75863e0fcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ pacquet-lockfile-preferred-versions = { path = "pacquet/crates/lockfile-pr pacquet-lockfile-verification = { path = "pacquet/crates/lockfile-verification" } pacquet-modules-yaml = { path = "pacquet/crates/modules-yaml" } pacquet-network = { path = "pacquet/crates/network" } +pacquet-network-web-auth = { path = "pacquet/crates/network-web-auth" } pacquet-config = { path = "pacquet/crates/config" } pacquet-config-dir = { path = "pacquet/crates/config-dir" } pacquet-config-parse-overrides = { path = "pacquet/crates/config-parse-overrides" } @@ -100,6 +101,7 @@ base64 = { version = "0.22.1" } bcrypt = { version = "0.19.1" } bytes = { version = "1.11.0" } chrono = { version = "0.4.44", default-features = false, features = ["clock"] } +crossterm = { version = "0.29.0" } dashmap = { version = "6.2.1" } derive_more = { version = "2.1.1", features = ["full"] } dialoguer = { version = "0.12.0", default-features = false } @@ -119,6 +121,7 @@ getrandom = { version = "0.4.2" } miette = { version = "7.6.0", features = ["fancy"] } num_cpus = { version = "1.17.0" } object_store = { version = "0.13", features = ["aws"] } +open = { version = "5.3.5" } os_display = { version = "0.1.4" } owo-colors = { version = "4", features = ["supports-colors"] } reflink-copy = { version = "0.1.29" } @@ -137,6 +140,7 @@ p256 = { version = "0.13.2", default-features = false, features = ["ecdsa", "pkc pathdiff = { version = "0.2.3" } pipe-trait = { version = "0.4.0" } pgp = { version = "0.19.0", default-features = false } +qrcode = { version = "0.14.1", default-features = false } rayon = { version = "1.12.0" } rmp-serde = { version = "1.3.0" } rusqlite = { version = "0.39.0", features = ["bundled"] } diff --git a/pacquet/crates/default-reporter/src/state.rs b/pacquet/crates/default-reporter/src/state.rs index f1d8064e53..53722ecf9b 100644 --- a/pacquet/crates/default-reporter/src/state.rs +++ b/pacquet/crates/default-reporter/src/state.rs @@ -317,6 +317,10 @@ impl ReporterState { LogEvent::LockfileVerification(log) => self.on_lockfile_verification(&log.message), LogEvent::RequestRetry(log) => self.on_request_retry(log), LogEvent::Pnpm(log) => self.on_pnpm(log.level, &log.message, &log.prefix), + // `pnpm:global` shares the "other" log stream with the `pnpm` + // channel but carries no prefix, so it always renders (the + // empty-prefix path in `on_pnpm`). + LogEvent::Global(log) => self.on_pnpm(log.level, &log.message, ""), LogEvent::ExecutionTime(log) => self.on_execution_time(log), // Debug-only / non-rendered channels in pnpm's default reporter. LogEvent::Hook(_) | LogEvent::BrokenModules(_) => {} diff --git a/pacquet/crates/default-reporter/tests/render.rs b/pacquet/crates/default-reporter/tests/render.rs index c1916b34d5..bdd23dff21 100644 --- a/pacquet/crates/default-reporter/tests/render.rs +++ b/pacquet/crates/default-reporter/tests/render.rs @@ -8,10 +8,10 @@ use pacquet_default_reporter::{ state::{Output, ReporterState}, }; use pacquet_reporter::{ - AddedRoot, ContextLog, DependencyType, ExecutionTimeLog, LifecycleLog, LifecycleMessage, - LifecycleStdio, LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog, PnpmLog, - ProgressLog, ProgressMessage, RootLog, RootMessage, Stage, StageLog, StatsLog, StatsMessage, - SummaryLog, + AddedRoot, ContextLog, DependencyType, ExecutionTimeLog, GlobalLog, LifecycleLog, + LifecycleMessage, LifecycleStdio, LogEvent, LogLevel, PackageImportMethod, + PackageImportMethodLog, PnpmLog, ProgressLog, ProgressMessage, RootLog, RootMessage, Stage, + StageLog, StatsLog, StatsMessage, SummaryLog, }; const CWD: &str = "/repo"; @@ -223,6 +223,22 @@ fn already_up_to_date_pnpm_log_renders() { assert_eq!(frame, "Already up to date"); } +/// A `pnpm:global` info message renders as a block, like the prefix-less +/// `pnpm`-channel path — the web-auth flow surfaces the auth URL this way. +#[test] +fn global_info_log_renders() { + let mut reporter = state(false); + let frame = render( + &mut reporter, + vec![LogEvent::Global(GlobalLog { + level: LogLevel::Info, + message: "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc" + .to_string(), + })], + ); + assert_eq!(frame, "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc"); +} + #[test] fn full_install_frame_orders_blocks_like_pnpm() { let mut reporter = state(false); diff --git a/pacquet/crates/network-web-auth/Cargo.toml b/pacquet/crates/network-web-auth/Cargo.toml new file mode 100644 index 0000000000..8e2f7d4dc8 --- /dev/null +++ b/pacquet/crates/network-web-auth/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pacquet-network-web-auth" +description = "Web-based authentication flow with QR code display and token polling" +version = "0.0.1" +publish = false +authors.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +crossterm = { workspace = true } +derive_more = { workspace = true } +dialoguer = { workspace = true } +open = { workspace = true } +pacquet-diagnostics = { workspace = true } +pacquet-reporter = { workspace = true } +qrcode = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +url = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/pacquet/crates/network-web-auth/src/capabilities.rs b/pacquet/crates/network-web-auth/src/capabilities.rs new file mode 100644 index 0000000000..416516156f --- /dev/null +++ b/pacquet/crates/network-web-auth/src/capabilities.rs @@ -0,0 +1,310 @@ +//! Per-capability dependency-injection traits and the production [`Host`] +//! provider for the web-authentication flow. +//! +//! Each trait is a single side effect the TypeScript package injected as a +//! `context` closure (`Date.now`, `setTimeout`, `fetch`, `enquirer.input`, +//! `createReadlineInterface`, `open`). Functions bind only the capabilities +//! they consume, composed on one `Sys` type parameter; production callers +//! turbofish [`Host`], and tests inject `fn`-bound unit-struct fakes. See +//! the "Dependency injection for tests" section of +//! `pacquet/CODE_STYLE_GUIDE.md`. + +use std::{ + future::Future, + io::{self, IsTerminal}, + pin::Pin, + sync::{ + Arc, LazyLock, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; + +use crate::poll_for_web_auth_token::{WebAuthFetchOptions, WebAuthFetchResponse}; + +/// Read the current wall-clock time as Unix-epoch milliseconds. +/// +/// Mirrors TS `Date.now()`. The poll loop measures elapsed time against a +/// timeout and stamps [`WebAuthTimeoutError`](crate::WebAuthTimeoutError)'s +/// `start_time` / `end_time` with these values, so the unit is +/// milliseconds rather than a `SystemTime`. +pub trait Clock { + fn now_ms() -> u64; +} + +/// Sleep for `ms` milliseconds. Mirrors TS `setTimeout(resolve, ms)`. +pub trait Sleep { + fn sleep_ms(ms: u64) -> impl Future; +} + +/// Perform the registry "done"-URL poll request. Mirrors TS +/// `fetch(url, options)`: a single `GET` whose response exposes status, +/// headers, and a separately fallible body. +/// +/// The `Err` arm stands in for `fetch` itself rejecting (a network +/// failure) — distinct from a successful response whose body fails to +/// parse, which the caller reads from +/// [`WebAuthFetchResponse::token`](crate::WebAuthFetchResponse::token). +pub trait WebAuthFetch { + fn fetch( + url: &str, + options: &WebAuthFetchOptions, + ) -> impl Future>; +} + +/// `fetch` itself failed — the request never produced a response. The +/// cause is intentionally opaque: the poll loop treats every fetch failure +/// the same way (swallow and retry on the next tick), so it never inspects +/// this value, matching the TS `catch { continue }`. +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("web-auth poll request failed")] +#[non_exhaustive] +pub struct WebAuthFetchError; + +/// Whether stdin is connected to an interactive terminal. Mirrors TS +/// `process.stdin.isTTY`. +pub trait StdinIsTty { + fn stdin_is_tty() -> bool; +} + +/// Whether stdout is connected to an interactive terminal. Mirrors TS +/// `process.stdout.isTTY`. +pub trait StdoutIsTty { + fn stdout_is_tty() -> bool; +} + +/// Open `url` in the user's default browser. Mirrors the TS `open` +/// package. +pub trait OpenUrl { + fn open_url(url: &str) -> io::Result<()>; +} + +/// Set up an interactive "press Enter" listener on stdin. Mirrors TS +/// `createReadlineInterface` plus its `once('line', ...)` registration. +/// +/// [`listen`](Self::listen) returns a [`Handle`](Self::Handle) that is a +/// `Future` resolving once the user presses Enter; **dropping the handle +/// closes the listener** (mirrors `readline.Interface.close`). Setup +/// itself can fail (e.g. raw mode unsupported), which the caller treats as +/// "warn and fall back to plain polling". +pub trait EnterKeyListener { + type Handle: Future + Unpin; + fn listen() -> io::Result; +} + +/// Prompt the user for a classic one-time password. Mirrors TS +/// `enquirer.input({ message })`: returns the entered string, `None` when +/// the prompt yields no value, or an error. +pub trait PromptOtp { + fn input(message: &str) -> impl Future, PromptError>>; +} + +/// Failure surface of [`PromptOtp::input`]. +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[non_exhaustive] +pub enum PromptError { + /// The user aborted the prompt (Ctrl-C). Mirrors enquirer's + /// `ExitPromptError`, which the caller handles by re-throwing the + /// original OTP challenge rather than this error. + #[display("the one-time password prompt was cancelled")] + Cancelled, + /// Any other failure while reading the prompt. + #[display("failed to read the one-time password prompt: {reason}")] + Other { reason: String }, +} + +/// Production implementation of every capability trait in this crate. Each +/// method calls into the real OS facility (`SystemTime`, `tokio`, +/// `reqwest`, `dialoguer`, the `open` crate, stdin). +pub struct Host; + +impl Clock for Host { + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)) + } +} + +impl Sleep for Host { + async fn sleep_ms(ms: u64) { + tokio::time::sleep(Duration::from_millis(ms)).await; + } +} + +impl WebAuthFetch for Host { + async fn fetch( + url: &str, + options: &WebAuthFetchOptions, + ) -> Result { + // One process-wide client so the poll loop reuses connections + // instead of opening a fresh pool every second. + static CLIENT: LazyLock = LazyLock::new(reqwest::Client::new); + + let mut request = CLIENT.get(url); + if let Some(timeout) = options.timeout { + request = request.timeout(Duration::from_millis(timeout)); + } + let response = request.send().await.map_err(|_| WebAuthFetchError)?; + let ok = response.status().is_success(); + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + // A body read failure is treated the same as `response.json()` + // rejecting upstream: an empty body makes `token()` fail to parse, + // so the poll loop retries — while `status` / `retry_after` stay + // usable for the 202 branch. + let body = response.text().await.unwrap_or_default(); + Ok(WebAuthFetchResponse { ok, status, retry_after, body }) + } +} + +impl StdinIsTty for Host { + fn stdin_is_tty() -> bool { + io::stdin().is_terminal() + } +} + +impl StdoutIsTty for Host { + fn stdout_is_tty() -> bool { + io::stdout().is_terminal() + } +} + +impl OpenUrl for Host { + fn open_url(url: &str) -> io::Result<()> { + // Detached so the call returns as soon as the browser is launched + // rather than blocking the async task until the launcher exits. + open::that_detached(url) + } +} + +/// How often the listener thread wakes to re-check the cancel flag. Bounds +/// how long the detached thread outlives a dropped handle. +const ENTER_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Production [`EnterKeyListener::Handle`]. Resolves once the background +/// thread observes an Enter keypress; dropping it sets the cancel flag so +/// that thread stops within one [`ENTER_POLL_INTERVAL`] — crossterm's +/// blocking `event::read()` cannot be cancelled, so the thread polls with +/// a timeout instead of blocking forever. +/// +/// Meant to be raced and dropped when another branch wins (e.g. inside a +/// `tokio::select!`): on a stdin read error it deliberately never resolves, +/// so awaiting it on its own would hang. +pub struct HostEnterHandle { + enter: tokio::sync::oneshot::Receiver<()>, + fired: bool, + cancel: Arc, +} + +impl Future for HostEnterHandle { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let this = self.get_mut(); + if this.fired { + return Poll::Pending; + } + match Pin::new(&mut this.enter).poll(cx) { + Poll::Pending => Poll::Pending, + // Enter pressed: the reader thread signalled. + Poll::Ready(Ok(())) => { + this.fired = true; + Poll::Ready(()) + } + // Reader exited without signalling (read error): never fire, so + // the browser is not opened spuriously. + Poll::Ready(Err(_)) => { + this.fired = true; + Poll::Pending + } + } + } +} + +impl Drop for HostEnterHandle { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +impl EnterKeyListener for Host { + type Handle = HostEnterHandle; + + /// Watch stdin for an Enter keypress without blocking uninterruptibly; + /// the returned handle documents the cancellation contract. `crossterm` + /// reads in the terminal's default (cooked) mode — no raw mode — matching + /// pnpm's plain `readline.createInterface({ input: process.stdin })`, + /// which reacts to a submitted line rather than individual keys. + fn listen() -> io::Result { + let (tx, enter) = tokio::sync::oneshot::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let reader_cancel = Arc::clone(&cancel); + thread::Builder::new().name("web-auth-enter-listener".to_owned()).spawn(move || { + while !reader_cancel.load(Ordering::Relaxed) { + match event::poll(ENTER_POLL_INTERVAL) { + // Input is ready, but skip it without consuming when the + // handle was dropped meanwhile — otherwise `read()` would + // steal a keystroke from whatever reads stdin next. + Ok(true) => { + if reader_cancel.load(Ordering::Relaxed) { + return; + } + // `poll() == Ok(true)` guarantees a complete event is + // ready, so `read()` does not block. In cooked mode the + // line is submitted on Enter, which maps to `Enter`. + match event::read() { + Ok(Event::Key(key)) + if key.code == KeyCode::Enter + && key.kind != KeyEventKind::Release => + { + let _ = tx.send(()); + return; + } + Ok(_) => {} + Err(_) => return, + } + } + // Timed out: loop back to re-check the cancel flag. + Ok(false) => {} + Err(_) => return, + } + } + })?; + Ok(HostEnterHandle { enter, fired: false, cancel }) + } +} + +impl PromptOtp for Host { + async fn input(message: &str) -> Result, PromptError> { + let message = message.to_owned(); + // `dialoguer` is blocking; keep it off the async runtime. + tokio::task::spawn_blocking(move || { + dialoguer::Input::::new().with_prompt(message).allow_empty(true).interact_text() + }) + .await + .map_err(|join_error| PromptError::Other { reason: join_error.to_string() })? + .map(Some) + .map_err(map_dialoguer_error) + } +} + +fn map_dialoguer_error(error: dialoguer::Error) -> PromptError { + match error { + dialoguer::Error::IO(io) if io.kind() == io::ErrorKind::Interrupted => { + PromptError::Cancelled + } + dialoguer::Error::IO(io) => PromptError::Other { reason: io.to_string() }, + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/network-web-auth/src/capabilities/tests.rs b/pacquet/crates/network-web-auth/src/capabilities/tests.rs new file mode 100644 index 0000000000..2762e7bafb --- /dev/null +++ b/pacquet/crates/network-web-auth/src/capabilities/tests.rs @@ -0,0 +1,20 @@ +use super::{Clock, Host, StdinIsTty, StdoutIsTty}; + +/// `0` is `Host::now_ms`'s pre-epoch fallback, so a non-zero read confirms +/// the real wall clock was queried rather than the fallback. +#[test] +fn host_clock_reads_a_non_zero_time() { + let now = Host::now_ms(); + eprintln!("Host::now_ms() = {now}"); + assert!(now > 0); +} + +/// The TTY probes are dispatchable and return a bool. The value depends on +/// how the test harness wired stdio, so only its type is asserted — the +/// behavioral branches are covered by fakes in the `prompt_browser_open` / +/// `with_otp_handling` tests. +#[test] +fn host_tty_probes_are_callable() { + let _: bool = Host::stdin_is_tty(); + let _: bool = Host::stdout_is_tty(); +} diff --git a/pacquet/crates/network-web-auth/src/generate_qr_code.rs b/pacquet/crates/network-web-auth/src/generate_qr_code.rs new file mode 100644 index 0000000000..39ce344cf1 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/generate_qr_code.rs @@ -0,0 +1,29 @@ +use qrcode::{QrCode, render::unicode}; + +/// Render `text` as a compact Unicode QR code for printing in a terminal. +/// +/// Ports pnpm's `generateQrCode`, which used `qrcode-terminal` with +/// `small: true`. The exact glyphs differ (this uses `qrcode`'s +/// half-block `Dense1x2` renderer), but the encoded payload and +/// scannability match. +/// +/// Returns an error only when `text` is too long to fit in any QR version +/// — pnpm treats this as a should-never-happen, but `text` comes from an +/// untrusted registry response, so this surfaces it as a recoverable error +/// rather than a panic. +pub fn generate_qr_code(text: &str) -> Result { + QrCode::new(text) + .map(|code| code.render::().build()) + .map_err(|source| GenerateQrCodeError { reason: source.to_string() }) +} + +/// `text` could not be encoded as a QR code (e.g. it exceeds the maximum +/// QR data capacity). +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("failed to generate a QR code: {reason}")] +pub struct GenerateQrCodeError { + reason: String, +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/network-web-auth/src/generate_qr_code/tests.rs b/pacquet/crates/network-web-auth/src/generate_qr_code/tests.rs new file mode 100644 index 0000000000..e1affe7846 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/generate_qr_code/tests.rs @@ -0,0 +1,14 @@ +use super::generate_qr_code; + +#[test] +fn returns_a_non_empty_string() { + let qr = generate_qr_code("https://example.com").expect("encode a short URL"); + assert!(!qr.is_empty()); +} + +#[test] +fn produces_different_output_for_different_inputs() { + let qr1 = generate_qr_code("https://example.com/a").expect("encode URL a"); + let qr2 = generate_qr_code("https://example.com/b").expect("encode URL b"); + assert_ne!(qr1, qr2); +} diff --git a/pacquet/crates/network-web-auth/src/global_log.rs b/pacquet/crates/network-web-auth/src/global_log.rs new file mode 100644 index 0000000000..623466bf02 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/global_log.rs @@ -0,0 +1,24 @@ +//! The web-auth flow's equivalent of pnpm's `globalInfo` / `globalWarn`. +//! +//! pnpm writes these messages to a `bole('pnpm:global')` logger; pacquet +//! routes them through the [`Reporter`] seam on the matching +//! [`LogEvent::Global`] channel, so the `--reporter` choice (silent vs +//! ndjson) still applies. Threaded as a `Reporter` generic rather than a +//! capability on `Sys` because the sink is a runtime choice, not a system +//! facility. + +use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter}; + +pub(crate) fn global_info(message: &str) { + Reporter::emit(&LogEvent::Global(GlobalLog { + level: LogLevel::Info, + message: message.to_owned(), + })); +} + +pub(crate) fn global_warn(message: &str) { + Reporter::emit(&LogEvent::Global(GlobalLog { + level: LogLevel::Warn, + message: message.to_owned(), + })); +} diff --git a/pacquet/crates/network-web-auth/src/lib.rs b/pacquet/crates/network-web-auth/src/lib.rs new file mode 100644 index 0000000000..fee22f458b --- /dev/null +++ b/pacquet/crates/network-web-auth/src/lib.rs @@ -0,0 +1,46 @@ +//! Web-based authentication flow: QR-code display, token polling, and OTP +//! challenge handling. +//! +//! This is the Rust port of the TypeScript `@pnpm/network.web-auth` +//! package. It is shared infrastructure for the registry-auth commands +//! (`pnpm login` / `pnpm publish` and friends) that pacquet has not ported +//! yet; the crate exists so those features have the flow ready when they +//! land. +//! +//! # Dependency-injection seam +//! +//! The TypeScript package injects every side effect — the clock, the +//! sleep timer, `fetch`, the OTP prompt, the "press Enter" readline, the +//! browser opener — as a bag of closures on a `context` object. This crate +//! ports that seam to pacquet's convention: one `self`-less capability +//! trait per effect ([`Clock`], [`Sleep`], [`WebAuthFetch`], [`OpenUrl`], +//! [`EnterKeyListener`], [`PromptOtp`], and the [`StdinIsTty`] / +//! [`StdoutIsTty`] probes), composed as bounds on a single `Sys` type +//! parameter, with the real OS behind [`Host`] and `fn`-bound unit-struct +//! fakes in tests. User-facing messages flow through the `R: Reporter` seam +//! on pacquet's `pnpm:global` channel rather than a capability, matching +//! pnpm's `globalInfo` / `globalWarn`. + +mod capabilities; +mod generate_qr_code; +mod global_log; +mod poll_for_web_auth_token; +mod prompt_browser_open; +mod web_auth_timeout_error; +mod with_otp_handling; + +pub use capabilities::{ + Clock, EnterKeyListener, Host, OpenUrl, PromptError, PromptOtp, Sleep, StdinIsTty, StdoutIsTty, + WebAuthFetch, WebAuthFetchError, +}; +pub use generate_qr_code::{GenerateQrCodeError, generate_qr_code}; +pub use poll_for_web_auth_token::{ + WebAuthFetchOptions, WebAuthFetchResponse, WebAuthRetryOptions, WebAuthTokenPollParams, + poll_for_web_auth_token, +}; +pub use prompt_browser_open::prompt_browser_open; +pub use web_auth_timeout_error::WebAuthTimeoutError; +pub use with_otp_handling::{ + OtpChallenge, OtpError, OtpErrorBody, OtpNonInteractiveError, OtpSecondChallengeError, + SyntheticOtpError, WithOtpError, with_otp_handling, +}; diff --git a/pacquet/crates/network-web-auth/src/poll_for_web_auth_token.rs b/pacquet/crates/network-web-auth/src/poll_for_web_auth_token.rs new file mode 100644 index 0000000000..c219835753 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/poll_for_web_auth_token.rs @@ -0,0 +1,161 @@ +use crate::{ + WebAuthTimeoutError, + capabilities::{Clock, Sleep, WebAuthFetch}, +}; + +#[cfg(test)] +mod tests; + +/// Options forwarded to each poll request. Ports TS `WebAuthFetchOptions` +/// (the `method` is always `GET`, so it is implicit here). `retry` mirrors +/// the undici retry knobs pnpm passes through; the production +/// [`Host`](crate::Host) fetch currently applies only `timeout` and leaves +/// `retry` for the consuming command to wire. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct WebAuthFetchOptions { + /// Per-request timeout in milliseconds. + pub timeout: Option, + pub retry: Option, +} + +/// Retry knobs forwarded with [`WebAuthFetchOptions`]. Field-for-field +/// port of pnpm's `WebAuthFetchOptions['retry']`. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct WebAuthRetryOptions { + pub factor: Option, + pub max_timeout: Option, + pub min_timeout: Option, + pub randomize: Option, + pub retries: Option, +} + +/// A poll response materialized by the [`WebAuthFetch`] capability. Ports +/// TS `WebAuthFetchResponse`: `ok` / `status`, the one header the poll +/// reads (`Retry-After`), and the body text that [`token`](Self::token) +/// parses. +#[derive(Debug, Clone)] +pub struct WebAuthFetchResponse { + pub ok: bool, + pub status: u16, + /// Value of the `Retry-After` response header, if present. + pub retry_after: Option, + /// Raw response body. + pub body: String, +} + +impl WebAuthFetchResponse { + /// Extract the `token` field from the JSON body. `Ok(None)` when the + /// body parses but carries no token; `Err` when the body is not the + /// expected JSON shape — mirroring TS `response.json()` rejecting, + /// which the poll loop swallows. + pub fn token(&self) -> Result, serde_json::Error> { + #[derive(serde::Deserialize)] + struct TokenBody { + #[serde(default)] + token: Option, + } + serde_json::from_str::(&self.body).map(|body| body.token) + } +} + +/// Parameters for [`poll_for_web_auth_token`]. +#[derive(Debug, Clone)] +pub struct WebAuthTokenPollParams { + pub done_url: String, + pub fetch_options: WebAuthFetchOptions, + /// Overall budget in milliseconds. Defaults to 5 minutes when `None`. + pub timeout_ms: Option, +} + +const DEFAULT_TIMEOUT_MS: u64 = 5 * 60 * 1000; +const POLL_INTERVAL_MS: u64 = 1000; + +/// Poll a registry's "done" URL until it returns an authentication token. +/// +/// The caller is responsible for displaying the authentication URL (and +/// optional QR code) before calling this. Returns the token string, or +/// [`WebAuthTimeoutError`] when the budget is exceeded. +/// +/// Ports pnpm's `pollForWebAuthToken`. +pub async fn poll_for_web_auth_token( + params: WebAuthTokenPollParams, +) -> Result +where + Sys: Clock + Sleep + WebAuthFetch, +{ + let WebAuthTokenPollParams { done_url, fetch_options, timeout_ms } = params; + let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS); + let start_time = Sys::now_ms(); + + loop { + let now = Sys::now_ms(); + if now.saturating_sub(start_time) > timeout_ms { + return Err(WebAuthTimeoutError::new(now, start_time, timeout_ms)); + } + + Sys::sleep_ms(POLL_INTERVAL_MS).await; + + let Ok(response) = Sys::fetch(&done_url, &fetch_options).await else { + continue; + }; + if !response.ok { + continue; + } + + if response.status == 202 { + // Registry is still waiting for authentication. + wait_for_retry_after::(&response, start_time, timeout_ms).await?; + continue; + } + + match response.token() { + Ok(Some(token)) if !token.is_empty() => return Ok(token), + _ => continue, + } + } +} + +/// Honor a 202 response's `Retry-After` header by sleeping the +/// *additional* time beyond the poll interval already waited, capped to +/// the remaining budget. Returns `Err` when the budget is already +/// exhausted. +async fn wait_for_retry_after( + response: &WebAuthFetchResponse, + start_time: u64, + timeout_ms: u64, +) -> Result<(), WebAuthTimeoutError> +where + Sys: Clock + Sleep, +{ + let retry_after_seconds = parse_js_number(response.retry_after.as_deref()); + if !retry_after_seconds.is_finite() { + return Ok(()); + } + let additional_ms = retry_after_seconds.mul_add(1000.0, -(POLL_INTERVAL_MS as f64)); + if additional_ms <= 0.0 { + return Ok(()); + } + let now_after_poll = Sys::now_ms(); + let remaining_ms = timeout_ms as i64 + - i64::try_from(now_after_poll.saturating_sub(start_time)).unwrap_or(i64::MAX); + if remaining_ms <= 0 { + return Err(WebAuthTimeoutError::new(now_after_poll, start_time, timeout_ms)); + } + let sleep_ms = additional_ms.min(remaining_ms as f64); + Sys::sleep_ms(sleep_ms as u64).await; + Ok(()) +} + +/// Mirror JavaScript's `Number(value)` for the `Retry-After` header. +/// Header-absent (`None`) and an empty / whitespace string both map to `0` +/// (as `Number(null)` / `Number('')` do); a non-numeric string maps to +/// `NaN`, so the caller skips the additional wait. +fn parse_js_number(value: Option<&str>) -> f64 { + match value { + None => 0.0, + Some(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { 0.0 } else { trimmed.parse::().unwrap_or(f64::NAN) } + } + } +} diff --git a/pacquet/crates/network-web-auth/src/poll_for_web_auth_token/tests.rs b/pacquet/crates/network-web-auth/src/poll_for_web_auth_token/tests.rs new file mode 100644 index 0000000000..6359cae771 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/poll_for_web_auth_token/tests.rs @@ -0,0 +1,430 @@ +use std::{ + cell::{Cell, RefCell}, + future::{self, Future}, + rc::Rc, +}; + +use pretty_assertions::assert_eq; + +use super::{ + WebAuthFetchOptions, WebAuthFetchResponse, WebAuthRetryOptions, WebAuthTokenPollParams, + poll_for_web_auth_token, +}; +use crate::capabilities::{Clock, Sleep, WebAuthFetch, WebAuthFetchError}; + +/// A scripted stand-in for one `fetch` call, given the request URL and +/// options so a test can both decide the response and capture the inputs. +type FetchScript = + Box Result>; + +/// How the [`Sleep`] fake moves the [`Clock`] fake forward. The two share +/// the `TIME` cell so a test can drive the timeout deterministically — +/// mirroring the TS tests where `setTimeout` mutates the same `time` +/// variable `Date.now` reads. +#[derive(Clone, Copy)] +enum SleepBehavior { + /// `Date.now` is pinned; sleeping records the delay but does not move + /// the clock. + NoAdvance, + /// Sleeping advances the clock by the requested milliseconds. + AdvanceByMs, + /// Sleeping advances the clock by a fixed amount regardless of the + /// requested delay (the TS `setTimeout: () => { time += K }` shape). + AdvanceByFixed(u64), +} + +thread_local! { + static TIME: Cell = const { Cell::new(0) }; + static SLEEP_BEHAVIOR: Cell = const { Cell::new(SleepBehavior::NoAdvance) }; + static SLEEPS: RefCell> = const { RefCell::new(Vec::new()) }; + static FETCH: RefCell> = const { RefCell::new(None) }; +} + +struct Fake; + +impl Clock for Fake { + fn now_ms() -> u64 { + TIME.with(Cell::get) + } +} + +impl Sleep for Fake { + fn sleep_ms(ms: u64) -> impl Future { + SLEEPS.with(|sleeps| sleeps.borrow_mut().push(ms)); + let delta = match SLEEP_BEHAVIOR.with(Cell::get) { + SleepBehavior::NoAdvance => 0, + SleepBehavior::AdvanceByMs => ms, + SleepBehavior::AdvanceByFixed(jump) => jump, + }; + if delta != 0 { + TIME.with(|time| time.set(time.get().saturating_add(delta))); + } + future::ready(()) + } +} + +impl WebAuthFetch for Fake { + fn fetch( + url: &str, + options: &WebAuthFetchOptions, + ) -> impl Future> { + let result = FETCH.with(|fetch| { + (fetch.borrow_mut().as_mut().expect("a fetch script must be installed"))(url, options) + }); + future::ready(result) + } +} + +/// Reset the shared fake state. Called first in every test so a reused +/// libtest worker thread never leaks state between tests. +fn reset() { + TIME.with(|time| time.set(0)); + SLEEP_BEHAVIOR.with(|behavior| behavior.set(SleepBehavior::NoAdvance)); + SLEEPS.with(|sleeps| sleeps.borrow_mut().clear()); + FETCH.with(|fetch| *fetch.borrow_mut() = None); +} + +fn set_sleep_behavior(behavior: SleepBehavior) { + SLEEP_BEHAVIOR.with(|cell| cell.set(behavior)); +} + +fn set_fetch(script: FetchScript) { + FETCH.with(|fetch| *fetch.borrow_mut() = Some(script)); +} + +fn recorded_sleeps() -> Vec { + SLEEPS.with(|sleeps| sleeps.borrow().clone()) +} + +fn ok_202(retry_after: Option<&str>) -> WebAuthFetchResponse { + WebAuthFetchResponse { + ok: true, + status: 202, + retry_after: retry_after.map(str::to_owned), + body: "{}".to_owned(), + } +} + +fn ok_token(token: &str) -> WebAuthFetchResponse { + WebAuthFetchResponse { + ok: true, + status: 200, + retry_after: None, + body: serde_json::json!({ "token": token }).to_string(), + } +} + +fn ok_json(body: &serde_json::Value) -> WebAuthFetchResponse { + WebAuthFetchResponse { ok: true, status: 200, retry_after: None, body: body.to_string() } +} + +fn not_ok() -> WebAuthFetchResponse { + WebAuthFetchResponse { ok: false, status: 404, retry_after: None, body: String::new() } +} + +fn params(timeout_ms: Option) -> WebAuthTokenPollParams { + WebAuthTokenPollParams { + done_url: "https://registry.npmjs.org/auth/done".to_owned(), + fetch_options: WebAuthFetchOptions::default(), + timeout_ms, + } +} + +#[tokio::test] +async fn returns_token_when_done_url_responds_with_200_and_token() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() < 3 { ok_202(Some("1")) } else { ok_token("web-token-123") }) + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "web-token-123"); + assert_eq!(calls.get(), 3); +} + +#[tokio::test] +async fn passes_done_url_and_fetch_options_to_fetch() { + reset(); + let captured = Rc::new(RefCell::new(Vec::<(String, WebAuthFetchOptions)>::new())); + let sink = Rc::clone(&captured); + set_fetch(Box::new(move |url, options| { + sink.borrow_mut().push((url.to_owned(), options.clone())); + Ok(ok_token("tok")) + })); + let options = WebAuthFetchOptions { + timeout: Some(5000), + retry: Some(WebAuthRetryOptions { retries: Some(3), ..WebAuthRetryOptions::default() }), + }; + + poll_for_web_auth_token::(WebAuthTokenPollParams { + done_url: "https://registry.example.com/done".to_owned(), + fetch_options: options.clone(), + timeout_ms: None, + }) + .await + .expect("a token"); + + assert_eq!(*captured.borrow(), vec![("https://registry.example.com/done".to_owned(), options)]); +} + +#[tokio::test] +async fn respects_retry_after_header_when_polling() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { ok_202(Some("5")) } else { ok_token("tok") }) + })); + + poll_for_web_auth_token::(params(None)).await.expect("a token"); + + // First the 1s poll interval, then the 5s Retry-After minus the 1s + // already waited, then the next iteration's 1s poll interval. + assert_eq!(recorded_sleeps(), vec![1000, 4000, 1000]); +} + +#[tokio::test] +async fn ignores_retry_after_when_value_is_not_a_finite_number() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { ok_202(Some("not-a-number")) } else { ok_token("tok") }) + })); + + poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(recorded_sleeps(), vec![1000, 1000]); +} + +#[tokio::test] +async fn ignores_retry_after_when_value_is_absent() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { ok_202(None) } else { ok_token("tok") }) + })); + + poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(recorded_sleeps(), vec![1000, 1000]); +} + +#[tokio::test] +async fn skips_additional_delay_when_retry_after_is_less_than_poll_interval() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { ok_202(Some("0.5")) } else { ok_token("tok") }) + })); + + poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(recorded_sleeps(), vec![1000, 1000]); +} + +#[tokio::test] +async fn caps_retry_after_additional_delay_to_remaining_timeout() { + reset(); + set_sleep_behavior(SleepBehavior::AdvanceByMs); + set_fetch(Box::new(|_url, _options| Ok(ok_202(Some("60"))))); + + // A 10s budget so the 60s Retry-After gets capped. + poll_for_web_auth_token::(params(Some(10_000))) + .await + .expect_err("polling should time out"); + + let sleeps = recorded_sleeps(); + assert_eq!(sleeps[0], 1000); + assert!(sleeps[1] <= 9000, "additional delay capped to the remaining budget, got {sleeps:?}"); +} + +#[tokio::test] +async fn throws_timeout_error_when_timeout_expires_during_retry_after_wait() { + reset(); + set_sleep_behavior(SleepBehavior::AdvanceByMs); + set_fetch(Box::new(|_url, _options| Ok(ok_202(Some("100"))))); + + let error = poll_for_web_auth_token::(params(Some(5000))) + .await + .expect_err("polling should time out"); + + assert_eq!(error.timeout, 5000); +} + +#[tokio::test] +async fn continues_polling_when_fetch_fails() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + if counter.get() == 1 { Err(WebAuthFetchError) } else { Ok(ok_token("tok")) } + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "tok"); + assert_eq!(calls.get(), 2); +} + +#[tokio::test] +async fn continues_polling_when_response_is_not_ok() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { not_ok() } else { ok_token("tok") }) + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "tok"); + assert_eq!(calls.get(), 2); +} + +#[tokio::test] +async fn continues_polling_when_response_body_is_not_json() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { + WebAuthFetchResponse { + ok: true, + status: 200, + retry_after: None, + body: "not json".to_owned(), + } + } else { + ok_token("tok") + }) + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "tok"); + assert_eq!(calls.get(), 2); +} + +#[tokio::test] +async fn continues_polling_when_response_body_has_no_token() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { + ok_json(&serde_json::json!({ "something": "else" })) + } else { + ok_token("tok") + }) + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "tok"); + assert_eq!(calls.get(), 2); +} + +#[tokio::test] +async fn continues_polling_when_token_is_empty_string() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() == 1 { ok_token("") } else { ok_token("real-tok") }) + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "real-tok"); + assert_eq!(calls.get(), 2); +} + +#[tokio::test] +async fn throws_timeout_error_after_timeout() { + reset(); + // Jump past the default 5-minute budget on the first sleep. + set_sleep_behavior(SleepBehavior::AdvanceByFixed(6 * 60 * 1000)); + set_fetch(Box::new(|_url, _options| Ok(ok_202(None)))); + + poll_for_web_auth_token::(params(None)).await.expect_err("polling should time out"); +} + +#[tokio::test] +async fn uses_custom_timeout_value() { + reset(); + set_sleep_behavior(SleepBehavior::AdvanceByFixed(2000)); + set_fetch(Box::new(|_url, _options| Ok(ok_202(None)))); + + let error = poll_for_web_auth_token::(params(Some(3000))) + .await + .expect_err("polling should time out"); + + assert_eq!(error.timeout, 3000); +} + +#[tokio::test] +async fn recovers_after_multiple_consecutive_fetch_errors() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + if counter.get() <= 5 { Err(WebAuthFetchError) } else { Ok(ok_token("recovered")) } + })); + + let token = poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(token, "recovered"); + assert_eq!(calls.get(), 6); +} + +#[tokio::test] +async fn waits_poll_interval_before_each_fetch_call() { + reset(); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + Ok(if counter.get() < 4 { ok_202(None) } else { ok_token("tok") }) + })); + + poll_for_web_auth_token::(params(None)).await.expect("a token"); + + assert_eq!(recorded_sleeps(), vec![1000, 1000, 1000, 1000]); +} + +#[tokio::test] +async fn throws_timeout_error_when_remaining_time_is_zero_during_retry_after() { + reset(); + set_sleep_behavior(SleepBehavior::AdvanceByMs); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + set_fetch(Box::new(move |_url, _options| { + counter.set(counter.get() + 1); + // The first 202 carries a Retry-After that, after capping to the + // remaining budget, lands the clock exactly on the timeout. + Ok(if counter.get() == 1 { ok_202(Some("10")) } else { ok_202(None) }) + })); + + let error = poll_for_web_auth_token::(params(Some(2000))) + .await + .expect_err("polling should time out"); + + assert_eq!(error.timeout, 2000); +} diff --git a/pacquet/crates/network-web-auth/src/prompt_browser_open.rs b/pacquet/crates/network-web-auth/src/prompt_browser_open.rs new file mode 100644 index 0000000000..9ca8329b65 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/prompt_browser_open.rs @@ -0,0 +1,85 @@ +use std::future::Future; + +use pacquet_reporter::Reporter; + +use crate::{ + capabilities::{EnterKeyListener, OpenUrl, StdinIsTty}, + global_log::{global_info, global_warn}, +}; + +#[cfg(test)] +mod tests; + +/// Race a token-polling future against an optional "press Enter to open in +/// browser" prompt. +/// +/// While `poll` runs, an Enter keypress opens `auth_url` in the user's +/// browser. The poll is awaited on its own — the keypress is a +/// fire-and-forget side effect — so authentication that completes on +/// another device (phone QR scan, pasted URL) returns without the user +/// ever pressing Enter. Whatever `poll` resolves to (token or error) is +/// returned, and the listener is always closed. +/// +/// Falls back to awaiting `poll` directly — no prompt — when stdin is not a +/// TTY, when `auth_url` is not an `http(s)` URL (it comes from an untrusted +/// registry response), or when the listener fails to set up. +/// +/// Ports pnpm's `promptBrowserOpen`. +pub async fn prompt_browser_open( + auth_url: &str, + poll: Poll, +) -> Result +where + Sys: StdinIsTty + EnterKeyListener + OpenUrl, + Reporter: self::Reporter, + Poll: Future>, +{ + if !Sys::stdin_is_tty() { + return poll.await; + } + + let Some(canonical_url) = canonical_http_url(auth_url) else { + return poll.await; + }; + + let mut listener = match Sys::listen() { + Ok(listener) => listener, + Err(error) => { + global_warn::(&format!("Could not set up keyboard listener: {error}")); + return poll.await; + } + }; + + global_info::("Press ENTER to open the URL in your browser."); + + tokio::pin!(poll); + tokio::select! { + result = &mut poll => result, + () = &mut listener => { + open_in_browser::(&canonical_url); + // The keypress fires once; keep awaiting the poll. + poll.await + } + } +} + +/// Open `url`, downgrading any failure to a warning so a missing +/// `xdg-open` (or equivalent) never interrupts the poll. +fn open_in_browser(url: &str) +where + Sys: OpenUrl, + Reporter: self::Reporter, +{ + if let Err(error) = Sys::open_url(url) { + global_warn::(&format!("Could not open browser automatically: {error}")); + global_info::("Please open the URL shown above manually."); + } +} + +/// Return the canonical form of `auth_url` when it is an `http(s)` URL, or +/// `None` otherwise. Guards `open` against `javascript:` / `file:` / other +/// schemes in an untrusted registry response. +fn canonical_http_url(auth_url: &str) -> Option { + let parsed = url::Url::parse(auth_url).ok()?; + matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string()) +} diff --git a/pacquet/crates/network-web-auth/src/prompt_browser_open/tests.rs b/pacquet/crates/network-web-auth/src/prompt_browser_open/tests.rs new file mode 100644 index 0000000000..f40722d3a4 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/prompt_browser_open/tests.rs @@ -0,0 +1,299 @@ +use std::{ + cell::{Cell, RefCell}, + future::{self, Future}, + io, + pin::Pin, + task::{Context, Poll}, +}; + +use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter}; +use pretty_assertions::assert_eq; +use tokio::{sync::oneshot, task::LocalSet}; + +use super::prompt_browser_open; +use crate::capabilities::{EnterKeyListener, OpenUrl, StdinIsTty}; + +#[derive(Clone, Copy)] +enum Outcome { + Succeed, + Fail, +} + +thread_local! { + static STDIN_TTY: Cell = const { Cell::new(true) }; + static LISTEN_OUTCOME: Cell = const { Cell::new(Outcome::Succeed) }; + static OPEN_OUTCOME: Cell = const { Cell::new(Outcome::Succeed) }; + static OPEN_CALLS: RefCell> = const { RefCell::new(Vec::new()) }; + static CLOSED: Cell = const { Cell::new(false) }; + static ENTER_TX: RefCell>> = const { RefCell::new(None) }; + static EMITTED: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct Fake; + +impl StdinIsTty for Fake { + fn stdin_is_tty() -> bool { + STDIN_TTY.with(Cell::get) + } +} + +impl OpenUrl for Fake { + fn open_url(url: &str) -> io::Result<()> { + OPEN_CALLS.with(|calls| calls.borrow_mut().push(url.to_owned())); + match OPEN_OUTCOME.with(Cell::get) { + Outcome::Succeed => Ok(()), + Outcome::Fail => Err(io::Error::other("xdg-open not found")), + } + } +} + +/// Resolves when the test simulates an Enter keypress; sets the `CLOSED` +/// flag on drop, standing in for `readline.Interface.close`. +struct FakeEnterHandle { + rx: oneshot::Receiver<()>, +} + +impl Future for FakeEnterHandle { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + Pin::new(&mut self.get_mut().rx).poll(cx).map(|_| ()) + } +} + +impl Drop for FakeEnterHandle { + fn drop(&mut self) { + CLOSED.with(|closed| closed.set(true)); + } +} + +impl EnterKeyListener for Fake { + type Handle = FakeEnterHandle; + + fn listen() -> io::Result { + match LISTEN_OUTCOME.with(Cell::get) { + Outcome::Fail => Err(io::Error::other("setRawMode not supported")), + Outcome::Succeed => { + let (tx, rx) = oneshot::channel(); + ENTER_TX.with(|cell| *cell.borrow_mut() = Some(tx)); + Ok(FakeEnterHandle { rx }) + } + } + } +} + +struct RecordingReporter; + +impl Reporter for RecordingReporter { + fn emit(event: &LogEvent) { + if let LogEvent::Global(GlobalLog { level, message }) = event { + EMITTED.with(|emitted| emitted.borrow_mut().push((*level, message.clone()))); + } + } +} + +fn reset() { + STDIN_TTY.with(|tty| tty.set(true)); + LISTEN_OUTCOME.with(|outcome| outcome.set(Outcome::Succeed)); + OPEN_OUTCOME.with(|outcome| outcome.set(Outcome::Succeed)); + OPEN_CALLS.with(|calls| calls.borrow_mut().clear()); + CLOSED.with(|closed| closed.set(false)); + ENTER_TX.with(|cell| *cell.borrow_mut() = None); + EMITTED.with(|emitted| emitted.borrow_mut().clear()); +} + +fn simulate_enter() { + if let Some(tx) = ENTER_TX.with(|cell| cell.borrow_mut().take()) { + let _ = tx.send(()); + } +} + +fn open_calls() -> Vec { + OPEN_CALLS.with(|calls| calls.borrow().clone()) +} + +fn closed() -> bool { + CLOSED.with(Cell::get) +} + +fn infos() -> Vec { + messages_at(LogLevel::Info) +} + +fn warns() -> Vec { + messages_at(LogLevel::Warn) +} + +fn messages_at(level: LogLevel) -> Vec { + EMITTED.with(|emitted| { + emitted + .borrow() + .iter() + .filter(|(emitted_level, _)| *emitted_level == level) + .map(|(_, message)| message.clone()) + .collect() + }) +} + +/// Poll error type. Carries a message so the rejection tests can assert +/// the propagated value. +#[derive(Debug)] +struct PollError(&'static str); + +const AUTH_URL: &str = "https://example.com/auth"; + +#[tokio::test] +async fn returns_the_poll_result_when_poll_completes_before_enter_keypress() { + reset(); + + let token = prompt_browser_open::( + AUTH_URL, + future::ready(Ok("my-token".to_owned())), + ) + .await + .expect("a token"); + + assert_eq!(token, "my-token"); + assert!(closed(), "the listener should be closed"); + assert!(open_calls().is_empty(), "the browser must not be opened without a keypress"); +} + +#[tokio::test] +async fn opens_browser_when_enter_key_is_pressed_before_poll_completes() { + reset(); + LocalSet::new() + .run_until(async { + let (poll_tx, poll_rx) = oneshot::channel::>(); + let handle = tokio::task::spawn_local(async move { + let poll = async move { poll_rx.await.expect("poll resolved") }; + prompt_browser_open::(AUTH_URL, poll).await + }); + + // Let the prompt register its listener, then press Enter. + tokio::task::yield_now().await; + simulate_enter(); + tokio::task::yield_now().await; + + assert_eq!(open_calls(), vec![AUTH_URL.to_owned()]); + + poll_tx.send(Ok("token-after-enter".to_owned())).expect("send poll result"); + let token = handle.await.expect("join").expect("a token"); + + assert_eq!(token, "token-after-enter"); + assert!(closed(), "the listener should be closed"); + }) + .await; +} + +#[tokio::test] +async fn warns_and_continues_polling_when_open_fails() { + reset(); + OPEN_OUTCOME.with(|outcome| outcome.set(Outcome::Fail)); + LocalSet::new() + .run_until(async { + let (poll_tx, poll_rx) = oneshot::channel::>(); + let handle = tokio::task::spawn_local(async move { + let poll = async move { poll_rx.await.expect("poll resolved") }; + prompt_browser_open::(AUTH_URL, poll).await + }); + + tokio::task::yield_now().await; + simulate_enter(); + tokio::task::yield_now().await; + + assert!( + warns().iter().any(|message| message.contains("xdg-open not found")), + "open failure should warn, got {:?}", + warns(), + ); + assert!(infos().contains(&"Please open the URL shown above manually.".to_owned())); + + poll_tx.send(Ok("tok".to_owned())).expect("send poll result"); + let token = handle.await.expect("join").expect("a token"); + assert_eq!(token, "tok"); + }) + .await; +} + +#[tokio::test] +async fn warns_and_falls_back_to_plain_poll_when_listen_fails() { + reset(); + LISTEN_OUTCOME.with(|outcome| outcome.set(Outcome::Fail)); + + let token = prompt_browser_open::( + AUTH_URL, + future::ready(Ok("fallback-token".to_owned())), + ) + .await + .expect("a token"); + + assert_eq!(token, "fallback-token"); + assert!( + warns().iter().any(|message| message.contains("setRawMode not supported")), + "listener setup failure should warn, got {:?}", + warns(), + ); + assert!(open_calls().is_empty()); +} + +#[tokio::test] +async fn falls_back_to_plain_poll_when_stdin_is_not_a_tty() { + reset(); + STDIN_TTY.with(|tty| tty.set(false)); + + let token = prompt_browser_open::( + AUTH_URL, + future::ready(Ok("plain-token".to_owned())), + ) + .await + .expect("a token"); + + assert_eq!(token, "plain-token"); + assert!(open_calls().is_empty()); +} + +#[tokio::test] +async fn shows_the_press_enter_message() { + reset(); + + prompt_browser_open::( + AUTH_URL, + future::ready(Ok("tok".to_owned())), + ) + .await + .expect("a token"); + + assert!(infos().contains(&"Press ENTER to open the URL in your browser.".to_owned())); +} + +#[tokio::test] +async fn does_not_open_browser_for_non_http_auth_url() { + for auth_url in ["javascript:alert(1)", "file:///etc/passwd", "not a url"] { + reset(); + + let token = prompt_browser_open::( + auth_url, + future::ready(Ok("tok".to_owned())), + ) + .await + .expect("a token"); + + assert_eq!(token, "tok"); + assert!(open_calls().is_empty(), "{auth_url} must not open a browser"); + } +} + +#[tokio::test] +async fn cleans_up_when_poll_rejects() { + reset(); + + let error = prompt_browser_open::( + AUTH_URL, + future::ready(Err(PollError("timeout"))), + ) + .await + .expect_err("poll rejected"); + + assert_eq!(error.0, "timeout"); + assert!(closed(), "the listener should be closed even when the poll rejects"); +} diff --git a/pacquet/crates/network-web-auth/src/web_auth_timeout_error.rs b/pacquet/crates/network-web-auth/src/web_auth_timeout_error.rs new file mode 100644 index 0000000000..cca78ca681 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/web_auth_timeout_error.rs @@ -0,0 +1,33 @@ +use pacquet_diagnostics::miette::{self, Diagnostic}; + +/// Web-based authentication did not complete before the timeout. +/// +/// Ports pnpm's `WebAuthTimeoutError`. The `code(...)` is part of the +/// public contract (). `start_time` / `end_time` +/// are the Unix-epoch-millisecond [`Clock`](crate::Clock) readings that +/// bracketed the poll, and `timeout` is the configured budget in +/// milliseconds — the same three numbers pnpm's error carries. +#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] +#[display("Web-based authentication timed out before it could be completed")] +#[diagnostic( + code(ERR_PNPM_WEBAUTH_TIMEOUT), + help( + "Re-run this command and complete the authentication step in your browser before the time \ + limit is reached" + ) +)] +pub struct WebAuthTimeoutError { + pub end_time: u64, + pub start_time: u64, + pub timeout: u64, +} + +impl WebAuthTimeoutError { + #[must_use] + pub fn new(end_time: u64, start_time: u64, timeout: u64) -> Self { + WebAuthTimeoutError { end_time, start_time, timeout } + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/network-web-auth/src/web_auth_timeout_error/tests.rs b/pacquet/crates/network-web-auth/src/web_auth_timeout_error/tests.rs new file mode 100644 index 0000000000..8ebe86462a --- /dev/null +++ b/pacquet/crates/network-web-auth/src/web_auth_timeout_error/tests.rs @@ -0,0 +1,32 @@ +use pacquet_diagnostics::miette::Diagnostic; +use pretty_assertions::assert_eq; + +use super::WebAuthTimeoutError; + +#[test] +fn stores_end_time_start_time_and_timeout() { + let err = WebAuthTimeoutError::new(310_000, 10_000, 300_000); + assert_eq!(err.end_time, 310_000); + assert_eq!(err.start_time, 10_000); + assert_eq!(err.timeout, 300_000); +} + +#[test] +fn has_webauth_timeout_code() { + let err = WebAuthTimeoutError::new(0, 0, 0); + assert_eq!(err.code().expect("a diagnostic code").to_string(), "ERR_PNPM_WEBAUTH_TIMEOUT"); +} + +#[test] +fn includes_a_hint_about_re_running_the_command() { + let err = WebAuthTimeoutError::new(0, 0, 0); + let help = err.help().expect("a help hint").to_string(); + assert!(help.contains("Re-run"), "help should mention re-running, got {help:?}"); +} + +#[test] +fn has_a_descriptive_message() { + let err = WebAuthTimeoutError::new(0, 0, 0); + let message = err.to_string(); + assert!(message.contains("timed out"), "message should mention timing out, got {message:?}"); +} diff --git a/pacquet/crates/network-web-auth/src/with_otp_handling.rs b/pacquet/crates/network-web-auth/src/with_otp_handling.rs new file mode 100644 index 0000000000..58b339e337 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/with_otp_handling.rs @@ -0,0 +1,252 @@ +use pacquet_diagnostics::miette::{self, Diagnostic}; +use pacquet_reporter::Reporter; +use serde_json::Value; + +use crate::{ + GenerateQrCodeError, WebAuthTimeoutError, + capabilities::{ + Clock, EnterKeyListener, OpenUrl, PromptError, PromptOtp, Sleep, StdinIsTty, StdoutIsTty, + WebAuthFetch, + }, + generate_qr_code::generate_qr_code, + global_log::{global_info, global_warn}, + poll_for_web_auth_token::{ + WebAuthFetchOptions, WebAuthTokenPollParams, poll_for_web_auth_token, + }, + prompt_browser_open::prompt_browser_open, +}; + +#[cfg(test)] +mod tests; + +/// The `authUrl` / `doneUrl` an OTP challenge may carry. Both are optional +/// because a registry may send neither (a classic OTP) or a malformed +/// body. Ports TS `OtpErrorBody`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct OtpErrorBody { + pub auth_url: Option, + pub done_url: Option, +} + +/// An EOTP challenge surfaced by an operation's error. Ports the +/// `{ code: 'EOTP', body? }` shape pnpm detects with `isOtpError`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct OtpChallenge { + pub body: Option, +} + +/// Implemented by an operation's error type so [`with_otp_handling`] can +/// detect an EOTP challenge and read its body. Ports TS `isOtpError` plus +/// the `error.body` read: returning `Some` is the moral equivalent of +/// `error.code === 'EOTP'`. +pub trait OtpError { + fn as_otp_challenge(&self) -> Option; +} + +/// Synthetic EOTP error meant to be thrown by an operation passed to +/// [`with_otp_handling`] and caught by it — never to propagate elsewhere. +/// Ports TS `SyntheticOtpError`. +#[derive(Debug, derive_more::Display, derive_more::Error, Clone)] +#[display( + "This error was meant to be caught by `with_otp_handling`, not to propagate to other parts of \ + the code" +)] +pub struct SyntheticOtpError { + body: Option, +} + +impl SyntheticOtpError { + #[must_use] + pub fn new(body: Option) -> Self { + SyntheticOtpError { body } + } + + /// Build a challenge from an arbitrary JSON body, keeping only string + /// `authUrl` / `doneUrl` fields and warning (via the `R: Reporter` + /// global-warn seam) when either is present with a non-string type. + /// Ports TS `SyntheticOtpError.fromUnknownBody`. + #[must_use] + pub fn from_unknown_body(body: Option<&Value>) -> Self { + let Some(Value::Object(map)) = body else { + return SyntheticOtpError { body: None }; + }; + let auth_url = extract_url_field::(map, "authUrl"); + let done_url = extract_url_field::(map, "doneUrl"); + SyntheticOtpError { body: Some(OtpErrorBody { auth_url, done_url }) } + } +} + +impl OtpError for SyntheticOtpError { + fn as_otp_challenge(&self) -> Option { + Some(OtpChallenge { body: self.body.clone() }) + } +} + +fn extract_url_field( + map: &serde_json::Map, + field: &str, +) -> Option { + match map.get(field) { + None => None, + Some(Value::String(value)) => Some(value.clone()), + Some(other) => { + global_warn::(&format!( + "OTP error body: {field} has type {}, expected string", + js_typeof(other), + )); + None + } + } +} + +/// JavaScript's `typeof` for a JSON value, used to mirror pnpm's warning +/// text. Note `typeof null === 'object'` and `typeof [] === 'object'`. +fn js_typeof(value: &Value) -> &'static str { + match value { + Value::Null | Value::Array(_) | Value::Object(_) => "object", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + } +} + +/// The registry required additional authentication but the terminal is not +/// interactive. Ports pnpm's `OtpNonInteractiveError` +/// (`ERR_PNPM_OTP_NON_INTERACTIVE`). +#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] +#[display( + "The registry requires additional authentication, but pnpm is not running in an interactive \ + terminal" +)] +#[diagnostic( + code(ERR_PNPM_OTP_NON_INTERACTIVE), + help( + "Re-run this command in an interactive terminal to complete authentication, or provide the \ + --otp option if you are using a classic one-time password (OTP)" + ) +)] +pub struct OtpNonInteractiveError; + +/// The registry asked for an OTP a second time after one was already +/// supplied. Ports pnpm's `OtpSecondChallengeError` +/// (`ERR_PNPM_OTP_SECOND_CHALLENGE`). +#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] +#[display( + "The registry requested a one-time password (OTP) a second time after one was already provided" +)] +#[diagnostic( + code(ERR_PNPM_OTP_SECOND_CHALLENGE), + help( + "This is unexpected behavior from the registry. Try the command again later and, if the \ + issue persists, verify that your registry supports OTP-based authentication or contact \ + the registry administrator." + ) +)] +pub struct OtpSecondChallengeError; + +/// Failure surface of [`with_otp_handling`]. `Operation` carries the +/// caller's own error (the original challenge re-thrown, or a non-OTP +/// failure from either operation attempt); the rest are this crate's +/// errors. +#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] +pub enum WithOtpError { + #[display("{_0}")] + #[diagnostic(transparent)] + Operation(Error), + + #[display("{_0}")] + #[diagnostic(transparent)] + NonInteractive(OtpNonInteractiveError), + + #[display("{_0}")] + #[diagnostic(transparent)] + SecondChallenge(OtpSecondChallengeError), + + #[display("{_0}")] + #[diagnostic(transparent)] + Timeout(WebAuthTimeoutError), + + #[display("{_0}")] + Prompt(PromptError), + + #[display("{_0}")] + QrCode(GenerateQrCodeError), +} + +/// Run `operation`, transparently satisfying an OTP challenge if it raises +/// one. +/// +/// On the first [`OtpError`] the operation returns, this either drives the +/// web-based authentication flow (when the challenge body carries both +/// `authUrl` and `doneUrl`) or prompts for a classic OTP, then retries the +/// operation once with the obtained one-time password. Any non-OTP error, +/// or an OTP challenge with no usable code, propagates unchanged. +/// +/// Ports pnpm's `withOtpHandling`. +pub async fn with_otp_handling( + fetch_options: WebAuthFetchOptions, + mut operation: Operation, +) -> Result> +where + Sys: Clock + + Sleep + + WebAuthFetch + + StdinIsTty + + StdoutIsTty + + EnterKeyListener + + OpenUrl + + PromptOtp, + Reporter: self::Reporter, + Error: OtpError + Diagnostic + 'static, + Operation: AsyncFnMut(Option<&str>) -> Result, +{ + let error = match operation(None).await { + Ok(value) => return Ok(value), + Err(error) => error, + }; + + let Some(challenge) = error.as_otp_challenge() else { + return Err(WithOtpError::Operation(error)); + }; + + if !Sys::stdin_is_tty() || !Sys::stdout_is_tty() { + return Err(WithOtpError::NonInteractive(OtpNonInteractiveError)); + } + + let otp = match challenge.body { + Some(OtpErrorBody { auth_url: Some(auth_url), done_url: Some(done_url) }) => { + let qr_code = generate_qr_code(&auth_url).map_err(WithOtpError::QrCode)?; + global_info::(&format!( + "Authenticate your account at:\n{auth_url}\n\n{qr_code}", + )); + let poll = poll_for_web_auth_token::(WebAuthTokenPollParams { + done_url, + fetch_options, + timeout_ms: None, + }); + prompt_browser_open::(&auth_url, poll) + .await + .map(Some) + .map_err(WithOtpError::Timeout)? + } + _ => match Sys::input("This operation requires a one-time password.\nEnter OTP:").await { + Ok(value) => value.filter(|otp| !otp.is_empty()), + // The user aborted the prompt: re-throw the original challenge, + // matching pnpm's `ExitPromptError` handling. + Err(PromptError::Cancelled) => return Err(WithOtpError::Operation(error)), + Err(other) => return Err(WithOtpError::Prompt(other)), + }, + }; + + let Some(otp) = otp else { + return Err(WithOtpError::Operation(error)); + }; + + match operation(Some(&otp)).await { + Ok(value) => Ok(value), + Err(retry_error) if retry_error.as_otp_challenge().is_some() => { + Err(WithOtpError::SecondChallenge(OtpSecondChallengeError)) + } + Err(retry_error) => Err(WithOtpError::Operation(retry_error)), + } +} diff --git a/pacquet/crates/network-web-auth/src/with_otp_handling/tests.rs b/pacquet/crates/network-web-auth/src/with_otp_handling/tests.rs new file mode 100644 index 0000000000..a6caa65622 --- /dev/null +++ b/pacquet/crates/network-web-auth/src/with_otp_handling/tests.rs @@ -0,0 +1,623 @@ +use std::{ + cell::{Cell, RefCell}, + future::{self, Future}, + io, + pin::Pin, + rc::Rc, + task::{Context, Poll}, +}; + +use pacquet_diagnostics::miette::{self, Diagnostic}; +use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter}; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::sync::oneshot; + +use super::{ + OtpChallenge, OtpError, OtpErrorBody, SyntheticOtpError, WithOtpError, with_otp_handling, +}; +use crate::{ + capabilities::{ + Clock, EnterKeyListener, OpenUrl, PromptError, PromptOtp, Sleep, StdinIsTty, StdoutIsTty, + WebAuthFetch, WebAuthFetchError, + }, + poll_for_web_auth_token::{WebAuthFetchOptions, WebAuthFetchResponse}, +}; + +/// An operation error that is either an EOTP challenge or a plain failure, +/// so a single `Error` type covers both the OTP and non-OTP test paths. +#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)] +enum TestError { + #[display("otp challenge")] + Otp { body: Option }, + #[display("{_0}")] + Other(#[error(not(source))] String), +} + +impl OtpError for TestError { + fn as_otp_challenge(&self) -> Option { + match self { + TestError::Otp { body } => Some(OtpChallenge { body: body.clone() }), + TestError::Other(_) => None, + } + } +} + +/// What the [`PromptOtp`] fake returns for the classic-OTP prompt. +enum InputResponse { + Value(Option), + Cancelled, +} + +#[derive(Clone, Copy)] +enum SleepBehavior { + NoAdvance, + AdvanceByFixed(u64), +} + +type FetchScript = Box Result>; + +thread_local! { + static STDIN_TTY: Cell = const { Cell::new(true) }; + static STDOUT_TTY: Cell = const { Cell::new(true) }; + static TIME: Cell = const { Cell::new(0) }; + static SLEEP_BEHAVIOR: Cell = const { Cell::new(SleepBehavior::NoAdvance) }; + static FETCH: RefCell> = const { RefCell::new(None) }; + static INPUT: RefCell = const { RefCell::new(InputResponse::Value(None)) }; + static ENTER_TX: RefCell>> = const { RefCell::new(None) }; + static EMITTED: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct Fake; + +impl StdinIsTty for Fake { + fn stdin_is_tty() -> bool { + STDIN_TTY.with(Cell::get) + } +} + +impl StdoutIsTty for Fake { + fn stdout_is_tty() -> bool { + STDOUT_TTY.with(Cell::get) + } +} + +impl Clock for Fake { + fn now_ms() -> u64 { + TIME.with(Cell::get) + } +} + +impl Sleep for Fake { + fn sleep_ms(ms: u64) -> impl Future { + let _ = ms; + if let SleepBehavior::AdvanceByFixed(jump) = SLEEP_BEHAVIOR.with(Cell::get) { + TIME.with(|time| time.set(time.get().saturating_add(jump))); + } + future::ready(()) + } +} + +impl WebAuthFetch for Fake { + fn fetch( + _url: &str, + _options: &WebAuthFetchOptions, + ) -> impl Future> { + let result = FETCH + .with(|fetch| (fetch.borrow_mut().as_mut().expect("a fetch script must be set"))()); + future::ready(result) + } +} + +impl PromptOtp for Fake { + fn input(_message: &str) -> impl Future, PromptError>> { + let response = INPUT.with(|input| match &*input.borrow() { + InputResponse::Value(value) => Ok(value.clone()), + InputResponse::Cancelled => Err(PromptError::Cancelled), + }); + future::ready(response) + } +} + +impl OpenUrl for Fake { + fn open_url(_url: &str) -> io::Result<()> { + Ok(()) + } +} + +/// Never resolves on its own — in these tests the web-auth poll always +/// wins or times out before any Enter keypress. +struct PendingEnterHandle { + rx: oneshot::Receiver<()>, +} + +impl Future for PendingEnterHandle { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + Pin::new(&mut self.get_mut().rx).poll(cx).map(|_| ()) + } +} + +impl EnterKeyListener for Fake { + type Handle = PendingEnterHandle; + + fn listen() -> io::Result { + let (tx, rx) = oneshot::channel(); + ENTER_TX.with(|cell| *cell.borrow_mut() = Some(tx)); + Ok(PendingEnterHandle { rx }) + } +} + +struct RecordingReporter; + +impl Reporter for RecordingReporter { + fn emit(event: &LogEvent) { + if let LogEvent::Global(GlobalLog { level, message }) = event { + EMITTED.with(|emitted| emitted.borrow_mut().push((*level, message.clone()))); + } + } +} + +/// Panics on any global message — the stand-in for the TS `globalWarn` +/// that throws when a test expects no warning. +struct UnexpectedReporter; + +impl Reporter for UnexpectedReporter { + fn emit(event: &LogEvent) { + if let LogEvent::Global(GlobalLog { message, .. }) = event { + panic!("unexpected global message: {message}"); + } + } +} + +fn reset() { + STDIN_TTY.with(|tty| tty.set(true)); + STDOUT_TTY.with(|tty| tty.set(true)); + TIME.with(|time| time.set(0)); + SLEEP_BEHAVIOR.with(|behavior| behavior.set(SleepBehavior::NoAdvance)); + FETCH.with(|fetch| *fetch.borrow_mut() = None); + INPUT.with(|input| *input.borrow_mut() = InputResponse::Value(None)); + ENTER_TX.with(|cell| *cell.borrow_mut() = None); + EMITTED.with(|emitted| emitted.borrow_mut().clear()); +} + +fn set_input(response: InputResponse) { + INPUT.with(|input| *input.borrow_mut() = response); +} + +fn set_fetch(script: FetchScript) { + FETCH.with(|fetch| *fetch.borrow_mut() = Some(script)); +} + +fn infos() -> Vec { + messages_at(LogLevel::Info) +} + +fn warns() -> Vec { + messages_at(LogLevel::Warn) +} + +fn messages_at(level: LogLevel) -> Vec { + EMITTED.with(|emitted| { + emitted + .borrow() + .iter() + .filter(|(emitted_level, _)| *emitted_level == level) + .map(|(_, message)| message.clone()) + .collect() + }) +} + +fn ok_202() -> WebAuthFetchResponse { + WebAuthFetchResponse { ok: true, status: 202, retry_after: None, body: "{}".to_owned() } +} + +fn ok_token(token: &str) -> WebAuthFetchResponse { + WebAuthFetchResponse { + ok: true, + status: 200, + retry_after: None, + body: json!({ "token": token }).to_string(), + } +} + +fn web_auth_body() -> Option { + Some(OtpErrorBody { + auth_url: Some("https://registry.npmjs.org/auth/abc".to_owned()), + done_url: Some("https://registry.npmjs.org/auth/abc/done".to_owned()), + }) +} + +#[tokio::test] +async fn returns_the_result_when_the_operation_succeeds_without_otp() { + reset(); + + let result = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Ok("success".to_owned()), + ) + .await + .expect("a result"); + + assert_eq!(result, "success"); +} + +#[tokio::test] +async fn throws_non_otp_errors_as_is() { + reset(); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Other("network error".to_owned())), + ) + .await + .expect_err("an error"); + + assert!( + matches!(&error, WithOtpError::Operation(TestError::Other(message)) if message == "network error"), + "expected the original error, got {error:?}", + ); +} + +#[tokio::test] +async fn throws_non_interactive_error_when_stdin_is_not_interactive() { + reset(); + STDIN_TTY.with(|tty| tty.set(false)); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Otp { body: None }), + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::NonInteractive(_)), "got {error:?}"); +} + +#[tokio::test] +async fn throws_non_interactive_error_when_stdout_is_not_interactive() { + reset(); + STDOUT_TTY.with(|tty| tty.set(false)); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Otp { body: None }), + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::NonInteractive(_)), "got {error:?}"); +} + +#[tokio::test] +async fn classic_flow_prompts_for_otp_and_retries_operation() { + reset(); + set_input(InputResponse::Value(Some("654321".to_owned()))); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + + let result = with_otp_handling::( + WebAuthFetchOptions::default(), + async move |otp| { + counter.set(counter.get() + 1); + if counter.get() == 1 { + Err(TestError::Otp { body: None }) + } else { + assert_eq!(otp, Some("654321")); + Ok("ok".to_owned()) + } + }, + ) + .await + .expect("a result"); + + assert_eq!(result, "ok"); + assert_eq!(calls.get(), 2); +} + +#[tokio::test] +async fn classic_flow_throws_second_challenge_error_if_retry_also_requires_otp() { + reset(); + set_input(InputResponse::Value(Some("123456".to_owned()))); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Otp { body: None }), + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::SecondChallenge(_)), "got {error:?}"); +} + +#[tokio::test] +async fn classic_flow_throws_non_otp_errors_from_the_retry_as_is() { + reset(); + set_input(InputResponse::Value(Some("123456".to_owned()))); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async move |_otp| { + counter.set(counter.get() + 1); + if counter.get() == 1 { + Err(TestError::Otp { body: None }) + } else { + Err(TestError::Other("server error".to_owned())) + } + }, + ) + .await + .expect_err("an error"); + + assert!( + matches!(&error, WithOtpError::Operation(TestError::Other(message)) if message == "server error"), + "got {error:?}", + ); +} + +#[tokio::test] +async fn classic_flow_re_throws_the_original_otp_error_when_prompt_returns_empty() { + reset(); + set_input(InputResponse::Value(Some(String::new()))); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Otp { body: None }), + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::Operation(TestError::Otp { .. })), "got {error:?}"); +} + +#[tokio::test] +async fn classic_flow_re_throws_the_original_otp_error_when_prompt_returns_none() { + reset(); + set_input(InputResponse::Value(None)); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Otp { body: None }), + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::Operation(TestError::Otp { .. })), "got {error:?}"); +} + +#[tokio::test] +async fn classic_flow_re_throws_the_original_otp_error_when_prompt_is_cancelled() { + reset(); + set_input(InputResponse::Cancelled); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async |_otp| Err(TestError::Otp { body: None }), + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::Operation(TestError::Otp { .. })), "got {error:?}"); +} + +/// Unlike the TS test, which omits `createReadlineInterface` so the prompt +/// stays silent, pacquet's listener is always available — so the flow also +/// emits the "Press ENTER" line. The assertion checks that the auth URL +/// was surfaced and the token round-tripped, not the exact message count. +#[tokio::test] +async fn web_auth_flow_polls_done_url_and_uses_returned_token() { + reset(); + let fetch_calls = Rc::new(Cell::new(0)); + let fetch_counter = Rc::clone(&fetch_calls); + set_fetch(Box::new(move || { + fetch_counter.set(fetch_counter.get() + 1); + Ok(if fetch_counter.get() < 3 { ok_202() } else { ok_token("web-token-123") }) + })); + let op_calls = Rc::new(Cell::new(0)); + let op_counter = Rc::clone(&op_calls); + + let result = with_otp_handling::( + WebAuthFetchOptions::default(), + async move |otp| { + op_counter.set(op_counter.get() + 1); + if op_counter.get() == 1 { + Err(TestError::Otp { body: web_auth_body() }) + } else { + assert_eq!(otp, Some("web-token-123")); + Ok("published".to_owned()) + } + }, + ) + .await + .expect("a result"); + + assert_eq!(result, "published"); + assert_eq!(op_calls.get(), 2); + assert_eq!(fetch_calls.get(), 3); + assert!( + infos().iter().any(|message| message.contains("https://registry.npmjs.org/auth/abc")), + "the auth URL should be surfaced, got {:?}", + infos(), + ); +} + +#[tokio::test] +async fn web_auth_flow_falls_back_to_classic_prompt_when_only_auth_url_is_present() { + reset(); + set_input(InputResponse::Value(Some("manual-code".to_owned()))); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + + let result = with_otp_handling::( + WebAuthFetchOptions::default(), + async move |otp| { + counter.set(counter.get() + 1); + if counter.get() == 1 { + Err(TestError::Otp { + body: Some(OtpErrorBody { + auth_url: Some("https://registry.npmjs.org/auth/abc".to_owned()), + done_url: None, + }), + }) + } else { + assert_eq!(otp, Some("manual-code")); + Ok("done".to_owned()) + } + }, + ) + .await + .expect("a result"); + + assert_eq!(result, "done"); +} + +#[tokio::test] +async fn web_auth_flow_falls_back_to_classic_prompt_when_only_done_url_is_present() { + reset(); + set_input(InputResponse::Value(Some("manual-code".to_owned()))); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + + let result = with_otp_handling::( + WebAuthFetchOptions::default(), + async move |otp| { + counter.set(counter.get() + 1); + if counter.get() == 1 { + Err(TestError::Otp { + body: Some(OtpErrorBody { + auth_url: None, + done_url: Some("https://registry.npmjs.org/auth/abc/done".to_owned()), + }), + }) + } else { + assert_eq!(otp, Some("manual-code")); + Ok("done".to_owned()) + } + }, + ) + .await + .expect("a result"); + + assert_eq!(result, "done"); +} + +#[tokio::test] +async fn web_auth_flow_throws_timeout_error_when_polling_times_out() { + reset(); + SLEEP_BEHAVIOR.with(|behavior| behavior.set(SleepBehavior::AdvanceByFixed(6 * 60 * 1000))); + set_fetch(Box::new(|| Ok(ok_202()))); + let calls = Rc::new(Cell::new(0)); + let counter = Rc::clone(&calls); + + let error = with_otp_handling::( + WebAuthFetchOptions::default(), + async move |_otp| { + counter.set(counter.get() + 1); + assert_eq!(counter.get(), 1, "the operation must not be retried after a timeout"); + Err(TestError::Otp { body: web_auth_body() }) + }, + ) + .await + .expect_err("an error"); + + assert!(matches!(error, WithOtpError::Timeout(_)), "got {error:?}"); + assert!( + infos().iter().any(|message| message.contains("https://registry.npmjs.org/auth/abc")), + "the auth URL should be surfaced, got {:?}", + infos(), + ); +} + +#[test] +fn synthetic_otp_error_is_an_otp_challenge() { + let error = SyntheticOtpError::new(web_auth_body()); + assert!(error.as_otp_challenge().is_some()); +} + +#[test] +fn synthetic_otp_error_stores_body() { + let body = OtpErrorBody { + auth_url: Some("https://example.com/auth".to_owned()), + done_url: Some("https://example.com/done".to_owned()), + }; + let error = SyntheticOtpError::new(Some(body.clone())); + assert_eq!(error.as_otp_challenge().expect("a challenge").body, Some(body)); +} + +#[test] +fn from_unknown_body_extracts_valid_string_auth_url_and_done_url() { + let error = SyntheticOtpError::from_unknown_body::(Some( + &json!({ "authUrl": "https://example.com/auth", "doneUrl": "https://example.com/done" }), + )); + assert_eq!( + error.as_otp_challenge().expect("a challenge").body, + Some(OtpErrorBody { + auth_url: Some("https://example.com/auth".to_owned()), + done_url: Some("https://example.com/done".to_owned()), + }), + ); +} + +#[test] +fn from_unknown_body_returns_no_body_when_body_is_null() { + let error = SyntheticOtpError::from_unknown_body::(Some(&json!(null))); + assert_eq!(error.as_otp_challenge().expect("a challenge").body, None); +} + +#[test] +fn from_unknown_body_returns_no_body_when_body_is_not_an_object() { + let error = + SyntheticOtpError::from_unknown_body::(Some(&json!("not an object"))); + assert_eq!(error.as_otp_challenge().expect("a challenge").body, None); +} + +#[test] +fn from_unknown_body_warns_when_auth_url_has_wrong_type() { + reset(); + let error = SyntheticOtpError::from_unknown_body::(Some( + &json!({ "authUrl": 123, "doneUrl": "https://example.com/done" }), + )); + assert!(warns().iter().any(|message| message.contains("authUrl")), "got {:?}", warns()); + let body = error.as_otp_challenge().expect("a challenge").body.expect("a body"); + assert_eq!(body.auth_url, None); + assert_eq!(body.done_url, Some("https://example.com/done".to_owned())); +} + +#[test] +fn from_unknown_body_warns_when_done_url_has_wrong_type() { + reset(); + let error = SyntheticOtpError::from_unknown_body::(Some( + &json!({ "authUrl": "https://example.com/auth", "doneUrl": true }), + )); + assert!(warns().iter().any(|message| message.contains("doneUrl")), "got {:?}", warns()); + let body = error.as_otp_challenge().expect("a challenge").body.expect("a body"); + assert_eq!(body.auth_url, Some("https://example.com/auth".to_owned())); + assert_eq!(body.done_url, None); +} + +#[test] +fn from_unknown_body_warns_for_both_when_both_have_wrong_types() { + reset(); + let error = SyntheticOtpError::from_unknown_body::(Some( + &json!({ "authUrl": 42, "doneUrl": false }), + )); + assert!(warns().iter().any(|message| message.contains("authUrl")), "got {:?}", warns()); + assert!(warns().iter().any(|message| message.contains("doneUrl")), "got {:?}", warns()); + let body = error.as_otp_challenge().expect("a challenge").body.expect("a body"); + assert_eq!(body.auth_url, None); + assert_eq!(body.done_url, None); +} + +#[test] +fn from_unknown_body_returns_empty_body_when_no_auth_url_or_done_url() { + let error = SyntheticOtpError::from_unknown_body::(Some( + &json!({ "something": "else" }), + )); + assert_eq!( + error.as_otp_challenge().expect("a challenge").body, + Some(OtpErrorBody { auth_url: None, done_url: None }), + ); +} diff --git a/pacquet/crates/reporter/src/lib.rs b/pacquet/crates/reporter/src/lib.rs index 38a23cc293..b33618fac6 100644 --- a/pacquet/crates/reporter/src/lib.rs +++ b/pacquet/crates/reporter/src/lib.rs @@ -205,6 +205,17 @@ pub enum LogEvent { #[serde(rename = "pnpm")] Pnpm(PnpmLog), + /// Global-logger message (`name: "pnpm:global"`). Mirrors pnpm's + /// [`globalInfo` / `globalWarn`](https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/core/logger/src/logger.ts#L15-L23), + /// which write to a `bole('pnpm:global')` logger with just a message + /// string — no `prefix`, unlike [`LogEvent::Pnpm`]. The interactive + /// web-authentication flow (`pacquet-network-web-auth`) emits on this + /// channel to surface the auth URL / QR code and the browser-open + /// prompts. `@pnpm/cli.default-reporter` routes these into the "other" + /// log stream. + #[serde(rename = "pnpm:global")] + Global(GlobalLog), + /// One per `context.log(...)` call a pnpmfile hook makes while it /// runs (`pnpm:hook`). `readPackage` and `afterAllResolved` hooks /// receive a `context` whose `log` forwards here, so a pnpmfile can @@ -799,6 +810,15 @@ pub struct PnpmLog { pub prefix: String, } +/// Global-channel (`name: "pnpm:global"`) payload. Carries only a +/// severity and a message — pnpm's `bole('pnpm:global')` logger takes a +/// bare string, with no `prefix`, so this struct has none either. +#[derive(Debug, Clone, Serialize)] +pub struct GlobalLog { + pub level: LogLevel, + pub message: String, +} + /// `pnpm:hook` payload. Field names match pnpm's `HookMessage` so /// `@pnpm/cli.default-reporter` accepts the record unchanged. `from` /// is the pnpmfile that defined the hook, `hook` is the hook name diff --git a/pacquet/crates/reporter/src/tests.rs b/pacquet/crates/reporter/src/tests.rs index bf38bed39a..c0117586b5 100644 --- a/pacquet/crates/reporter/src/tests.rs +++ b/pacquet/crates/reporter/src/tests.rs @@ -6,11 +6,11 @@ use serde_json::Value; use crate::{ AddedRoot, BrokenModulesLog, ContextLog, DependencyType, Envelope, FetchingProgressLog, - FetchingProgressMessage, GetHostName, HookLog, Host, IgnoredScriptsLog, LifecycleLog, - LifecycleMessage, LifecycleStdio, LockfileVerificationLog, LockfileVerificationMessage, - LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog, PackageManifestLog, - PackageManifestMessage, PnpmLog, ProgressLog, ProgressMessage, RemovedRoot, Reporter, - RequestRetryError, RequestRetryLog, RootLog, RootMessage, SilentReporter, + FetchingProgressMessage, GetHostName, GlobalLog, HookLog, Host, IgnoredScriptsLog, + LifecycleLog, LifecycleMessage, LifecycleStdio, LockfileVerificationLog, + LockfileVerificationMessage, LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog, + PackageManifestLog, PackageManifestMessage, PnpmLog, ProgressLog, ProgressMessage, RemovedRoot, + Reporter, RequestRetryError, RequestRetryLog, RootLog, RootMessage, SilentReporter, SkippedOptionalDependencyLog, SkippedOptionalPackage, SkippedOptionalReason, Stage, StageLog, StatsLog, StatsMessage, SummaryLog, }; @@ -121,6 +121,33 @@ fn pnpm_event_matches_pnpm_wire_shape() { assert_eq!(json["prefix"], "/some/project"); } +/// Global-channel (`name: "pnpm:global"`) log carries the +/// `pnpm:global` channel name and a bare `message` with no `prefix` — +/// matching pnpm's `bole('pnpm:global')` writes. A `prefix` field would +/// diverge from the upstream shape `@pnpm/cli.default-reporter` parses. +#[test] +fn global_event_matches_pnpm_wire_shape() { + let event = LogEvent::Global(GlobalLog { + level: LogLevel::Info, + message: "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc".to_string(), + }); + let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event }; + + let json: Value = envelope + .pipe_ref(serde_json::to_string) + .expect("serialize envelope") + .pipe_as_ref(serde_json::from_str) + .expect("parse JSON"); + + assert_eq!(json["name"], "pnpm:global"); + assert_eq!(json["level"], "info"); + assert_eq!( + json["message"], + "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc", + ); + assert!(json.get("prefix").is_none(), "pnpm:global must not carry a prefix, got {json:?}"); +} + /// Hook log (`name: "pnpm:hook"`) carries the `from` / `hook` / /// `message` / `prefix` fields pnpm's `hookLogger` emits, at the /// `debug` level the hook-context logger uses. `@pnpm/cli.default-reporter`