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;