1use std::net::{IpAddr, SocketAddr};
2use std::num::NonZeroU32;
3use std::time::Duration;
4use thiserror::Error;
5use tokio::sync::mpsc;
6
7#[cfg(all(unix, not(target_os = "macos")))]
8use crate::linux::{BrowseGuard, browse_start, resolve_once};
9#[cfg(target_os = "macos")]
10use crate::macos::{BrowseGuard, browse_start, resolve_once};
11#[cfg(target_os = "windows")]
12use crate::windows::{BrowseGuard, browse_start, resolve_once};
13
14pub(crate) type BrowseEventSender = mpsc::UnboundedSender<Result<BrowseEvent, ServiceBrowseError>>;
16pub(crate) type BrowseEventReceiver =
18 mpsc::UnboundedReceiver<Result<BrowseEvent, ServiceBrowseError>>;
19
20#[derive(Debug, Clone, Default)]
45pub struct ServiceBrowserBuilder {
46 pub(crate) service_type: Option<String>,
48 pub(crate) domain: Option<String>,
49 pub(crate) interface_index: Option<NonZeroU32>,
50}
51
52impl ServiceBrowserBuilder {
53 pub fn new() -> Self {
57 Self::default()
58 }
59
60 pub fn service_type(&mut self, service_type: impl AsRef<str>) -> &mut Self {
68 self.service_type = Some(service_type.as_ref().to_string());
69 self
70 }
71
72 pub fn domain(&mut self, domain: impl AsRef<str>) -> &mut Self {
77 self.domain = Some(domain.as_ref().to_string());
78 self
79 }
80
81 pub fn interface_index(&mut self, index: NonZeroU32) -> &mut Self {
88 self.interface_index = Some(index);
89 self
90 }
91
92 pub async fn browse(&self) -> Result<ServiceBrowser, ServiceBrowseError> {
96 let (rx, guard) =
97 browse_start(&self.service_type, &self.domain, self.interface_index).await?;
98 Ok(ServiceBrowser { rx, _guard: guard })
99 }
100}
101
102pub struct ServiceBrowser {
107 rx: BrowseEventReceiver,
108 _guard: BrowseGuard,
110}
111
112impl ServiceBrowser {
113 pub async fn recv(&mut self) -> Option<Result<BrowseEvent, ServiceBrowseError>> {
119 self.rx.recv().await
120 }
121}
122
123#[derive(Debug, Clone)]
158pub struct ServiceResolverBuilder {
159 name: String,
160 service_type: String,
161 domain: String,
162 interface_index: Option<NonZeroU32>,
163 timeout: Option<Duration>,
164}
165
166impl ServiceResolverBuilder {
167 pub fn new(
174 name: impl AsRef<str>,
175 service_type: impl AsRef<str>,
176 domain: impl AsRef<str>,
177 ) -> Self {
178 Self {
179 name: name.as_ref().to_string(),
180 service_type: service_type.as_ref().to_string(),
181 domain: domain.as_ref().to_string(),
182 interface_index: None,
183 timeout: None,
184 }
185 }
186
187 pub fn interface_index(&mut self, index: NonZeroU32) -> &mut Self {
192 self.interface_index = Some(index);
193 self
194 }
195
196 pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
204 self.timeout = Some(timeout);
205 self
206 }
207
208 pub async fn resolve(&self) -> Result<DiscoveredService, ServiceBrowseError> {
213 let resolve = resolve_once(
214 &self.name,
215 &self.service_type,
216 &self.domain,
217 self.interface_index,
218 );
219 match self.timeout {
220 Some(timeout) => tokio::time::timeout(timeout, resolve)
221 .await
222 .unwrap_or_else(|_| {
223 Err(ServiceBrowseError::ResolveFailed(
224 self.name.clone(),
225 format!("timed out after {timeout:?}"),
226 ))
227 }),
228 None => resolve.await,
229 }
230 }
231}
232
233#[derive(Debug, Clone)]
235pub enum BrowseEvent {
236 Found(DiscoveredService),
238 Removed(RemovedService),
240}
241
242#[derive(Debug, Clone)]
244pub struct DiscoveredService {
245 pub name: String,
247 pub service_type: String,
249 pub domain: String,
251 pub host_name: String,
253 pub port: u16,
255 pub addresses: Vec<IpAddr>,
258 pub txt_records: Vec<TxtRecord>,
260 pub interface_index: Option<NonZeroU32>,
262}
263
264impl DiscoveredService {
265 pub fn socket_addrs(&self) -> impl Iterator<Item = SocketAddr> + '_ {
268 self.addresses
269 .iter()
270 .map(move |&ip| SocketAddr::new(ip, self.port))
271 }
272
273 pub fn txt(&self, key: &str) -> Option<&[u8]> {
278 self.txt_records
279 .iter()
280 .find(|r| r.key == key)
281 .and_then(|r| r.value.as_deref())
282 }
283}
284
285#[derive(Debug, Clone)]
290pub struct RemovedService {
291 pub name: String,
293 pub service_type: String,
295 pub domain: String,
297 pub interface_index: Option<NonZeroU32>,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct TxtRecord {
307 pub key: String,
309 pub value: Option<Vec<u8>>,
311}
312
313#[derive(Error, Debug)]
315pub enum ServiceBrowseError {
316 #[error("DNS-SD not available on system: {0}")]
318 DnsSdUnavailable(String),
319
320 #[error("parameter {0:?} contains interior nul byte at position {1}")]
322 ParameterContainsInteriorNulByte(String, usize),
323
324 #[error("interface index {0} is invalid")]
326 InvalidInterfaceIndex(u32),
327
328 #[error("browse operation failed: {0}")]
330 BrowseFailed(String),
331
332 #[error("failed to resolve service {0:?}: {1}")]
334 ResolveFailed(String, String),
335}
336
337#[cfg(any(unix, fuzzing))]
342pub(crate) fn parse_txt_entry(entry: &[u8]) -> TxtRecord {
343 match entry.iter().position(|&b| b == b'=') {
344 Some(pos) => TxtRecord {
345 key: String::from_utf8_lossy(&entry[..pos]).into_owned(),
346 value: Some(entry[pos + 1..].to_vec()),
347 },
348 None => TxtRecord {
349 key: String::from_utf8_lossy(entry).into_owned(),
350 value: None,
351 },
352 }
353}
354
355#[cfg(any(target_os = "macos", fuzzing))]
358pub(crate) fn parse_txt_buffer(buf: &[u8]) -> Vec<TxtRecord> {
359 let mut records = Vec::new();
360 let mut i = 0;
361 while i < buf.len() {
362 let len = buf[i] as usize;
363 i += 1;
364 if i + len > buf.len() {
365 break;
366 }
367 if len > 0 {
368 records.push(parse_txt_entry(&buf[i..i + len]));
369 }
370 i += len;
371 }
372 records
373}
374
375#[cfg(any(target_os = "macos", target_os = "windows", fuzzing))]
378pub(crate) fn trim_dot(s: &str) -> String {
379 s.trim_end_matches('.').to_string()
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::net::{Ipv4Addr, Ipv6Addr};
386
387 fn sample_service() -> DiscoveredService {
388 DiscoveredService {
389 name: "My Web Server".to_string(),
390 service_type: "_http._tcp".to_string(),
391 domain: "local".to_string(),
392 host_name: "macbook.local".to_string(),
393 port: 8080,
394 addresses: vec![
395 IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
396 IpAddr::V6(Ipv6Addr::LOCALHOST),
397 ],
398 txt_records: vec![
399 TxtRecord {
400 key: "path".to_string(),
401 value: Some(b"/index.html".to_vec()),
402 },
403 TxtRecord {
404 key: "empty".to_string(),
405 value: Some(Vec::new()),
406 },
407 TxtRecord {
408 key: "flag".to_string(),
409 value: None,
410 },
411 ],
412 interface_index: NonZeroU32::new(3),
413 }
414 }
415
416 #[test]
417 fn builder_defaults_to_browsing_all_types() {
418 let builder = ServiceBrowserBuilder::new();
419 assert_eq!(builder.service_type, None);
420 assert_eq!(builder.domain, None);
421 assert_eq!(builder.interface_index, None);
422 }
423
424 #[test]
425 fn builder_default_matches_new() {
426 let from_default = ServiceBrowserBuilder::default();
427 let from_new = ServiceBrowserBuilder::new();
428 assert_eq!(from_default.service_type, from_new.service_type);
429 assert_eq!(from_default.domain, from_new.domain);
430 assert_eq!(from_default.interface_index, from_new.interface_index);
431 }
432
433 #[test]
434 fn builder_setters_record_values_and_chain() {
435 let mut builder = ServiceBrowserBuilder::new();
436 builder
437 .service_type("_http._tcp")
438 .domain("local")
439 .interface_index(NonZeroU32::new(7).unwrap());
440
441 assert_eq!(builder.service_type.as_deref(), Some("_http._tcp"));
442 assert_eq!(builder.domain.as_deref(), Some("local"));
443 assert_eq!(builder.interface_index, NonZeroU32::new(7));
444 }
445
446 #[test]
447 fn builder_setters_accept_string_and_overwrite() {
448 let mut builder = ServiceBrowserBuilder::new();
449 builder.service_type(String::from("_ftp._tcp"));
450 builder.service_type("_ipp._tcp");
451 assert_eq!(builder.service_type.as_deref(), Some("_ipp._tcp"));
452 }
453
454 #[test]
455 fn socket_addrs_pairs_each_address_with_port() {
456 let service = sample_service();
457 let addrs: Vec<SocketAddr> = service.socket_addrs().collect();
458 assert_eq!(
459 addrs,
460 vec![
461 SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 8080),
462 SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080),
463 ]
464 );
465 }
466
467 #[test]
468 fn socket_addrs_is_empty_without_addresses() {
469 let mut service = sample_service();
470 service.addresses.clear();
471 assert_eq!(service.socket_addrs().count(), 0);
472 }
473
474 #[test]
475 fn txt_returns_value_for_present_key() {
476 let service = sample_service();
477 assert_eq!(service.txt("path"), Some(&b"/index.html"[..]));
478 }
479
480 #[test]
481 fn txt_returns_empty_slice_for_present_empty_value() {
482 let service = sample_service();
483 assert_eq!(service.txt("empty"), Some(&[][..]));
484 }
485
486 #[test]
487 fn txt_returns_none_for_key_only_entry() {
488 let service = sample_service();
489 assert_eq!(service.txt("flag"), None);
490 }
491
492 #[test]
493 fn txt_returns_none_for_absent_key() {
494 let service = sample_service();
495 assert_eq!(service.txt("missing"), None);
496 }
497
498 #[test]
499 fn txt_returns_first_match_for_duplicate_keys() {
500 let mut service = sample_service();
501 service.txt_records.push(TxtRecord {
502 key: "path".to_string(),
503 value: Some(b"/second".to_vec()),
504 });
505 assert_eq!(service.txt("path"), Some(&b"/index.html"[..]));
506 }
507
508 #[test]
509 fn error_messages_render_expected_text() {
510 assert_eq!(
511 ServiceBrowseError::DnsSdUnavailable("no avahi".into()).to_string(),
512 "DNS-SD not available on system: no avahi"
513 );
514 assert_eq!(
515 ServiceBrowseError::ParameterContainsInteriorNulByte("a\0b".into(), 1).to_string(),
516 "parameter \"a\\0b\" contains interior nul byte at position 1"
517 );
518 assert_eq!(
519 ServiceBrowseError::InvalidInterfaceIndex(42).to_string(),
520 "interface index 42 is invalid"
521 );
522 assert_eq!(
523 ServiceBrowseError::BrowseFailed("boom".into()).to_string(),
524 "browse operation failed: boom"
525 );
526 assert_eq!(
527 ServiceBrowseError::ResolveFailed("svc".into(), "timeout".into()).to_string(),
528 "failed to resolve service \"svc\": timeout"
529 );
530 }
531
532 #[cfg(unix)]
533 #[test]
534 fn parse_txt_entry_splits_key_and_value() {
535 let record = parse_txt_entry(b"path=/index.html");
536 assert_eq!(record.key, "path");
537 assert_eq!(record.value.as_deref(), Some(&b"/index.html"[..]));
538 }
539
540 #[cfg(unix)]
541 #[test]
542 fn parse_txt_entry_empty_value_after_equals() {
543 let record = parse_txt_entry(b"key=");
544 assert_eq!(record.key, "key");
545 assert_eq!(record.value.as_deref(), Some(&[][..]));
546 }
547
548 #[cfg(unix)]
549 #[test]
550 fn parse_txt_entry_key_only_has_no_value() {
551 let record = parse_txt_entry(b"flag");
552 assert_eq!(record.key, "flag");
553 assert_eq!(record.value, None);
554 }
555
556 #[cfg(unix)]
557 #[test]
558 fn parse_txt_entry_splits_on_first_equals_only() {
559 let record = parse_txt_entry(b"k=a=b");
560 assert_eq!(record.key, "k");
561 assert_eq!(record.value.as_deref(), Some(&b"a=b"[..]));
562 }
563
564 #[cfg(unix)]
565 #[test]
566 fn parse_txt_entry_preserves_binary_value() {
567 let record = parse_txt_entry(b"bin=\x00\xff\x01");
568 assert_eq!(record.key, "bin");
569 assert_eq!(record.value.as_deref(), Some(&[0x00, 0xff, 0x01][..]));
570 }
571
572 #[cfg(unix)]
573 #[test]
574 fn parse_txt_entry_lossily_decodes_invalid_utf8_key() {
575 let record = parse_txt_entry(b"\xff\xffkey");
576 assert!(record.key.contains('\u{FFFD}'));
577 assert_eq!(record.value, None);
578 }
579
580 #[cfg(target_os = "macos")]
581 #[test]
582 fn parse_txt_buffer_reads_length_prefixed_entries() {
583 let buf = [3u8, b'a', b'=', b'1', 4u8, b'f', b'l', b'a', b'g'];
585 let records = parse_txt_buffer(&buf);
586 assert_eq!(records.len(), 2);
587 assert_eq!(records[0].key, "a");
588 assert_eq!(records[0].value.as_deref(), Some(&b"1"[..]));
589 assert_eq!(records[1].key, "flag");
590 assert_eq!(records[1].value, None);
591 }
592
593 #[cfg(target_os = "macos")]
594 #[test]
595 fn parse_txt_buffer_skips_empty_entries() {
596 let buf = [0u8, 3u8, b'a', b'=', b'1'];
597 let records = parse_txt_buffer(&buf);
598 assert_eq!(records.len(), 1);
599 assert_eq!(records[0].key, "a");
600 }
601
602 #[cfg(target_os = "macos")]
603 #[test]
604 fn parse_txt_buffer_stops_on_truncated_entry() {
605 let buf = [5u8, b'a', b'b'];
607 assert!(parse_txt_buffer(&buf).is_empty());
608 }
609
610 #[cfg(target_os = "macos")]
611 #[test]
612 fn parse_txt_buffer_empty_input_yields_no_records() {
613 assert!(parse_txt_buffer(&[]).is_empty());
614 }
615
616 #[cfg(any(target_os = "macos", target_os = "windows"))]
617 #[test]
618 fn trim_dot_strips_trailing_dots() {
619 assert_eq!(trim_dot("host.local."), "host.local");
620 assert_eq!(trim_dot("host.local"), "host.local");
621 }
622
623 #[cfg(any(target_os = "macos", target_os = "windows"))]
624 #[test]
625 fn trim_dot_strips_multiple_trailing_dots() {
626 assert_eq!(trim_dot("host.local.."), "host.local");
627 }
628
629 #[cfg(any(target_os = "macos", target_os = "windows"))]
630 #[test]
631 fn trim_dot_preserves_interior_dots() {
632 assert_eq!(trim_dot("a.b.c."), "a.b.c");
633 }
634}