Skip to main content

mdns_proto/
name.rs

1//! Owned, canonical DNS name.
2//!
3//! [`Name`] stores names in **canonical lowercase form** (mDNS is
4//! case-insensitive per RFC 6762 §16) with a feature-conditional backing:
5//! [`smol_str::SmolStr`] under `alloc`/`std`, or `portable_atomic_util::Arc<str>`
6//! under `no-atomic` (cores without native atomic CAS).
7//!
8//! Under bare `--no-default-features` (none of `alloc`/`std`/`no-atomic`) the
9//! owned `Name` type is absent — parse names as the borrowed `NameRef`
10//! instead, then enable a backing feature to own them.
11
12use crate::constants::{MAX_LABEL_BYTES, MAX_NAME_BYTES};
13use derive_more::{Display, IsVariant, TryUnwrap, Unwrap};
14
15/// Detail payload for [`NameError::LabelTooLong`].
16#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Display, thiserror::Error)]
17#[display("label of {len} bytes exceeds max {MAX_LABEL_BYTES}")]
18pub struct LabelTooLongDetail {
19  len: usize,
20}
21
22impl LabelTooLongDetail {
23  #[inline(always)]
24  pub(crate) const fn new(len: usize) -> Self {
25    Self { len }
26  }
27
28  /// Bytes in the rejected label.
29  #[inline(always)]
30  pub const fn len(&self) -> usize {
31    self.len
32  }
33
34  /// Returns `true` if the rejected label had zero bytes (always false in
35  /// practice — a zero-length label produces [`NameError::EmptyLabel`]).
36  #[inline(always)]
37  pub const fn is_empty(&self) -> bool {
38    self.len == 0
39  }
40}
41
42/// Detail payload for [`NameError::NameTooLong`].
43#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Display, thiserror::Error)]
44#[display("name of {len} bytes exceeds max {MAX_NAME_BYTES}")]
45pub struct NameTooLongDetail {
46  len: usize,
47}
48
49impl NameTooLongDetail {
50  cfg_heap! {
51  #[inline(always)]
52  pub(crate) const fn new(len: usize) -> Self {
53    Self { len }
54  }
55  }
56
57  /// Bytes in the rejected name.
58  #[inline(always)]
59  pub const fn len(&self) -> usize {
60    self.len
61  }
62
63  /// Returns `true` if the rejected name had zero bytes (always false in
64  /// practice — empty names pass validation).
65  #[inline(always)]
66  pub const fn is_empty(&self) -> bool {
67    self.len == 0
68  }
69}
70
71/// Reasons a string cannot be accepted as a [`Name`].
72#[derive(
73  Debug, Clone, Copy, Eq, PartialEq, Hash, IsVariant, Unwrap, TryUnwrap, thiserror::Error,
74)]
75#[unwrap(ref)]
76#[try_unwrap(ref)]
77#[non_exhaustive]
78pub enum NameError {
79  /// A single label exceeded [`MAX_LABEL_BYTES`].
80  #[error(transparent)]
81  LabelTooLong(LabelTooLongDetail),
82
83  /// The complete name exceeded [`MAX_NAME_BYTES`].
84  #[error(transparent)]
85  NameTooLong(NameTooLongDetail),
86
87  /// The input contained an empty label (e.g. consecutive dots).
88  #[error("name contains an empty label")]
89  EmptyLabel,
90}
91
92cfg_heap! {
93/// Validates that `s` is a syntactically acceptable DNS name (per-label and
94/// total length, no empty internal labels). Trailing `.` (FQDN form) is
95/// permitted.
96fn validate_name(s: &str) -> Result<(), NameError> {
97  if s.len() > MAX_NAME_BYTES {
98    return Err(NameError::NameTooLong(NameTooLongDetail::new(s.len())));
99  }
100  if s.is_empty() {
101    return Ok(());
102  }
103  let trimmed = match s.strip_suffix('.') {
104    Some(rest) => rest,
105    None => s,
106  };
107  // RFC 1035 §3.1 / §2.3.4: the 255-octet limit is on the WIRE form — each
108  // label contributes one length octet plus its bytes, terminated by the root
109  // (one octet). A presentation string of N bytes encodes to N+2 octets, so a
110  // string-length check alone (s.len() <= 255) would wrongly accept names whose
111  // wire form is 256–257 octets. Accumulate the wire length and enforce it.
112  let mut wire_len: usize = 1; // terminating root label
113  for label in trimmed.split('.') {
114    if label.is_empty() {
115      return Err(NameError::EmptyLabel);
116    }
117    let len = label.len();
118    if len > MAX_LABEL_BYTES as usize {
119      return Err(NameError::LabelTooLong(LabelTooLongDetail::new(len)));
120    }
121    wire_len = wire_len.saturating_add(1).saturating_add(len);
122  }
123  if wire_len > MAX_NAME_BYTES {
124    return Err(NameError::NameTooLong(NameTooLongDetail::new(wire_len)));
125  }
126  Ok(())
127}
128}
129
130// ── Backing-type selection ────────────────────────────────────────────
131// Exactly one of these `cfg` arms is active in any valid build. Under
132// `--no-default-features` with none of `alloc`/`std`/`no-atomic`, **none** are
133// active and `Name` itself is absent.
134
135#[cfg(any(feature = "alloc", feature = "std"))]
136type NameInner = smol_str::SmolStr;
137
138// No-atomic alloc tier: a portable-atomic `Arc<str>` (cheap clone without native
139// atomic CAS). Same heap-string shape as `SmolStr` minus the small-string
140// optimization; built through the same `NameInner::from` path.
141#[cfg(all(feature = "no-atomic", not(any(feature = "alloc", feature = "std"))))]
142type NameInner = portable_atomic_util::Arc<str>;
143
144cfg_heap! {
145/// Owned, canonical DNS name (lowercased on construction).
146#[derive(Debug, Clone, Eq, PartialEq, Hash)]
147pub struct Name(NameInner);
148
149impl Name {
150  /// Returns the canonical lowercase form of this name.
151  #[inline(always)]
152  pub fn as_str(&self) -> &str {
153    // `as_ref` (not `as_str`) so the same body compiles whether `NameInner` is
154    // `SmolStr` (alloc/std) or the no-atomic `Arc<str>` (which has no inherent
155    // `as_str`).
156    self.0.as_ref()
157  }
158
159  /// Returns the length in bytes.
160  #[inline(always)]
161  pub fn len(&self) -> usize {
162    self.as_str().len()
163  }
164
165  /// Returns `true` if this name is empty.
166  #[inline(always)]
167  pub fn is_empty(&self) -> bool {
168    self.as_str().is_empty()
169  }
170}
171
172impl core::fmt::Display for Name {
173  fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
174    f.write_str(self.as_str())
175  }
176}
177}
178
179// Heap-backed construction: active for the `SmolStr` (alloc/std) and the
180// no-atomic `Arc<str>` backings. Both build `NameInner` from an owned `String`
181// (`std` is the `extern crate alloc as std` alias under `no-atomic`), so a
182// single body serves both.
183cfg_heap! {
184const _: () = {
185  use std::string::String;
186
187  impl Name {
188    /// Constructs a [`Name`] from a string, validating label lengths and
189    /// total length, normalizing to canonical lowercase.
190    pub fn try_from_str(s: &str) -> Result<Self, NameError> {
191      validate_name(s)?;
192      // Case-fold ASCII only (DNS case-insensitivity is ASCII-only, RFC 4343)
193      // and iterate CHARS so non-ASCII UTF-8 — DNS-SD instance names are UTF-8
194      // (RFC 6763 §4.1) — is preserved. `byte as char` would Latin-1-reinterpret
195      // each byte and double-encode multi-byte sequences.
196      let mut buf = String::with_capacity(s.len());
197      for ch in s.chars() {
198        buf.push(ch.to_ascii_lowercase());
199      }
200      Ok(Self(NameInner::from(buf)))
201    }
202
203    /// Builds a canonical [`Name`] directly from a sequence of raw wire labels
204    /// (each the decompressed bytes of one DNS label, no length prefix),
205    /// joining them with `.` plus a trailing `.`. Labels are ASCII case-folded
206    /// (RFC 4343); non-ASCII bytes are preserved, and the assembled name must
207    /// be valid UTF-8 — DNS-SD names are UTF-8 (RFC 6763 §4.1). Returns `None`
208    /// on a malformed label (`Err` item), a label containing the `.` separator
209    /// byte, non-UTF-8 bytes, or a label/total length violation.
210    ///
211    /// This is the wire-decode counterpart to [`Name::try_from_str`]: it skips
212    /// the throwaway presentation `String` a caller would otherwise assemble
213    /// and — unlike a `byte as char` join — never Latin-1-reinterprets a
214    /// multi-byte UTF-8 sequence into mojibake.
215    ///
216    /// Length limits are checked incrementally, before each label is decoded or
217    /// pushed, so an oversized iterator is rejected without unbounded allocation.
218    pub fn from_wire_labels<'a, E, I>(labels: I) -> Option<Self>
219    where
220      I: IntoIterator<Item = Result<&'a [u8], E>>,
221    {
222      // `from_wire_labels` is public and accepts ANY iterator, so the length
223      // limits must be enforced incrementally, BEFORE decoding or pushing each
224      // label — otherwise a hostile caller could drive allocation proportional
225      // to the input for a name that is ultimately rejected (OOM). The bounded
226      // `NameRef::labels()` the endpoint passes always satisfies these, so this
227      // only rejects out-of-spec callers.
228      let mut buf = String::with_capacity(MAX_NAME_BYTES);
229      let mut wire_len: usize = 1; // terminating root label (RFC 1035 §3.1)
230      for label in labels {
231        let label = label.ok()?;
232        if label.len() > MAX_LABEL_BYTES as usize {
233          return None;
234        }
235        wire_len = wire_len.saturating_add(1).saturating_add(label.len());
236        if wire_len > MAX_NAME_BYTES {
237          return None;
238        }
239        // A wire label may legally carry a literal '.' byte, but `Name` joins
240        // labels with '.' as the separator — so a dot-bearing label would alias
241        // a different label sequence to the same string (["a.b","local"] would
242        // equal ["a","b","local"]) and poison cache identity (insert / TTL=0
243        // removal / cache-flush clamp all key on this `Name`). Reject it — the
244        // same contract the discovery layer enforces, since `Name` cannot
245        // represent a dot-bearing label faithfully.
246        if label.contains(&b'.') {
247          return None;
248        }
249        for ch in core::str::from_utf8(label).ok()?.chars() {
250          buf.push(ch.to_ascii_lowercase());
251        }
252        buf.push('.');
253      }
254      validate_name(&buf).ok()?;
255      Some(Self(NameInner::from(buf)))
256    }
257  }
258};
259}
260
261#[cfg(all(test, any(feature = "alloc", feature = "std", feature = "no-atomic")))]
262#[allow(clippy::unwrap_used, clippy::expect_used)]
263mod tests;