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