Skip to main content

ebus_discovery/
txt.rs

1//! TXT records: decoding, checks and the `_ebus._tcp` parse (ebus.py:133-311).
2
3use alloc::borrow::Cow;
4use alloc::string::{String, ToString};
5use alloc::vec::Vec;
6use core::fmt;
7
8/// Longest single TXT string (`key=value`): one length byte.
9pub const TXT_STRING_MAX: usize = 255;
10/// A TXT string longer than this is reported by [`check_txt`].
11pub const TXT_STRING_WARN: usize = 200;
12/// A TXT record longer than this on the wire is reported by [`check_txt`]
13/// (RFC 6763 section 6.2).
14pub const TXT_TOTAL_WARN: usize = 1300;
15
16/// The `_ebus._tcp` keys the specification requires (framework.md:242-249).
17const EBUS_REQUIRED_KEYS: [&str; 4] = ["txtvers", "ebus_version", "roles", "device_id"];
18
19/// A DNS-SD TXT record: ordered keys, each with a value or none.
20///
21/// Keys compare case-insensitively and are unique: the first occurrence of a
22/// key wins (RFC 6763 section 6.4). Values are bytes.
23#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
24pub struct TxtRecord {
25    entries: Vec<(String, Option<Vec<u8>>)>,
26}
27
28pub(crate) fn key_eq(a: &str, b: &str) -> bool {
29    a.chars()
30        .flat_map(char::to_lowercase)
31        .eq(b.chars().flat_map(char::to_lowercase))
32}
33
34impl TxtRecord {
35    /// An empty record.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Decode the strings of a received record. Each splits at its first `=`;
41    /// one without `=` is a key with no value, and one with an empty key is
42    /// skipped. Keys are decoded as UTF-8 with replacement and lowercased
43    /// (ebus.py:136-153); a later duplicate of a key is dropped.
44    pub fn from_strings<I, S>(strings: I) -> Self
45    where
46        I: IntoIterator<Item = S>,
47        S: AsRef<[u8]>,
48    {
49        let mut txt = Self::new();
50        for s in strings {
51            let s = s.as_ref();
52            let (key, value) = match s.iter().position(|&b| b == b'=') {
53                Some(at) => (&s[..at], Some(s[at + 1..].to_vec())),
54                None => (s, None),
55            };
56            if key.is_empty() {
57                continue;
58            }
59            txt.insert_raw(String::from_utf8_lossy(key).to_lowercase(), value);
60        }
61        txt
62    }
63
64    /// Add `key` with `value` unless the record already holds the key;
65    /// returns whether it was added.
66    pub fn insert_raw(&mut self, key: String, value: Option<Vec<u8>>) -> bool {
67        if self.contains_key(&key) {
68            return false;
69        }
70        self.entries.push((key, value));
71        true
72    }
73
74    /// Add `key=value` unless the record already holds the key; returns
75    /// whether it was added.
76    pub fn insert(&mut self, key: &str, value: &str) -> bool {
77        self.insert_raw(key.to_string(), Some(value.as_bytes().to_vec()))
78    }
79
80    /// True when the record holds `key`.
81    pub fn contains_key(&self, key: &str) -> bool {
82        self.entries.iter().any(|(k, _)| key_eq(k, key))
83    }
84
85    /// The value of `key` as text, decoded as UTF-8 with replacement; `""`
86    /// for a key with no value; `None` when the key is absent.
87    pub fn get(&self, key: &str) -> Option<Cow<'_, str>> {
88        self.get_raw(key).map(|v| match v {
89            Some(bytes) => String::from_utf8_lossy(bytes),
90            None => Cow::Borrowed(""),
91        })
92    }
93
94    /// The raw value of `key`: `Some(None)` for a key with no value.
95    pub fn get_raw(&self, key: &str) -> Option<Option<&[u8]>> {
96        self.entries
97            .iter()
98            .find(|(k, _)| key_eq(k, key))
99            .map(|(_, v)| v.as_deref())
100    }
101
102    /// The entries in order.
103    pub fn iter(&self) -> impl Iterator<Item = (&str, Option<&[u8]>)> + '_ {
104        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_deref()))
105    }
106
107    /// The number of keys.
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    /// True when the record holds no key.
113    pub fn is_empty(&self) -> bool {
114        self.entries.is_empty()
115    }
116
117    /// The record's strings, `key=value` or `key`, in order.
118    pub fn to_strings(&self) -> Vec<Vec<u8>> {
119        self.entries
120            .iter()
121            .map(|(k, v)| entry_string(k, v.as_deref()))
122            .collect()
123    }
124
125    /// Bytes the record occupies on the wire: a length byte per string.
126    pub fn wire_size(&self) -> usize {
127        self.entries
128            .iter()
129            .map(|(k, v)| 1 + entry_len(k, v.as_deref()))
130            .sum()
131    }
132}
133
134fn entry_len(key: &str, value: Option<&[u8]>) -> usize {
135    key.len() + value.map_or(0, |v| 1 + v.len())
136}
137
138fn entry_string(key: &str, value: Option<&[u8]>) -> Vec<u8> {
139    let mut s = Vec::with_capacity(entry_len(key, value));
140    s.extend_from_slice(key.as_bytes());
141    if let Some(v) = value {
142        s.push(b'=');
143        s.extend_from_slice(v);
144    }
145    s
146}
147
148/// A TXT record mDNS cannot carry, or an advertisement that cannot be built.
149#[derive(Clone, Debug, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum TxtError {
152    /// A key that is empty, contains `=`, or is not printable ASCII.
153    InvalidKey {
154        /// The record's service type.
155        service_type: String,
156        /// The key.
157        key: String,
158    },
159    /// A `key=value` string over [`TXT_STRING_MAX`] bytes.
160    StringTooLong {
161        /// The record's service type.
162        service_type: String,
163        /// The key.
164        key: String,
165        /// The string's length in bytes.
166        bytes: usize,
167    },
168    /// An identity with no device id.
169    NoDeviceId,
170    /// An empty device id, or one containing a comma.
171    InvalidDeviceId(String),
172    /// An identity with no role.
173    NoRole,
174    /// An empty role, or one containing a comma.
175    InvalidRole(String),
176    /// A required identity field left empty: `manufacturer`, `model`,
177    /// `serial_number` or `ebus_version`.
178    Missing(&'static str),
179    /// A Homie domain that is not one topic level.
180    InvalidHomieDomain(String),
181    /// Two brokers of one type in one advertisement.
182    DuplicateBrokerType(crate::BrokerType),
183}
184
185impl fmt::Display for TxtError {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self {
188            Self::InvalidKey { service_type, key } => {
189                write!(f, "{service_type}: invalid TXT key {key:?}")
190            }
191            Self::StringTooLong {
192                service_type,
193                key,
194                bytes,
195            } => write!(
196                f,
197                "{service_type}: TXT {key:?} is {bytes} bytes; one TXT string holds at most {TXT_STRING_MAX}"
198            ),
199            Self::NoDeviceId => f.write_str("identity needs at least one device id"),
200            Self::InvalidDeviceId(id) => {
201                write!(f, "invalid device id {id:?} (empty or contains a comma)")
202            }
203            Self::NoRole => f.write_str("identity needs at least one role"),
204            Self::InvalidRole(role) => write!(f, "invalid role {role:?}"),
205            Self::Missing(field) => write!(f, "identity {field} is required"),
206            Self::InvalidHomieDomain(d) => {
207                write!(f, "invalid Homie domain {d:?} (one topic level)")
208            }
209            Self::DuplicateBrokerType(t) => {
210                write!(f, "more than one broker of type {}", t.as_str())
211            }
212        }
213    }
214}
215
216impl core::error::Error for TxtError {}
217
218/// What [`check_txt`] found near the limits; nothing here stops a record.
219#[derive(Clone, Debug, Default, PartialEq, Eq)]
220#[non_exhaustive]
221pub struct TxtReport {
222    /// Keys whose string is over [`TXT_STRING_WARN`] bytes, with its length.
223    pub long_strings: Vec<(String, usize)>,
224    /// The record's size on the wire.
225    pub total_bytes: usize,
226}
227
228impl TxtReport {
229    /// True when the record is over [`TXT_TOTAL_WARN`] bytes on the wire.
230    pub fn is_large(&self) -> bool {
231        self.total_bytes > TXT_TOTAL_WARN
232    }
233}
234
235/// Check that mDNS can carry `txt` (ebus.py:282-311): every key non-empty,
236/// without `=` and printable ASCII, and every string at most
237/// [`TXT_STRING_MAX`] bytes. Strings and records near the limits are listed
238/// in the report and logged as warnings.
239pub fn check_txt(service_type: &str, txt: &TxtRecord) -> Result<TxtReport, TxtError> {
240    let mut report = TxtReport::default();
241    for (key, value) in txt.iter() {
242        if key.is_empty() || !key.bytes().all(|b| (0x20..=0x7e).contains(&b) && b != b'=') {
243            return Err(TxtError::InvalidKey {
244                service_type: service_type.to_string(),
245                key: key.to_string(),
246            });
247        }
248        let n = entry_len(key, value);
249        if n > TXT_STRING_MAX {
250            return Err(TxtError::StringTooLong {
251                service_type: service_type.to_string(),
252                key: key.to_string(),
253                bytes: n,
254            });
255        }
256        if n > TXT_STRING_WARN {
257            crate::log::warn!(
258                "txtStringNearLimit",
259                service = service_type,
260                key = key,
261                bytes = n
262            );
263            report.long_strings.push((key.to_string(), n));
264        }
265    }
266    report.total_bytes = txt.wire_size();
267    if report.is_large() {
268        crate::log::warn!(
269            "txtRecordLarge",
270            service = service_type,
271            bytes = report.total_bytes
272        );
273    }
274    Ok(report)
275}
276
277/// A parsed `_ebus._tcp` TXT record (ebus.py:178-216).
278#[derive(Clone, Debug, PartialEq, Eq)]
279#[non_exhaustive]
280pub struct EbusTxt {
281    /// `txtvers`, or `""`.
282    pub txtvers: String,
283    /// `ebus_version`, or `""`.
284    pub ebus_version: String,
285    /// The `roles` list.
286    pub roles: Vec<String>,
287    /// The `device_id` list.
288    pub device_ids: Vec<String>,
289    /// The `auth_methods` list.
290    pub auth_methods: Vec<String>,
291    /// The whole record.
292    pub txt: TxtRecord,
293}
294
295impl EbusTxt {
296    /// The required keys absent or empty; never a reason to drop the record.
297    pub fn missing(&self) -> Vec<&'static str> {
298        EBUS_REQUIRED_KEYS
299            .into_iter()
300            .filter(|k| self.txt.get(k).is_none_or(|v| v.is_empty()))
301            .collect()
302    }
303
304    /// True when `roles` lists `broker-host`.
305    pub fn is_broker_host(&self) -> bool {
306        self.roles.iter().any(|r| r == crate::ROLE_BROKER_HOST)
307    }
308
309    /// The value of any key, as [`TxtRecord::get`].
310    pub fn get(&self, key: &str) -> Option<Cow<'_, str>> {
311        self.txt.get(key)
312    }
313}
314
315/// A comma-separated TXT list: items trimmed, empty items dropped
316/// (ebus.py:156-159).
317pub(crate) fn split_list(value: &str) -> Vec<String> {
318    value
319        .split(',')
320        .map(str::trim)
321        .filter(|s| !s.is_empty())
322        .map(String::from)
323        .collect()
324}
325
326/// Parse an `_ebus._tcp` record: `roles`, `device_id` and `auth_methods` are
327/// comma-separated lists, and a missing key parses as empty.
328pub fn parse_ebus_txt(txt: &TxtRecord) -> EbusTxt {
329    let text = |k: &str| txt.get(k).map(Cow::into_owned).unwrap_or_default();
330    let list = |k: &str| txt.get(k).map(|v| split_list(&v)).unwrap_or_default();
331    EbusTxt {
332        txtvers: text("txtvers"),
333        ebus_version: text("ebus_version"),
334        roles: list("roles"),
335        device_ids: list("device_id"),
336        auth_methods: list("auth_methods"),
337        txt: txt.clone(),
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use alloc::vec;
345
346    #[test]
347    fn decoding() {
348        let txt = TxtRecord::from_strings([
349            &b"Roles=device"[..],
350            b"flag",
351            b"=nokey",
352            b"",
353            b"empty=",
354            b"ROLES=second",
355            b"bin=\xff",
356            b"eq=a=b",
357        ]);
358        let keys: Vec<&str> = txt.iter().map(|(k, _)| k).collect();
359        assert_eq!(keys, ["roles", "flag", "empty", "bin", "eq"]);
360        assert_eq!(txt.get("roles").as_deref(), Some("device"));
361        assert_eq!(txt.get("FLAG").as_deref(), Some(""));
362        assert_eq!(txt.get_raw("flag"), Some(None));
363        assert_eq!(txt.get_raw("empty"), Some(Some(&b""[..])));
364        assert_eq!(txt.get("bin").as_deref(), Some("\u{fffd}"));
365        assert_eq!(txt.get("eq").as_deref(), Some("a=b"));
366        assert_eq!(txt.get("absent"), None);
367        assert_eq!(
368            txt.to_strings(),
369            vec![
370                b"roles=device".to_vec(),
371                b"flag".to_vec(),
372                b"empty=".to_vec(),
373                b"bin=\xff".to_vec(),
374                b"eq=a=b".to_vec()
375            ]
376        );
377        assert_eq!(txt.wire_size(), 13 + 5 + 7 + 6 + 7);
378    }
379
380    #[test]
381    fn insert_keeps_first() {
382        let mut txt = TxtRecord::new();
383        assert!(txt.insert("Key", "1"));
384        assert!(!txt.insert("kEY", "2"));
385        assert_eq!(txt.get("key").as_deref(), Some("1"));
386        assert_eq!(txt.iter().next(), Some(("Key", Some(&b"1"[..]))));
387    }
388
389    #[test]
390    fn checks() {
391        let mut ok = TxtRecord::new();
392        ok.insert("a key", "v");
393        assert_eq!(check_txt("_x._tcp", &ok).map(|r| r.total_bytes), Ok(8));
394        for bad in ["", "a=b", "caf\u{e9}", "tab\t"] {
395            let mut txt = TxtRecord::new();
396            txt.insert_raw(bad.into(), None);
397            assert!(matches!(
398                check_txt("_x._tcp", &txt),
399                Err(TxtError::InvalidKey { .. })
400            ));
401        }
402        let mut long = TxtRecord::new();
403        long.insert("k", &"v".repeat(253));
404        let r = check_txt("_x._tcp", &long).unwrap();
405        assert_eq!(r.long_strings, [("k".to_string(), 255)]);
406        assert!(!r.is_large());
407        let mut too_long = TxtRecord::new();
408        too_long.insert("k", &"v".repeat(254));
409        assert!(matches!(
410            check_txt("_x._tcp", &too_long),
411            Err(TxtError::StringTooLong { bytes: 256, .. })
412        ));
413        let mut large = TxtRecord::new();
414        for i in 0..7 {
415            large.insert(&alloc::format!("k{i}"), &"v".repeat(190));
416        }
417        let r = check_txt("_x._tcp", &large).unwrap();
418        assert!(r.is_large());
419        assert!(r.long_strings.is_empty());
420    }
421
422    #[test]
423    fn ebus_txt() {
424        let txt = TxtRecord::from_strings([
425            "txtvers=1",
426            "roles= device , ,broker-host",
427            "device_id=a,b",
428            "ebus_version=",
429        ]);
430        let e = parse_ebus_txt(&txt);
431        assert_eq!(e.roles, ["device", "broker-host"]);
432        assert_eq!(e.device_ids, ["a", "b"]);
433        assert!(e.auth_methods.is_empty());
434        assert!(e.is_broker_host());
435        assert_eq!(e.missing(), ["ebus_version"]);
436    }
437}