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}