use std::collections::HashSet;
use std::net::IpAddr;
use std::net::SocketAddr;
use std::num::NonZeroU16;
use std::str::FromStr;
use crate::ClientError;
use crate::ClientResult;
use cheetah_string::CheetahString;
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub(crate) struct NameServerTarget {
authority: CheetahString,
}
impl NameServerTarget {
#[inline]
pub(crate) fn authority(&self) -> &CheetahString {
&self.authority
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct NormalizedNameServerTargets {
targets: Vec<NameServerTarget>,
canonical: CheetahString,
}
impl NormalizedNameServerTargets {
#[inline]
pub(crate) fn canonical(&self) -> &str {
self.canonical.as_str()
}
pub(crate) fn authorities(&self) -> Vec<&str> {
self.targets.iter().map(|target| target.authority().as_str()).collect()
}
pub(crate) fn into_addresses(self) -> Vec<CheetahString> {
self.targets.into_iter().map(|target| target.authority).collect()
}
}
pub(crate) fn parse_legacy_namesrv_addr(value: &str) -> ClientResult<NormalizedNameServerTargets> {
let value = normalize_java_endpoint(value.trim());
let mut seen = HashSet::new();
let mut targets = Vec::new();
for raw_target in value.split(';').map(str::trim).filter(|target| !target.is_empty()) {
let authority = normalize_authority(raw_target)?;
if seen.insert(authority.clone()) {
targets.push(NameServerTarget {
authority: authority.into(),
});
}
}
if targets.is_empty() {
return Err(invalid_namesrv_addr(
value,
"must contain at least one host:port target",
));
}
let canonical = targets
.iter()
.map(|target| target.authority().as_str())
.collect::<Vec<_>>()
.join(";")
.into();
Ok(NormalizedNameServerTargets { targets, canonical })
}
fn normalize_java_endpoint(value: &str) -> &str {
value
.strip_prefix("http://")
.and_then(|_| value.rsplit('/').next())
.unwrap_or(value)
}
fn normalize_authority(value: &str) -> ClientResult<String> {
if value.contains("//") || value.contains(['/', '?', '#', '@']) || value.chars().any(char::is_whitespace) {
return Err(invalid_namesrv_addr(
value,
"contains unsupported URI or whitespace characters",
));
}
if let Ok(socket_addr) = SocketAddr::from_str(value) {
if socket_addr.port() == 0 {
return Err(invalid_namesrv_addr(value, "port must be between 1 and 65535"));
}
return Ok(socket_addr.to_string());
}
let Some((host, port)) = value.rsplit_once(':') else {
return Err(invalid_namesrv_addr(value, "port is required"));
};
if host.is_empty() || host.contains(':') || host.starts_with('[') || host.ends_with(']') {
return Err(invalid_namesrv_addr(
value,
"IPv6 targets must use bracketed [address]:port form",
));
}
let port = port
.parse::<u16>()
.ok()
.and_then(NonZeroU16::new)
.ok_or_else(|| invalid_namesrv_addr(value, "port must be between 1 and 65535"))?;
if IpAddr::from_str(host).is_ok() {
return Ok(format!("{host}:{port}"));
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
validate_dns_name(&host).map_err(|reason| invalid_namesrv_addr(value, reason))?;
Ok(format!("{host}:{port}"))
}
fn validate_dns_name(host: &str) -> Result<(), &'static str> {
if host.is_empty() || host.len() > 253 {
return Err("DNS name must contain between 1 and 253 characters");
}
for label in host.split('.') {
if label.is_empty() || label.len() > 63 {
return Err("DNS labels must contain between 1 and 63 characters");
}
if !label
.chars()
.all(|value| value.is_ascii_alphanumeric() || matches!(value, '-' | '_'))
{
return Err("DNS labels may only contain ASCII letters, digits, '-' or '_'");
}
if label.starts_with('-') || label.ends_with('-') {
return Err("DNS labels must not start or end with '-'");
}
}
Ok(())
}
fn invalid_namesrv_addr(value: &str, reason: impl Into<String>) -> ClientError {
ClientError::config_invalid("namesrv_addr", value, reason.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_supported_legacy_nameserver_targets() {
let ipv4 = parse_legacy_namesrv_addr("127.0.0.1:9876").expect("IPv4 target");
assert_eq!(ipv4.canonical(), "127.0.0.1:9876");
let dns = parse_legacy_namesrv_addr("NameSrv.Default.Svc.:9876").expect("DNS target");
assert_eq!(dns.canonical(), "namesrv.default.svc:9876");
let ipv6 = parse_legacy_namesrv_addr("[::1]:9876").expect("IPv6 target");
assert_eq!(ipv6.canonical(), "[::1]:9876");
}
#[test]
fn cleans_semicolon_lists_and_stably_deduplicates_targets() {
let parsed = parse_legacy_namesrv_addr(" ns-a:9876 ; ; ns-b:9876 ; ns-a:9876 ").expect("clean target list");
assert_eq!(parsed.canonical(), "ns-a:9876;ns-b:9876");
assert_eq!(parsed.authorities(), ["ns-a:9876", "ns-b:9876"]);
}
#[test]
fn normalizes_java_http_endpoint_shape_without_fetching_http() {
let parsed = parse_legacy_namesrv_addr("http://MQ_INST_x.example.com:9876").expect("Java-compatible endpoint");
assert_eq!(parsed.canonical(), "mq_inst_x.example.com:9876");
}
#[test]
fn rejects_malformed_or_ambiguous_targets() {
for value in [
"",
"; ;",
"namesrv",
"namesrv:0",
"namesrv:65536",
"::1:9876",
"https://namesrv:9876",
"http://user@namesrv:9876",
"http://namesrv:9876/path",
"namesrv:9876?zone=a",
] {
let error = parse_legacy_namesrv_addr(value).expect_err(value);
assert!(
error.is(&rocketmq_error::CORE_CONFIGURATION_INVALID),
"unexpected error for {value}: {error:?}"
);
}
}
fn label(len: usize) -> String {
"a".repeat(len)
}
#[test]
fn validates_dns_label_length_boundary() {
let host_63 = format!("{}.example.com", label(63));
let parsed = parse_legacy_namesrv_addr(&format!("{host_63}:9876")).expect("63-byte label should be accepted");
assert_eq!(parsed.canonical(), format!("{host_63}:9876"));
let host_64 = format!("{}.example.com", label(64));
let error =
parse_legacy_namesrv_addr(&format!("{host_64}:9876")).expect_err("64-byte label should be rejected");
assert!(error.is(&rocketmq_error::CORE_CONFIGURATION_INVALID));
}
#[test]
fn validates_total_dns_name_length_boundary_with_labels_within_limit() {
let host_253 = format!("{}.{}.{}.{}", label(63), label(63), label(63), label(61));
assert_eq!(host_253.len(), 253);
let parsed = parse_legacy_namesrv_addr(&format!("{host_253}:9876")).expect("253-byte name should be accepted");
assert_eq!(parsed.canonical(), format!("{host_253}:9876"));
let host_254 = format!("{}.{}.{}.{}", label(63), label(63), label(63), label(62));
assert_eq!(host_254.len(), 254);
let error =
parse_legacy_namesrv_addr(&format!("{host_254}:9876")).expect_err("254-byte name should be rejected");
assert!(error.is(&rocketmq_error::CORE_CONFIGURATION_INVALID));
}
#[test]
fn rejects_empty_and_hyphen_bounded_labels_while_accepting_interior_hyphen_and_underscore() {
for value in [
"ns..example:9876",
".ns.example:9876",
"-ns.example:9876",
"ns-.example:9876",
] {
let error = parse_legacy_namesrv_addr(value).expect_err(value);
assert!(
error.is(&rocketmq_error::CORE_CONFIGURATION_INVALID),
"unexpected error for {value}: {error:?}"
);
}
let parsed = parse_legacy_namesrv_addr("ns-a_b.example:9876")
.expect("interior hyphen and underscore should be accepted");
assert_eq!(parsed.canonical(), "ns-a_b.example:9876");
}
#[test]
fn deduplicates_case_and_trailing_dot_variants_without_reordering_other_targets() {
let parsed = parse_legacy_namesrv_addr("NS-A.Example.COM:9876;ns-b:9876;ns-a.example.com.:9876")
.expect("mixed case / trailing dot variants should normalize and dedupe");
assert_eq!(parsed.authorities(), ["ns-a.example.com:9876", "ns-b:9876"]);
}
}