Skip to main content

mdns_sd_discovery/
browse.rs

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
14/// Sender used by platform back-ends to publish discovery events.
15pub(crate) type BrowseEventSender = mpsc::UnboundedSender<Result<BrowseEvent, ServiceBrowseError>>;
16/// Receiver held by [`ServiceBrowser`] to deliver discovery events to the caller.
17pub(crate) type BrowseEventReceiver =
18    mpsc::UnboundedReceiver<Result<BrowseEvent, ServiceBrowseError>>;
19
20/// Builder for a DNS-SD service browse (discovery) operation.
21///
22/// By default a browser discovers **all** service types advertised on the
23/// network and reports the instances of each (using the DNS-SD service-type
24/// enumeration meta-query, see [RFC 6763 §9](https://datatracker.ietf.org/doc/html/rfc6763#section-9)).
25/// Filter on [`DiscoveredService::service_type`] for the types you care about,
26/// or call [`service_type`](Self::service_type) to narrow to a single type.
27///
28/// # Example
29///
30/// ```no_run
31/// use mdns_sd_discovery::{BrowseEvent, ServiceBrowserBuilder};
32///
33/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
34/// let mut browser = ServiceBrowserBuilder::new().browse().await?;
35/// while let Some(event) = browser.recv().await {
36///     match event? {
37///         BrowseEvent::Found(svc) => println!("+ {} {}:{}", svc.name, svc.host_name, svc.port),
38///         BrowseEvent::Removed(svc) => println!("- {}", svc.name),
39///     }
40/// }
41/// # Ok(())
42/// # }
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct ServiceBrowserBuilder {
46    /// `None` means browse every service type via the meta-query cascade.
47    pub(crate) service_type: Option<String>,
48    pub(crate) domain: Option<String>,
49    pub(crate) interface_index: Option<NonZeroU32>,
50}
51
52impl ServiceBrowserBuilder {
53    /// Initializes a builder that browses for **all** service types on the network.
54    ///
55    /// Call [`service_type`](Self::service_type) to narrow to a single type.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Restricts browsing to a single service type, e.g. `_http._tcp`.
61    ///
62    /// The service type is the service followed by the transport protocol,
63    /// separated by a dot (e.g. `_ftp._tcp`). When unset (the default), all
64    /// service types are discovered via the DNS-SD meta-query
65    /// (`_services._dns-sd._udp`); setting a type skips the meta-query and
66    /// browses that type directly.
67    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    /// Browses a specific domain.
73    ///
74    /// Most applications will not specify a domain, instead browsing the
75    /// default browse domain(s) (usually `local`).
76    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    /// Restricts browsing to a single interface
82    /// (the index for a given interface is determined via the `if_nametoindex()`
83    /// family of calls.)
84    ///
85    /// Most applications will not specify an interface, instead browsing on all
86    /// available interfaces.
87    pub fn interface_index(&mut self, index: NonZeroU32) -> &mut Self {
88        self.interface_index = Some(index);
89        self
90    }
91
92    /// Starts browsing, returning a live [`ServiceBrowser`] handle.
93    ///
94    /// The browse runs until the returned handle is dropped.
95    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
102/// A live handle to an ongoing browse operation.
103///
104/// Call [`recv`](Self::recv) to await discovery events. The underlying native
105/// browse operation is stopped when this handle is dropped.
106pub struct ServiceBrowser {
107    rx: BrowseEventReceiver,
108    /// Platform guard whose `Drop` tears down the native browse operation.
109    _guard: BrowseGuard,
110}
111
112impl ServiceBrowser {
113    /// Awaits the next discovery event.
114    ///
115    /// Returns `None` once the browse has terminated (the back-end shut down or
116    /// the handle is being dropped). A failure to resolve an individual service
117    /// is reported as `Some(Err(..))` and does **not** end the stream.
118    pub async fn recv(&mut self) -> Option<Result<BrowseEvent, ServiceBrowseError>> {
119        self.rx.recv().await
120    }
121}
122
123/// Builder for a one-shot DNS-SD *resolve* of a single, already-known service
124/// instance.
125///
126/// Where [`ServiceBrowserBuilder`] discovers instances over time, this resolves
127/// one named instance *now* — mapping the identity fields from a previous
128/// browse [`BrowseEvent::Found`] (or [`BrowseEvent::Removed`]) event back to a
129/// connectable [`DiscoveredService`]. Its main use is as a liveness probe: a
130/// dead instance fails with [`ServiceBrowseError::ResolveFailed`] rather than
131/// hanging, so consumers can re-confirm services they haven't heard from
132/// recently and drop the ones that repeatedly fail.
133///
134/// This matters for the *no-goodbye* case (a host powered off, crashed, or that
135/// dropped off Wi-Fi without multicasting mDNS goodbye packets): the OS keeps
136/// the browse-driving PTR record until its (long) TTL expires, so no `Removed`
137/// event is delivered for a long time, yet a fresh resolve of the vanished
138/// instance fails promptly.
139///
140/// # Example
141///
142/// ```no_run
143/// use mdns_sd_discovery::{ServiceBrowseError, ServiceResolverBuilder};
144///
145/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
146/// match ServiceResolverBuilder::new("My Web Server", "_http._tcp", "local")
147///     .resolve()
148///     .await
149/// {
150///     Ok(svc) => println!("still alive at {}:{}", svc.host_name, svc.port),
151///     Err(ServiceBrowseError::ResolveFailed(name, _)) => println!("{name} is gone"),
152///     Err(err) => return Err(err.into()),
153/// }
154/// # Ok(())
155/// # }
156/// ```
157#[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    /// Initializes a builder to resolve the instance identified by `name`,
168    /// `service_type` (e.g. `_http._tcp`) and `domain` (e.g. `local`).
169    ///
170    /// These are exactly the [`name`](DiscoveredService::name),
171    /// [`service_type`](DiscoveredService::service_type) and
172    /// [`domain`](DiscoveredService::domain) fields reported by a browse event.
173    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    /// Restricts the resolve to a single interface (the index reported on the
188    /// originating browse event, via [`DiscoveredService::interface_index`]).
189    ///
190    /// When unset (the default) the instance is resolved on all interfaces.
191    pub fn interface_index(&mut self, index: NonZeroU32) -> &mut Self {
192        self.interface_index = Some(index);
193        self
194    }
195
196    /// Bounds how long [`resolve`](Self::resolve) may take before failing with
197    /// [`ServiceBrowseError::ResolveFailed`].
198    ///
199    /// This is what makes the resolve usable as a liveness probe: without it,
200    /// resolving a vanished instance is only bounded by the platform default
201    /// (on Linux, the D-Bus method timeout of roughly 25 s). Set a shorter
202    /// timeout to fail faster; the platform default still applies as a ceiling.
203    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
204        self.timeout = Some(timeout);
205        self
206    }
207
208    /// Resolves the instance, returning a connectable [`DiscoveredService`].
209    ///
210    /// Returns [`ServiceBrowseError::ResolveFailed`] if the instance no longer
211    /// responds (or the configured [`timeout`](Self::timeout) elapses first).
212    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/// A single change in the set of discovered services.
234#[derive(Debug, Clone)]
235pub enum BrowseEvent {
236    /// A service appeared and was resolved to a connectable endpoint.
237    Found(DiscoveredService),
238    /// A previously seen service went away. Only identity fields are known.
239    Removed(RemovedService),
240}
241
242/// A resolved, connectable service instance.
243#[derive(Debug, Clone)]
244pub struct DiscoveredService {
245    /// The service instance name, e.g. `My Web Server`.
246    pub name: String,
247    /// The service type, e.g. `_http._tcp`.
248    pub service_type: String,
249    /// The domain the service was discovered in, e.g. `local`.
250    pub domain: String,
251    /// The SRV target host name, e.g. `macbook.local`.
252    pub host_name: String,
253    /// The port the service accepts connections on.
254    pub port: u16,
255    /// Resolved IP addresses for [`host_name`](Self::host_name). May be empty
256    /// if address resolution did not (yet) yield any records.
257    pub addresses: Vec<IpAddr>,
258    /// The service's TXT record entries.
259    pub txt_records: Vec<TxtRecord>,
260    /// The interface the service was discovered on, if known.
261    pub interface_index: Option<NonZeroU32>,
262}
263
264impl DiscoveredService {
265    /// Returns the resolved socket addresses (each [address](Self::addresses)
266    /// paired with the [port](Self::port)).
267    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    /// Returns the value of the first TXT record entry matching `key`, if any.
274    ///
275    /// Returns `Some(&[])` for a key that is present with an empty value and
276    /// `None` for a key that is absent or key-only.
277    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/// Identity of a service instance that disappeared.
286///
287/// Browsing does not provide resolved data on removal, so only the identifying
288/// fields are available.
289#[derive(Debug, Clone)]
290pub struct RemovedService {
291    /// The service instance name.
292    pub name: String,
293    /// The service type, e.g. `_http._tcp`.
294    pub service_type: String,
295    /// The domain the service was discovered in.
296    pub domain: String,
297    /// The interface the service was discovered on, if known.
298    pub interface_index: Option<NonZeroU32>,
299}
300
301/// A single TXT record key/value entry.
302///
303/// `value` is binary-safe and is `None` for a key-only entry (a key advertised
304/// without an `=`).
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct TxtRecord {
307    /// The TXT record key.
308    pub key: String,
309    /// The TXT record value, or `None` for a key-only entry.
310    pub value: Option<Vec<u8>>,
311}
312
313/// Error type for service browse / discovery failures.
314#[derive(Error, Debug)]
315pub enum ServiceBrowseError {
316    /// DNS-SD not available on system (Linux only - either D-Bus or Avahi unavailable).
317    #[error("DNS-SD not available on system: {0}")]
318    DnsSdUnavailable(String),
319
320    /// A string parameter contains an interior NUL byte.
321    #[error("parameter {0:?} contains interior nul byte at position {1}")]
322    ParameterContainsInteriorNulByte(String, usize),
323
324    /// The interface index is not valid.
325    #[error("interface index {0} is invalid")]
326    InvalidInterfaceIndex(u32),
327
328    /// The browse operation failed.
329    #[error("browse operation failed: {0}")]
330    BrowseFailed(String),
331
332    /// A discovered service could not be resolved to a connectable endpoint.
333    #[error("failed to resolve service {0:?}: {1}")]
334    ResolveFailed(String, String),
335}
336
337/// Parses a single `key` or `key=value` TXT entry into a [`TxtRecord`].
338///
339/// Everything before the first `=` is the key; everything after is the
340/// (binary-safe) value. An entry with no `=` is key-only.
341#[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/// Parses a packed DNS-SD TXT record buffer (a sequence of length-prefixed
356/// `key[=value]` entries) into [`TxtRecord`]s. Empty entries are skipped.
357#[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/// Strips trailing `.` characters from a DNS name (DNS-SD names are reported
376/// fully qualified, e.g. `macbook.local.`).
377#[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        // "a=1" (3) , "flag" (4)
584        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        // claims length 5 but only 2 bytes follow
606        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}