1use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28pub const ADVERTISE_METHOD: &str = "mdns.advertise";
30pub const ADVERTISE_CAPABILITY: &str = "network.listen";
33pub const BROWSE_METHOD: &str = "mdns.browse";
35pub const BROWSE_CAPABILITY: &str = "network.outbound";
38
39pub const RESERVED_SERVICE_TYPES: &[&str] = &["_ados._tcp", "_ados-receiver._tcp"];
44
45pub const BROWSE_DEFAULT_TIMEOUT_MS: u32 = 3_000;
47pub const BROWSE_MAX_TIMEOUT_MS: u32 = 5_000;
49pub const BROWSE_MIN_TIMEOUT_MS: u32 = 100;
51pub const BROWSE_MAX_RESULTS: usize = 64;
53
54pub const TXT_MAX_ENTRIES: usize = 16;
56pub const TXT_MAX_KEY_LEN: usize = 32;
58pub const TXT_MAX_VALUE_LEN: usize = 200;
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AdvertiseRequest {
64 pub service_type: String,
67 pub port: u16,
70 #[serde(default)]
72 pub txt: BTreeMap<String, String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Advertised {
78 pub fullname: String,
81 pub hostname: String,
83 pub port: u16,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct BrowseRequest {
89 pub service_type: String,
91 #[serde(default)]
95 pub timeout_ms: Option<u32>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
100pub struct BrowseReply {
101 pub services: Vec<DiscoveredService>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct DiscoveredService {
109 pub fullname: String,
111 pub hostname: String,
113 pub port: u16,
114 pub addresses: Vec<String>,
116 pub txt: BTreeMap<String, String>,
117}
118
119impl BrowseRequest {
120 pub fn timeout_ms(&self) -> u32 {
122 self.timeout_ms
123 .unwrap_or(BROWSE_DEFAULT_TIMEOUT_MS)
124 .clamp(BROWSE_MIN_TIMEOUT_MS, BROWSE_MAX_TIMEOUT_MS)
125 }
126}
127
128pub fn normalize_service_type(raw: &str) -> Result<String, String> {
135 let lowered = raw.trim().to_ascii_lowercase();
136 let bare = lowered
137 .strip_suffix(".local.")
138 .or_else(|| lowered.strip_suffix(".local"))
139 .unwrap_or(&lowered);
140 let (name, proto) = bare
141 .rsplit_once('.')
142 .ok_or_else(|| format!("service type {raw:?} is not _<name>._tcp or _<name>._udp"))?;
143 if proto != "_tcp" && proto != "_udp" {
144 return Err(format!("service type {raw:?} must end in ._tcp or ._udp"));
145 }
146 let label = name
147 .strip_prefix('_')
148 .ok_or_else(|| format!("service type {raw:?} must start with an underscore"))?;
149 let valid = (1..=15).contains(&label.len())
150 && label
151 .bytes()
152 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
153 && label.bytes().any(|b| b.is_ascii_lowercase())
154 && !label.starts_with('-')
155 && !label.ends_with('-')
156 && !label.contains("--");
157 if !valid {
158 return Err(format!(
159 "service type {raw:?}: the name must be 1-15 letters, digits and single inner hyphens"
160 ));
161 }
162 Ok(format!("_{label}.{proto}"))
163}
164
165pub fn advertisable_service_type(raw: &str) -> Result<String, String> {
168 let ty = normalize_service_type(raw)?;
169 if !ty.ends_with("._tcp") {
170 return Err(format!(
171 "service type {ty} is not TCP; a plugin serves only the TCP ports it declares"
172 ));
173 }
174 if RESERVED_SERVICE_TYPES.contains(&ty.as_str()) {
175 return Err(format!(
176 "service type {ty} is published by the agent itself"
177 ));
178 }
179 Ok(ty)
180}
181
182pub fn validate_txt(txt: &BTreeMap<String, String>) -> Result<(), String> {
186 if txt.len() > TXT_MAX_ENTRIES {
187 return Err(format!(
188 "txt carries {} entries; at most {TXT_MAX_ENTRIES}",
189 txt.len()
190 ));
191 }
192 for (key, value) in txt {
193 let key_ok = (1..=TXT_MAX_KEY_LEN).contains(&key.len())
194 && key.bytes().all(|b| (0x21..=0x7e).contains(&b) && b != b'=');
195 if !key_ok {
196 return Err(format!(
197 "txt key {key:?} must be 1-{TXT_MAX_KEY_LEN} printable ASCII characters without '='"
198 ));
199 }
200 if value.len() > TXT_MAX_VALUE_LEN {
201 return Err(format!(
202 "txt value for {key:?} is {} bytes; at most {TXT_MAX_VALUE_LEN}",
203 value.len()
204 ));
205 }
206 }
207 Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn service_types_normalize_to_the_bare_form() {
216 for raw in [
217 "_ados-compute._tcp",
218 "_ados-compute._tcp.local",
219 "_ados-compute._tcp.local.",
220 " _ADOS-Compute._TCP.local. ",
221 ] {
222 assert_eq!(
223 normalize_service_type(raw).as_deref(),
224 Ok("_ados-compute._tcp"),
225 "{raw}"
226 );
227 }
228 assert_eq!(
229 normalize_service_type("_rtsp._udp").as_deref(),
230 Ok("_rtsp._udp")
231 );
232 }
233
234 #[test]
235 fn malformed_service_types_are_refused() {
236 for raw in [
237 "",
238 "ados._tcp",
239 "_ados._sctp",
240 "_._tcp",
241 "_-ados._tcp",
242 "_ados-._tcp",
243 "_ad--os._tcp",
244 "_1234._tcp",
245 "_sixteen-chars-xx._tcp",
246 "_ados_x._tcp",
247 "_ados.x._tcp",
248 ] {
249 assert!(normalize_service_type(raw).is_err(), "{raw:?}");
250 }
251 }
252
253 #[test]
254 fn a_plugin_may_not_advertise_a_core_type_or_a_udp_one() {
255 assert!(advertisable_service_type("_ados._tcp.local.").is_err());
256 assert!(advertisable_service_type("_ADOS-receiver._tcp").is_err());
257 assert!(advertisable_service_type("_ados-compute._udp").is_err());
258 assert_eq!(
259 advertisable_service_type("_ados-compute._tcp").as_deref(),
260 Ok("_ados-compute._tcp")
261 );
262 }
263
264 #[test]
265 fn txt_records_are_bounded() {
266 let one = |k: &str, v: &str| BTreeMap::from([(k.to_string(), v.to_string())]);
267 assert!(validate_txt(&one("deviceId", "compute-8270beb76258")).is_ok());
268 assert!(validate_txt(&one("", "x")).is_err());
269 assert!(validate_txt(&one("a=b", "x")).is_err());
270 assert!(validate_txt(&one("sp ace", "x")).is_err());
271 assert!(validate_txt(&one(&"k".repeat(TXT_MAX_KEY_LEN + 1), "x")).is_err());
272 assert!(validate_txt(&one("k", &"v".repeat(TXT_MAX_VALUE_LEN + 1))).is_err());
273 let many: BTreeMap<String, String> = (0..=TXT_MAX_ENTRIES)
274 .map(|i| (format!("k{i}"), String::new()))
275 .collect();
276 assert!(validate_txt(&many).is_err());
277 }
278
279 #[test]
280 fn a_browse_window_is_clamped() {
281 let req = |t: Option<u32>| BrowseRequest {
282 service_type: "_x._tcp".into(),
283 timeout_ms: t,
284 };
285 assert_eq!(req(None).timeout_ms(), BROWSE_DEFAULT_TIMEOUT_MS);
286 assert_eq!(req(Some(0)).timeout_ms(), BROWSE_MIN_TIMEOUT_MS);
287 assert_eq!(req(Some(60_000)).timeout_ms(), BROWSE_MAX_TIMEOUT_MS);
288 assert_eq!(req(Some(1_500)).timeout_ms(), 1_500);
289 }
290}