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;