dstar_gateway_core/codec/dplus/
auth.rs

1//! `DPlus` auth chunk parser.
2//!
3//! Parses the framed TCP response from `auth.dstargateway.org:20001`
4//! into a list of known reflector callsigns and IP addresses.
5//!
6//! Reference: `ircDDBGateway/Common/DPlusAuthenticator.cpp:151-192`.
7
8use std::net::IpAddr;
9
10use crate::validator::{AuthHostSkipReason, Diagnostic, DiagnosticSink};
11
12use super::error::DPlusError;
13
14const CHUNK_HEADER_SIZE: usize = 8;
15const RECORD_SIZE: usize = 26;
16const IP_FIELD_SIZE: usize = 16;
17const CALLSIGN_FIELD_SIZE: usize = 8;
18
19/// A single entry from the `DPlus` auth server's host list response.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DPlusHost {
22    /// Reflector callsign (e.g. `"REF030"`), trimmed of trailing spaces.
23    pub callsign: String,
24    /// Reflector IPv4 address.
25    pub address: IpAddr,
26}
27
28/// Parsed host list from the `DPlus` auth TCP response.
29#[derive(Debug, Clone, Default)]
30pub struct HostList {
31    hosts: Vec<DPlusHost>,
32}
33
34impl HostList {
35    /// Create an empty host list.
36    #[must_use]
37    pub const fn new() -> Self {
38        Self { hosts: Vec::new() }
39    }
40
41    /// Return a slice of all parsed hosts.
42    #[must_use]
43    pub fn hosts(&self) -> &[DPlusHost] {
44        &self.hosts
45    }
46
47    /// Number of hosts in the list.
48    #[must_use]
49    pub const fn len(&self) -> usize {
50        self.hosts.len()
51    }
52
53    /// True if the list is empty.
54    #[must_use]
55    pub const fn is_empty(&self) -> bool {
56        self.hosts.is_empty()
57    }
58
59    /// Look up a host by callsign (case-insensitive).
60    #[must_use]
61    pub fn find(&self, callsign: &str) -> Option<&DPlusHost> {
62        self.hosts
63            .iter()
64            .find(|h| h.callsign.eq_ignore_ascii_case(callsign))
65    }
66}
67
68/// Parse a `DPlus` auth TCP response into a [`HostList`].
69///
70/// Lenient parser: malformed records are skipped with a diagnostic.
71/// Fatal errors (truncated chunk header, invalid flags, invalid type
72/// byte, undersized chunk length) return `Err`.
73///
74/// # Errors
75///
76/// Returns `DPlusError::AuthChunk*` variants for fatal format errors.
77pub fn parse_auth_response(
78    data: &[u8],
79    sink: &mut dyn DiagnosticSink,
80) -> Result<HostList, DPlusError> {
81    let mut hosts = Vec::new();
82    let mut cursor = 0usize;
83
84    while cursor < data.len() {
85        let chunk_len = validate_chunk_header(data, cursor)?;
86        let chunk_end = cursor + chunk_len;
87        let chunk = data.get(cursor..chunk_end).unwrap_or(&[]);
88        parse_chunk_records(chunk, cursor, &mut hosts, sink);
89        cursor = chunk_end;
90    }
91
92    Ok(HostList { hosts })
93}
94
95/// Validate a chunk header at `cursor` and return the declared chunk length.
96fn validate_chunk_header(data: &[u8], cursor: usize) -> Result<usize, DPlusError> {
97    let remaining = data.len() - cursor;
98    if remaining < 3 {
99        return Err(DPlusError::AuthChunkTruncated {
100            offset: cursor,
101            need: 3,
102            have: remaining,
103        });
104    }
105
106    let b0 = *data.get(cursor).unwrap_or(&0);
107    let b1 = *data.get(cursor + 1).unwrap_or(&0);
108    let b2 = *data.get(cursor + 2).unwrap_or(&0);
109
110    let chunk_len = (usize::from(b1 & 0x0F) * 256) + usize::from(b0);
111
112    if (b1 & 0xC0) != 0xC0 {
113        return Err(DPlusError::AuthChunkFlagsInvalid {
114            offset: cursor,
115            byte: b1,
116        });
117    }
118    if b2 != 0x01 {
119        return Err(DPlusError::AuthChunkTypeInvalid {
120            offset: cursor,
121            byte: b2,
122        });
123    }
124    if chunk_len < CHUNK_HEADER_SIZE {
125        return Err(DPlusError::AuthChunkUndersized {
126            offset: cursor,
127            claimed: chunk_len,
128        });
129    }
130    if cursor + chunk_len > data.len() {
131        return Err(DPlusError::AuthChunkTruncated {
132            offset: cursor,
133            need: chunk_len,
134            have: data.len() - cursor,
135        });
136    }
137    Ok(chunk_len)
138}
139
140/// Walk records in a single chunk (skipping the 8-byte header), filtering
141/// and appending to `hosts`. Malformed records emit a diagnostic and are
142/// skipped. Trailing non-record bytes also emit a diagnostic.
143fn parse_chunk_records(
144    chunk: &[u8],
145    chunk_offset: usize,
146    hosts: &mut Vec<DPlusHost>,
147    sink: &mut dyn DiagnosticSink,
148) {
149    let mut i = CHUNK_HEADER_SIZE;
150    while i + RECORD_SIZE <= chunk.len() {
151        let record_offset = chunk_offset + i;
152        let record = chunk.get(i..i + RECORD_SIZE).unwrap_or(&[]);
153        i += RECORD_SIZE;
154
155        if let Some(host) = parse_record(record, record_offset, sink) {
156            hosts.push(host);
157        }
158    }
159
160    // Trailing bytes inside this chunk that didn't form a complete record.
161    let leftover = chunk.len() - i;
162    if leftover > 0 {
163        sink.record(Diagnostic::AuthChunkTrailingBytes {
164            offset: chunk_offset + i,
165            bytes: leftover,
166        });
167    }
168}
169
170/// Parse a single 26-byte record into a [`DPlusHost`] or skip it via a
171/// diagnostic.
172fn parse_record(
173    record: &[u8],
174    record_offset: usize,
175    sink: &mut dyn DiagnosticSink,
176) -> Option<DPlusHost> {
177    let ip_bytes = record.get(..IP_FIELD_SIZE).unwrap_or(&[]);
178    let callsign_bytes = record
179        .get(IP_FIELD_SIZE..IP_FIELD_SIZE + CALLSIGN_FIELD_SIZE)
180        .unwrap_or(&[]);
181    let active_byte = record.get(25).copied().unwrap_or(0);
182    let active = (active_byte & 0x80) == 0x80;
183
184    let ip_str = std::str::from_utf8(ip_bytes)
185        .unwrap_or("")
186        .trim_matches(['\0', ' ']);
187    let callsign_str = std::str::from_utf8(callsign_bytes)
188        .unwrap_or("")
189        .trim_matches(['\0', ' ']);
190
191    if !active {
192        sink.record(Diagnostic::AuthHostSkipped {
193            offset: record_offset,
194            reason: AuthHostSkipReason::Inactive,
195        });
196        return None;
197    }
198    if ip_str.is_empty() {
199        sink.record(Diagnostic::AuthHostSkipped {
200            offset: record_offset,
201            reason: AuthHostSkipReason::EmptyIp,
202        });
203        return None;
204    }
205    if callsign_str.is_empty() {
206        sink.record(Diagnostic::AuthHostSkipped {
207            offset: record_offset,
208            reason: AuthHostSkipReason::EmptyCallsign,
209        });
210        return None;
211    }
212    if callsign_str.starts_with("XRF") {
213        sink.record(Diagnostic::AuthHostSkipped {
214            offset: record_offset,
215            reason: AuthHostSkipReason::XrfPrefix,
216        });
217        return None;
218    }
219
220    let Ok(address) = ip_str.parse::<IpAddr>() else {
221        sink.record(Diagnostic::AuthHostSkipped {
222            offset: record_offset,
223            reason: AuthHostSkipReason::MalformedIp,
224        });
225        return None;
226    };
227
228    Some(DPlusHost {
229        callsign: callsign_str.to_owned(),
230        address,
231    })
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::validator::VecSink;
238
239    /// Build one chunk wrapping the given host records.
240    fn build_chunk(records: &[[u8; 26]]) -> Vec<u8> {
241        let body_len = 8 + records.len() * 26;
242        assert!(body_len <= 0x0FFF);
243        let mut chunk = Vec::with_capacity(body_len);
244        #[expect(clippy::cast_possible_truncation, reason = "mask guarantees 0..=255")]
245        let lo = (body_len & 0xFF) as u8;
246        #[expect(clippy::cast_possible_truncation, reason = "mask guarantees 0..=15")]
247        let hi = ((body_len >> 8) & 0x0F) as u8;
248        chunk.push(lo);
249        chunk.push(0xC0 | hi);
250        chunk.push(0x01);
251        chunk.extend_from_slice(&[0u8; 5]);
252        for r in records {
253            chunk.extend_from_slice(r);
254        }
255        assert_eq!(chunk.len(), body_len);
256        chunk
257    }
258
259    /// Build one 26-byte host record: space-padded ASCII IP, space-padded
260    /// callsign, module byte 0, active flag set.
261    fn build_record(ip: &str, call: &str) -> Result<[u8; 26], Box<dyn std::error::Error>> {
262        let mut rec = [b' '; 26];
263        let ip_bytes = ip.as_bytes();
264        assert!(ip_bytes.len() <= 16);
265        rec.get_mut(..ip_bytes.len())
266            .ok_or("ip_bytes within rec")?
267            .copy_from_slice(ip_bytes);
268        let cs_bytes = call.as_bytes();
269        assert!(cs_bytes.len() <= 8);
270        rec.get_mut(16..16 + cs_bytes.len())
271            .ok_or("cs_bytes within rec")?
272            .copy_from_slice(cs_bytes);
273        rec[24] = 0;
274        rec[25] = 0x80; // active
275        Ok(rec)
276    }
277
278    type TestResult = Result<(), Box<dyn std::error::Error>>;
279
280    #[test]
281    fn empty_input_returns_empty_list() -> TestResult {
282        let mut sink = VecSink::default();
283        let list = parse_auth_response(&[], &mut sink)?;
284        assert_eq!(list.len(), 0);
285        Ok(())
286    }
287
288    #[test]
289    fn single_record_parses() -> TestResult {
290        let rec = build_record("192.168.1.1", "REF030")?;
291        let data = build_chunk(&[rec]);
292        let mut sink = VecSink::default();
293        let list = parse_auth_response(&data, &mut sink)?;
294        assert_eq!(list.len(), 1);
295        assert_eq!(
296            list.hosts()
297                .first()
298                .ok_or("expected at least one host")?
299                .callsign,
300            "REF030"
301        );
302        Ok(())
303    }
304
305    #[test]
306    fn inactive_record_is_skipped_with_diagnostic() -> TestResult {
307        let mut inactive = build_record("192.168.1.1", "REF030")?;
308        inactive[25] = 0x00; // clear active bit
309        let data = build_chunk(&[inactive]);
310        let mut sink = VecSink::default();
311        let list = parse_auth_response(&data, &mut sink)?;
312        assert_eq!(list.len(), 0);
313        assert_eq!(sink.len(), 1);
314        Ok(())
315    }
316
317    #[test]
318    fn xrf_prefix_is_skipped() -> TestResult {
319        let rec = build_record("192.168.1.1", "XRF030")?;
320        let data = build_chunk(&[rec]);
321        let mut sink = VecSink::default();
322        let list = parse_auth_response(&data, &mut sink)?;
323        assert_eq!(list.len(), 0);
324        assert_eq!(sink.len(), 1);
325        Ok(())
326    }
327
328    #[test]
329    fn malformed_ip_is_skipped_with_diagnostic() -> TestResult {
330        let rec = build_record("notanipaddr", "REF030")?;
331        let data = build_chunk(&[rec]);
332        let mut sink = VecSink::default();
333        let list = parse_auth_response(&data, &mut sink)?;
334        assert_eq!(list.len(), 0);
335        assert_eq!(sink.len(), 1);
336        Ok(())
337    }
338
339    #[test]
340    fn invalid_flags_returns_error() -> TestResult {
341        let mut data = build_chunk(&[build_record("10.0.0.1", "REF001")?]);
342        *data.get_mut(1).ok_or("index 1 within data")? = 0x80; // corrupt flags (must have top two bits set)
343        let mut sink = VecSink::default();
344        let Err(err) = parse_auth_response(&data, &mut sink) else {
345            return Err("expected error for invalid flags".into());
346        };
347        assert!(matches!(err, DPlusError::AuthChunkFlagsInvalid { .. }));
348        Ok(())
349    }
350
351    #[test]
352    fn invalid_type_byte_returns_error() -> TestResult {
353        let mut data = build_chunk(&[build_record("10.0.0.1", "REF001")?]);
354        *data.get_mut(2).ok_or("index 2 within data")? = 0x02;
355        let mut sink = VecSink::default();
356        let Err(err) = parse_auth_response(&data, &mut sink) else {
357            return Err("expected error for invalid type".into());
358        };
359        assert!(matches!(err, DPlusError::AuthChunkTypeInvalid { .. }));
360        Ok(())
361    }
362
363    #[test]
364    fn truncated_chunk_returns_error() -> TestResult {
365        let full = build_chunk(&[build_record("10.0.0.1", "REF001")?]);
366        let truncated = full
367            .get(..full.len() - 1)
368            .ok_or("truncated slice within full")?;
369        let mut sink = VecSink::default();
370        let Err(err) = parse_auth_response(truncated, &mut sink) else {
371            return Err("expected error for truncated chunk".into());
372        };
373        assert!(matches!(err, DPlusError::AuthChunkTruncated { .. }));
374        Ok(())
375    }
376
377    #[test]
378    fn case_insensitive_lookup() -> TestResult {
379        let rec = build_record("10.0.0.1", "REF030")?;
380        let data = build_chunk(&[rec]);
381        let mut sink = VecSink::default();
382        let list = parse_auth_response(&data, &mut sink)?;
383        assert!(list.find("ref030").is_some());
384        assert!(list.find("Ref030").is_some());
385        assert!(list.find("XYZ999").is_none());
386        Ok(())
387    }
388}