mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-14 17:52:44 -04:00
feat(pacquet): port dist-tag command (#12667)
Port the native dist-tag command to pacquet so the Rust CLI can manage package distribution tags without delegating to another client. The new command mirrors pnpm's TypeScript behavior for subcommand dispatch, default list handling, exact semver validation on add, latest-tag removal refusal, scoped registry/auth selection, dist-tag URL encoding, OTP headers, and sorted list output. Add spawned-CLI integration coverage against a mock registry for list/add/remove behavior, alias handling, registry override, scoped auth, empty output, versioned package specs, and error cases.
This commit is contained in:
@@ -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
|
||||
|
||||
684
pacquet/crates/cli/src/cli_args/dist_tag.rs
Normal file
684
pacquet/crates/cli/src/cli_args/dist_tag.rs
Normal file
@@ -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<String>,
|
||||
|
||||
/// One-time password for registries that require two-factor authentication.
|
||||
#[clap(long)]
|
||||
pub otp: Option<String>,
|
||||
|
||||
/// dist-tag subcommand and arguments.
|
||||
pub params: Vec<String>,
|
||||
}
|
||||
|
||||
#[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 <token>."
|
||||
)]
|
||||
#[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<String, String>,
|
||||
otp: Option<String>,
|
||||
}
|
||||
|
||||
struct PackageSpec {
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
impl DistTagArgs {
|
||||
pub async fn run(self, config: &Config) -> miette::Result<Option<String>> {
|
||||
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<DistTagContext<'config>> {
|
||||
let mut registries: HashMap<String, String> =
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<BTreeMap<String, String>> {
|
||||
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<BTreeMap<String, String>, 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<ErrorType>(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<u8>,
|
||||
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<LimitedBody, reqwest::Error> {
|
||||
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<WebOtpChallenge> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
context.config.auth_headers.for_url_with_package(registry_url, Some(package_name))
|
||||
}
|
||||
|
||||
fn build_http_client(config: &Config) -> miette::Result<ThrottledClient> {
|
||||
ThrottledClient::for_installs(
|
||||
&config.proxy,
|
||||
&config.tls,
|
||||
&config.tls_by_uri,
|
||||
&NetworkSettings {
|
||||
network_concurrency: config.network_concurrency,
|
||||
fetch_timeout: Duration::from_millis(config.fetch_timeout),
|
||||
user_agent: config.user_agent.clone(),
|
||||
},
|
||||
)
|
||||
.into_diagnostic()
|
||||
.wrap_err("create the network client for dist-tag")
|
||||
}
|
||||
|
||||
fn parse_package_spec(spec: &str) -> Result<PackageSpec, DistTagError> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String, DistTagError> {
|
||||
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<String> {
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
757
pacquet/crates/cli/tests/dist_tag.rs
Normal file
757
pacquet/crates/cli/tests/dist_tag.rs
Normal file
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user