use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostClass {
Loopback,
LinkLocal,
CloudMetadata,
PrivateV4,
Cgnat,
UniqueLocalV6,
Unspecified,
InternalName,
Global,
}
impl HostClass {
#[must_use]
pub const fn never_a_registry(self) -> bool {
matches!(
self,
Self::Loopback | Self::LinkLocal | Self::CloudMetadata | Self::Unspecified
)
}
}
impl std::fmt::Display for HostClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Loopback => "loopback",
Self::LinkLocal => "link-local",
Self::CloudMetadata => "cloud metadata",
Self::PrivateV4 => "private (RFC1918)",
Self::Cgnat => "carrier-grade NAT",
Self::UniqueLocalV6 => "unique-local IPv6",
Self::Unspecified => "unspecified",
Self::InternalName => "internal name",
Self::Global => "global",
})
}
}
fn unwrap_mapped_v4(addr: IpAddr) -> IpAddr {
match addr {
IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.or_else(|| nat64_embedded_v4(v6))
.map_or(addr, IpAddr::V4),
IpAddr::V4(_) => addr,
}
}
fn nat64_embedded_v4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
let segments = v6.segments();
if segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6] == [0, 0, 0, 0] {
let [a, b] = segments[6].to_be_bytes();
let [c, d] = segments[7].to_be_bytes();
Some(Ipv4Addr::new(a, b, c, d))
} else {
None
}
}
fn classify_ip(addr: IpAddr) -> HostClass {
match addr {
IpAddr::V4(v4) => {
if v4.is_loopback() {
HostClass::Loopback
} else if v4 == Ipv4Addr::new(169, 254, 169, 254) {
HostClass::CloudMetadata
} else if v4.is_link_local() {
HostClass::LinkLocal
} else if v4.is_unspecified() {
HostClass::Unspecified
} else if v4.is_private() {
HostClass::PrivateV4
} else if v4.octets()[0] == 100 && (v4.octets()[1] & 0b1100_0000) == 0b0100_0000 {
HostClass::Cgnat
} else {
HostClass::Global
}
}
IpAddr::V6(v6) => {
if v6.is_loopback() {
HostClass::Loopback
} else if v6.segments() == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x254] {
HostClass::CloudMetadata
} else if (v6.segments()[0] & 0xffc0) == 0xfe80 {
HostClass::LinkLocal
} else if v6.is_unspecified() {
HostClass::Unspecified
} else if (v6.segments()[0] & 0xfe00) == 0xfc00 {
HostClass::UniqueLocalV6
} else {
HostClass::Global
}
}
}
}
fn classify_name(host: &str) -> HostClass {
let lower = host.to_ascii_lowercase();
let lower = lower.trim_end_matches('.');
if lower == "localhost" || lower.ends_with(".localhost") {
return HostClass::Loopback;
}
if lower == "metadata.google.internal" || lower == "metadata.goog" {
return HostClass::CloudMetadata;
}
if lower.ends_with(".internal") || lower.ends_with(".local") || lower.ends_with(".home.arpa") {
return HostClass::InternalName;
}
if !lower.contains('.') {
return HostClass::InternalName;
}
HostClass::Global
}
#[must_use]
pub fn classify_addr(addr: IpAddr) -> HostClass {
classify_ip(unwrap_mapped_v4(addr))
}
#[must_use]
pub fn classify_host(url: &url::Url) -> HostClass {
match url.host() {
Some(url::Host::Ipv4(v4)) => classify_ip(unwrap_mapped_v4(IpAddr::V4(v4))),
Some(url::Host::Ipv6(v6)) => classify_ip(unwrap_mapped_v4(IpAddr::V6(v6))),
Some(url::Host::Domain(name)) => classify_name(name),
None => HostClass::InternalName,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WorkspaceRegistryAccess {
Off,
#[default]
PublicOnly,
All,
}
impl WorkspaceRegistryAccess {
#[must_use]
pub const fn allows(self, class: HostClass) -> bool {
match self {
Self::Off => false,
Self::PublicOnly => matches!(class, HostClass::Global),
Self::All => true,
}
}
pub(crate) const fn to_u8(self) -> u8 {
match self {
Self::Off => 0,
Self::PublicOnly => 1,
Self::All => 2,
}
}
const fn from_u8(value: u8) -> Self {
match value {
0 => Self::Off,
2 => Self::All,
_ => Self::PublicOnly,
}
}
}
#[derive(Debug)]
pub struct RegistryAccessPolicy(AtomicU8);
impl RegistryAccessPolicy {
#[must_use]
pub fn new(initial: WorkspaceRegistryAccess) -> Self {
Self(AtomicU8::new(initial.to_u8()))
}
#[must_use]
pub fn get(&self) -> WorkspaceRegistryAccess {
WorkspaceRegistryAccess::from_u8(self.0.load(Ordering::Relaxed))
}
pub fn set(&self, value: WorkspaceRegistryAccess) {
self.0.store(value.to_u8(), Ordering::Relaxed);
}
}
impl Default for RegistryAccessPolicy {
fn default() -> Self {
Self::new(WorkspaceRegistryAccess::default())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IndexUrlError {
#[error("not a valid URL: {0}")]
InvalidUrl(String),
#[error("registry index must use https, got scheme {0:?}")]
NotHttps(String),
#[error("registry index URL must not carry userinfo")]
UserInfoPresent,
#[error("registry index host class {class} blocked by registries.workspace_registries policy")]
BlockedHost {
class: HostClass,
},
}
#[derive(Debug, Clone, Copy)]
pub enum PolicyGate<'a> {
Skip,
Enforce(&'a RegistryAccessPolicy),
}
#[must_use]
pub fn redact_userinfo(raw: &str) -> String {
let Ok(mut url) = url::Url::parse(raw) else {
return redact_userinfo_unparseable(raw);
};
if url.cannot_be_a_base() {
return redact_userinfo_unparseable(raw);
}
if url.username().is_empty() && url.password().is_none() {
return raw.to_string();
}
if url.set_username("***").is_err() || url.set_password(None).is_err() {
return "<redacted: index URL contained userinfo>".to_string();
}
url.as_str().to_string()
}
fn redact_userinfo_unparseable(raw: &str) -> String {
let authority_start = raw.find("://").map_or(0, |scheme_end| scheme_end + 3);
let authority = &raw[authority_start..];
let host_boundary = authority.find(['/', '?', '#']).unwrap_or(authority.len());
let Some(at) = authority[..host_boundary].rfind('@') else {
return raw.to_string();
};
format!("{}***@{}", &raw[..authority_start], &authority[at + 1..])
}
#[cfg(any(test, feature = "test-util"))]
fn is_loopback_url(url: &url::Url) -> bool {
url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1"))
}
pub fn validate_index_url(
candidate: &str,
raw_for_log: &str,
ecosystem: &'static str,
gate: PolicyGate<'_>,
) -> Result<url::Url, IndexUrlError> {
let url = url::Url::parse(candidate)
.map_err(|_| IndexUrlError::InvalidUrl(redact_userinfo(raw_for_log)))?;
let is_https = url.scheme() == "https";
#[cfg(any(test, feature = "test-util"))]
let is_https = is_https || is_loopback_url(&url);
if !is_https {
return Err(IndexUrlError::NotHttps(url.scheme().to_string()));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(IndexUrlError::UserInfoPresent);
}
if let PolicyGate::Enforce(policy) = gate {
let class = classify_host(&url);
if !policy.get().allows(class) {
tracing::warn!(
url = raw_for_log,
?class,
ecosystem,
"workspace-declared registry index host blocked by registries.workspace_registries policy"
);
return Err(IndexUrlError::BlockedHost { class });
}
}
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
fn host_class(url: &str) -> HostClass {
classify_host(&url::Url::parse(url).unwrap())
}
#[test]
fn test_classify_cloud_metadata_ipv4() {
assert_eq!(
host_class("https://169.254.169.254/"),
HostClass::CloudMetadata
);
}
#[test]
fn test_classify_cloud_metadata_ipv4_mapped_v6_bypass() {
assert_eq!(
host_class("https://[::ffff:169.254.169.254]/"),
HostClass::CloudMetadata
);
}
#[test]
fn test_classify_cloud_metadata_nat64_bypass() {
assert_eq!(
host_class("https://[64:ff9b::a9fe:a9fe]/"),
HostClass::CloudMetadata
);
}
#[test]
fn test_classify_cloud_metadata_ec2_ipv6() {
assert_eq!(
host_class("https://[fd00:ec2::254]/"),
HostClass::CloudMetadata
);
}
#[test]
fn test_classify_link_local_ipv6() {
assert_eq!(host_class("https://[fe80::1]/"), HostClass::LinkLocal);
}
#[test]
fn test_classify_private_v4() {
assert_eq!(host_class("https://10.0.0.1/"), HostClass::PrivateV4);
}
#[test]
fn test_classify_cgnat() {
assert_eq!(host_class("https://100.64.0.1/"), HostClass::Cgnat);
}
#[test]
fn test_classify_private_v4_192_168() {
assert_eq!(host_class("https://192.168.1.1/"), HostClass::PrivateV4);
}
#[test]
fn test_classify_unique_local_v6() {
assert_eq!(host_class("https://[fc00::1]/"), HostClass::UniqueLocalV6);
}
#[test]
fn test_classify_unspecified_v4() {
assert_eq!(host_class("https://0.0.0.0/"), HostClass::Unspecified);
}
#[test]
fn test_classify_localhost_name() {
assert_eq!(host_class("https://localhost/"), HostClass::Loopback);
}
#[test]
fn test_classify_localhost_subdomain() {
assert_eq!(host_class("https://foo.localhost/"), HostClass::Loopback);
}
#[test]
fn test_classify_google_metadata_name() {
assert_eq!(
host_class("https://metadata.google.internal/"),
HostClass::CloudMetadata
);
}
#[test]
fn test_classify_internal_suffix_name() {
assert_eq!(
host_class("https://registry.internal/"),
HostClass::InternalName
);
}
#[test]
fn test_classify_single_label_name() {
assert_eq!(host_class("https://single-label/"), HostClass::InternalName);
}
#[test]
fn test_classify_localhost_trailing_dot() {
assert_eq!(host_class("https://localhost./"), HostClass::Loopback);
}
#[test]
fn test_classify_google_metadata_trailing_dot() {
assert_eq!(
host_class("https://metadata.google.internal./"),
HostClass::CloudMetadata
);
}
#[test]
fn test_classify_internal_suffix_trailing_dot() {
assert_eq!(
host_class("https://registry.internal./"),
HostClass::InternalName
);
}
#[test]
fn test_classify_localhost_double_trailing_dot() {
assert_eq!(host_class("https://localhost../"), HostClass::Loopback);
}
#[test]
fn test_classify_global_public_name() {
assert_eq!(host_class("https://index.crates.io/"), HostClass::Global);
}
#[test]
fn test_classify_addr_cloud_metadata() {
let addr: IpAddr = "169.254.169.254".parse().unwrap();
assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
}
#[test]
fn test_classify_addr_private_v4() {
let addr: IpAddr = "10.0.0.1".parse().unwrap();
assert_eq!(classify_addr(addr), HostClass::PrivateV4);
}
#[test]
fn test_classify_addr_unwraps_mapped_v4() {
let addr: IpAddr = "::ffff:169.254.169.254".parse().unwrap();
assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
}
#[test]
fn test_classify_addr_unwraps_nat64_cloud_metadata() {
let addr: IpAddr = "64:ff9b::a9fe:a9fe".parse().unwrap();
assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
}
#[test]
fn test_classify_addr_unwraps_nat64_loopback() {
let addr: IpAddr = "64:ff9b::7f00:1".parse().unwrap();
assert_eq!(classify_addr(addr), HostClass::Loopback);
}
#[test]
fn test_classify_addr_global() {
let addr: IpAddr = "1.1.1.1".parse().unwrap();
assert_eq!(classify_addr(addr), HostClass::Global);
}
#[test]
fn test_never_a_registry_classes() {
assert!(HostClass::Loopback.never_a_registry());
assert!(HostClass::LinkLocal.never_a_registry());
assert!(HostClass::CloudMetadata.never_a_registry());
assert!(HostClass::Unspecified.never_a_registry());
assert!(!HostClass::PrivateV4.never_a_registry());
assert!(!HostClass::Cgnat.never_a_registry());
assert!(!HostClass::UniqueLocalV6.never_a_registry());
assert!(!HostClass::InternalName.never_a_registry());
assert!(!HostClass::Global.never_a_registry());
}
#[test]
fn test_workspace_registry_access_off_blocks_everything() {
let policy = WorkspaceRegistryAccess::Off;
assert!(!policy.allows(HostClass::Global));
assert!(!policy.allows(HostClass::PrivateV4));
assert!(!policy.allows(HostClass::Loopback));
}
#[test]
fn test_workspace_registry_access_public_only_allows_global_only() {
let policy = WorkspaceRegistryAccess::PublicOnly;
assert!(policy.allows(HostClass::Global));
assert!(!policy.allows(HostClass::PrivateV4));
assert!(!policy.allows(HostClass::CloudMetadata));
}
#[test]
fn test_workspace_registry_access_all_allows_everything() {
let policy = WorkspaceRegistryAccess::All;
assert!(policy.allows(HostClass::Global));
assert!(policy.allows(HostClass::PrivateV4));
assert!(policy.allows(HostClass::Loopback));
}
#[test]
fn test_registry_access_policy_default_is_public_only() {
let policy = RegistryAccessPolicy::default();
assert_eq!(policy.get(), WorkspaceRegistryAccess::PublicOnly);
}
#[test]
fn test_registry_access_policy_live_update() {
let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::All);
assert_eq!(policy.get(), WorkspaceRegistryAccess::All);
policy.set(WorkspaceRegistryAccess::Off);
assert_eq!(policy.get(), WorkspaceRegistryAccess::Off);
}
#[test]
fn test_validate_index_url_userinfo_rejected_before_policy_gate() {
let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off);
let result = validate_index_url(
"https://user:pass@169.254.169.254/",
"https://user:pass@169.254.169.254/",
"cargo",
PolicyGate::Enforce(&policy),
);
assert_eq!(result, Err(IndexUrlError::UserInfoPresent));
}
#[test]
fn test_validate_index_url_policy_gate_skip_vs_enforce() {
let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off);
assert!(
validate_index_url(
"https://169.254.169.254/",
"https://169.254.169.254/",
"cargo",
PolicyGate::Skip
)
.is_ok()
);
assert!(matches!(
validate_index_url(
"https://169.254.169.254/",
"https://169.254.169.254/",
"cargo",
PolicyGate::Enforce(&policy)
),
Err(IndexUrlError::BlockedHost { .. })
));
}
#[test]
fn test_redact_userinfo_noop_cases() {
assert_eq!(
redact_userinfo("https://registry.example/simple"),
"https://registry.example/simple"
);
assert_eq!(redact_userinfo("not-a-valid-url"), "not-a-valid-url");
}
#[test]
fn test_redact_userinfo_strips_username_and_password() {
let redacted = redact_userinfo("https://user:hunter2@registry.example/simple");
assert!(!redacted.contains("hunter2"));
assert!(!redacted.contains("user:"));
assert_eq!(redacted, "https://***@registry.example/simple");
}
#[test]
fn test_redact_userinfo_redacts_unparseable_url_with_userinfo() {
let redacted = redact_userinfo("https://user:hunter2@registry.example:99999/simple");
assert!(!redacted.contains("hunter2"));
assert!(!redacted.contains("user:"));
assert_eq!(redacted, "https://***@registry.example:99999/simple");
}
#[test]
fn test_redact_userinfo_redacts_schemeless_userinfo() {
let redacted = redact_userinfo("user:hunter2@registry.example/simple");
assert!(!redacted.contains("hunter2"));
assert!(!redacted.contains("user:"));
assert_eq!(redacted, "***@registry.example/simple");
}
#[test]
fn test_redact_userinfo_unparseable_no_userinfo_is_noop() {
assert_eq!(
redact_userinfo("https://registry.example:99999/simple"),
"https://registry.example:99999/simple"
);
}
#[test]
fn test_validate_index_url_redacts_userinfo_in_invalid_url_error() {
let raw = "https://user:hunter2@registry.example:99999/simple";
let err = validate_index_url(raw, raw, "cargo", PolicyGate::Skip).unwrap_err();
let IndexUrlError::InvalidUrl(redacted) = &err else {
panic!("expected InvalidUrl, got {err:?}");
};
assert!(!redacted.contains("hunter2"), "redacted: {redacted}");
assert!(!err.to_string().contains("hunter2"), "Display: {err}");
}
}