use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const ADVERTISE_METHOD: &str = "mdns.advertise";
pub const ADVERTISE_CAPABILITY: &str = "network.listen";
pub const BROWSE_METHOD: &str = "mdns.browse";
pub const BROWSE_CAPABILITY: &str = "network.outbound";
pub const RESERVED_SERVICE_TYPES: &[&str] = &["_ados._tcp", "_ados-receiver._tcp"];
pub const BROWSE_DEFAULT_TIMEOUT_MS: u32 = 3_000;
pub const BROWSE_MAX_TIMEOUT_MS: u32 = 5_000;
pub const BROWSE_MIN_TIMEOUT_MS: u32 = 100;
pub const BROWSE_MAX_RESULTS: usize = 64;
pub const TXT_MAX_ENTRIES: usize = 16;
pub const TXT_MAX_KEY_LEN: usize = 32;
pub const TXT_MAX_VALUE_LEN: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdvertiseRequest {
pub service_type: String,
pub port: u16,
#[serde(default)]
pub txt: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Advertised {
pub fullname: String,
pub hostname: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowseRequest {
pub service_type: String,
#[serde(default)]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BrowseReply {
pub services: Vec<DiscoveredService>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredService {
pub fullname: String,
pub hostname: String,
pub port: u16,
pub addresses: Vec<String>,
pub txt: BTreeMap<String, String>,
}
impl BrowseRequest {
pub fn timeout_ms(&self) -> u32 {
self.timeout_ms
.unwrap_or(BROWSE_DEFAULT_TIMEOUT_MS)
.clamp(BROWSE_MIN_TIMEOUT_MS, BROWSE_MAX_TIMEOUT_MS)
}
}
pub fn normalize_service_type(raw: &str) -> Result<String, String> {
let lowered = raw.trim().to_ascii_lowercase();
let bare = lowered
.strip_suffix(".local.")
.or_else(|| lowered.strip_suffix(".local"))
.unwrap_or(&lowered);
let (name, proto) = bare
.rsplit_once('.')
.ok_or_else(|| format!("service type {raw:?} is not _<name>._tcp or _<name>._udp"))?;
if proto != "_tcp" && proto != "_udp" {
return Err(format!("service type {raw:?} must end in ._tcp or ._udp"));
}
let label = name
.strip_prefix('_')
.ok_or_else(|| format!("service type {raw:?} must start with an underscore"))?;
let valid = (1..=15).contains(&label.len())
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& label.bytes().any(|b| b.is_ascii_lowercase())
&& !label.starts_with('-')
&& !label.ends_with('-')
&& !label.contains("--");
if !valid {
return Err(format!(
"service type {raw:?}: the name must be 1-15 letters, digits and single inner hyphens"
));
}
Ok(format!("_{label}.{proto}"))
}
pub fn advertisable_service_type(raw: &str) -> Result<String, String> {
let ty = normalize_service_type(raw)?;
if !ty.ends_with("._tcp") {
return Err(format!(
"service type {ty} is not TCP; a plugin serves only the TCP ports it declares"
));
}
if RESERVED_SERVICE_TYPES.contains(&ty.as_str()) {
return Err(format!(
"service type {ty} is published by the agent itself"
));
}
Ok(ty)
}
pub fn validate_txt(txt: &BTreeMap<String, String>) -> Result<(), String> {
if txt.len() > TXT_MAX_ENTRIES {
return Err(format!(
"txt carries {} entries; at most {TXT_MAX_ENTRIES}",
txt.len()
));
}
for (key, value) in txt {
let key_ok = (1..=TXT_MAX_KEY_LEN).contains(&key.len())
&& key.bytes().all(|b| (0x21..=0x7e).contains(&b) && b != b'=');
if !key_ok {
return Err(format!(
"txt key {key:?} must be 1-{TXT_MAX_KEY_LEN} printable ASCII characters without '='"
));
}
if value.len() > TXT_MAX_VALUE_LEN {
return Err(format!(
"txt value for {key:?} is {} bytes; at most {TXT_MAX_VALUE_LEN}",
value.len()
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_types_normalize_to_the_bare_form() {
for raw in [
"_ados-compute._tcp",
"_ados-compute._tcp.local",
"_ados-compute._tcp.local.",
" _ADOS-Compute._TCP.local. ",
] {
assert_eq!(
normalize_service_type(raw).as_deref(),
Ok("_ados-compute._tcp"),
"{raw}"
);
}
assert_eq!(
normalize_service_type("_rtsp._udp").as_deref(),
Ok("_rtsp._udp")
);
}
#[test]
fn malformed_service_types_are_refused() {
for raw in [
"",
"ados._tcp",
"_ados._sctp",
"_._tcp",
"_-ados._tcp",
"_ados-._tcp",
"_ad--os._tcp",
"_1234._tcp",
"_sixteen-chars-xx._tcp",
"_ados_x._tcp",
"_ados.x._tcp",
] {
assert!(normalize_service_type(raw).is_err(), "{raw:?}");
}
}
#[test]
fn a_plugin_may_not_advertise_a_core_type_or_a_udp_one() {
assert!(advertisable_service_type("_ados._tcp.local.").is_err());
assert!(advertisable_service_type("_ADOS-receiver._tcp").is_err());
assert!(advertisable_service_type("_ados-compute._udp").is_err());
assert_eq!(
advertisable_service_type("_ados-compute._tcp").as_deref(),
Ok("_ados-compute._tcp")
);
}
#[test]
fn txt_records_are_bounded() {
let one = |k: &str, v: &str| BTreeMap::from([(k.to_string(), v.to_string())]);
assert!(validate_txt(&one("deviceId", "compute-8270beb76258")).is_ok());
assert!(validate_txt(&one("", "x")).is_err());
assert!(validate_txt(&one("a=b", "x")).is_err());
assert!(validate_txt(&one("sp ace", "x")).is_err());
assert!(validate_txt(&one(&"k".repeat(TXT_MAX_KEY_LEN + 1), "x")).is_err());
assert!(validate_txt(&one("k", &"v".repeat(TXT_MAX_VALUE_LEN + 1))).is_err());
let many: BTreeMap<String, String> = (0..=TXT_MAX_ENTRIES)
.map(|i| (format!("k{i}"), String::new()))
.collect();
assert!(validate_txt(&many).is_err());
}
#[test]
fn a_browse_window_is_clamped() {
let req = |t: Option<u32>| BrowseRequest {
service_type: "_x._tcp".into(),
timeout_ms: t,
};
assert_eq!(req(None).timeout_ms(), BROWSE_DEFAULT_TIMEOUT_MS);
assert_eq!(req(Some(0)).timeout_ms(), BROWSE_MIN_TIMEOUT_MS);
assert_eq!(req(Some(60_000)).timeout_ms(), BROWSE_MAX_TIMEOUT_MS);
assert_eq!(req(Some(1_500)).timeout_ms(), 1_500);
}
}