1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
//! TXT record schema for `_rtl_tcp._tcp.local.` advertisements.
//!
//! Keys are kept short (under 10 chars) because mDNS packs TXT entries
//! into a single DNS record limited to 400 bytes in practice. Under
//! that limit, clients see the record in one resolve without follow-up
//! queries.
use std::collections::HashMap;
use crate::error::DiscoveryError;
/// TXT record payload attached to a server advertisement. Each field
/// serializes to `key=value` in the mDNS TXT record; missing fields
/// are omitted entirely so older clients don't see empty-string junk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TxtRecord {
/// Tuner family as reported by the dongle (e.g. "R820T", "E4000").
/// Lets the client show "this is an R820T" without connecting.
pub tuner: String,
/// Advertiser version — typically the announcing program's own
/// version string. Clients render it as "running *name* *X.Y.Z*"
/// vs. "unknown rtl_tcp source" for an advertisement without it.
pub version: String,
/// Number of discrete gain steps the tuner exposes. The actual
/// gain table is NOT in TXT — clients assume the R820T table for
/// dB display or drive via set-gain-by-index and show step N of M.
pub gains: u32,
/// Human-readable nickname (user-editable). Defaults to the host's
/// hostname on the server side; clients render this as the primary
/// label in the discovered-servers list.
pub nickname: String,
/// Optional buffer-depth hint (bytes) for latency awareness.
pub txbuf: Option<usize>,
/// Optional codec bitmask: a raw wire byte advertising which
/// stream codecs the server is willing to negotiate in an
/// extended handshake. `None` means "unknown" — a server that
/// doesn't publish this key is assumed to speak only the legacy
/// uncompressed protocol, so a client should not attempt an
/// extended hello (doing so corrupts vanilla command framing on
/// a server that doesn't expect it).
///
/// Kept as a plain `u8` so this crate stays codec-agnostic; the
/// server/client define what the bits mean and convert at the
/// boundary.
pub codecs: Option<u8>,
/// Whether the server requires pre-shared-key auth.
/// `Some(true)` → serialized as `auth_required=true` so a client
/// can prompt for a key *before* dispatching connect. `None` or
/// `Some(false)` → the key is omitted entirely; clients treat
/// absence as "auth not required". Kept as `Option<bool>` rather
/// than `bool` so "unknown" (an advertiser that didn't publish
/// the field) is distinguishable from "explicitly off" at the
/// parser level — both are safe to treat as no-auth, but logging
/// can note the difference for troubleshooting.
pub auth_required: Option<bool>,
}
impl TxtRecord {
/// Maximum combined TXT byte count we'll emit. mDNS DNS records can
/// be larger in theory, but staying under 400 bytes keeps the whole
/// registration in a single UDP packet and avoids the "truncated,
/// follow up with a query" path that some clients handle poorly.
pub const MAX_TOTAL_BYTES: usize = 400;
/// Render as an `mdns-sd` properties map. Omits `txbuf` when None
/// so the field is simply absent rather than stored as `"txbuf="`.
/// Returns `Err(InvalidTxt)` if any value contains a NUL byte or
/// is too long for a single TXT entry (255 bytes).
pub fn to_properties(&self) -> Result<HashMap<String, String>, DiscoveryError> {
let mut m = HashMap::new();
insert_checked(&mut m, "tuner", &self.tuner)?;
insert_checked(&mut m, "version", &self.version)?;
insert_checked(&mut m, "gains", &self.gains.to_string())?;
insert_checked(&mut m, "nickname", &self.nickname)?;
if let Some(n) = self.txbuf {
insert_checked(&mut m, "txbuf", &n.to_string())?;
}
if let Some(c) = self.codecs {
insert_checked(&mut m, "codecs", &c.to_string())?;
}
// Only emit `auth_required` when explicitly true. `None` and
// `Some(false)` both leave the key off the wire — clients
// default to "no auth required" on absence, which matches
// "no auth" being the default before the feature existed.
if self.auth_required == Some(true) {
insert_checked(&mut m, "auth_required", "true")?;
}
let total: usize = m.iter().map(|(k, v)| k.len() + v.len() + 2).sum();
if total > Self::MAX_TOTAL_BYTES {
return Err(DiscoveryError::InvalidTxt(format!(
"TXT record total {total} bytes exceeds {} byte cap",
Self::MAX_TOTAL_BYTES
)));
}
Ok(m)
}
/// Parse an mDNS properties slice back into a `TxtRecord`. Missing
/// fields get sensible defaults ("unknown" / 0) so a partial /
/// corrupt advertisement still renders instead of dropping the
/// server entry.
pub fn from_properties<I, K, V>(properties: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let mut tuner = String::from("unknown");
let mut version = String::from("unknown");
let mut gains: u32 = 0;
let mut nickname = String::new();
let mut txbuf: Option<usize> = None;
let mut codecs: Option<u8> = None;
let mut auth_required: Option<bool> = None;
for (k, v) in properties {
match k.as_ref() {
"tuner" => tuner = v.as_ref().to_string(),
"version" => version = v.as_ref().to_string(),
"gains" => gains = v.as_ref().parse().unwrap_or(0),
"nickname" => nickname = v.as_ref().to_string(),
"txbuf" => txbuf = v.as_ref().parse().ok(),
"codecs" => codecs = v.as_ref().parse().ok(),
// Parse any of the common truthy spellings as `true`.
// Anything else (including the literal string "false")
// becomes `Some(false)` so the parser round-trips
// explicit-false in case a future server wants to
// publish it. Absence stays `None`.
"auth_required" => {
let raw = v.as_ref();
auth_required = Some(matches!(
raw.to_ascii_lowercase().as_str(),
"true" | "1" | "yes"
));
}
_ => {
tracing::trace!(
key = %k.as_ref(),
"unknown rtl_tcp TXT key, ignoring"
);
}
}
}
Self {
tuner,
version,
gains,
nickname,
txbuf,
codecs,
auth_required,
}
}
}
/// TXT entry checker — rejects NUL bytes (mDNS doesn't allow them in
/// keys OR values) and anything over 255 bytes for a single entry.
fn insert_checked(
m: &mut HashMap<String, String>,
key: &str,
value: &str,
) -> Result<(), DiscoveryError> {
if key.contains('\0') || value.contains('\0') {
return Err(DiscoveryError::InvalidTxt(format!(
"key or value for `{key}` contains NUL byte"
)));
}
let entry_len = key.len() + value.len() + 1; // +1 for the `=`
if entry_len > 255 {
return Err(DiscoveryError::InvalidTxt(format!(
"`{key}` entry is {entry_len} bytes, exceeds 255 byte cap"
)));
}
m.insert(key.to_string(), value.to_string());
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn sample() -> TxtRecord {
TxtRecord {
tuner: "R820T".into(),
version: "0.1.0".into(),
gains: 29,
nickname: "home-scanner".into(),
txbuf: Some(128 * 1024),
// An arbitrary non-zero codec-mask byte for the test —
// this crate is codec-agnostic, so the bits are opaque here.
codecs: Some(0b11),
auth_required: None,
}
}
#[test]
fn to_properties_includes_all_fields() {
let props = sample().to_properties().unwrap();
assert_eq!(props.get("tuner").map(String::as_str), Some("R820T"));
assert_eq!(props.get("version").map(String::as_str), Some("0.1.0"));
assert_eq!(props.get("gains").map(String::as_str), Some("29"));
assert_eq!(
props.get("nickname").map(String::as_str),
Some("home-scanner")
);
assert_eq!(props.get("txbuf").map(String::as_str), Some("131072"));
assert_eq!(props.get("codecs").map(String::as_str), Some("3"));
}
#[test]
fn to_properties_omits_missing_txbuf() {
let mut r = sample();
r.txbuf = None;
let props = r.to_properties().unwrap();
assert!(!props.contains_key("txbuf"));
}
#[test]
fn to_properties_omits_missing_codecs() {
// An older server that doesn't advertise a codec mask must
// not emit an empty `codecs=` entry — clients interpret
// absence as "legacy only".
let mut r = sample();
r.codecs = None;
let props = r.to_properties().unwrap();
assert!(!props.contains_key("codecs"));
}
#[test]
fn from_properties_fills_defaults_for_missing_fields() {
let r = TxtRecord::from_properties(std::iter::empty::<(&str, &str)>());
assert_eq!(r.tuner, "unknown");
assert_eq!(r.version, "unknown");
assert_eq!(r.gains, 0);
assert_eq!(r.nickname, "");
assert_eq!(r.txbuf, None);
assert_eq!(r.codecs, None);
assert_eq!(r.auth_required, None);
}
#[test]
fn to_properties_emits_auth_required_only_when_true() {
// Contract: only `Some(true)` lands on the wire. `None` and
// `Some(false)` both omit the key, so a client defaults to
// "no auth" on absence.
let mut r = sample();
r.auth_required = Some(true);
let props = r.to_properties().unwrap();
assert_eq!(props.get("auth_required").map(String::as_str), Some("true"));
r.auth_required = Some(false);
let props = r.to_properties().unwrap();
assert!(!props.contains_key("auth_required"));
r.auth_required = None;
let props = r.to_properties().unwrap();
assert!(!props.contains_key("auth_required"));
}
#[test]
fn from_properties_parses_auth_required_truthy_spellings() {
// Server implementations in other languages may pick
// slightly different truthy spellings. Accept "true", "1",
// and "yes" (case-insensitive) as `Some(true)`; anything
// else becomes `Some(false)` (explicit-false surface for
// round-trip of a future `Some(false)` publisher).
for raw in ["true", "True", "TRUE", "1", "yes", "YES"] {
let r = TxtRecord::from_properties([("auth_required", raw)]);
assert_eq!(r.auth_required, Some(true), "failed to parse {raw:?}");
}
for raw in ["false", "0", "no", "off", "garbage"] {
let r = TxtRecord::from_properties([("auth_required", raw)]);
assert_eq!(r.auth_required, Some(false), "failed to parse {raw:?}");
}
}
#[test]
fn auth_required_round_trips_through_wire() {
// Pin the emit + parse contract together: a `Some(true)`
// record round-trips through `to_properties` →
// `from_properties` with value preserved. `None` round-trips
// as `None`. `Some(false)` intentionally DOES NOT round-trip
// — it serializes as absence, which parses back as `None`.
// Document this explicitly so a future refactor that adds
// `auth_required=false` emission doesn't silently break the
// "absence == unknown, no auth" contract clients rely on.
let mut r = sample();
r.auth_required = Some(true);
let props = r.to_properties().unwrap();
let back = TxtRecord::from_properties(props.iter().map(|(k, v)| (k.clone(), v.clone())));
assert_eq!(back.auth_required, Some(true));
r.auth_required = None;
let props = r.to_properties().unwrap();
let back = TxtRecord::from_properties(props.iter().map(|(k, v)| (k.clone(), v.clone())));
assert_eq!(back.auth_required, None);
r.auth_required = Some(false);
let props = r.to_properties().unwrap();
let back = TxtRecord::from_properties(props.iter().map(|(k, v)| (k.clone(), v.clone())));
// Some(false) → omitted → None on the way back. Intentional;
// asserted here so the asymmetry stays documented.
assert_eq!(back.auth_required, None);
}
#[test]
fn from_properties_parses_codecs() {
let r = TxtRecord::from_properties([("codecs", "3")]);
assert_eq!(r.codecs, Some(3));
}
#[test]
fn from_properties_tolerates_garbage_codecs() {
// Non-numeric `codecs` value falls back to None (unknown)
// rather than rejecting the whole record — same safety
// pattern as `gains`.
let r = TxtRecord::from_properties([("codecs", "not-a-number")]);
assert_eq!(r.codecs, None);
}
#[test]
fn roundtrip_preserves_fields() {
let original = sample();
let props = original.to_properties().unwrap();
let roundtripped =
TxtRecord::from_properties(props.iter().map(|(k, v)| (k.clone(), v.clone())));
assert_eq!(original, roundtripped);
}
#[test]
fn rejects_nul_bytes_in_values() {
let mut r = sample();
r.nickname = "has\0nul".into();
assert!(matches!(
r.to_properties(),
Err(DiscoveryError::InvalidTxt(_))
));
}
#[test]
fn rejects_oversized_single_entry() {
// Single entry above 255 bytes should fail. Nickname is the
// user-controlled one most likely to be pathological.
let mut r = sample();
r.nickname = "x".repeat(300);
assert!(matches!(
r.to_properties(),
Err(DiscoveryError::InvalidTxt(_))
));
}
#[test]
fn from_properties_ignores_unknown_keys() {
// A server running a future schema that added a `compression`
// key shouldn't break our client — we just ignore it.
let r = TxtRecord::from_properties([
("tuner", "R820T"),
("version", "2.0.0"),
("compression", "zstd"),
("future_field", "surprise"),
]);
assert_eq!(r.tuner, "R820T");
assert_eq!(r.version, "2.0.0");
}
#[test]
fn gains_parse_failure_becomes_zero_not_error() {
// Corrupt / non-numeric `gains` shouldn't prevent the server
// from showing up in the list — just render "0 gain steps."
let r = TxtRecord::from_properties([("gains", "not-a-number")]);
assert_eq!(r.gains, 0);
}
}