use crate::{ApplyPermit, PackageId, Version};
use std::time::Duration;
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RegistryError {
#[error("Rate limited. Retry after {0:?}")]
RateLimited(Duration),
#[error("Authentication failed: {0}")]
AuthFailed(String),
#[error("Network error: {0}")]
Network(String),
#[error("Other registry error: {0}")]
Other(String),
}
impl RegistryError {
pub fn kind_str(&self) -> &'static str {
match self {
RegistryError::RateLimited(_) => "rateLimited",
RegistryError::AuthFailed(_) => "authFailed",
RegistryError::Network(_) => "network",
RegistryError::Other(_) => "other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PublishOutcome {
Published,
AlreadyPublished,
}
pub trait RegistryClient: Send + Sync {
fn is_published(&self, package: &PackageId, version: &Version) -> Result<bool, RegistryError>;
fn publish(
&self,
package: &PackageId,
version: &Version,
permit: &ApplyPermit,
) -> Result<PublishOutcome, RegistryError>;
}
pub trait RateLimitPolicy: Send + Sync {
fn check_rate_limit(&self, retry_after: Duration) -> Result<(), RegistryError>;
}
pub trait TimeProvider: Send + Sync {
fn now(&self) -> std::time::SystemTime;
fn sleep(&self, duration: Duration);
}
pub fn redact_known_secrets(text: &str, secrets: &[String]) -> String {
let mut redacted = text.to_string();
for secret in secrets {
if !secret.is_empty() {
redacted = redacted.replace(secret.as_str(), "[REDACTED]");
}
}
redact_url_userinfo(&redacted)
}
pub fn known_credential_env_values(vars: impl Iterator<Item = (String, String)>) -> Vec<String> {
vars.filter(|(key, value)| {
!value.is_empty()
&& (matches!(
key.as_str(),
"NPM_TOKEN" | "TWINE_PASSWORD" | "CARGO_REGISTRY_TOKEN" | "GITHUB_TOKEN" | "GH_TOKEN"
) || (key.starts_with("CARGO_REGISTRIES_") && key.ends_with("_TOKEN")))
})
.map(|(_, value)| value)
.collect()
}
fn redact_url_userinfo(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut rest = text;
while let Some(scheme_pos) = rest.find("://") {
let split_at = scheme_pos + 3;
result.push_str(&rest[..split_at]);
let after = &rest[split_at..];
let stop = after
.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>'))
.unwrap_or(after.len());
let candidate = &after[..stop];
if let Some(at_pos) = candidate.rfind('@') {
result.push_str("[REDACTED]@");
rest = &after[at_pos + 1..];
continue;
}
rest = after;
}
result.push_str(rest);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_a_known_secret_value_from_text() {
let text = "npm ERR! 403 Forbidden - PUT https://registry.npmjs.org/pkg - token abc123secret was rejected";
let redacted = redact_known_secrets(text, &["abc123secret".to_string()]);
assert!(!redacted.contains("abc123secret"));
assert!(redacted.contains("[REDACTED]"));
}
#[test]
fn redacts_multiple_occurrences_of_the_same_secret() {
let text = "token=s3cr3t failed; retrying with token=s3cr3t again";
let redacted = redact_known_secrets(text, &["s3cr3t".to_string()]);
assert!(!redacted.contains("s3cr3t"));
assert_eq!(redacted.matches("[REDACTED]").count(), 2);
}
#[test]
fn leaves_text_unchanged_when_no_secrets_match() {
let text = "cargo publish failed: crate version already exists";
let redacted = redact_known_secrets(text, &["unrelated-secret".to_string()]);
assert_eq!(redacted, text);
}
#[test]
fn empty_string_secret_in_the_list_does_not_corrupt_output() {
let text = "normal error message";
let redacted = redact_known_secrets(text, &[String::new(), "real-secret".to_string()]);
assert_eq!(redacted, text);
}
#[test]
fn redacts_url_userinfo_even_when_it_matches_no_known_secret() {
let text = "error: failed to fetch https://alice:hunter2@registry.example.com/pkg.tgz: 401";
let redacted = redact_known_secrets(text, &[]);
assert!(!redacted.contains("hunter2"));
assert!(!redacted.contains("alice"));
assert_eq!(
redacted,
"error: failed to fetch https://[REDACTED]@registry.example.com/pkg.tgz: 401"
);
}
#[test]
fn does_not_touch_a_url_with_no_userinfo() {
let text = "fetching https://registry.npmjs.org/pkg failed with 404";
let redacted = redact_known_secrets(text, &[]);
assert_eq!(redacted, text);
}
#[test]
fn redacts_userinfo_when_the_password_itself_contains_a_slash() {
let text = "error: failed to fetch https://alice:p/ss@registry.example.com/index: 401";
let redacted = redact_known_secrets(text, &[]);
assert!(!redacted.contains("p/ss"));
assert_eq!(
redacted,
"error: failed to fetch https://[REDACTED]@registry.example.com/index: 401"
);
}
#[test]
fn redacts_userinfo_when_the_password_itself_contains_an_at_sign() {
let text = "error: failed to fetch https://alice:p@ssw0rd@registry.example.com/index: 401";
let redacted = redact_known_secrets(text, &[]);
assert!(!redacted.contains("ssw0rd"));
assert_eq!(
redacted,
"error: failed to fetch https://[REDACTED]@registry.example.com/index: 401"
);
}
#[test]
fn conservatively_redacts_a_bare_at_sign_after_a_path_segment_too() {
let text = "GET https://registry.npmjs.org/@myorg/pkg failed";
let redacted = redact_known_secrets(text, &[]);
assert_eq!(redacted, "GET https://[REDACTED]@myorg/pkg failed");
}
#[test]
fn known_credential_env_values_matches_fixed_and_dynamic_names() {
let snapshot = vec![
("NPM_TOKEN".to_string(), "npm-secret".to_string()),
("TWINE_PASSWORD".to_string(), "twine-secret".to_string()),
("CARGO_REGISTRY_TOKEN".to_string(), "cargo-secret".to_string()),
(
"CARGO_REGISTRIES_MY_REGISTRY_TOKEN".to_string(),
"dynamic-secret".to_string(),
),
("PATH".to_string(), "/usr/bin".to_string()),
("NPM_TOKEN_BUT_NOT_QUITE".to_string(), "not-a-match".to_string()),
];
let values = known_credential_env_values(snapshot.into_iter());
assert_eq!(values.len(), 4);
assert!(values.contains(&"npm-secret".to_string()));
assert!(values.contains(&"twine-secret".to_string()));
assert!(values.contains(&"cargo-secret".to_string()));
assert!(values.contains(&"dynamic-secret".to_string()));
}
#[test]
fn known_credential_env_values_skips_empty_values() {
let snapshot = vec![("NPM_TOKEN".to_string(), String::new())];
let values = known_credential_env_values(snapshot.into_iter());
assert!(values.is_empty());
}
#[test]
fn known_credential_env_values_matches_github_token_names() {
let snapshot = vec![
("GITHUB_TOKEN".to_string(), "gh-secret".to_string()),
("GH_TOKEN".to_string(), "gh-cli-secret".to_string()),
];
let values = known_credential_env_values(snapshot.into_iter());
assert_eq!(values.len(), 2);
assert!(values.contains(&"gh-secret".to_string()));
assert!(values.contains(&"gh-cli-secret".to_string()));
}
}