dstar_gateway_core/validator/diagnostic.rs
1//! Structured warnings emitted by the lenient parsers.
2//!
3//! This file holds the cross-protocol union of diagnostic variants.
4//! Every protocol codec (`DPlus`, `DExtra`, DCS) contributes the variants
5//! its parser can fire, but the type is shared so a single
6//! `DiagnosticSink` can route diagnostics across all protocols without
7//! branching on codec.
8
9use std::net::SocketAddr;
10use std::time::Duration;
11
12use crate::types::{Callsign, ProtocolKind, StreamId};
13
14/// One observable malformation detected by a lenient parser.
15///
16/// **Lenient parsing rule**: the codec must NEVER reject a packet
17/// solely because of a recoverable malformation. Every malformation
18/// becomes a `Diagnostic` and the packet still parses with whatever
19/// content was extractable. Strict-mode is implemented in the
20/// consumer's `DiagnosticSink`, never in the parser.
21#[derive(Debug, Clone, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum Diagnostic {
24 // ─── Header (any DSVT-framed protocol) ──────────────────────
25 /// Header CRC does not match recomputed CRC.
26 HeaderCrcMismatch {
27 /// Originating protocol.
28 protocol: ProtocolKind,
29 /// CRC computed from the wire bytes.
30 computed: u16,
31 /// CRC carried in bytes 39-40 of the header.
32 on_wire: u16,
33 /// MY callsign from the header (for log correlation).
34 my_call: Callsign,
35 },
36
37 /// Header carries non-zero flag bytes (the DSVT-embedded form
38 /// is supposed to zero them — see `HeaderData.cpp:665-667`).
39 HeaderFlagsNonZero {
40 /// Originating protocol.
41 protocol: ProtocolKind,
42 /// Observed flag1.
43 flag1: u8,
44 /// Observed flag2.
45 flag2: u8,
46 /// Observed flag3.
47 flag3: u8,
48 },
49
50 /// Callsign field contains a byte outside ASCII printable range.
51 CallsignNonPrintable {
52 /// Originating protocol.
53 protocol: ProtocolKind,
54 /// Which callsign field (rpt2 / rpt1 / ur / my / suffix).
55 field: CallsignField,
56 /// Byte offset within the 8-byte (or 4-byte for suffix) field.
57 offset_in_field: u8,
58 /// The non-printable byte observed.
59 byte: u8,
60 },
61
62 // ─── Voice / DSVT ───────────────────────────────────────────
63 /// Stream id flipped mid-stream without an EOT in between.
64 StreamIdSwitchWithoutEot {
65 /// Originating protocol.
66 protocol: ProtocolKind,
67 /// Previous stream id (now abandoned).
68 previous: StreamId,
69 /// New stream id (just observed).
70 new: StreamId,
71 /// How long since the last frame on `previous`.
72 elapsed_since_last_frame: Duration,
73 },
74
75 /// Voice data sequence number outside 0..21.
76 VoiceSeqOutOfRange {
77 /// Originating protocol.
78 protocol: ProtocolKind,
79 /// The stream the seq belongs to.
80 stream_id: StreamId,
81 /// The out-of-range seq value (0x40 bit stripped).
82 got: u8,
83 },
84
85 /// Voice data with bit 0x40 set on a non-EOT-length packet, or
86 /// EOT-length packet without bit 0x40 set.
87 VoiceEotBitMismatch {
88 /// Originating protocol.
89 protocol: ProtocolKind,
90 /// Total packet length (including `DPlus` prefix where applicable).
91 packet_len: usize,
92 /// Observed seq byte.
93 seq_byte: u8,
94 },
95
96 /// EOT trailer pattern doesn't match `0x55 0x55 0x55 0x55 0xC8 0x7A`.
97 /// `xlxd` writes a different trailer; reflectors talking to xlxd
98 /// may emit either form.
99 VoiceEotTrailerMismatch {
100 /// Originating protocol.
101 protocol: ProtocolKind,
102 /// Last 6 bytes of the EOT packet as observed.
103 observed: [u8; 6],
104 },
105
106 // ─── DPlus connect / state machine ──────────────────────────
107 /// LINK1 ACK arrived after LINK2 was already sent.
108 DuplicateLink1Ack {
109 /// The peer that sent the duplicate.
110 peer: SocketAddr,
111 },
112
113 /// 8-byte LINK2 reply at offsets `[4..8]` is neither OKRW nor BUSY.
114 UnknownLink2Reply {
115 /// The 4-byte tag (typically interpreted as ASCII).
116 reply: [u8; 4],
117 },
118
119 // ─── DPlus auth ─────────────────────────────────────────────
120 /// Auth response chunk had a record dropped per the lenient filter.
121 AuthHostSkipped {
122 /// Byte offset of the dropped record.
123 offset: usize,
124 /// Why it was dropped.
125 reason: AuthHostSkipReason,
126 },
127
128 /// Auth chunk had trailing bytes that don't form a complete record.
129 AuthChunkTrailingBytes {
130 /// Byte offset where the trailing bytes started.
131 offset: usize,
132 /// How many trailing bytes there were.
133 bytes: usize,
134 },
135}
136
137/// Which callsign field a [`Diagnostic::CallsignNonPrintable`] refers to.
138#[non_exhaustive]
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CallsignField {
141 /// RPT2 (gateway repeater).
142 Rpt2,
143 /// RPT1 (access repeater).
144 Rpt1,
145 /// YOUR callsign.
146 Ur,
147 /// MY callsign.
148 My,
149 /// MY suffix.
150 MySuffix,
151 /// `Authenticate` packet callsign field.
152 Authenticate,
153 /// LINK request callsign field.
154 LinkRequest,
155}
156
157/// Reason an auth host record was skipped during chunk parsing.
158#[non_exhaustive]
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum AuthHostSkipReason {
161 /// Record's active flag (high bit of byte 25) was clear.
162 Inactive,
163 /// Callsign field starts with `XRF` — the reference filters these
164 /// out before caching.
165 XrfPrefix,
166 /// IP field is empty after trimming.
167 EmptyIp,
168 /// Callsign field is empty after trimming.
169 EmptyCallsign,
170 /// IP field couldn't be parsed as IPv4 ASCII.
171 MalformedIp,
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn diagnostic_header_crc_mismatch_constructible() {
180 let cs = Callsign::from_wire_bytes(*b"W1AW ");
181 let d = Diagnostic::HeaderCrcMismatch {
182 protocol: ProtocolKind::DPlus,
183 computed: 0x1073,
184 on_wire: 0xFFFF,
185 my_call: cs,
186 };
187 assert!(matches!(d, Diagnostic::HeaderCrcMismatch { .. }));
188 }
189
190 #[test]
191 fn diagnostic_callsign_non_printable_carries_field() {
192 let d = Diagnostic::CallsignNonPrintable {
193 protocol: ProtocolKind::DPlus,
194 field: CallsignField::My,
195 offset_in_field: 0,
196 byte: 0xC3,
197 };
198 assert!(
199 matches!(
200 d,
201 Diagnostic::CallsignNonPrintable {
202 field: CallsignField::My,
203 byte: 0xC3,
204 ..
205 }
206 ),
207 "expected CallsignNonPrintable {{ My, 0xC3 }}, got {d:?}"
208 );
209 }
210
211 #[test]
212 fn diagnostic_auth_host_skipped_inactive() {
213 let d = Diagnostic::AuthHostSkipped {
214 offset: 0,
215 reason: AuthHostSkipReason::Inactive,
216 };
217 assert!(
218 matches!(
219 d,
220 Diagnostic::AuthHostSkipped {
221 reason: AuthHostSkipReason::Inactive,
222 ..
223 }
224 ),
225 "expected AuthHostSkipped {{ Inactive }}, got {d:?}"
226 );
227 }
228}