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;