Skip to main content

hick_reactor/
discovery.rs

1//! DNS-SD service discovery — browse a service type and resolve each instance
2//! into a fully-populated [`ServiceEntry`].
3//!
4//! This is the high-level client layer over [`Endpoint::start_query`]. A
5//! [`Lookup`] performs the RFC 6763 browse → resolve chain:
6//!
7//! 1. **Browse** (§4.1): a PTR query for the service type
8//!    (`_service._proto.local.`) yields the names of the published instances.
9//! 2. **Resolve** (§5): for each instance, an SRV query gives the target host
10//!    and port and a TXT query gives the metadata.
11//! 3. **Address** (§5): A / AAAA queries against the SRV target host give the
12//!    reachable addresses.
13//!
14//! An instance is surfaced as a [`ServiceEntry`] once it has a port (SRV), TXT
15//! data, and at least one address. Each step is a real [`crate::Query`], so
16//! retransmission, caching, and TTL handling are inherited from the
17//! proto/driver layers; the whole lookup is bounded by the per-query timeouts
18//! and by [`QueryParam::with_max_entries`], and is cancelled by dropping the
19//! [`Lookup`].
20//!
21//! # Design
22//!
23//! Modeled on quinn's `ConnectionDriver`: a spawned [`LookupDriver`] task owns
24//! the browse/resolve sub-queries and the aggregation state machine, and pushes
25//! resolved entries into shared state ([`LookupQueue`]) that the [`Lookup`]
26//! handle drains — mirroring the [`crate::Query`] mailbox/doorbell split. Driving
27//! the aggregation in its own task (rather than only while the caller awaits
28//! [`Lookup::next`]) means the sub-query mailboxes are drained promptly, so a
29//! slow consumer cannot stall resolution; the shared queue stays bounded by
30//! coalescing repeated snapshots of an instance.
31//!
32//! Instance/host names that the [`Name`] type cannot represent faithfully — a
33//! label containing a `.` or a non-ASCII byte — are skipped rather than
34//! silently corrupted (`Name` is an ASCII, dot-separated, no-escaping type).
35
36use std::{
37  collections::{HashMap, HashSet, VecDeque},
38  net::{IpAddr, Ipv4Addr, Ipv6Addr},
39  sync::{Arc, Mutex, MutexGuard},
40  time::{Duration, Instant},
41};
42
43use async_channel::{Receiver, Sender};
44use bytes::Bytes;
45use futures::{
46  FutureExt, StreamExt, pin_mut, select_biased,
47  stream::{BoxStream, SelectAll},
48};
49use mdns_proto::{
50  Name, QuerySpec,
51  wire::{A, AAAA, NameRef, Ptr, ResourceType, Srv, Txt},
52};
53use smol_str::SmolStr;
54
55use crate::{
56  Endpoint, QueryEvent,
57  error::StartQueryError,
58  query::{DroppedHandle, Query},
59};
60
61/// Default cap on the number of distinct instances a [`Lookup`] tracks, so a
62/// chatty or hostile responder flooding PTR answers cannot grow the builder map
63/// or the in-flight sub-query set without bound.
64pub const DEFAULT_MAX_ENTRIES: usize = 64;
65
66/// Cap on the addresses kept per host per family (and per instance), so a
67/// responder flooding distinct A/AAAA records for one host cannot grow the
68/// address vectors (in the host cache or any builder) without bound.
69const MAX_ADDRS_PER_HOST: usize = 16;
70
71/// A resolved DNS-SD service instance.
72///
73/// Produced by [`Lookup::next`] once the instance's SRV (host + port), TXT, and
74/// at least one address have been collected.
75#[derive(Debug, Clone)]
76pub struct ServiceEntry {
77  instance: Name,
78  host: Name,
79  port: u16,
80  ipv4: Arc<[Ipv4Addr]>,
81  ipv6: Arc<[Ipv6Addr]>,
82  txt: Arc<[Bytes]>,
83}
84
85impl ServiceEntry {
86  /// The fully-qualified service instance name (the PTR target), e.g.
87  /// `myprinter._ipp._tcp.local.`.
88  #[inline]
89  pub fn instance_name(&self) -> &Name {
90    &self.instance
91  }
92
93  /// The target host the SRV record points at, e.g. `printer.local.`.
94  #[inline]
95  pub fn host(&self) -> &Name {
96    &self.host
97  }
98
99  /// The service port from the SRV record.
100  #[inline]
101  pub const fn port(&self) -> u16 {
102    self.port
103  }
104
105  /// The host's IPv4 addresses (may be empty if only IPv6 resolved).
106  #[inline]
107  pub fn ipv4_addresses(&self) -> &[Ipv4Addr] {
108    &self.ipv4
109  }
110
111  /// The host's IPv6 addresses (may be empty if only IPv4 resolved).
112  #[inline]
113  pub fn ipv6_addresses(&self) -> &[Ipv6Addr] {
114    &self.ipv6
115  }
116
117  /// All resolved addresses (IPv4 first, then IPv6).
118  pub fn addresses(&self) -> impl Iterator<Item = IpAddr> + '_ {
119    self
120      .ipv4
121      .iter()
122      .copied()
123      .map(IpAddr::V4)
124      .chain(self.ipv6.iter().copied().map(IpAddr::V6))
125  }
126
127  /// The raw TXT record segments (each a length-prefixed `key=value` string in
128  /// DNS-SD usage, but treated as opaque bytes since TXT data may be binary). A
129  /// service with no metadata has a single empty segment (RFC 6763 §6.1).
130  #[inline]
131  pub fn txt(&self) -> &[Bytes] {
132    &self.txt
133  }
134}
135
136/// Parameters for a [`Lookup`] / browse.
137#[derive(Debug, Clone)]
138pub struct QueryParam {
139  service: Name,
140  timeout: Duration,
141  resolve_timeout: Option<Duration>,
142  unicast_response: bool,
143  max_entries: usize,
144}
145
146impl QueryParam {
147  /// Browse the given fully-qualified service type, e.g.
148  /// `Name::try_from_str("_ipp._tcp.local.")`.
149  pub fn new(service: Name) -> Self {
150    Self {
151      service,
152      timeout: Duration::from_secs(1),
153      resolve_timeout: None,
154      unicast_response: false,
155      max_entries: DEFAULT_MAX_ENTRIES,
156    }
157  }
158
159  /// How long the browse (the PTR query) runs before terminating.
160  #[must_use]
161  pub const fn with_timeout(mut self, timeout: Duration) -> Self {
162    self.timeout = timeout;
163    self
164  }
165
166  /// How long each per-instance resolve query (SRV / TXT / A / AAAA) runs.
167  /// Defaults to the browse timeout.
168  #[must_use]
169  pub const fn with_resolve_timeout(mut self, timeout: Duration) -> Self {
170    self.resolve_timeout = Some(timeout);
171    self
172  }
173
174  /// Request unicast responses on the issued queries (RFC 6762 §5.4). Defaults
175  /// to `false` (standard multicast browse).
176  #[must_use]
177  pub const fn with_unicast_response(mut self, unicast: bool) -> Self {
178    self.unicast_response = unicast;
179    self
180  }
181
182  /// Cap on the number of distinct instances tracked; instances discovered
183  /// beyond it are dropped (counted by [`Lookup::dropped`]). Defaults to
184  /// `DEFAULT_MAX_ENTRIES`. A value of `0` is treated as `1`.
185  #[must_use]
186  pub const fn with_max_entries(mut self, max: usize) -> Self {
187    self.max_entries = if max == 0 { 1 } else { max };
188    self
189  }
190}
191
192/// Which resolve step an answer belongs to. The string is the case-folded key
193/// of the owning instance (SRV/TXT) or host (A/AAAA).
194#[derive(Clone)]
195#[allow(clippy::upper_case_acronyms)] // `AAAA` mirrors the DNS record-type name
196enum Step {
197  Ptr,
198  Srv(SmolStr),
199  Txt(SmolStr),
200  A(SmolStr),
201  AAAA(SmolStr),
202}
203
204/// One answer, tagged with the resolve step that produced it.
205struct Tagged {
206  step: Step,
207  event: QueryEvent,
208}
209
210/// A follow-up query the driver should launch as a result of feeding the
211/// [`Resolver`] an answer.
212#[derive(Clone)]
213struct Start {
214  name: Name,
215  step: Step,
216}
217
218impl Start {
219  const fn qtype(&self) -> ResourceType {
220    match self.step {
221      Step::Srv(_) => ResourceType::Srv,
222      Step::Txt(_) => ResourceType::Txt,
223      Step::A(_) => ResourceType::A,
224      Step::AAAA(_) => ResourceType::AAAA,
225      Step::Ptr => ResourceType::Ptr,
226    }
227  }
228}
229
230/// In-progress aggregation of one service instance.
231struct Builder {
232  instance: Name,
233  host: Option<Name>,
234  host_key: Option<SmolStr>,
235  /// Whether an SRV record has been seen. Tracked separately from `port`
236  /// because `0` is a valid SRV port (the full `u16` range is parsed and the
237  /// registration API does not reject it), so it cannot double as a sentinel.
238  has_srv: bool,
239  port: u16,
240  ipv4: Vec<Ipv4Addr>,
241  ipv6: Vec<Ipv6Addr>,
242  txt: Option<Vec<Bytes>>,
243  emitted: bool,
244}
245
246impl Builder {
247  fn new(instance: Name) -> Self {
248    Self {
249      instance,
250      host: None,
251      host_key: None,
252      has_srv: false,
253      port: 0,
254      ipv4: Vec::new(),
255      ipv6: Vec::new(),
256      txt: None,
257      emitted: false,
258    }
259  }
260
261  /// Complete once it has an SRV (host + port), TXT, and at least one address.
262  fn complete(&self) -> bool {
263    self.has_srv && self.txt.is_some() && !(self.ipv4.is_empty() && self.ipv6.is_empty())
264  }
265
266  fn finalize(&self) -> Option<ServiceEntry> {
267    Some(ServiceEntry {
268      instance: self.instance.clone(),
269      host: self.host.clone()?,
270      port: self.port,
271      ipv4: self.ipv4.as_slice().into(),
272      ipv6: self.ipv6.as_slice().into(),
273      txt: self.txt.as_deref()?.into(),
274    })
275  }
276}
277
278/// Addresses already learned for a host, so a later instance whose SRV resolves
279/// to the same host picks them up even though the A/AAAA answer has come and
280/// gone (the shared-host, SRV-after-A ordering).
281#[derive(Default)]
282struct HostAddrs {
283  ipv4: Vec<Ipv4Addr>,
284  ipv6: Vec<Ipv6Addr>,
285}
286
287/// Pure browse/resolve aggregation state machine — no I/O. The [`LookupDriver`]
288/// feeds it parsed answers and launches the follow-up queries it requests.
289struct Resolver {
290  builders: HashMap<SmolStr, Builder>,
291  host_addrs: HashMap<SmolStr, HostAddrs>,
292  hosts_queried: HashSet<SmolStr>,
293  ready: VecDeque<ServiceEntry>,
294  /// Cap on distinct instances tracked.
295  max_entries: usize,
296  /// Cap on distinct hosts A/AAAA-queried. Set equal to `max_entries`: honest
297  /// browsing has at most one host per instance, so this never bites a real
298  /// responder, but it bounds an instance that floods distinct SRV targets
299  /// (which would otherwise grow `hosts_queried`/`host_addrs` and the in-flight
300  /// A/AAAA sub-query set without limit).
301  max_hosts: usize,
302  dropped: u64,
303}
304
305impl Resolver {
306  fn new(max_entries: usize) -> Self {
307    Self {
308      builders: HashMap::new(),
309      host_addrs: HashMap::new(),
310      hosts_queried: HashSet::new(),
311      ready: VecDeque::new(),
312      max_entries,
313      max_hosts: max_entries,
314      dropped: 0,
315    }
316  }
317
318  /// A newly discovered instance: register it (subject to the cap) and request
319  /// its SRV + TXT resolves.
320  fn on_ptr(&mut self, instance: Name) -> Vec<Start> {
321    let key = fold(&instance);
322    if self.builders.contains_key(&key) {
323      return Vec::new(); // already discovered
324    }
325    if self.builders.len() >= self.max_entries {
326      self.dropped = self.dropped.saturating_add(1);
327      return Vec::new();
328    }
329    self
330      .builders
331      .insert(key.clone(), Builder::new(instance.clone()));
332    vec![
333      Start {
334        name: instance.clone(),
335        step: Step::Srv(key.clone()),
336      },
337      Start {
338        name: instance,
339        step: Step::Txt(key),
340      },
341    ]
342  }
343
344  /// An instance's SRV: record host + port, adopt any addresses already learned
345  /// for that host, and request A/AAAA the first time we see the host — subject
346  /// to the distinct-host cap, which bounds an SRV-target flood.
347  fn on_srv(&mut self, inst_key: &str, host: Name, port: u16) -> Vec<Start> {
348    let host_key = fold(&host);
349    let cached = self.host_addrs.get(&host_key);
350    let mut changed = false;
351    if let Some(b) = self.builders.get_mut(inst_key) {
352      let host_changed = b.host_key.as_deref() != Some(host_key.as_str());
353      // SRV retargeting the instance to a DIFFERENT host invalidates the old
354      // host's addresses — drop them before adopting the new host's, so a
355      // re-emit never yields an entry whose host is the new target but whose
356      // addresses still belong to the old one.
357      if host_changed {
358        b.ipv4.clear();
359        b.ipv6.clear();
360      }
361      // A host or port change to an already-surfaced instance must re-emit even
362      // when the new host's addresses are already cached — no fresh A/AAAA event
363      // will arrive to trigger the re-emit, so without this the consumer keeps a
364      // stale host/port.
365      changed = host_changed || b.port != port;
366      b.has_srv = true;
367      b.host = Some(host.clone());
368      b.host_key = Some(host_key.clone());
369      b.port = port;
370      if let Some(addrs) = cached {
371        for &a in &addrs.ipv4 {
372          push_capped(&mut b.ipv4, a);
373        }
374        for &a in &addrs.ipv6 {
375          push_capped(&mut b.ipv6, a);
376        }
377      }
378    }
379    self.try_emit(inst_key, changed);
380    if self.hosts_queried.contains(&host_key) {
381      return Vec::new(); // already querying this host
382    }
383    if self.hosts_queried.len() >= self.max_hosts {
384      // SRV-target flood guard: refuse to query an unbounded set of hosts.
385      // Counted so the resulting partial view is observable via `dropped`.
386      self.dropped = self.dropped.saturating_add(1);
387      return Vec::new();
388    }
389    self.hosts_queried.insert(host_key.clone());
390    vec![
391      Start {
392        name: host.clone(),
393        step: Step::A(host_key.clone()),
394      },
395      Start {
396        name: host,
397        step: Step::AAAA(host_key),
398      },
399    ]
400  }
401
402  fn on_txt(&mut self, inst_key: &str, segs: Vec<Bytes>) {
403    let mut changed = false;
404    if let Some(b) = self.builders.get_mut(inst_key) {
405      // A TXT change to an already-surfaced instance must re-emit so the
406      // consumer sees the new metadata; a duplicate TXT must not (no spurious
407      // re-emit). First TXT flips this from `None`, driving the first emit once
408      // the instance is otherwise complete.
409      changed = b.txt.as_deref() != Some(segs.as_slice());
410      b.txt = Some(segs);
411    }
412    self.try_emit(inst_key, changed);
413  }
414
415  /// An A/AAAA answer for a host: cache it (capped) and apply it to every
416  /// instance whose SRV already pointed at that host. A newly-added address may
417  /// re-emit an already-surfaced instance with the fuller address set.
418  ///
419  /// Only called for a host we actually launched A/AAAA queries for (a step key
420  /// from a query we started), so `host_addrs` stays bounded by `hosts_queried`.
421  fn on_addr(&mut self, host_key: &str, addr: IpAddr) {
422    let cache = self.host_addrs.entry(SmolStr::from(host_key)).or_default();
423    match addr {
424      IpAddr::V4(a) => push_capped(&mut cache.ipv4, a),
425      IpAddr::V6(a) => push_capped(&mut cache.ipv6, a),
426    };
427    let keys: Vec<SmolStr> = self
428      .builders
429      .iter()
430      .filter(|(_, b)| b.host_key.as_deref() == Some(host_key))
431      .map(|(k, _)| k.clone())
432      .collect();
433    for k in keys {
434      let added = match self.builders.get_mut(&k) {
435        Some(b) => match addr {
436          IpAddr::V4(a) => push_capped(&mut b.ipv4, a),
437          IpAddr::V6(a) => push_capped(&mut b.ipv6, a),
438        },
439        None => false,
440      };
441      if added {
442        self.try_emit(&k, true);
443      }
444    }
445  }
446
447  /// Emit the instance if it is complete: the first time it completes, or — when
448  /// `allow_reemit` — again with an updated snapshot (e.g. a late AAAA after the
449  /// entry was first surfaced on its A address).
450  fn try_emit(&mut self, inst_key: &str, allow_reemit: bool) {
451    if let Some(b) = self.builders.get_mut(inst_key) {
452      if !b.complete() {
453        return;
454      }
455      if !b.emitted {
456        if let Some(entry) = b.finalize() {
457          b.emitted = true;
458          self.ready.push_back(entry);
459        }
460      } else if allow_reemit && let Some(entry) = b.finalize() {
461        self.ready.push_back(entry);
462      }
463    }
464  }
465
466  fn take_ready(&mut self) -> Option<ServiceEntry> {
467    self.ready.pop_front()
468  }
469}
470
471/// Bounded, instance-coalescing queue of resolved entries shared between the
472/// spawned [`LookupDriver`] (which fills it) and the [`Lookup`] handle (which
473/// drains it via [`Lookup::next`]). Mirrors the [`crate::Query`] mailbox.
474struct LookupQueue {
475  ready: VecDeque<ServiceEntry>,
476  /// Set once the driver task has finished (all sub-queries terminated, or the
477  /// handle was dropped). A drained-empty queue with `done` set is end-of-stream.
478  done: bool,
479  /// Snapshot of the resolver's drop counter, surfaced via [`Lookup::dropped`].
480  dropped: u64,
481}
482
483impl LookupQueue {
484  #[inline(always)]
485  const fn new() -> Self {
486    Self {
487      ready: VecDeque::new(),
488      done: false,
489      dropped: 0,
490    }
491  }
492
493  /// Enqueue a resolved entry, coalescing by instance so a slow consumer cannot
494  /// grow the queue past the live instance set: a newer snapshot for an instance
495  /// supersedes any still-pending one (matching the "later yield supersedes
496  /// earlier" contract on [`Lookup::next`]).
497  fn enqueue(&mut self, entry: ServiceEntry) {
498    if let Some(slot) = self
499      .ready
500      .iter_mut()
501      .find(|e| e.instance.as_str() == entry.instance.as_str())
502    {
503      *slot = entry;
504    } else {
505      self.ready.push_back(entry);
506    }
507  }
508}
509
510/// Lock the shared queue, recovering the guard if a previous holder panicked
511/// (the lock is never held across a fallible operation, so the data is sound).
512fn lock(q: &Mutex<LookupQueue>) -> MutexGuard<'_, LookupQueue> {
513  q.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
514}
515
516/// Spawned task that owns the browse/resolve sub-queries and the [`Resolver`],
517/// feeding resolved entries into the shared [`LookupQueue`]. Quinn's
518/// `ConnectionDriver`, scoped to a single lookup.
519struct LookupDriver {
520  endpoint: Endpoint,
521  streams: SelectAll<BoxStream<'static, Tagged>>,
522  resolver: Resolver,
523  /// Drop counter of the browse (PTR) query. Surplus instances evicted by the
524  /// PTR query's bounded answer pool before [`Resolver::on_ptr`] sees them are
525  /// counted here rather than in `resolver.dropped`; folding both into
526  /// [`Lookup::dropped`] keeps the partial-view signal complete.
527  ptr_drops: DroppedHandle,
528  queue: Arc<Mutex<LookupQueue>>,
529  /// Capacity-1 wakeup the consumer parks on; rung after the queue changes.
530  doorbell: Sender<()>,
531  /// Closes when the [`Lookup`] handle is dropped, which stops the task and
532  /// (by dropping the sub-query [`Query`] handles) cancels every sub-query.
533  cancel: Receiver<()>,
534  resolve_timeout: Duration,
535  unicast: bool,
536}
537
538impl LookupDriver {
539  async fn run(mut self) {
540    loop {
541      // Wait for the next answer or for the handle to be dropped. The futures
542      // borrow disjoint fields; act on `self` only after the select resolves
543      // (mirrors the main driver loop's borrow discipline).
544      let tagged = {
545        let next = self.streams.next().fuse();
546        let stop = self.cancel.recv().fuse();
547        pin_mut!(next, stop);
548        select_biased! {
549          _ = stop => None,   // handle dropped → stop
550          t = next => t,      // Some(answer), or None when all sub-queries end
551        }
552      };
553      match tagged {
554        Some(t) => self.process(t).await,
555        None => break,
556      }
557    }
558    // Signal end-of-stream and wake any parked consumer.
559    {
560      let mut q = lock(&self.queue);
561      q.done = true;
562      q.dropped = self.dropped_total();
563    }
564    let _ = self.doorbell.try_send(());
565  }
566
567  /// Feed one answer to the resolver, launch any follow-up queries it requests,
568  /// then flush newly-resolved entries to the consumer.
569  async fn process(&mut self, tagged: Tagged) {
570    for start in feed(&mut self.resolver, tagged) {
571      // Launch inline. If the handle was dropped mid-launch the `start_query`
572      // round-trip still completes promptly (the main driver replies regardless),
573      // and the next loop iteration's `cancel` arm stops the task.
574      self.launch(start).await;
575    }
576    self.flush();
577  }
578
579  /// Total observable drops surfaced via [`Lookup::dropped`]: instances refused
580  /// by the resolver caps plus surplus PTR answers the browse query's bounded
581  /// answer pool evicted before the resolver could see them (disjoint counts).
582  fn dropped_total(&self) -> u64 {
583    self.resolver.dropped.saturating_add(self.ptr_drops.get())
584  }
585
586  /// Move newly-resolved entries into the shared queue and wake the consumer.
587  fn flush(&mut self) {
588    let mut woke = false;
589    let mut q = lock(&self.queue);
590    while let Some(entry) = self.resolver.take_ready() {
591      q.enqueue(entry);
592      woke = true;
593    }
594    q.dropped = self.dropped_total();
595    drop(q);
596    if woke {
597      let _ = self.doorbell.try_send(());
598    }
599  }
600
601  /// Start a resolve sub-query and fold its answers into the merged stream.
602  async fn launch(&mut self, start: Start) {
603    let qtype = start.qtype();
604    let spec = QuerySpec::new(start.name, qtype)
605      .with_timeout(self.resolve_timeout)
606      .with_unicast_response(self.unicast);
607    if let Ok(query) = self.endpoint.start_query(spec).await {
608      self.streams.push(tagged_stream(query, start.step));
609    }
610  }
611}
612
613/// A running DNS-SD lookup.
614///
615/// Call [`Self::next`] to receive resolved [`ServiceEntry`] values as they
616/// complete; it returns `None` once every query (browse + resolves) has timed
617/// out. Resolution is driven by a spawned `LookupDriver` task, so it proceeds
618/// even between calls to `next`. Dropping the `Lookup` stops that task and
619/// cancels all of its in-flight queries.
620pub struct Lookup {
621  queue: Arc<Mutex<LookupQueue>>,
622  /// Capacity-1 wakeup the driver rings after filling the queue.
623  doorbell: async_channel::Receiver<()>,
624  /// Held only so dropping the `Lookup` closes the driver's `cancel` channel.
625  _cancel: async_channel::Sender<()>,
626}
627
628impl Lookup {
629  /// Wait for the next resolved service instance, or `None` when the lookup is
630  /// finished (all queries timed out).
631  ///
632  /// An instance may be yielded more than once as additional addresses resolve
633  /// (e.g. a late AAAA after the entry was first surfaced on its A address); a
634  /// later yield for the same [`ServiceEntry::instance_name`] supersedes the
635  /// earlier one. Single-consumer: takes `&mut self`, so there is at most one
636  /// in-flight `next()` per `Lookup`, matching the single-waiter doorbell.
637  pub async fn next(&mut self) -> Option<ServiceEntry> {
638    loop {
639      {
640        let mut q = lock(&self.queue);
641        if let Some(entry) = q.ready.pop_front() {
642          return Some(entry);
643        }
644        if q.done {
645          return None;
646        }
647      }
648      // Nothing ready: park until the driver rings. A closed doorbell means the
649      // driver task exited — do one final drain (entries or the `done` flag it
650      // set just before exiting are already visible under the lock).
651      if self.doorbell.recv().await.is_err() {
652        return lock(&self.queue).ready.pop_front();
653      }
654    }
655  }
656
657  /// Number of discoveries dropped because a bound was reached: the
658  /// distinct-instance cap ([`QueryParam::with_max_entries`]), the distinct-host
659  /// cap that bounds an SRV-target flood, or surplus PTR answers evicted by the
660  /// browse query's bounded answer pool before they could be tracked. A non-zero
661  /// value means the result set is a partial view.
662  pub fn dropped(&self) -> u64 {
663    lock(&self.queue).dropped
664  }
665}
666
667impl Endpoint {
668  /// Browse for instances of a DNS-SD service type, resolving each into a
669  /// [`ServiceEntry`]. See [`Lookup`] and [`QueryParam`].
670  pub async fn browse(&self, param: QueryParam) -> Result<Lookup, StartQueryError> {
671    let resolve_timeout = param.resolve_timeout.unwrap_or(param.timeout);
672    let ptr_spec = QuerySpec::new(param.service, ResourceType::Ptr)
673      .with_timeout(param.timeout)
674      .with_unicast_response(param.unicast_response)
675      // Size the PTR answer pool to the requested instance cap so a max_entries
676      // above the query's default answer cap is actually reachable, and the
677      // Resolver — not the query's answer pool — is what bounds and counts
678      // instances (`Lookup::dropped`). Without this, surplus PTR answers would be
679      // evicted before `on_ptr` could track or count them.
680      .with_max_answers(param.max_entries);
681    // Start the browse query up front so a start failure surfaces synchronously
682    // to the caller rather than vanishing inside the spawned task.
683    let ptr_query = self.start_query(ptr_spec).await?;
684    // Capture the browse query's drop counter before it is moved into the
685    // merged stream, so the driver can fold its evictions into `Lookup::dropped`.
686    let ptr_drops = ptr_query.dropped_handle();
687
688    let mut streams = SelectAll::new();
689    streams.push(tagged_stream(ptr_query, Step::Ptr));
690
691    let queue = Arc::new(Mutex::new(LookupQueue::new()));
692    let (doorbell_tx, doorbell_rx) = async_channel::bounded(1);
693    let (cancel_tx, cancel_rx) = async_channel::bounded(1);
694
695    let driver = LookupDriver {
696      endpoint: self.clone(),
697      streams,
698      resolver: Resolver::new(param.max_entries),
699      ptr_drops,
700      queue: Arc::clone(&queue),
701      doorbell: doorbell_tx,
702      cancel: cancel_rx,
703      resolve_timeout,
704      unicast: param.unicast_response,
705    };
706    self.spawn_lookup(driver.run())?;
707
708    Ok(Lookup {
709      queue,
710      doorbell: doorbell_rx,
711      _cancel: cancel_tx,
712    })
713  }
714
715  /// Convenience for [`Self::browse`] with default parameters and the given
716  /// browse timeout.
717  pub async fn lookup(&self, service: Name, timeout: Duration) -> Result<Lookup, StartQueryError> {
718    self
719      .browse(QueryParam::new(service).with_timeout(timeout))
720      .await
721  }
722
723  /// Resolve a host name to its addresses via mDNS A / AAAA queries (RFC 6762),
724  /// without the DNS-SD browse/resolve chain.
725  ///
726  /// Issues both queries and collects every advertised address for the
727  /// `timeout` window (the answer window for multicast responses), returning
728  /// them IPv4 first then IPv6, deduplicated and capped per family. The result
729  /// is empty if nothing answers. Unlike [`Self::resolve_instance`] this does
730  /// not require — or interpret — DNS-SD records; it is the multicast analogue
731  /// of resolving a hostname.
732  pub async fn resolve_host(
733    &self,
734    host: Name,
735    timeout: Duration,
736  ) -> Result<Vec<IpAddr>, StartQueryError> {
737    let host_key = fold(&host);
738    let a = self
739      .start_query(QuerySpec::new(host.clone(), ResourceType::A).with_timeout(timeout))
740      .await?;
741    let aaaa = self
742      .start_query(QuerySpec::new(host, ResourceType::AAAA).with_timeout(timeout))
743      .await?;
744    let mut streams = SelectAll::new();
745    streams.push(tagged_stream(a, Step::A(host_key.clone())));
746    streams.push(tagged_stream(aaaa, Step::AAAA(host_key)));
747
748    // Drive both queries to their terminal (the timeout), gathering addresses.
749    // The consumer here is this future itself, so the streams drain promptly.
750    let mut ipv4: Vec<Ipv4Addr> = Vec::new();
751    let mut ipv6: Vec<Ipv6Addr> = Vec::new();
752    while let Some(tagged) = streams.next().await {
753      let ans = match tagged.event {
754        QueryEvent::Answer(a) => a,
755        QueryEvent::Terminal(_) => continue,
756      };
757      match ans.rtype() {
758        ResourceType::A => {
759          if let Ok(r) = A::try_from_rdata(ans.rdata_slice()) {
760            push_capped(&mut ipv4, r.addr());
761          }
762        }
763        ResourceType::AAAA => {
764          if let Ok(r) = AAAA::try_from_rdata(ans.rdata_slice()) {
765            push_capped(&mut ipv6, r.addr());
766          }
767        }
768        _ => {}
769      }
770    }
771    Ok(
772      ipv4
773        .into_iter()
774        .map(IpAddr::V4)
775        .chain(ipv6.into_iter().map(IpAddr::V6))
776        .collect(),
777    )
778  }
779
780  /// Resolve a *known* DNS-SD service instance directly into a [`ServiceEntry`],
781  /// skipping the PTR browse step (e.g.
782  /// `Name::try_from_str("Office._ipp._tcp.local.")`).
783  ///
784  /// Issues SRV + TXT for the instance and A / AAAA for the SRV target host, and
785  /// returns the first complete resolution — host + port, TXT, and at least one
786  /// address — or `None` if it does not complete within `timeout`. Use
787  /// [`Self::browse`] instead when the instance names are not known in advance.
788  pub async fn resolve_instance(
789    &self,
790    instance: Name,
791    timeout: Duration,
792  ) -> Result<Option<ServiceEntry>, StartQueryError> {
793    // One deadline shared across stages: follow-up A/AAAA queries get the
794    // REMAINING budget, not a fresh full `timeout`, so the whole call stays
795    // bounded by `timeout` as documented (an SRV arriving late can't grant the
796    // address queries a second full window).
797    // `checked_add` so a pathological `timeout` (e.g. `Duration::MAX`) cannot
798    // panic the way `Instant + Duration` would. An overflow means "no effective
799    // deadline", so each stage just receives the full (huge) `timeout`, which
800    // `QuerySpec` clamps with its own checked arithmetic.
801    let deadline = Instant::now().checked_add(timeout);
802    let remaining = || deadline.map_or(timeout, |d| d.saturating_duration_since(Instant::now()));
803    let mut resolver = Resolver::new(1);
804    let mut streams = SelectAll::new();
805    // Seed the resolver with the instance and issue its SRV + TXT (no PTR).
806    for start in resolver.on_ptr(instance) {
807      streams.push(self.launch_resolve(start, remaining()).await?);
808    }
809    // Drive inline until the instance completes or every sub-query times out.
810    while let Some(tagged) = streams.next().await {
811      for start in feed(&mut resolver, tagged) {
812        streams.push(self.launch_resolve(start, remaining()).await?);
813      }
814      if let Some(entry) = resolver.take_ready() {
815        return Ok(Some(entry));
816      }
817    }
818    Ok(resolver.take_ready())
819  }
820
821  /// Start a resolve sub-query and wrap it as a tagged stream. Used by the
822  /// one-shot resolve conveniences, which drive the merged streams inline rather
823  /// than via a spawned [`LookupDriver`].
824  async fn launch_resolve(
825    &self,
826    start: Start,
827    timeout: Duration,
828  ) -> Result<futures::stream::BoxStream<'static, Tagged>, StartQueryError> {
829    let qtype = start.qtype();
830    let query = self
831      .start_query(QuerySpec::new(start.name, qtype).with_timeout(timeout))
832      .await?;
833    Ok(tagged_stream(query, start.step))
834  }
835}
836
837/// Decode one tagged answer, fold it into `resolver`, and return any follow-up
838/// queries it requests. Shared by the streaming [`LookupDriver`] and the
839/// one-shot [`Endpoint::resolve_instance`] convenience.
840fn feed(resolver: &mut Resolver, tagged: Tagged) -> Vec<Start> {
841  let answer = match tagged.event {
842    QueryEvent::Answer(a) => a,
843    QueryEvent::Terminal(_) => return Vec::new(),
844  };
845  match tagged.step {
846    Step::Ptr => {
847      if answer.rtype() != ResourceType::Ptr {
848        return Vec::new();
849      }
850      match parse_name(answer.rdata_slice()) {
851        Some(instance) => resolver.on_ptr(instance),
852        None => Vec::new(),
853      }
854    }
855    Step::Srv(inst_key) => {
856      if answer.rtype() != ResourceType::Srv {
857        return Vec::new();
858      }
859      match parse_srv(answer.rdata_slice()) {
860        Some((host, port)) => resolver.on_srv(&inst_key, host, port),
861        None => Vec::new(),
862      }
863    }
864    Step::Txt(inst_key) => {
865      if answer.rtype() != ResourceType::Txt {
866        return Vec::new();
867      }
868      resolver.on_txt(&inst_key, parse_txt(answer.rdata_slice()));
869      Vec::new()
870    }
871    Step::A(host_key) => {
872      if let Ok(r) = A::try_from_rdata(answer.rdata_slice())
873        && answer.rtype() == ResourceType::A
874      {
875        resolver.on_addr(&host_key, IpAddr::V4(r.addr()));
876      }
877      Vec::new()
878    }
879    Step::AAAA(host_key) => {
880      if let Ok(r) = AAAA::try_from_rdata(answer.rdata_slice())
881        && answer.rtype() == ResourceType::AAAA
882      {
883        resolver.on_addr(&host_key, IpAddr::V6(r.addr()));
884      }
885      Vec::new()
886    }
887  }
888}
889
890/// Wrap a [`Query`] as a `'static` stream of [`Tagged`] answers for the given
891/// resolve step. The stream ends when the query reaches its terminal.
892fn tagged_stream(query: Query, step: Step) -> futures::stream::BoxStream<'static, Tagged> {
893  futures::stream::unfold((query, step), |(mut query, step)| async move {
894    let event = query.next().await?;
895    let tagged = Tagged {
896      step: step.clone(),
897      event,
898    };
899    Some((tagged, (query, step)))
900  })
901  .boxed()
902}
903
904/// Push `item` into `v` if it is new and `v` is under [`MAX_ADDRS_PER_HOST`].
905/// Returns `true` if it was added (so the caller can decide to (re)emit).
906fn push_capped<T: PartialEq>(v: &mut Vec<T>, item: T) -> bool {
907  if v.len() >= MAX_ADDRS_PER_HOST || v.contains(&item) {
908    return false;
909  }
910  v.push(item);
911  true
912}
913
914/// Case-fold a name to its lookup key (DNS names are case-insensitive,
915/// RFC 6762 §16).
916fn fold(name: &Name) -> SmolStr {
917  // `Name` is already stored canonical-lowercase (RFC 6762 §16), so a plain
918  // `SmolStr::new` suffices — and inlines names ≤23 bytes, whereas collecting
919  // from a `char` iterator always takes SmolStr's heap path.
920  SmolStr::new(name.as_str())
921}
922
923/// Decode an owner-less wire-form domain name (a decompressed PTR/SRV target as
924/// stored in a [`mdns_proto::CollectedAnswer`]) into an owned [`Name`].
925///
926/// Returns `None` for any name the [`Name`] type cannot represent faithfully:
927/// a label containing a `.` (always a separator) or a non-ASCII byte (mangled
928/// by `Name`). Such instances are skipped rather than silently corrupted.
929fn name_from_ref(nr: &NameRef<'_>) -> Option<Name> {
930  let mut s = String::new();
931  for label in nr.labels() {
932    let label = label.ok()?;
933    if label.is_empty() {
934      break; // root terminator
935    }
936    if label.iter().any(|&b| b >= 0x80 || b == b'.') {
937      return None; // not representable by `Name` without corruption
938    }
939    // Verified ASCII above, so this is always valid UTF-8.
940    s.push_str(core::str::from_utf8(label).ok()?);
941    s.push('.');
942  }
943  if s.is_empty() {
944    return None;
945  }
946  Name::try_from_str(&s).ok()
947}
948
949/// Parse a PTR rdata slice (a decompressed wire-form name) into a [`Name`].
950fn parse_name(rdata: &[u8]) -> Option<Name> {
951  let ptr = Ptr::try_from_message(rdata, 0, rdata.len()).ok()?;
952  name_from_ref(ptr.target())
953}
954
955/// Parse an SRV rdata slice into `(target host, port)`.
956fn parse_srv(rdata: &[u8]) -> Option<(Name, u16)> {
957  let srv = Srv::try_from_message(rdata, 0, rdata.len()).ok()?;
958  let host = name_from_ref(srv.target())?;
959  Some((host, srv.port()))
960}
961
962/// Parse a TXT rdata slice into its segments (dropping a malformed tail).
963fn parse_txt(rdata: &[u8]) -> Vec<Bytes> {
964  Txt::from_rdata(rdata)
965    .segments()
966    .map_while(Result::ok)
967    .map(Bytes::copy_from_slice)
968    .collect()
969}
970
971#[cfg(test)]
972#[allow(clippy::unwrap_used)]
973mod tests;