diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index d39a8cf34b..db0d1ab9f3 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -6,6 +6,7 @@ pub mod cat_file; pub mod cat_index; pub mod create; pub mod dedupe; +pub mod dist_tag; pub mod dlx; pub mod exec; pub mod fetch; @@ -51,6 +52,7 @@ use cat_index::CatIndexArgs; use clap::{Parser, Subcommand, ValueEnum}; use create::CreateArgs; use dedupe::DedupeArgs; +use dist_tag::DistTagArgs; use dlx::DlxArgs; use exec::ExecArgs; use fetch::FetchArgs; @@ -196,6 +198,9 @@ pub enum CliCommand { Why(WhyArgs), /// Displays your pnpm username. Whoami, + /// Manage a package's distribution tags. + #[clap(name = "dist-tag", visible_alias = "dist-tags")] + DistTag(DistTagArgs), /// Rebuild a package. #[clap(visible_alias = "rb")] Rebuild(RebuildArgs), @@ -537,6 +542,19 @@ impl CliArgs { Ok(()) }) } + CliCommand::DistTag(args) => { + let cfg: &Config = config()?; + Box::pin(async move { + if let Some(output) = args.run(cfg).await? { + let output = sanitize::sanitize(&output); + if output.is_empty() { + return Ok(()); + } + println!("{output}"); + } + Ok(()) + }) + } // `pack` prints the tarball summary (or JSON) its handler // returns; the reporter type only affects the lifecycle-script // output, so it's threaded into `run` and the result printed diff --git a/pacquet/crates/cli/src/cli_args/dist_tag.rs b/pacquet/crates/cli/src/cli_args/dist_tag.rs new file mode 100644 index 0000000000..9d27d8db63 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/dist_tag.rs @@ -0,0 +1,684 @@ +use super::sanitize; +use clap::Args; +use derive_more::{Display, Error}; +use futures_util::StreamExt as _; +use miette::{Context, Diagnostic, IntoDiagnostic}; +use node_semver::Version; +use pacquet_config::Config; +use pacquet_network::{ + NetworkSettings, RetryOpts, ThrottledClient, redact_url_credentials, retry_async, + send_with_retry, +}; +use pacquet_resolving_npm_resolver::pick_registry_for_package; +use pacquet_resolving_parse_wanted_dependency::parse_wanted_dependency; +use reqwest::{RequestBuilder, Response, StatusCode}; +use serde::Deserialize; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::Write as _, + time::Duration, +}; + +const DIST_TAGS_BODY_LIMIT: usize = 1024 * 1024; +const DIST_TAG_ERROR_BODY_LIMIT: usize = 64 * 1024; + +#[derive(Debug, Args)] +pub struct DistTagArgs { + /// The base URL of the npm registry. + #[clap(long)] + pub registry: Option, + + /// One-time password for registries that require two-factor authentication. + #[clap(long)] + pub otp: Option, + + /// dist-tag subcommand and arguments. + pub params: Vec, +} + +#[derive(Debug, Display, Error, Diagnostic)] +#[non_exhaustive] +pub enum DistTagError { + #[display("Package name is required")] + #[diagnostic(code(ERR_PNPM_DIST_TAG_LS_PACKAGE_REQUIRED))] + LsPackageRequired, + + #[display("Package name and version are required (e.g., pnpm dist-tag add pkg@1.0.0 latest)")] + #[diagnostic(code(ERR_PNPM_DIST_TAG_ADD_SPEC_REQUIRED))] + AddSpecRequired, + + #[display("Version is required (e.g., pnpm dist-tag add pkg@1.0.0 latest)")] + #[diagnostic(code(ERR_PNPM_DIST_TAG_ADD_VERSION_REQUIRED))] + AddVersionRequired, + + #[display(r#"Version must be an exact semver version, got "{version}""#)] + #[diagnostic(code(ERR_PNPM_DIST_TAG_ADD_INVALID_VERSION))] + AddInvalidVersion { + #[error(not(source))] + version: String, + }, + + #[display("Package name and tag are required (e.g., pnpm dist-tag rm pkg tag)")] + #[diagnostic(code(ERR_PNPM_DIST_TAG_RM_ARGS_REQUIRED))] + RmArgsRequired, + + #[display(r#"Removing the "latest" dist-tag is not allowed"#)] + #[diagnostic(code(ERR_PNPM_DIST_TAG_RM_LATEST))] + RmLatest, + + #[display(r#"dist-tag "{tag}" is not set on package "{package_name}""#)] + #[diagnostic(code(ERR_PNPM_DIST_TAG_NOT_FOUND))] + DistTagNotFound { + #[error(not(source))] + tag: String, + #[error(not(source))] + package_name: String, + }, + + #[display(r#"Package "{package_name}" not found in registry"#)] + #[diagnostic(code(ERR_PNPM_PACKAGE_NOT_FOUND))] + PackageNotFound { + #[error(not(source))] + package_name: String, + }, + + #[display("Invalid package spec: {spec}")] + #[diagnostic(code(ERR_PNPM_INVALID_PACKAGE_SPEC))] + InvalidPackageSpec { + #[error(not(source))] + spec: String, + }, + + #[display("You must be logged in to {action} packages. {body}")] + #[diagnostic(code(ERR_PNPM_UNAUTHORIZED))] + Unauthorized { + #[error(not(source))] + action: String, + #[error(not(source))] + body: String, + }, + + #[display("You do not have permission to {action} this package. {body}")] + #[diagnostic(code(ERR_PNPM_FORBIDDEN))] + Forbidden { + #[error(not(source))] + action: String, + #[error(not(source))] + body: String, + }, + + #[display( + "This registry requires web-based OTP to {action} packages. Open {auth_url}, wait for {done_url} to finish, then rerun with --otp ." + )] + #[diagnostic(code(ERR_PNPM_DIST_TAG_WEB_OTP_REQUIRED))] + WebOtpRequired { + #[error(not(source))] + action: String, + #[error(not(source))] + auth_url: String, + #[error(not(source))] + done_url: String, + }, + + #[display("Failed to {action} package: {status} {status_text}. {body}")] + #[diagnostic(code(ERR_PNPM_REGISTRY_ERROR))] + RegistryWriteFailed { + #[error(not(source))] + action: String, + status: u16, + #[error(not(source))] + status_text: String, + #[error(not(source))] + body: String, + }, + + #[display("Failed to fetch package info: {status} {status_text}")] + #[diagnostic(code(ERR_PNPM_REGISTRY_ERROR))] + RegistryFetchFailed { + status: u16, + #[error(not(source))] + status_text: String, + }, + + #[display("Failed to {operation}: {reason}")] + #[diagnostic(code(ERR_PNPM_REGISTRY_ERROR))] + RegistryOperationFailed { + #[error(not(source))] + operation: &'static str, + #[error(not(source))] + reason: String, + }, + + #[display("Registry response for {resource} exceeded {limit} bytes")] + #[diagnostic(code(ERR_PNPM_REGISTRY_RESPONSE_TOO_LARGE))] + RegistryResponseTooLarge { + #[error(not(source))] + resource: &'static str, + limit: usize, + }, +} + +#[derive(Clone, Copy)] +enum AuthType { + Legacy, + Web, +} + +struct DistTagContext<'a> { + config: &'a Config, + http_client: ThrottledClient, + retry_opts: RetryOpts, + registries: HashMap, + otp: Option, +} + +struct PackageSpec { + name: String, + version: Option, +} + +impl DistTagArgs { + pub async fn run(self, config: &Config) -> miette::Result> { + let context = self.context(config)?; + let Some(subcommand) = self.params.first().map(String::as_str) else { + return dist_tag_ls(&context, &[]).await.map(Some); + }; + match subcommand { + "add" => dist_tag_add(&context, &self.params[1..]).await.map(Some), + "rm" => dist_tag_rm(&context, &self.params[1..]).await.map(Some), + "ls" | "list" => dist_tag_ls(&context, &self.params[1..]).await.map(Some), + _ => dist_tag_ls(&context, &self.params).await.map(Some), + } + } + + fn context<'config>(&self, config: &'config Config) -> miette::Result> { + let mut registries: HashMap = + config.resolved_registries().into_iter().collect(); + if let Some(registry) = &self.registry { + registries.insert("default".to_string(), normalize_registry_url(registry)); + } + Ok(DistTagContext { + config, + http_client: build_http_client(config)?, + 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), + }, + registries, + otp: self.otp.clone(), + }) + } +} + +async fn dist_tag_ls(context: &DistTagContext<'_>, params: &[String]) -> miette::Result { + let package_name = params.first().ok_or(DistTagError::LsPackageRequired)?; + let package_name = package_name_for_url(package_name)?; + let registry_url = registry_for_package(context, &package_name); + let auth_header = auth_header_for_registry(context, ®istry_url, &package_name); + let dist_tags = + fetch_dist_tags(context, &package_name, ®istry_url, auth_header.as_deref()).await?; + let mut lines = Vec::with_capacity(dist_tags.len()); + for (tag, version) in dist_tags { + lines.push(format!("{tag}: {version}")); + } + Ok(lines.join("\n")) +} + +async fn dist_tag_add(context: &DistTagContext<'_>, params: &[String]) -> miette::Result { + let spec = params.first().ok_or(DistTagError::AddSpecRequired)?; + let PackageSpec { name: package_name, version } = parse_package_spec(spec)?; + let raw_version = version.ok_or(DistTagError::AddVersionRequired)?; + let Some(version) = normalize_exact_semver(&raw_version) else { + return Err(DistTagError::AddInvalidVersion { version: raw_version }.into()); + }; + let tag = params.get(1).map_or("latest", String::as_str); + let registry_url = registry_for_package(context, &package_name); + let auth_header = auth_header_for_registry(context, ®istry_url, &package_name); + let auth_type = if context.otp.is_some() { AuthType::Legacy } else { AuthType::Web }; + set_dist_tag( + context, + SetDistTagRequest { + package_name: &package_name, + version: &version, + tag, + registry_url: ®istry_url, + auth_header: auth_header.as_deref(), + auth_type, + otp: context.otp.as_deref(), + }, + ) + .await?; + Ok(format!("+{tag}: {package_name}@{version}")) +} + +async fn dist_tag_rm(context: &DistTagContext<'_>, params: &[String]) -> miette::Result { + if params.len() < 2 { + return Err(DistTagError::RmArgsRequired.into()); + } + let package_name = package_name_for_url(¶ms[0])?; + let tag = ¶ms[1]; + if tag == "latest" { + return Err(DistTagError::RmLatest.into()); + } + let registry_url = registry_for_package(context, &package_name); + let auth_header = auth_header_for_registry(context, ®istry_url, &package_name); + let dist_tags = + fetch_dist_tags(context, &package_name, ®istry_url, auth_header.as_deref()).await?; + let version = dist_tags.get(tag).ok_or_else(|| DistTagError::DistTagNotFound { + tag: tag.clone(), + package_name: package_name.clone(), + })?; + let auth_type = if context.otp.is_some() { AuthType::Legacy } else { AuthType::Web }; + delete_dist_tag( + context, + DeleteDistTagRequest { + package_name: &package_name, + tag, + registry_url: ®istry_url, + auth_header: auth_header.as_deref(), + auth_type, + otp: context.otp.as_deref(), + }, + ) + .await?; + Ok(format!("-{tag}: {package_name}@{version}")) +} + +struct SetDistTagRequest<'a> { + package_name: &'a str, + version: &'a str, + tag: &'a str, + registry_url: &'a str, + auth_header: Option<&'a str>, + auth_type: AuthType, + otp: Option<&'a str>, +} + +async fn set_dist_tag( + context: &DistTagContext<'_>, + request: SetDistTagRequest<'_>, +) -> miette::Result<()> { + let url = dist_tag_url(request.package_name, request.registry_url, request.tag)?; + let body = serde_json::to_string(request.version).expect("a string serializes"); + let (_guard, response) = + send_with_retry(&context.http_client, &url, context.retry_opts, |client| { + let builder = + client.put(&url).header("content-type", "application/json").body(body.clone()); + apply_dist_tag_mutation_headers( + builder, + request.auth_header, + request.auth_type, + request.otp, + ) + }) + .await + .map_err(|source| { + registry_operation_error("requesting the registry dist-tag endpoint", source) + })?; + if response.status().is_success() { + return Ok(()); + } + let action = format!(r#"set dist-tag "{}" on"#, request.tag); + write_error_from_response(response, action).await +} + +struct DeleteDistTagRequest<'a> { + package_name: &'a str, + tag: &'a str, + registry_url: &'a str, + auth_header: Option<&'a str>, + auth_type: AuthType, + otp: Option<&'a str>, +} + +async fn delete_dist_tag( + context: &DistTagContext<'_>, + request: DeleteDistTagRequest<'_>, +) -> miette::Result<()> { + let url = dist_tag_url(request.package_name, request.registry_url, request.tag)?; + let (_guard, response) = + send_with_retry(&context.http_client, &url, context.retry_opts, |client| { + apply_dist_tag_mutation_headers( + client.delete(&url), + request.auth_header, + request.auth_type, + request.otp, + ) + }) + .await + .map_err(|source| { + registry_operation_error("requesting the registry dist-tag endpoint", source) + })?; + if response.status().is_success() { + return Ok(()); + } + let action = format!(r#"remove dist-tag "{}" from"#, request.tag); + write_error_from_response(response, action).await +} + +fn apply_dist_tag_mutation_headers( + mut builder: RequestBuilder, + auth_header: Option<&str>, + auth_type: AuthType, + otp: Option<&str>, +) -> RequestBuilder { + builder = builder.header("npm-auth-type", auth_type.header_value()); + if let Some(auth_header) = auth_header { + builder = builder.header("authorization", auth_header); + } + if let Some(otp) = otp { + builder = builder.header("npm-otp", otp); + } + builder +} + +async fn fetch_dist_tags( + context: &DistTagContext<'_>, + package_name: &str, + registry_url: &str, + auth_header: Option<&str>, +) -> miette::Result> { + let url = dist_tags_url(package_name, registry_url)?; + retry_async(&url, context.retry_opts, DistTagsFetchError::is_retryable, || async { + fetch_dist_tags_once(context, &url, auth_header).await + }) + .await + .map_err(|error| map_dist_tags_fetch_error(error, package_name)) +} + +async fn fetch_dist_tags_once( + context: &DistTagContext<'_>, + url: &str, + auth_header: Option<&str>, +) -> Result, DistTagsFetchError> { + let (_guard, response) = + send_with_retry(&context.http_client, url, context.retry_opts, |client| { + let mut builder = client.get(url); + if let Some(auth_header) = auth_header { + builder = builder.header("authorization", auth_header); + } + builder + }) + .await + .map_err(DistTagsFetchError::Request)?; + if response.status() == StatusCode::NOT_FOUND { + return Err(DistTagsFetchError::NotFound); + } + if !response.status().is_success() { + return Err(DistTagsFetchError::Status { status: response.status() }); + } + if response.content_length().is_some_and(|length| length > DIST_TAGS_BODY_LIMIT as u64) { + return Err(DistTagsFetchError::BodyTooLarge); + } + let body = read_limited_body(response, DIST_TAGS_BODY_LIMIT) + .await + .map_err(DistTagsFetchError::Body)?; + if body.truncated { + return Err(DistTagsFetchError::BodyTooLarge); + } + serde_json::from_slice(&body.bytes).map_err(DistTagsFetchError::InvalidJson) +} + +async fn write_error_from_response(response: Response, action: String) -> miette::Result<()> { + let status = response.status(); + let status_text = status.canonical_reason().unwrap_or_default().to_string(); + let body = read_limited_body(response, DIST_TAG_ERROR_BODY_LIMIT).await.map_err(|source| { + registry_operation_error("reading the registry dist-tag error response", source) + })?; + let web_otp_challenge = + if body.truncated { None } else { parse_web_otp_challenge(&body.bytes) }; + let body = body.into_display_string(); + if status == StatusCode::UNAUTHORIZED { + if let Some(challenge) = web_otp_challenge { + return Err(DistTagError::WebOtpRequired { + action, + auth_url: sanitize::sanitize(&challenge.auth_url).into_owned(), + done_url: sanitize::sanitize(&challenge.done_url).into_owned(), + } + .into()); + } + return Err(DistTagError::Unauthorized { action, body }.into()); + } + if status == StatusCode::FORBIDDEN { + return Err(DistTagError::Forbidden { action, body }.into()); + } + Err(DistTagError::RegistryWriteFailed { action, status: status.as_u16(), status_text, body } + .into()) +} + +#[derive(Debug)] +enum DistTagsFetchError { + Request(reqwest::Error), + Body(reqwest::Error), + InvalidJson(serde_json::Error), + BodyTooLarge, + NotFound, + Status { status: StatusCode }, +} + +impl DistTagsFetchError { + fn is_retryable(&self) -> bool { + matches!(self, Self::Body(_) | Self::InvalidJson(_)) + } +} + +fn map_dist_tags_fetch_error(error: DistTagsFetchError, package_name: &str) -> miette::Report { + match error { + DistTagsFetchError::Request(error) => { + registry_operation_error("requesting the registry dist-tags endpoint", error) + } + DistTagsFetchError::Body(error) => { + registry_operation_error("reading the dist-tags response", error) + } + DistTagsFetchError::InvalidJson(error) => { + registry_operation_error("parsing the dist-tags response", error) + } + DistTagsFetchError::BodyTooLarge => DistTagError::RegistryResponseTooLarge { + resource: "dist-tags", + limit: DIST_TAGS_BODY_LIMIT, + } + .into(), + DistTagsFetchError::NotFound => { + DistTagError::PackageNotFound { package_name: package_name.to_string() }.into() + } + DistTagsFetchError::Status { status } => DistTagError::RegistryFetchFailed { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or_default().to_string(), + } + .into(), + } +} + +fn registry_operation_error(operation: &'static str, error: ErrorType) -> miette::Report +where + ErrorType: std::fmt::Display, +{ + DistTagError::RegistryOperationFailed { + operation, + reason: redact_url_credentials(&error.to_string()), + } + .into() +} + +struct LimitedBody { + bytes: Vec, + truncated: bool, +} + +impl LimitedBody { + fn into_display_string(self) -> String { + let body = String::from_utf8_lossy(&self.bytes); + let mut body = sanitize::sanitize(&body).into_owned(); + if self.truncated { + if !body.is_empty() && !body.chars().next_back().is_some_and(char::is_whitespace) { + body.push(' '); + } + body.push_str("(response body truncated)"); + } + body + } +} + +async fn read_limited_body( + response: Response, + limit: usize, +) -> Result { + let header_exceeds_limit = + response.content_length().is_some_and(|length| length > limit as u64); + let mut bytes = Vec::new(); + let mut truncated = header_exceeds_limit; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + let remaining = limit.saturating_sub(bytes.len()); + if chunk.len() > remaining { + bytes.extend_from_slice(&chunk[..remaining]); + truncated = true; + break; + } + bytes.extend_from_slice(&chunk); + } + Ok(LimitedBody { bytes, truncated }) +} + +#[derive(Deserialize)] +struct WebOtpChallenge { + #[serde(rename = "authUrl")] + auth_url: String, + #[serde(rename = "doneUrl")] + done_url: String, +} + +fn parse_web_otp_challenge(body: &[u8]) -> Option { + let challenge: WebOtpChallenge = serde_json::from_slice(body).ok()?; + Some(WebOtpChallenge { + auth_url: display_safe_web_otp_url(&challenge.auth_url)?, + done_url: display_safe_web_otp_url(&challenge.done_url)?, + }) +} + +fn display_safe_web_otp_url(value: &str) -> Option { + if value.chars().any(char::is_control) { + return None; + } + let url = reqwest::Url::parse(value).ok()?; + match url.scheme() { + "http" | "https" => Some(redact_url_credentials(url.as_str())), + _ => None, + } +} + +fn registry_for_package(context: &DistTagContext<'_>, package_name: &str) -> String { + pick_registry_for_package(&context.registries, package_name, None) +} + +fn auth_header_for_registry( + context: &DistTagContext<'_>, + registry_url: &str, + package_name: &str, +) -> Option { + context.config.auth_headers.for_url_with_package(registry_url, Some(package_name)) +} + +fn build_http_client(config: &Config) -> miette::Result { + ThrottledClient::for_installs( + &config.proxy, + &config.tls, + &config.tls_by_uri, + &NetworkSettings { + network_concurrency: config.network_concurrency, + fetch_timeout: Duration::from_millis(config.fetch_timeout), + user_agent: config.user_agent.clone(), + }, + ) + .into_diagnostic() + .wrap_err("create the network client for dist-tag") +} + +fn parse_package_spec(spec: &str) -> Result { + let parsed = parse_wanted_dependency(spec); + let name = + parsed.alias.ok_or_else(|| DistTagError::InvalidPackageSpec { spec: spec.to_string() })?; + let version = parsed.bare_specifier.filter(|version| !version.is_empty()); + Ok(PackageSpec { name, version }) +} + +fn normalize_exact_semver(version: &str) -> Option { + if let Some(version) = version.strip_prefix('v') + && Version::parse(version).is_ok() + { + return Some(version.to_string()); + } + if Version::parse(version).is_ok() { + return Some(version.to_string()); + } + None +} + +fn dist_tags_url(package_name: &str, registry_url: &str) -> miette::Result { + let package_name = package_name_for_url(package_name)?; + registry_endpoint_url( + registry_url, + &format!("-/package/{}/dist-tags", escaped_package_name(&package_name)), + ) +} + +fn dist_tag_url(package_name: &str, registry_url: &str, tag: &str) -> miette::Result { + let package_name = package_name_for_url(package_name)?; + registry_endpoint_url( + registry_url, + &format!( + "-/package/{}/dist-tags/{}", + escaped_package_name(&package_name), + encode_uri_component(tag), + ), + ) +} + +fn package_name_for_url(package_name: &str) -> Result { + parse_wanted_dependency(package_name) + .alias + .ok_or_else(|| DistTagError::InvalidPackageSpec { spec: package_name.to_string() }) +} + +fn registry_endpoint_url(registry_url: &str, path: &str) -> miette::Result { + reqwest::Url::parse(&normalize_registry_url(registry_url)) + .and_then(|url| url.join(path)) + .map(|url| url.to_string()) + .map_err(|source| registry_operation_error("build registry dist-tag URL", source)) +} + +fn normalize_registry_url(registry_url: &str) -> String { + if registry_url.ends_with('/') { registry_url.to_string() } else { format!("{registry_url}/") } +} + +fn escaped_package_name(package_name: &str) -> String { + match package_name.strip_prefix('@') { + Some(rest) => format!("@{}", encode_uri_component(rest).replace("%2F", "%2f")), + None => encode_uri_component(package_name), + } +} + +fn encode_uri_component(input: &str) -> String { + const UNRESERVED: &[u8] = b"-_.!~*'()"; + let mut output = String::with_capacity(input.len()); + for &byte in input.as_bytes() { + if byte.is_ascii_alphanumeric() || UNRESERVED.contains(&byte) { + output.push(byte as char); + } else { + write!(output, "%{byte:02X}").expect("writing to a String never fails"); + } + } + output +} + +impl AuthType { + fn header_value(self) -> &'static str { + match self { + AuthType::Legacy => "legacy", + AuthType::Web => "web", + } + } +} diff --git a/pacquet/crates/cli/tests/dist_tag.rs b/pacquet/crates/cli/tests/dist_tag.rs new file mode 100644 index 0000000000..3ca9499ca3 --- /dev/null +++ b/pacquet/crates/cli/tests/dist_tag.rs @@ -0,0 +1,757 @@ +use assert_cmd::prelude::*; +use command_extra::CommandExtra; +use mockito::Matcher; +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) +} + +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('/')) +} + +fn configure(root: &Path, workspace: &Path, registry: &str, auth_file_contents: &str) -> PathBuf { + fs::write(workspace.join(".npmrc"), format!("registry={registry}\n")) + .expect("write project .npmrc"); + let auth_file = root.join("auth-npmrc"); + fs::write(&auth_file, auth_file_contents).expect("write auth .npmrc"); + auth_file +} + +fn run_dist_tag(workspace: &Path, auth_file: &Path, args: &[&str]) -> std::process::Output { + run_dist_tag_command(workspace, auth_file, "dist-tag", args) +} + +fn run_dist_tag_command( + workspace: &Path, + auth_file: &Path, + command: &str, + args: &[&str], +) -> std::process::Output { + pacquet_at(workspace) + .with_arg("--npmrc-auth-file") + .with_arg(auth_file) + .with_arg(command) + .with_args(args) + .output() + .expect("spawn pacquet dist-tag") +} + +#[test] +fn lists_dist_tags_sorted() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"2.0.0","beta":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag ls must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "beta: 1.0.0\nlatest: 2.0.0\n"); + drop((root, server)); +} + +#[test] +fn ls_sanitizes_registry_control_chars() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"\u001b[31mlatest":"1.0.0\u001b[0m"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag ls must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains('\u{1b}'), "stdout must not include raw escape bytes: {stdout:?}"); + assert_eq!(stdout, "[31mlatest: 1.0.0[0m\n"); + drop((root, server)); +} + +#[test] +fn lists_no_output_when_no_dist_tags_exist() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = + server.mock("GET", "/-/package/pkg/dist-tags").with_status(200).with_body("{}").create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag ls must succeed for empty tags (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), ""); + drop((root, server)); +} + +#[test] +fn lists_dist_tags_without_subcommand() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag default ls must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn list_alias_lists_dist_tags() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["list", "pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag list alias must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn ls_uses_package_name_from_versioned_spec_for_url() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "pkg@1.0.0"]); + + mock.assert(); + assert!( + output.status.success(), + "versioned ls package specs must query the package name (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn ls_uses_clean_package_name_for_versioned_scoped_auth() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/@scope%2fpkg/dist-tags") + .match_header("authorization", "Bearer scoped-token") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file_contents = format!( + "{}:_authToken=default-token\n{}:@scope:_authToken=scoped-token\n", + nerf(®istry), + nerf(®istry), + ); + let auth_file = configure(root.path(), &workspace, ®istry, &auth_file_contents); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "@scope/pkg@1.0.0"]); + + mock.assert(); + assert!( + output.status.success(), + "versioned scoped ls specs must use scoped auth (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn dist_tags_alias_lists_dist_tags() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag_command(&workspace, &auth_file, "dist-tags", &["ls", "pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tags alias must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn dist_tags_alias_defaults_to_listing() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag_command(&workspace, &auth_file, "dist-tags", &["pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tags alias default ls must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn registry_option_overrides_config_registry() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, "http://127.0.0.1:1/", ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "pkg", "--registry", ®istry]); + + mock.assert(); + assert!( + output.status.success(), + "--registry must override config registry (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn ls_uses_package_scoped_auth() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/@scope%2fpkg/dist-tags") + .match_header("authorization", "Bearer scoped-token") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file_contents = format!( + "{}:_authToken=default-token\n{}:@scope:_authToken=scoped-token\n", + nerf(®istry), + nerf(®istry), + ); + let auth_file = configure(root.path(), &workspace, ®istry, &auth_file_contents); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "@scope/pkg"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag ls must succeed with scoped auth (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "latest: 1.0.0\n"); + drop((root, server)); +} + +#[test] +fn ls_redacts_registry_credentials_on_network_error() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let auth_file = configure(root.path(), &workspace, "http://127.0.0.1:1/", ""); + + let output = pacquet_at(&workspace) + .with_env("PNPM_CONFIG_FETCH_RETRIES", "0") + .with_env("PNPM_CONFIG_FETCH_TIMEOUT", "100") + .with_arg("--npmrc-auth-file") + .with_arg(&auth_file) + .with_arg("dist-tag") + .with_args(["ls", "pkg", "--registry", "http://user:pass@127.0.0.1:1/"]) + .output() + .expect("spawn pacquet dist-tag"); + + assert!(!output.status.success(), "transport failure must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("ERR_PNPM_REGISTRY_ERROR"), "stderr:\n{stderr}"); + assert!(!stderr.contains("user:pass"), "credentials leaked into stderr:\n{stderr}"); + assert!(!stderr.contains("pass@"), "credentials leaked into stderr:\n{stderr}"); + drop(root); +} + +#[test] +fn add_sets_a_dist_tag_with_otp() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/beta") + .match_header("authorization", "Bearer token") + .match_header("content-type", Matcher::Regex(r"^application/json\b".to_string())) + .match_header("npm-auth-type", "legacy") + .match_header("npm-otp", "123456") + .match_body(Matcher::Exact(r#""1.0.0""#.to_string())) + .with_status(201) + .create(); + let auth_file_contents = format!("{}:_authToken=token\n", nerf(®istry)); + let auth_file = configure(root.path(), &workspace, ®istry, &auth_file_contents); + + let output = + run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0", "beta", "--otp", "123456"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag add must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "+beta: pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn add_uses_scoped_auth_for_scoped_package() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/@scope%2fpkg/dist-tags/beta") + .match_header("authorization", "Bearer scoped-token") + .match_header("npm-auth-type", "web") + .match_body(Matcher::Exact(r#""1.0.0""#.to_string())) + .with_status(201) + .create(); + let auth_file_contents = format!( + "{}:_authToken=default-token\n{}:@scope:_authToken=scoped-token\n", + nerf(®istry), + nerf(®istry), + ); + let auth_file = configure(root.path(), &workspace, ®istry, &auth_file_contents); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "@scope/pkg@1.0.0", "beta"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag add must use scoped auth for scoped packages (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "+beta: @scope/pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn add_normalizes_v_prefixed_versions() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/beta") + .match_body(Matcher::Exact(r#""1.0.0""#.to_string())) + .with_status(201) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@v1.0.0", "beta"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag add must normalize v-prefixed versions (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "+beta: pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn add_defaults_to_latest_tag() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/latest") + .match_header("npm-auth-type", "web") + .match_header("npm-otp", Matcher::Missing) + .match_body(Matcher::Exact(r#""1.0.0""#.to_string())) + .with_status(201) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag add latest must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "+latest: pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn add_encodes_dist_tag_path_segment() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/release%2Fcandidate") + .match_body(Matcher::Exact(r#""1.0.0""#.to_string())) + .with_status(201) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0", "release/candidate"]); + + mock.assert(); + assert!( + output.status.success(), + "dist-tag add must encode reserved tag characters (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "+release/candidate: pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn add_reports_web_otp_challenge() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/beta") + .match_header("npm-auth-type", "web") + .with_status(401) + .with_body( + r#"{"authUrl":"https://auth.example/login","doneUrl":"https://auth.example/done"}"#, + ) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0", "beta"]); + + mock.assert(); + assert!(!output.status.success(), "web OTP challenge must fail with guidance"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_DIST_TAG_WEB_OTP_REQUIRED") + && stderr.contains("https://auth.example/login") + && stderr.contains("--otp"), + "stderr must include web OTP guidance; got:\n{stderr}", + ); + drop((root, server)); +} + +#[test] +fn add_rejects_unsafe_web_otp_challenge_urls() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/beta") + .match_header("npm-auth-type", "web") + .with_status(401) + .with_body(r#"{"authUrl":"javascript:alert(1)","doneUrl":"https://auth.example/done"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0", "beta"]); + + mock.assert(); + assert!(!output.status.success(), "unsafe web OTP challenge must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("ERR_PNPM_UNAUTHORIZED"), "stderr:\n{stderr}"); + assert!( + !stderr.contains("ERR_PNPM_DIST_TAG_WEB_OTP_REQUIRED"), + "unsafe challenge must not be printed as web OTP guidance:\n{stderr}", + ); + assert!(!stderr.contains("Open javascript:"), "stderr:\n{stderr}"); + drop((root, server)); +} + +#[test] +fn add_rejects_web_otp_challenge_urls_with_control_chars() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/beta") + .match_header("npm-auth-type", "web") + .with_status(401) + .with_body( + r#"{"authUrl":"https://auth.example/login\nspoof","doneUrl":"https://auth.example/done"}"#, + ) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0", "beta"]); + + mock.assert(); + assert!(!output.status.success(), "unsafe web OTP challenge must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("ERR_PNPM_UNAUTHORIZED"), "stderr:\n{stderr}"); + assert!( + !stderr.contains("ERR_PNPM_DIST_TAG_WEB_OTP_REQUIRED"), + "unsafe challenge must not be printed as web OTP guidance:\n{stderr}", + ); + drop((root, server)); +} + +#[test] +fn rm_removes_an_existing_dist_tag() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let get_mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0","beta":"1.0.0"}"#) + .create(); + let delete_mock = server + .mock("DELETE", "/-/package/pkg/dist-tags/beta") + .match_header("npm-auth-type", "web") + .with_status(200) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["rm", "pkg", "beta"]); + + get_mock.assert(); + delete_mock.assert(); + assert!( + output.status.success(), + "dist-tag rm must succeed (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "-beta: pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn rm_encodes_dist_tag_path_segment() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let get_mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0","release/candidate":"1.0.0"}"#) + .create(); + let delete_mock = server + .mock("DELETE", "/-/package/pkg/dist-tags/release%2Fcandidate") + .with_status(200) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["rm", "pkg", "release/candidate"]); + + get_mock.assert(); + delete_mock.assert(); + assert!( + output.status.success(), + "dist-tag rm must encode reserved tag characters (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "-release/candidate: pkg@1.0.0\n"); + drop((root, server)); +} + +#[test] +fn rm_refuses_latest() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let auth_file = configure(root.path(), &workspace, "http://127.0.0.1:1/", ""); + + let output = run_dist_tag(&workspace, &auth_file, &["rm", "pkg", "latest"]); + + assert!( + !output.status.success(), + "dist-tag rm latest must fail (stdout: {})", + String::from_utf8_lossy(&output.stdout), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_DIST_TAG_RM_LATEST") + && stderr.contains(r#"Removing the "latest" dist-tag is not allowed"#), + "stderr must name the refusal; got:\n{stderr}", + ); + drop(root); +} + +#[test] +fn rm_fails_when_tag_is_missing() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server + .mock("GET", "/-/package/pkg/dist-tags") + .with_status(200) + .with_body(r#"{"latest":"1.0.0"}"#) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["rm", "pkg", "beta"]); + + mock.assert(); + assert!(!output.status.success(), "missing tag must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_DIST_TAG_NOT_FOUND") + && stderr.contains(r#"dist-tag "beta" is not set on package "pkg""#), + "stderr must name the missing tag; got:\n{stderr}", + ); + drop((root, server)); +} + +#[test] +fn ls_fails_when_package_is_missing() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let mock = server.mock("GET", "/-/package/missing/dist-tags").with_status(404).create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "missing"]); + + mock.assert(); + assert!(!output.status.success(), "missing package must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_PACKAGE_NOT_FOUND") + && stderr.contains(r#"Package "missing" not found in registry"#), + "stderr must name the missing package; got:\n{stderr}", + ); + drop((root, server)); +} + +#[test] +fn ls_rejects_oversized_dist_tags_response() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let body = format!(r#"{{"latest":"{}"}}"#, "1".repeat(1024 * 1024)); + let mock = + server.mock("GET", "/-/package/pkg/dist-tags").with_status(200).with_body(body).create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["ls", "pkg"]); + + mock.assert(); + assert!(!output.status.success(), "oversized dist-tags responses must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_REGISTRY_RESPONSE_TOO_LARGE"), + "stderr must name the oversized response; got:\n{stderr}", + ); + drop((root, server)); +} + +#[test] +fn add_rejects_non_exact_semver_versions() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let auth_file = configure(root.path(), &workspace, "http://127.0.0.1:1/", ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@^1.0.0", "beta"]); + + assert!(!output.status.success(), "range version must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_DIST_TAG_ADD_INVALID_VERSION") + && stderr.contains("Version must be an exact semver version"), + "stderr must name the invalid version; got:\n{stderr}", + ); + drop(root); +} + +#[test] +fn add_sanitizes_and_truncates_registry_error_body() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut server = mockito::Server::new(); + let registry = format!("{}/", server.url()); + let body = format!("{}{}", "x".repeat(70 * 1024), "\u{1b}[31m"); + let mock = server + .mock("PUT", "/-/package/pkg/dist-tags/beta") + .with_status(400) + .with_body(body) + .create(); + let auth_file = configure(root.path(), &workspace, ®istry, ""); + + let output = run_dist_tag(&workspace, &auth_file, &["add", "pkg@1.0.0", "beta"]); + + mock.assert(); + assert!(!output.status.success(), "registry write errors must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERR_PNPM_REGISTRY_ERROR") + && stderr.contains("response body truncated") + && !stderr.contains('\u{1b}'), + "stderr must sanitize and truncate the registry body; got:\n{stderr}", + ); + drop((root, server)); +}