use std::fmt;
pub const DEFAULT_DOMAIN: &str = "local";
const MAX_SERVICE_NAME_LEN: usize = 15;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiscoveryBackend {
#[default]
MdnsSd,
#[cfg(feature = "fake")]
Fake,
#[cfg(feature = "zeroconf")]
Zeroconf,
}
impl DiscoveryBackend {
pub fn name(self) -> &'static str {
match self {
DiscoveryBackend::MdnsSd => "mdns-sd",
#[cfg(feature = "fake")]
DiscoveryBackend::Fake => "fake",
#[cfg(feature = "zeroconf")]
DiscoveryBackend::Zeroconf => "zeroconf",
}
}
fn supports_custom_domain(self) -> bool {
match self {
DiscoveryBackend::MdnsSd => true,
#[cfg(feature = "fake")]
DiscoveryBackend::Fake => true,
#[cfg(feature = "zeroconf")]
DiscoveryBackend::Zeroconf => false,
}
}
}
impl fmt::Display for DiscoveryBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportProtocol {
Tcp,
Udp,
}
impl TransportProtocol {
pub fn as_str(self) -> &'static str {
match self {
TransportProtocol::Tcp => "tcp",
TransportProtocol::Udp => "udp",
}
}
}
impl fmt::Display for TransportProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceTypeFilter {
name: String,
protocol: TransportProtocol,
}
impl ServiceTypeFilter {
pub fn parse(value: &str) -> Result<Self, DiscoveryOptionError> {
Self::parse_parts(value).map_err(|reason| DiscoveryOptionError::ServiceType {
value: value.to_string(),
reason,
})
}
fn parse_parts(value: &str) -> Result<Self, &'static str> {
let rest = value
.strip_prefix('_')
.ok_or("a service type begins with `_`")?;
let (name, protocol) = rest
.split_once('.')
.ok_or("expected a name and a transport, as in `_ssh._tcp`")?;
let protocol = protocol
.strip_prefix('_')
.ok_or("the transport begins with `_`, as in `._tcp`")?;
let protocol = match protocol.to_ascii_lowercase().as_str() {
"tcp" => TransportProtocol::Tcp,
"udp" => TransportProtocol::Udp,
_ => return Err("the transport must be `_tcp` or `_udp`"),
};
Ok(Self {
name: validate_service_name(name)?.to_ascii_lowercase(),
protocol,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn protocol(&self) -> TransportProtocol {
self.protocol
}
}
impl fmt::Display for ServiceTypeFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "_{}._{}", self.name, self.protocol)
}
}
fn validate_service_name(name: &str) -> Result<&str, &'static str> {
if name.is_empty() {
return Err("the service name is empty");
}
if !name.is_ascii() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("the service name may contain only ASCII letters, digits, and hyphens");
}
if name.len() > MAX_SERVICE_NAME_LEN {
return Err("the service name may be at most 15 characters");
}
let (first, last) = (name.as_bytes()[0], name.as_bytes()[name.len() - 1]);
if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
return Err("the service name must begin and end with a letter or digit");
}
if name.contains("--") {
return Err("the service name must not contain consecutive hyphens");
}
if !name.bytes().any(|b| b.is_ascii_alphabetic()) {
return Err("the service name must contain at least one letter");
}
Ok(name)
}
fn canonical_domain(domain: &str) -> String {
if domain.is_empty()
|| domain.eq_ignore_ascii_case(DEFAULT_DOMAIN)
|| domain.eq_ignore_ascii_case("local.")
{
return DEFAULT_DOMAIN.to_string();
}
domain.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscoveryOptionError {
ServiceType {
value: String,
reason: &'static str,
},
UnsupportedDomain {
backend: DiscoveryBackend,
domain: String,
},
}
impl fmt::Display for DiscoveryOptionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DiscoveryOptionError::ServiceType { value, reason } => write!(
f,
"`{value}` is not a DNS-SD service type: {reason}. \
Use a type such as `_ssh._tcp` or `_dns-sd._udp`, \
or omit it to browse every service type",
),
DiscoveryOptionError::UnsupportedDomain { backend, domain } => write!(
f,
"the `{backend}` backend cannot browse the `{domain}` domain: \
it can only browse the default `{DEFAULT_DOMAIN}` domain. \
Browse `{DEFAULT_DOMAIN}`, or select the `mdns-sd` backend, \
which supports custom domains",
),
}
}
}
impl std::error::Error for DiscoveryOptionError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryConfig {
pub backend: DiscoveryBackend,
pub domain: String,
pub service_type: Option<String>,
}
impl DiscoveryConfig {
pub fn validate(self) -> Result<DiscoveryOptions, DiscoveryOptionError> {
let service_type = self
.service_type
.as_deref()
.map(ServiceTypeFilter::parse)
.transpose()?;
let domain = canonical_domain(&self.domain);
if domain != DEFAULT_DOMAIN && !self.backend.supports_custom_domain() {
return Err(DiscoveryOptionError::UnsupportedDomain {
backend: self.backend,
domain,
});
}
Ok(DiscoveryOptions {
backend: self.backend,
domain,
service_type,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryOptions {
backend: DiscoveryBackend,
domain: String,
service_type: Option<ServiceTypeFilter>,
}
impl DiscoveryOptions {
pub fn backend(&self) -> DiscoveryBackend {
self.backend
}
pub fn domain(&self) -> &str {
&self.domain
}
pub fn service_type(&self) -> Option<&ServiceTypeFilter> {
self.service_type.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(domain: &str, service_type: Option<&str>) -> DiscoveryConfig {
DiscoveryConfig {
backend: DiscoveryBackend::MdnsSd,
domain: domain.to_string(),
service_type: service_type.map(str::to_string),
}
}
fn service_type_error(value: &str) -> String {
let err = ServiceTypeFilter::parse(value).unwrap_err();
assert!(
matches!(&err, DiscoveryOptionError::ServiceType { value: v, .. } if v == value),
"the error must quote the offending value: {err:?}"
);
err.to_string()
}
#[test]
fn valid_tcp_and_udp_service_types_are_accepted() {
for (input, name, protocol) in [
("_ssh._tcp", "ssh", TransportProtocol::Tcp),
("_dns-sd._udp", "dns-sd", TransportProtocol::Udp),
(
"_a1-b2-c3-d4-e5._tcp",
"a1-b2-c3-d4-e5",
TransportProtocol::Tcp,
),
("_x._udp", "x", TransportProtocol::Udp),
] {
let filter = ServiceTypeFilter::parse(input).expect(input);
assert_eq!(filter.name(), name);
assert_eq!(filter.protocol(), protocol);
assert_eq!(filter.to_string(), input);
}
}
#[test]
fn service_types_are_canonicalized_to_lower_case() {
let filter = ServiceTypeFilter::parse("_SSH._TCP").expect("case-insensitive");
assert_eq!(filter.to_string(), "_ssh._tcp");
assert_eq!(filter, ServiceTypeFilter::parse("_ssh._tcp").unwrap());
}
#[test]
fn malformed_service_types_are_rejected_with_the_rule_they_broke() {
for (input, expected) in [
("not a service type", "begins with `_`"),
("ssh._tcp", "begins with `_`"),
("_ssh", "expected a name and a transport"),
("_ssh.tcp", "the transport begins with `_`"),
("_ssh._sctp", "must be `_tcp` or `_udp`"),
("_ssh._tcp.", "must be `_tcp` or `_udp`"),
("_ssh._tcp._x", "must be `_tcp` or `_udp`"),
("__tcp", "expected a name and a transport"),
("_._tcp", "the service name is empty"),
("_s h._tcp", "only ASCII letters, digits, and hyphens"),
("_s_h._tcp", "only ASCII letters, digits, and hyphens"),
("_sshé._tcp", "only ASCII letters, digits, and hyphens"),
("_abcdefghijklmnop._tcp", "at most 15 characters"),
("_-ssh._tcp", "begin and end with a letter or digit"),
("_ssh-._tcp", "begin and end with a letter or digit"),
("_s--h._tcp", "consecutive hyphens"),
("_123._tcp", "at least one letter"),
("_1-2._tcp", "at least one letter"),
] {
let message = service_type_error(input);
assert!(
message.contains(expected),
"`{input}` should be rejected for {expected:?}, got: {message}"
);
}
}
#[test]
fn a_rejected_service_type_names_the_value_and_the_remedy() {
let message = service_type_error("bogus");
assert!(message.contains("`bogus`"));
assert!(message.contains("_ssh._tcp"));
assert!(message.contains("omit it to browse every service type"));
}
#[test]
fn the_service_name_length_limit_is_fifteen() {
assert!(ServiceTypeFilter::parse("_abcdefghijklmno._tcp").is_ok());
assert!(ServiceTypeFilter::parse("_abcdefghijklmnop._tcp").is_err());
}
#[test]
fn no_service_type_means_browse_every_type() {
let options = config("local", None).validate().unwrap();
assert_eq!(options.service_type(), None);
}
#[test]
fn the_default_domain_is_canonicalized_from_its_spellings() {
for spelling in ["", "local", "local.", "LOCAL", "Local.", "LoCaL"] {
let options = config(spelling, None).validate().unwrap();
assert_eq!(
options.domain(),
DEFAULT_DOMAIN,
"`{spelling}` names the default domain"
);
}
}
#[test]
fn a_custom_domain_is_passed_through_unchanged() {
for domain in ["corp", "corp.example.com", "Corp"] {
let options = config(domain, None).validate().unwrap();
assert_eq!(options.domain(), domain);
}
}
#[test]
fn mdns_sd_accepts_a_custom_domain() {
let options = config("corp", Some("_ssh._tcp")).validate().unwrap();
assert_eq!(options.backend(), DiscoveryBackend::MdnsSd);
assert_eq!(options.domain(), "corp");
assert_eq!(options.service_type().unwrap().to_string(), "_ssh._tcp");
}
#[cfg(feature = "zeroconf")]
#[test]
fn zeroconf_rejects_a_custom_domain_with_actionable_text() {
let mut config = config("corp", None);
config.backend = DiscoveryBackend::Zeroconf;
let err = config.validate().unwrap_err();
assert_eq!(
err,
DiscoveryOptionError::UnsupportedDomain {
backend: DiscoveryBackend::Zeroconf,
domain: "corp".to_string(),
}
);
let message = err.to_string();
assert!(message.contains("`zeroconf`"));
assert!(message.contains("`corp`"));
assert!(
message.contains("mdns-sd"),
"the remedy must name a backend that can: {message}"
);
}
#[cfg(feature = "zeroconf")]
#[test]
fn zeroconf_accepts_every_spelling_of_the_default_domain() {
for spelling in ["", "local", "local.", "LOCAL"] {
let mut config = config(spelling, None);
config.backend = DiscoveryBackend::Zeroconf;
let options = config.validate().expect(spelling);
assert_eq!(options.domain(), DEFAULT_DOMAIN);
}
}
#[cfg(feature = "fake")]
#[test]
fn fake_backend_accepts_a_custom_domain() {
let mut config = config("corp", None);
config.backend = DiscoveryBackend::Fake;
let options = config.validate().expect("fake supports custom domains");
assert_eq!(options.domain(), "corp");
assert_eq!(options.backend(), DiscoveryBackend::Fake);
}
#[cfg(feature = "fake")]
#[test]
fn fake_backend_still_validates_the_service_type() {
let mut config = config("corp", Some("not a service type"));
config.backend = DiscoveryBackend::Fake;
let err = config.validate().unwrap_err();
assert!(matches!(err, DiscoveryOptionError::ServiceType { .. }));
}
}