Skip to main content

rama_net/address/domain/
label.rs

1//! [`Label`] — a single DNS label in presentation format.
2//!
3//! `Label` is a `?Sized`, `#[repr(transparent)]` view over `str`. It enforces
4//! the per-label invariants of [`Domain`](super::Domain) (length, charset,
5//! hyphen placement, wildcard form) and provides case-insensitive ASCII
6//! equality, hashing, and ordering, so that `Domain` can delegate those impls
7//! to its label sequence.
8//!
9//! This module is presentation-format only — there is no DNS wire format,
10//! octets generic, or DNSSEC layer.
11
12use core::hash::{Hash, Hasher};
13use core::{cmp::Ordering, fmt};
14
15/// A single DNS label in presentation format.
16///
17/// `Label` is a borrowed, unsized view: you'll always work with `&Label`,
18/// produced either by [`Label::from_str`] (validated) or by internal helpers
19/// that already maintain the invariant.
20///
21/// # Invariants
22///
23/// A `&Label`'s contents always satisfy:
24///
25/// - non-empty, at most [`Label::MAX_LEN`] (`63`) bytes
26/// - either the single-byte wildcard form `"*"`, **or** a sequence of ASCII
27///   alphanumerics, `_`, and `-`, with no leading or trailing `-`
28///
29/// # Equality / ordering
30///
31/// `Label`'s `PartialEq`, `Eq`, `Hash`, `Ord`, and `PartialOrd` impls are
32/// **ASCII-case-insensitive**. `"Foo"`, `"foo"`, and `"FOO"` compare equal and
33/// hash to the same value.
34#[repr(transparent)]
35pub struct Label(str);
36
37impl Label {
38    /// Maximum byte length of a single label (RFC 1035).
39    pub const MAX_LEN: usize = 63;
40
41    /// Parses a single label.
42    ///
43    /// # Errors
44    ///
45    /// Returns a [`LabelError`] if `s` violates any [invariant](Self).
46    #[expect(
47        clippy::should_implement_trait,
48        reason = "Label is !Sized; FromStr requires Sized + returns Self by value"
49    )]
50    pub fn from_str(s: &str) -> Result<&Self, LabelError> {
51        validate_label_bytes(s.as_bytes())?;
52        // Safety: `validate_label_bytes` guarantees the invariant.
53        Ok(unsafe { Self::from_str_unchecked(s) })
54    }
55
56    /// Constructs a `&Label` without validation.
57    ///
58    /// # Safety
59    ///
60    /// The caller must guarantee that `s` upholds every [invariant](Self).
61    /// In practice this is used when iterating over a `Domain`'s buffer, which
62    /// was already fully validated at construction.
63    pub(crate) unsafe fn from_str_unchecked(s: &str) -> &Self {
64        // Safety: `Label` is `#[repr(transparent)]` over `str`, so the layout
65        // of `&str` and `&Label` is identical.
66        unsafe { &*(s as *const str as *const Self) }
67    }
68
69    /// Returns the label as its underlying string.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74
75    /// Returns the label length in bytes.
76    ///
77    /// A label is non-empty by [invariant](Self), so length is always `>= 1`;
78    /// there is intentionally no `is_empty` method.
79    #[expect(
80        clippy::len_without_is_empty,
81        reason = "Label is non-empty by invariant; is_empty would be trivially false"
82    )]
83    #[must_use]
84    pub fn len(&self) -> usize {
85        self.0.len()
86    }
87
88    /// Returns `true` if this is the wildcard label `"*"`.
89    #[must_use]
90    pub fn is_wildcard(&self) -> bool {
91        self.0.as_bytes() == b"*"
92    }
93}
94
95impl AsRef<str> for Label {
96    fn as_ref(&self) -> &str {
97        &self.0
98    }
99}
100
101impl fmt::Debug for Label {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "Label({:?})", &self.0)
104    }
105}
106
107impl fmt::Display for Label {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        self.0.fmt(f)
110    }
111}
112
113impl PartialEq for Label {
114    fn eq(&self, other: &Self) -> bool {
115        self.0.eq_ignore_ascii_case(&other.0)
116    }
117}
118
119impl Eq for Label {}
120
121impl Hash for Label {
122    fn hash<H: Hasher>(&self, state: &mut H) {
123        // Length-prefix so concatenated labels don't collide with longer ones.
124        state.write_usize(self.0.len());
125        for b in self.0.bytes() {
126            state.write_u8(b.to_ascii_lowercase());
127        }
128    }
129}
130
131impl Ord for Label {
132    fn cmp(&self, other: &Self) -> Ordering {
133        cmp_ignore_ascii_case(&self.0, &other.0)
134    }
135}
136
137/// Byte-by-byte ASCII-case-insensitive ordering on `&str`.
138///
139/// Equivalent of `str::eq_ignore_ascii_case` for `Ord`. Pulled out so both
140/// `Label::cmp` and the `cmp_segments` helper feed through one place.
141pub(super) fn cmp_ignore_ascii_case(a: &str, b: &str) -> Ordering {
142    a.bytes()
143        .map(|c| c.to_ascii_lowercase())
144        .cmp(b.bytes().map(|c| c.to_ascii_lowercase()))
145}
146
147impl PartialOrd for Label {
148    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
149        Some(self.cmp(other))
150    }
151}
152
153/// Error returned by [`Label::from_str`].
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct LabelError(LabelErrorKind);
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158enum LabelErrorKind {
159    Empty,
160    TooLong { len: usize },
161    LeadingHyphen,
162    TrailingHyphen,
163    InvalidChar { byte: u8, at: usize },
164}
165
166impl LabelError {
167    #[inline]
168    pub(crate) const fn empty() -> Self {
169        Self(LabelErrorKind::Empty)
170    }
171    #[inline]
172    pub(crate) const fn too_long(len: usize) -> Self {
173        Self(LabelErrorKind::TooLong { len })
174    }
175    #[inline]
176    pub(crate) const fn leading_hyphen() -> Self {
177        Self(LabelErrorKind::LeadingHyphen)
178    }
179    #[inline]
180    pub(crate) const fn trailing_hyphen() -> Self {
181        Self(LabelErrorKind::TrailingHyphen)
182    }
183    #[inline]
184    pub(crate) const fn invalid_char(byte: u8, at: usize) -> Self {
185        Self(LabelErrorKind::InvalidChar { byte, at })
186    }
187}
188
189impl fmt::Display for LabelError {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match &self.0 {
192            LabelErrorKind::Empty => f.write_str("empty domain label"),
193            LabelErrorKind::TooLong { len } => write!(
194                f,
195                "domain label is {len} bytes long, max is {}",
196                Label::MAX_LEN
197            ),
198            LabelErrorKind::LeadingHyphen => f.write_str("domain label may not start with '-'"),
199            LabelErrorKind::TrailingHyphen => f.write_str("domain label may not end with '-'"),
200            LabelErrorKind::InvalidChar { byte, at } => {
201                write!(f, "invalid byte 0x{byte:02x} in domain label at index {at}")
202            }
203        }
204    }
205}
206
207impl core::error::Error for LabelError {}
208
209/// Shared validation: also used by [`Domain`](super::Domain)'s internal parser
210/// so error reporting agrees byte-for-byte between the two surfaces.
211///
212/// `const` because both `Domain::from_static` (compile-time) and
213/// `Domain::try_from` (runtime) call through this — keeping the algorithm
214/// in one place avoids the validator-drift class of bug.
215pub(crate) const fn validate_label_bytes(bytes: &[u8]) -> Result<(), LabelError> {
216    if bytes.is_empty() {
217        return Err(LabelError::empty());
218    }
219    if bytes.len() > Label::MAX_LEN {
220        return Err(LabelError::too_long(bytes.len()));
221    }
222
223    // Wildcard label is the single byte `*`. Manual comparison because
224    // `<[u8] as PartialEq>::eq` is not const.
225    if bytes.len() == 1 && bytes[0] == b'*' {
226        return Ok(());
227    }
228
229    if bytes[0] == b'-' {
230        return Err(LabelError::leading_hyphen());
231    }
232    if bytes[bytes.len() - 1] == b'-' {
233        return Err(LabelError::trailing_hyphen());
234    }
235
236    let mut i = 0;
237    while i < bytes.len() {
238        let c = bytes[i];
239        if !crate::byte_sets::is_label_byte(c) {
240            return Err(LabelError::invalid_char(c, i));
241        }
242        i += 1;
243    }
244    Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    use ahash::{HashMap, HashMapExt as _};
252
253    #[test]
254    fn valid_labels() {
255        for s in [
256            "a",
257            "A",
258            "aA1",
259            "example",
260            "_acme-challenge",
261            "_acme_challenge_",
262            "a-b-c",
263            "rr5---sn-q4fl6n6s",
264            "127",
265            "*",
266        ] {
267            Label::from_str(s).unwrap_or_else(|e| panic!("expected ok for {s:?}: {e}"));
268        }
269    }
270
271    #[test]
272    fn invalid_labels() {
273        let cases: &[(&str, &str)] = &[
274            ("", "empty"),
275            ("-foo", "leading hyphen"),
276            ("foo-", "trailing hyphen"),
277            ("-", "leading hyphen"),
278            ("foo.bar", "dot not allowed inside label"),
279            ("foo*bar", "embedded wildcard"),
280            ("*foo", "wildcard with extra"),
281            ("foo*", "wildcard with extra"),
282            ("こんにちは", "non-ascii"),
283            ("foo bar", "space"),
284        ];
285        for (s, why) in cases {
286            assert!(
287                Label::from_str(s).is_err(),
288                "expected error for {s:?} ({why})"
289            );
290        }
291
292        // too long
293        let too_long = "a".repeat(Label::MAX_LEN + 1);
294        let err = Label::from_str(&too_long).unwrap_err();
295        assert!(format!("{err}").contains("max is 63"));
296    }
297
298    #[test]
299    fn ascii_case_insensitive_eq_hash_ord() {
300        let a = Label::from_str("Example").unwrap();
301        let b = Label::from_str("eXaMpLe").unwrap();
302        assert_eq!(a, b);
303        assert_eq!(a.cmp(b), Ordering::Equal);
304
305        let mut m: HashMap<&Label, ()> = HashMap::new();
306        m.insert(Label::from_str("Foo").unwrap(), ());
307        assert!(m.contains_key(Label::from_str("FOO").unwrap()));
308        assert!(m.contains_key(Label::from_str("foo").unwrap()));
309        assert!(!m.contains_key(Label::from_str("foo2").unwrap()));
310    }
311
312    #[test]
313    fn ordering_lex_case_folded() {
314        let a = Label::from_str("Apple").unwrap();
315        let b = Label::from_str("banana").unwrap();
316        assert!(a < b);
317        assert!(b > a);
318
319        // length-tiebreak: prefix is less than longer name
320        let pre = Label::from_str("foo").unwrap();
321        let longer = Label::from_str("foobar").unwrap();
322        assert!(pre < longer);
323    }
324
325    #[test]
326    fn wildcard_helper() {
327        assert!(Label::from_str("*").unwrap().is_wildcard());
328        assert!(!Label::from_str("foo").unwrap().is_wildcard());
329    }
330
331    #[test]
332    fn unchecked_constructor_layout() {
333        // Compile-time-ish: confirm round trip through unchecked goes via repr(transparent).
334        let s = "valid";
335        let l = unsafe { Label::from_str_unchecked(s) };
336        assert_eq!(l.as_str(), s);
337        assert_eq!(l.len(), s.len());
338    }
339
340    #[test]
341    fn label_byte_set_matches_predicate() {
342        // Drift guard: the LUT must agree byte-for-byte with the
343        // grammar `ALPHA / DIGIT / "_" / "-"` it claims to encode.
344        // If the table-build loop in `crate::byte_sets` ever drops a byte,
345        // this catches it on the label-grammar side too.
346        for b in 0u8..=255 {
347            let expected = b.is_ascii_alphanumeric() || b == b'_' || b == b'-';
348            assert_eq!(
349                crate::byte_sets::is_label_byte(b),
350                expected,
351                "byte 0x{b:02x} ({}) — LUT disagreed with predicate",
352                if b.is_ascii_graphic() {
353                    format!("{:?}", b as char)
354                } else {
355                    "non-graphic".to_owned()
356                }
357            );
358        }
359    }
360}