dstar_gateway_core/codec/dplus/
auth.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DPlusHost {
22 pub callsign: String,
24 pub address: IpAddr,
26}
27
28#[derive(Debug, Clone, Default)]
30pub struct HostList {
31 hosts: Vec<DPlusHost>,
32}
33
34impl HostList {
35 #[must_use]
37 pub const fn new() -> Self {
38 Self { hosts: Vec::new() }
39 }
40
41 #[must_use]
43 pub fn hosts(&self) -> &[DPlusHost] {
44 &self.hosts
45 }
46
47 #[must_use]
49 pub const fn len(&self) -> usize {
50 self.hosts.len()
51 }
52
53 #[must_use]
55 pub const fn is_empty(&self) -> bool {
56 self.hosts.is_empty()
57 }
58
59 #[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
68pub 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
95fn 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
140fn 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 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
170fn 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 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 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; 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; 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; 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}