diff --git a/crates/ax25-node-core/src/ax25/mod.rs b/crates/ax25-node-core/src/ax25/mod.rs index a19b2ed..726541c 100644 --- a/crates/ax25-node-core/src/ax25/mod.rs +++ b/crates/ax25-node-core/src/ax25/mod.rs @@ -14,8 +14,12 @@ pub mod address; pub mod callsign; pub mod frame; pub mod parse_options; +pub mod xid; pub use address::{Address, ADDRESS_LEN}; pub use callsign::Callsign; pub use frame::{Frame, ParseError, PID_NETROM, PID_NO_LAYER3, PID_SEGMENTED}; pub use parse_options::Ax25ParseOptions; +pub use xid::{ + ClassesOfProcedures, HdlcOptionalFunctions, RejectMode, XidParameters, XidParseOptions, +}; diff --git a/crates/ax25-node-core/src/ax25/xid/classes_of_procedures.rs b/crates/ax25-node-core/src/ax25/xid/classes_of_procedures.rs new file mode 100644 index 0000000..116d9d1 --- /dev/null +++ b/crates/ax25-node-core/src/ax25/xid/classes_of_procedures.rs @@ -0,0 +1,76 @@ +//! The XID "Classes of Procedures" parameter (PI=2) — ports +//! `Packet.Ax25.Xid.ClassesOfProcedures`. +//! +//! A 16-bit (PL=2) field per AX.25 v2.2 §4.3.3.7 (Figure 4.5) and the +//! negotiation rules in §6.3.2. For AX.25 only the duplex selection is +//! negotiable; the remaining bits are fixed. +//! +//! Bit layout (LSB-first within each octet; octet 0 transmitted first): +//! bit 0 — Balanced ABM: always 1; bits 1–4 — always 0; bit 5 — Half Duplex; +//! bit 6 — Full Duplex (exactly one of bit 5 / bit 6 set); bits 7–15 — 0. +//! +//! Note the spec prose (§6.3.2 ¶1080) says "bit 0 always 1" but Figure 4.6 +//! encodes PV `0x22 0x00` = ABM(bit1)+half-duplex(bit5). We follow the Figure 4.5 +//! table + normative prose: ABM is the low bit (0x01), half-duplex is 0x20, so +//! half-duplex ABM encodes as `0x21 0x00`. + +/// Balanced ABM — bit 0, always set for AX.25. +const BIT_ABM_BALANCED: u32 = 0; +/// Half-duplex operation — bit 5. +const BIT_HALF_DUPLEX: u32 = 5; +/// Full-duplex operation — bit 6. +const BIT_FULL_DUPLEX: u32 = 6; + +/// The XID Classes of Procedures parameter — the duplex selection (§4.3.3.7, +/// Figure 4.5). Byte-for-byte with C# `ClassesOfProcedures`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClassesOfProcedures { + /// True for half-duplex, false for full-duplex. The default per §6.3.2 is + /// half-duplex ("reverts to half-duplex if either TNC cannot support + /// full-duplex"). + pub half_duplex: bool, +} + +impl ClassesOfProcedures { + /// Half-duplex Classes of Procedures (the AX.25 default). Mirrors C# + /// `ClassesOfProcedures.HalfDuplexDefault`. + pub const HALF_DUPLEX_DEFAULT: Self = Self { half_duplex: true }; + + /// Full-duplex Classes of Procedures. Mirrors C# + /// `ClassesOfProcedures.FullDuplexCapable`. + pub const FULL_DUPLEX_CAPABLE: Self = Self { half_duplex: false }; + + /// Encode to the 2-octet PV (octet 0 first). ABM (bit 0) is forced set; + /// exactly one of half-duplex (bit 5) / full-duplex (bit 6) is set; all + /// other bits are zero per the Figure 4.5 fixed values. + pub fn to_octets(self) -> [u8; 2] { + let field: u32 = (1 << BIT_ABM_BALANCED) + | (1 << if self.half_duplex { + BIT_HALF_DUPLEX + } else { + BIT_FULL_DUPLEX + }); + // LSB-first per octet: octet0 = bits 0–7, octet1 = bits 8–15. + [(field & 0xFF) as u8, ((field >> 8) & 0xFF) as u8] + } + + /// Decode from the (up to) 2-octet PV. Duplex is read from bits 5/6; if + /// neither is set we default to half-duplex (the spec default). All other + /// bits are ignored on receive — only the duplex selection is meaningful. + pub fn from_octets(octet0: u8, octet1: u8) -> Self { + let field: u32 = octet0 as u32 | ((octet1 as u32) << 8); + let full = (field & (1 << BIT_FULL_DUPLEX)) != 0; + let half = (field & (1 << BIT_HALF_DUPLEX)) != 0; + // Half-duplex unless only full-duplex is asserted. + Self { + half_duplex: !(full && !half), + } + } +} + +impl Default for ClassesOfProcedures { + /// Half-duplex — the AX.25 default (§6.3.2). Matches C# `new ClassesOfProcedures()`. + fn default() -> Self { + Self::HALF_DUPLEX_DEFAULT + } +} diff --git a/crates/ax25-node-core/src/ax25/xid/hdlc_optional_functions.rs b/crates/ax25-node-core/src/ax25/xid/hdlc_optional_functions.rs new file mode 100644 index 0000000..64f1f14 --- /dev/null +++ b/crates/ax25-node-core/src/ax25/xid/hdlc_optional_functions.rs @@ -0,0 +1,170 @@ +//! The XID "HDLC Optional Functions" parameter (PI=3) — ports +//! `Packet.Ax25.Xid.HdlcOptionalFunctions` + `RejectMode`. +//! +//! A 24-bit (PL=3) field per AX.25 v2.2 §4.3.3.7 (Figure 4.5) and §6.3.2 +//! ¶1082–1090. For AX.25 this carries the two genuinely-negotiated selections — +//! the reject scheme (REJ vs SREJ) and the modulo (8 vs 128) — plus the +//! segmenter/reassembler bit; every other bit is fixed. +//! +//! Bit layout (logical bits 0–23; bit 0 = the low bit of the low-order octet): +//! bit 1 — REJ (set ⇒ implicit reject); bit 2 — SREJ (set ⇒ selective reject); +//! bit 7 — Extended address (always 1); bit 10 — Modulo 8; bit 11 — Modulo 128; +//! bit 13 — TEST (always 1); bit 15 — 16-bit FCS (always 1); bit 17 — +//! Synchronous transmit (always 1); bit 21 — SREJ multiframe; bit 22 — +//! Segmenter/reassembler; every other bit fixed 0. +//! +//! **Octet order.** The 3-octet PV goes on the wire most-significant octet first +//! per §3.8 ("high-order octet first"). Figure 4.6 prints the PV +//! least-significant-octet first (`82 A8 22`; §3.8-correct it is `22 A8 82`) — a +//! figure-rendering error that contradicts §3.8; we follow §3.8, matching +//! direwolf and LinBPQ on the wire (proven: BPQ accepts the MSB-first PV and +//! negotiates SREJ, silently drops the LSB-first one). + +/// The reject scheme negotiated by the HDLC Optional Functions field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectMode { + /// Implicit reject (REJ) — bit 1 set, bit 2 reset (§6.3.2 ¶1086). + ImplicitReject, + /// Selective reject (SREJ) — bit 1 reset, bit 2 set (§6.3.2 ¶1087). + SelectiveReject, +} + +const BIT_REJ: u32 = 1; +const BIT_SREJ: u32 = 2; +const BIT_EXTENDED_ADDRESS: u32 = 7; // always 1 +const BIT_MODULO8: u32 = 10; +const BIT_MODULO128: u32 = 11; +const BIT_TEST: u32 = 13; // always 1 +const BIT_FCS16: u32 = 15; // always 1 +const BIT_SYNC_TX: u32 = 17; // always 1 +const BIT_SREJ_MULTIFRAME: u32 = 21; +const BIT_SEGMENTER: u32 = 22; + +/// The XID HDLC Optional Functions parameter — reject scheme + modulo + +/// segmenter (§4.3.3.7, Figure 4.5). Byte-for-byte with C# +/// `HdlcOptionalFunctions`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HdlcOptionalFunctions { + /// The reject scheme — implicit (REJ) or selective (SREJ). + pub reject: RejectMode, + /// True ⇒ modulo-128 selected; false ⇒ modulo-8. + pub modulo128: bool, + /// True ⇒ the SREJ-multiframe option (bit 21) is asserted. + pub srej_multiframe: bool, + /// True ⇒ the segmenter/reassembler option (bit 22) is asserted. + pub segmenter_reassembler: bool, +} + +impl HdlcOptionalFunctions { + /// The AX.25 v2.2 default per §6.3.2 ¶1090: selective reject, modulo 128, no + /// segmenter. Mirrors C# `HdlcOptionalFunctions.Default`. + pub const DEFAULT: Self = Self { + reject: RejectMode::SelectiveReject, + modulo128: true, + srej_multiframe: false, + segmenter_reassembler: false, + }; + + /// Encode to the 3-octet PV, most-significant octet first (§3.8; direwolf / + /// BPQ). Forces the always-1 bits (extended address, TEST, 16-bit FCS, + /// synchronous Tx) and sets exactly one reject bit and exactly one modulo bit. + pub fn to_octets(self) -> [u8; 3] { + self.to_octets_ordered(false) + } + + /// Encode to the 3-octet PV with the octet order selectable. When + /// `lsb_octet_first` is `false` (the default, spec-correct) the value is + /// transmitted most-significant octet first (§3.8). `true` reproduces the + /// repo's historical (§3.8-violating) least-significant-octet-first layout, + /// kept only for regression study. Mirrors C# `ToOctets(bool lsbOctetFirst)`. + pub fn to_octets_ordered(self, lsb_octet_first: bool) -> [u8; 3] { + let mut field: u32 = + (1 << BIT_EXTENDED_ADDRESS) | (1 << BIT_TEST) | (1 << BIT_FCS16) | (1 << BIT_SYNC_TX); + + field |= match self.reject { + RejectMode::ImplicitReject => 1 << BIT_REJ, + RejectMode::SelectiveReject => 1 << BIT_SREJ, + }; + field |= if self.modulo128 { + 1 << BIT_MODULO128 + } else { + 1 << BIT_MODULO8 + }; + + if self.srej_multiframe { + field |= 1 << BIT_SREJ_MULTIFRAME; + } + if self.segmenter_reassembler { + field |= 1 << BIT_SEGMENTER; + } + + if lsb_octet_first { + // legacy (incorrect) least-significant octet first + [ + (field & 0xFF) as u8, + ((field >> 8) & 0xFF) as u8, + ((field >> 16) & 0xFF) as u8, + ] + } else { + // spec-correct most-significant octet first (§3.8; direwolf / BPQ) + [ + ((field >> 16) & 0xFF) as u8, + ((field >> 8) & 0xFF) as u8, + (field & 0xFF) as u8, + ] + } + } + + /// Decode from the (up to) 3-octet PV, most-significant octet first (§3.8). + /// Octets beyond the first three are ignored. See [`Self::from_octets_ordered`]. + pub fn from_octets(pv: &[u8]) -> Self { + Self::from_octets_ordered(pv, false) + } + + /// Decode from the (up to) 3-octet PV with the octet order selectable. Reads + /// the reject scheme from bits 1/2 and the modulo from bits 10/11; if a + /// selection is ambiguous or absent it falls back to the spec defaults (SREJ, + /// modulo 128). Mirrors C# `FromOctets(pv, bool lsbOctetFirst)`. + pub fn from_octets_ordered(pv: &[u8], lsb_octet_first: bool) -> Self { + let mut field: u32 = 0; + let n = core::cmp::min(pv.len(), 3); + for (i, &byte) in pv.iter().take(n).enumerate() { + let shift = if lsb_octet_first { + 8 * i as u32 + } else { + 8 * (n - 1 - i) as u32 + }; + field |= (byte as u32) << shift; + } + + let rej = (field & (1 << BIT_REJ)) != 0; + let srej = (field & (1 << BIT_SREJ)) != 0; + // SREJ takes precedence if both are (illegally) set; default SREJ if neither. + let reject = if srej { + RejectMode::SelectiveReject + } else if rej { + RejectMode::ImplicitReject + } else { + RejectMode::SelectiveReject + }; + + let mod128 = (field & (1 << BIT_MODULO128)) != 0; + let mod8 = (field & (1 << BIT_MODULO8)) != 0; + // Default modulo 128 if neither; mod-8 only if it alone is asserted. + let is_mod128 = !(mod8 && !mod128); + + Self { + reject, + modulo128: is_mod128, + srej_multiframe: (field & (1 << BIT_SREJ_MULTIFRAME)) != 0, + segmenter_reassembler: (field & (1 << BIT_SEGMENTER)) != 0, + } + } +} + +impl Default for HdlcOptionalFunctions { + /// The AX.25 v2.2 default (SREJ + modulo 128). See [`Self::DEFAULT`]. + fn default() -> Self { + Self::DEFAULT + } +} diff --git a/crates/ax25-node-core/src/ax25/xid/info_field.rs b/crates/ax25-node-core/src/ax25/xid/info_field.rs new file mode 100644 index 0000000..468e997 --- /dev/null +++ b/crates/ax25-node-core/src/ax25/xid/info_field.rs @@ -0,0 +1,618 @@ +//! Codec for the AX.25 v2.2 XID (Exchange Identification) *information field* — +//! ports `Packet.Ax25.Xid.XidInfoField`. +//! +//! The TLV parameter-negotiation payload carried inside an XID U-frame (§4.3.3.7, +//! parameter table Figure 4.5, worked example Figure 4.6). The resulting bytes go +//! into an XID frame's info field; bytes pulled off a received XID frame come back +//! here. +//! +//! ```text +//! FI (1) Format Identifier = 0x82 (general-purpose XID information) +//! GI (1) Group Identifier = 0x80 (parameter-negotiation identifier) +//! GL (2) Group Length = length of the parameter field that follows, +//! big-endian, NOT counting FI/GI/GL themselves +//! parameter field: a run of PI/PL/PV triples in ascending PI order +//! PI (1) Parameter Identifier +//! PL (1) Parameter Length = length of PV in octets (excludes PI and PL) +//! PV (PL) Parameter Value +//! ``` +//! +//! A `PL` of zero means the PV is absent and the parameter takes its default; an +//! omitted PI/PL/PV triple means "use the currently-negotiated value"; an +//! unrecognised PI is ignored (§4.3.3.7 ¶1024). We model "absent" as a `None` +//! field on [`XidParameters`], distinct from a present-but-default value. +//! +//! **Strict by construction.** [`encode`] emits exactly the fields set on +//! [`XidParameters`], in ascending-PI order, with the fixed/reserved bits forced +//! to their spec-mandated constants. Parser leniency lives behind named flags on +//! [`XidParseOptions`]; the default is spec-strict. +//! +//! `no_std` + `alloc`: [`encode_into`] is the zero-alloc primary; [`encode`] +//! allocates the returned `Vec`. + +extern crate alloc; +use alloc::vec::Vec; + +use super::parameters::XidParameters; +use super::parse_options::XidParseOptions; +use super::{ClassesOfProcedures, HdlcOptionalFunctions}; + +/// Format Identifier for general-purpose XID information (§4.3.3.7 ¶1019). +pub const FORMAT_IDENTIFIER: u8 = 0x82; +/// Group Identifier for the parameter-negotiation group (§4.3.3.7 ¶1020). +pub const GROUP_IDENTIFIER: u8 = 0x80; +/// Minimum encoded length: FI + GI + GL with an empty parameter field. +pub const HEADER_LENGTH: usize = 4; + +/// PI=2 — Classes of Procedures (half/full duplex, ABM). Figure 4.5. +pub const PI_CLASSES_OF_PROCEDURES: u8 = 0x02; +/// PI=3 — HDLC Optional Functions (REJ/SREJ, modulo, segmenter, …). Figure 4.5. +pub const PI_HDLC_OPTIONAL_FUNCTIONS: u8 = 0x03; +/// PI=5 — I Field Length Transmit (bits). ISO 8885; not negotiated by AX.25. +pub const PI_I_FIELD_LENGTH_TX: u8 = 0x05; +/// PI=6 — I Field Length Receive, in **bits** (N1×8). Figure 4.5. +pub const PI_I_FIELD_LENGTH_RX: u8 = 0x06; +/// PI=7 — Window Size Transmit. ISO 8885; not negotiated by AX.25. +pub const PI_WINDOW_SIZE_TX: u8 = 0x07; +/// PI=8 — Window Size Receive (k frames). Figure 4.5. +pub const PI_WINDOW_SIZE_RX: u8 = 0x08; +/// PI=9 — Acknowledge Timer T1, in milliseconds. Figure 4.5. +pub const PI_ACK_TIMER: u8 = 0x09; +/// PI=10 (0x0A) — Retries (N2). Figure 4.6 labels this "Retries (N2)". +pub const PI_RETRIES: u8 = 0x0A; + +/// A fixed upper bound on an encoded XID info field: header (4) + Classes (4) + +/// HDLC (5) + N1 Rx (2+4) + window Rx (3) + T1 (2+4) + N2 (2+4) = 34, rounded up. +const MAX_ENCODED_LEN: usize = 64; + +/// Encode a set of negotiation parameters into the XID information-field bytes +/// (FI + GI + GL + ordered PI/PL/PV). Only the non-`None` fields of `parameters` +/// are emitted, in ascending PI order per §4.3.3.7 ¶1024. Mirrors C# +/// `XidInfoField.Encode`. +pub fn encode(parameters: &XidParameters) -> Vec { + let mut buf = [0u8; MAX_ENCODED_LEN]; + let n = encode_into(parameters, &mut buf).expect("XID info field fits the fixed buffer"); + buf[..n].to_vec() +} + +/// Zero-alloc encode: write the XID information field into `dst`, returning the +/// number of octets written, or `None` if `dst` is too small. Same wire bytes as +/// [`encode`]. +pub fn encode_into(parameters: &XidParameters, dst: &mut [u8]) -> Option { + if dst.len() < HEADER_LENGTH { + return None; + } + let mut off = HEADER_LENGTH; + + if let Some(cop) = parameters.classes_of_procedures { + // PI=2, PL=2, PV = 16-bit field (LSB-first within each octet). + push_parameter(dst, &mut off, PI_CLASSES_OF_PROCEDURES, &cop.to_octets())?; + } + if let Some(hof) = parameters.hdlc_optional_functions { + // PI=3, PL=3, PV = 24-bit field, most-significant octet first (§3.8). + push_parameter(dst, &mut off, PI_HDLC_OPTIONAL_FUNCTIONS, &hof.to_octets())?; + } + if let Some(bits) = parameters.i_field_length_rx_bits { + let be = bits.to_be_bytes(); + push_parameter(dst, &mut off, PI_I_FIELD_LENGTH_RX, minimal_be(&be))?; + } + if let Some(k) = parameters.window_size_rx { + // Window size is a single-octet count 0..127 (Figure 4.5: bits 0–6). + push_parameter(dst, &mut off, PI_WINDOW_SIZE_RX, &[(k & 0x7F) as u8])?; + } + if let Some(t1) = parameters.ack_timer_millis { + let be = t1.to_be_bytes(); + push_parameter(dst, &mut off, PI_ACK_TIMER, minimal_be(&be))?; + } + if let Some(n2) = parameters.retries { + let be = n2.to_be_bytes(); + push_parameter(dst, &mut off, PI_RETRIES, minimal_be(&be))?; + } + + let group_length = (off - HEADER_LENGTH) as u16; + dst[0] = FORMAT_IDENTIFIER; + dst[1] = GROUP_IDENTIFIER; + dst[2] = (group_length >> 8) as u8; + dst[3] = (group_length & 0xFF) as u8; + Some(off) +} + +/// Parse an XID information field into a [`XidParameters`], spec-strict. Returns +/// `None` on a malformed buffer. Mirrors C# +/// `XidInfoField.TryParse(info, out)` (the strict default). +pub fn parse(info: &[u8]) -> Option { + parse_with(info, &XidParseOptions::STRICT) +} + +/// Parse an XID information field applying the supplied [`XidParseOptions`]. +/// Returns `None` (without panicking) on a malformed buffer — a bad FI/GI, a +/// truncated header, a Group Length that overruns the buffer, or (under strict) a +/// PI/PL whose PV runs past the parameter field. Unrecognised PIs are skipped per +/// §4.3.3.7 ¶1024. Mirrors C# `XidInfoField.TryParse(info, options, out)`. +pub fn parse_with(info: &[u8], options: &XidParseOptions) -> Option { + if info.len() < HEADER_LENGTH { + return None; + } + if info[0] != FORMAT_IDENTIFIER { + return None; + } + if info[1] != GROUP_IDENTIFIER { + return None; + } + + let mut group_length = ((info[2] as usize) << 8) | info[3] as usize; + let available = info.len() - HEADER_LENGTH; + + if group_length > available { + // GL claims more parameter bytes than the buffer holds. + if !options.allow_group_length_overrun { + return None; + } + group_length = available; // lenient: clamp to what we actually have + } + + let pf = &info[HEADER_LENGTH..HEADER_LENGTH + group_length]; + + let mut params = XidParameters::default(); + + let mut pos = 0usize; + while pos < pf.len() { + let pi = pf[pos]; + pos += 1; + if pos >= pf.len() { + // A trailing PI with no room for a PL octet. + if !options.allow_truncated_parameter { + return None; + } + break; + } + + let mut pl = pf[pos] as usize; + pos += 1; + if pos + pl > pf.len() { + // PV runs past the end of the parameter field. + if !options.allow_truncated_parameter { + return None; + } + pl = pf.len() - pos; // lenient: take what remains + } + + let pv = &pf[pos..pos + pl]; + pos += pl; + + // A `PL=0` PV (guard `pl >= 1` fails) falls to the no-op arm ⇒ the field + // stays `None` — "absent, take default" per §4.3.3.7 ¶1024, matching C#. + match pi { + PI_CLASSES_OF_PROCEDURES if pl >= 1 => { + let octet0 = pv[0]; + let octet1 = if pl >= 2 { pv[1] } else { 0 }; + params.classes_of_procedures = + Some(ClassesOfProcedures::from_octets(octet0, octet1)); + } + PI_HDLC_OPTIONAL_FUNCTIONS if pl >= 1 => { + params.hdlc_optional_functions = Some(HdlcOptionalFunctions::from_octets(pv)); + } + PI_I_FIELD_LENGTH_RX if pl >= 1 => { + params.i_field_length_rx_bits = Some(decode_unsigned(pv)); + } + PI_WINDOW_SIZE_RX if pl >= 1 => { + params.window_size_rx = Some((pv[0] & 0x7F) as u32); + } + PI_ACK_TIMER if pl >= 1 => { + params.ack_timer_millis = Some(decode_unsigned(pv)); + } + PI_RETRIES if pl >= 1 => { + params.retries = Some(decode_unsigned(pv)); + } + // PI=5 / PI=7 (Tx variants), a PL=0 triple, and any unrecognised PI + // are ignored per §4.3.3.7 ¶1024. + _ => {} + } + } + + Some(params) +} + +/// Write a `PI/PL/PV` triple to `dst` at `*off`, advancing it. Returns `None` if +/// the buffer overruns. +fn push_parameter(dst: &mut [u8], off: &mut usize, pi: u8, pv: &[u8]) -> Option<()> { + let end = *off + 2 + pv.len(); + if end > dst.len() { + return None; + } + dst[*off] = pi; + dst[*off + 1] = pv.len() as u8; + dst[*off + 2..end].copy_from_slice(pv); + *off = end; + Some(()) +} + +/// The minimal big-endian representation of a 4-octet big-endian value: strip +/// leading zero octets, keeping at least one octet (so `0` ⇒ `[0]`). Mirrors C# +/// `EncodeUnsigned` — Type-B numeric fields are variable-length big-endian. +fn minimal_be(be: &[u8; 4]) -> &[u8] { + let first = be.iter().position(|&b| b != 0).unwrap_or(be.len() - 1); + &be[first..] +} + +/// Decode a big-endian Type-B numeric field of arbitrary octet width, saturating +/// at `i32::MAX` on pathological widths (matching C# `DecodeUnsigned`, which +/// returns an `int`). +fn decode_unsigned(pv: &[u8]) -> u32 { + let mut acc: u64 = 0; + for &b in pv { + acc = (acc << 8) | b as u64; + if acc > i32::MAX as u64 { + acc = i32::MAX as u64; + } + } + acc as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::RejectMode; + + // The information field from Figure 4.6 (NJ7P → N7LEM), GL = 0x17 (23 octets). + // NOTE on the HDLC PV (P3 = 03 03 22 A8 82): §3.8 sends multiple-octet fields + // HIGH-ORDER OCTET FIRST, so the same logical selection Figure 4.6 prints as + // `82 A8 22` (LSB-first, §3.8-violating) serialises here as `22 A8 82`. + const FIGURE_46_INFO: [u8; 27] = [ + 0x82, 0x80, 0x00, 0x17, // + 0x02, 0x02, 0x22, 0x00, // + 0x03, 0x03, 0x22, 0xA8, 0x82, // + 0x06, 0x02, 0x04, 0x00, // + 0x08, 0x01, 0x02, // + 0x09, 0x02, 0x10, 0x00, // + 0x0A, 0x01, 0x03, + ]; + + #[test] + fn header_constants_match_spec() { + assert_eq!(FORMAT_IDENTIFIER, 0x82); + assert_eq!(GROUP_IDENTIFIER, 0x80); + assert_eq!(PI_CLASSES_OF_PROCEDURES, 2); + assert_eq!(PI_HDLC_OPTIONAL_FUNCTIONS, 3); + assert_eq!(PI_I_FIELD_LENGTH_RX, 6); + assert_eq!(PI_WINDOW_SIZE_RX, 8); + assert_eq!(PI_ACK_TIMER, 9); + assert_eq!(PI_RETRIES, 0x0A); + } + + #[test] + fn parses_figure_4_6_worked_example() { + let p = parse(&FIGURE_46_INFO).expect("figure 4.6 parses"); + + // Classes of Procedures: PV 0x22 0x00 ⇒ ABM + half-duplex. + assert!(p.classes_of_procedures.unwrap().half_duplex); + + // HDLC: PV 22 A8 82 (MSB-first) ⇒ REJ (bit 1) + mod-128 (bit 11) + + // SREJ-multiframe (bit 21) + the always-1 bits. (Fig 4.6's caption says + // SREJ; the bytes select REJ — the caption is loose.) + let hof = p.hdlc_optional_functions.unwrap(); + assert_eq!(hof.reject, RejectMode::ImplicitReject); + assert!(hof.modulo128); + assert!(hof.srej_multiframe); + assert!(!hof.segmenter_reassembler); + + // N1 Rx: PV 0x04 0x00 = 1024 bits = 128 octets. + assert_eq!(p.i_field_length_rx_bits, Some(1024)); + assert_eq!(p.i_field_length_rx_octets(), Some(128)); + // Window k Rx: PV 0x02 = 2 frames. + assert_eq!(p.window_size_rx, Some(2)); + // T1: PV 0x10 0x00 = 4096 ms. + assert_eq!(p.ack_timer_millis, Some(4096)); + // N2: PV 0x03 = 3 retries. + assert_eq!(p.retries, Some(3)); + } + + #[test] + fn encode_reproduces_figure_4_6_except_for_figure_abm_anomaly() { + // The parameters the Figure 4.6 bytes encode. + let params = XidParameters { + classes_of_procedures: Some(ClassesOfProcedures::HALF_DUPLEX_DEFAULT), + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: RejectMode::ImplicitReject, + modulo128: true, + srej_multiframe: true, + segmenter_reassembler: false, + }), + i_field_length_rx_bits: Some(1024), + window_size_rx: Some(2), + ack_timer_millis: Some(4096), + retries: Some(3), + }; + + let encoded = encode(¶ms); + + // KNOWN SPEC DEFECT: the table/prose put Balanced-ABM at bit 0 (0x21); + // Fig 4.6's byte index 6 is 0x22 (its off-by-one). We follow the table. + const ABM_ANOMALY_INDEX: usize = 6; + assert_eq!(encoded[ABM_ANOMALY_INDEX], 0x21); + assert_eq!(FIGURE_46_INFO[ABM_ANOMALY_INDEX], 0x22); + + // Splice the figure's anomalous byte in and the rest must match exactly. + let mut with_figure_abm = encoded.clone(); + with_figure_abm[ABM_ANOMALY_INDEX] = 0x22; + assert_eq!(with_figure_abm.as_slice(), &FIGURE_46_INFO[..]); + } + + #[test] + fn encode_empty_parameters_emits_bare_header_with_zero_group_length() { + assert_eq!(encode(&XidParameters::default()), alloc::vec![0x82, 0x80, 0x00, 0x00]); + } + + #[test] + fn encode_sets_group_length_to_parameter_field_length_only() { + let bytes = encode(&XidParameters { + window_size_rx: Some(7), + ..Default::default() + }); + assert_eq!(bytes[0], 0x82); + assert_eq!(bytes[1], 0x80); + assert_eq!(bytes[2], 0x00); + assert_eq!(bytes[3], 0x03); // GL counts only the 3 PI/PL/PV bytes + assert_eq!(&bytes[4..], &[0x08, 0x01, 0x07]); + } + + #[test] + fn encode_orders_parameters_by_ascending_pi() { + let bytes = encode(&XidParameters { + retries: Some(5), // PI 0x0A + classes_of_procedures: Some(ClassesOfProcedures::HALF_DUPLEX_DEFAULT), // PI 0x02 + window_size_rx: Some(4), // PI 0x08 + ack_timer_millis: Some(3000), // PI 0x09 + ..Default::default() + }); + + let mut pis = Vec::new(); + let mut pos = HEADER_LENGTH; + while pos < bytes.len() { + pis.push(bytes[pos]); + let pl = bytes[pos + 1] as usize; + pos += 2 + pl; + } + assert_eq!(pis, alloc::vec![0x02, 0x08, 0x09, 0x0A]); + } + + #[test] + fn roundtrip_classes_of_procedures_duplex() { + for half_duplex in [true, false] { + let p = XidParameters { + classes_of_procedures: Some(ClassesOfProcedures { half_duplex }), + ..Default::default() + }; + let got = parse(&encode(&p)).unwrap(); + assert_eq!(got.classes_of_procedures.unwrap().half_duplex, half_duplex); + } + } + + #[test] + fn classes_of_procedures_always_sets_abm_bit() { + // bit 0 (ABM) always 1; half-duplex sets bit 5 ⇒ 0x21; full ⇒ 0x41. + assert_eq!(ClassesOfProcedures::HALF_DUPLEX_DEFAULT.to_octets(), [0x21, 0x00]); + assert_eq!(ClassesOfProcedures::FULL_DUPLEX_CAPABLE.to_octets(), [0x41, 0x00]); + } + + #[test] + fn roundtrip_hdlc_reject_and_modulo() { + for reject in [RejectMode::ImplicitReject, RejectMode::SelectiveReject] { + for mod128 in [true, false] { + let p = XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject, + modulo128: mod128, + srej_multiframe: false, + segmenter_reassembler: false, + }), + ..Default::default() + }; + let got = parse(&encode(&p)).unwrap(); + let hof = got.hdlc_optional_functions.unwrap(); + assert_eq!(hof.reject, reject); + assert_eq!(hof.modulo128, mod128); + } + } + } + + #[test] + fn hdlc_forces_always_one_bits() { + // ToOctets serialises MSB-octet first (octets[0] is bits 16-23); rebuild + // the 24-bit field to check the (order-independent) bit positions. + let octets = HdlcOptionalFunctions::DEFAULT.to_octets(); + let field: u32 = ((octets[0] as u32) << 16) | ((octets[1] as u32) << 8) | octets[2] as u32; + assert_eq!((field >> 7) & 1, 1, "bit 7 extended address always 1"); + assert_eq!((field >> 13) & 1, 1, "bit 13 TEST always 1"); + assert_eq!((field >> 15) & 1, 1, "bit 15 16-bit FCS always 1"); + assert_eq!((field >> 17) & 1, 1, "bit 17 synchronous Tx always 1"); + assert_eq!((field >> 1) & 1, 0, "SREJ selected ⇒ bit 1 (REJ) reset"); + assert_eq!((field >> 2) & 1, 1, "SREJ selected ⇒ bit 2 set"); + assert_eq!((field >> 10) & 1, 0, "mod128 ⇒ bit 10 (mod8) reset"); + assert_eq!((field >> 11) & 1, 1, "mod128 ⇒ bit 11 set"); + } + + #[test] + fn roundtrip_hdlc_segmenter_and_srej_multiframe() { + let p = XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: RejectMode::SelectiveReject, + modulo128: true, + srej_multiframe: true, + segmenter_reassembler: true, + }), + ..Default::default() + }; + let got = parse(&encode(&p)).unwrap().hdlc_optional_functions.unwrap(); + assert!(got.srej_multiframe); + assert!(got.segmenter_reassembler); + } + + #[test] + fn roundtrip_i_field_length_rx_bits() { + for bits in [2048u32, 1024, 8, 65535] { + let p = XidParameters { + i_field_length_rx_bits: Some(bits), + ..Default::default() + }; + let got = parse(&encode(&p)).unwrap(); + assert_eq!(got.i_field_length_rx_bits, Some(bits)); + assert_eq!(got.i_field_length_rx_octets(), Some(bits / 8)); + } + } + + #[test] + fn roundtrip_window_size_rx() { + for k in [0u32, 4, 32, 127] { + let p = XidParameters { + window_size_rx: Some(k), + ..Default::default() + }; + assert_eq!(parse(&encode(&p)).unwrap().window_size_rx, Some(k)); + } + } + + #[test] + fn roundtrip_ack_timer() { + for millis in [3000u32, 4096, 255, 60000] { + let p = XidParameters { + ack_timer_millis: Some(millis), + ..Default::default() + }; + assert_eq!(parse(&encode(&p)).unwrap().ack_timer_millis, Some(millis)); + } + } + + #[test] + fn roundtrip_retries() { + for n2 in [1u32, 10, 255] { + let p = XidParameters { + retries: Some(n2), + ..Default::default() + }; + assert_eq!(parse(&encode(&p)).unwrap().retries, Some(n2)); + } + } + + #[test] + fn roundtrip_all_parameters_together() { + let p = XidParameters { + classes_of_procedures: Some(ClassesOfProcedures::FULL_DUPLEX_CAPABLE), + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: RejectMode::SelectiveReject, + modulo128: true, + srej_multiframe: false, + segmenter_reassembler: true, + }), + i_field_length_rx_bits: Some(XidParameters::octets_to_bits(256)), + window_size_rx: Some(32), + ack_timer_millis: Some(3000), + retries: Some(10), + }; + assert_eq!(parse(&encode(&p)), Some(p)); + } + + #[test] + fn absent_fields_parse_as_none_not_default() { + let got = parse(&encode(&XidParameters { + window_size_rx: Some(4), + ..Default::default() + })) + .unwrap(); + assert_eq!(got.window_size_rx, Some(4)); + assert!(got.classes_of_procedures.is_none()); + assert!(got.hdlc_optional_functions.is_none()); + assert!(got.i_field_length_rx_bits.is_none()); + assert!(got.ack_timer_millis.is_none()); + assert!(got.retries.is_none()); + } + + #[test] + fn empty_parameter_field_parses_to_all_none() { + let got = parse(&[0x82, 0x80, 0x00, 0x00]).unwrap(); + assert_eq!(got, XidParameters::default()); + } + + #[test] + fn zero_length_pv_is_absent_parameter() { + // PL=0 ⇒ PV absent ⇒ field stays None (¶1024). + let info = [0x82, 0x80, 0x00, 0x02, PI_WINDOW_SIZE_RX, 0x00]; + assert!(parse(&info).unwrap().window_size_rx.is_none()); + } + + #[test] + fn unrecognised_pi_is_skipped() { + let info = [ + 0x82, 0x80, 0x00, 0x07, // + 0x42, 0x02, 0xDE, 0xAD, // unknown PI, skipped + 0x08, 0x01, 0x05, // window k = 5 + ]; + assert_eq!(parse(&info).unwrap().window_size_rx, Some(5)); + } + + #[test] + fn tx_variants_pi5_pi7_are_skipped() { + let info = [ + 0x82, 0x80, 0x00, 0x0A, // + 0x05, 0x02, 0x08, 0x00, // PI=5 Tx N1 — skipped + 0x07, 0x01, 0x10, // PI=7 Tx window — skipped + 0x08, 0x01, 0x05, // PI=8 Rx window = 5 + ]; + assert_eq!(parse(&info).unwrap().window_size_rx, Some(5)); + } + + #[test] + fn parse_rejects_short_header() { + assert!(parse(&[]).is_none()); + assert!(parse(&[0x82]).is_none()); + assert!(parse(&[0x82, 0x80, 0x00]).is_none()); + } + + #[test] + fn parse_rejects_wrong_format_or_group_identifier() { + assert!(parse(&[0x81, 0x80, 0x00, 0x00]).is_none()); + assert!(parse(&[0x82, 0x81, 0x00, 0x00]).is_none()); + } + + #[test] + fn strict_rejects_group_length_overrun_but_lenient_clamps() { + // GL claims 8 parameter bytes; only 3 follow. + let info = [0x82, 0x80, 0x00, 0x08, 0x08, 0x01, 0x05]; + assert!(parse_with(&info, &XidParseOptions::STRICT).is_none()); + let got = parse_with(&info, &XidParseOptions::LENIENT).unwrap(); + assert_eq!(got.window_size_rx, Some(5)); + } + + #[test] + fn strict_rejects_truncated_parameter_but_lenient_tolerates() { + // GL=4: a window param (3 bytes) then a stray PI 0x09 with no PL octet. + let info = [0x82, 0x80, 0x00, 0x04, 0x08, 0x01, 0x05, 0x09]; + assert!(parse_with(&info, &XidParseOptions::STRICT).is_none()); + let got = parse_with(&info, &XidParseOptions::LENIENT).unwrap(); + assert_eq!(got.window_size_rx, Some(5)); + } + + #[test] + fn strict_rejects_pv_longer_than_remaining_but_lenient_truncates() { + // GL=4: PI 0x09 (T1) PL=3 but only 1 PV byte before the field ends. + let info = [0x82, 0x80, 0x00, 0x04, 0x09, 0x03, 0x10]; + assert!(parse_with(&info, &XidParseOptions::STRICT).is_none()); + let got = parse_with(&info, &XidParseOptions::LENIENT).unwrap(); + assert_eq!(got.ack_timer_millis, Some(0x10)); // 1 available octet ⇒ 16 + } + + #[test] + fn encode_into_matches_encode_and_reports_too_small() { + let p = XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions::DEFAULT), + window_size_rx: Some(7), + ..Default::default() + }; + let want = encode(&p); + let mut buf = [0u8; MAX_ENCODED_LEN]; + let n = encode_into(&p, &mut buf).unwrap(); + assert_eq!(&buf[..n], want.as_slice()); + // A buffer that can't even hold the header fails. + assert!(encode_into(&p, &mut [0u8; 3]).is_none()); + } +} diff --git a/crates/ax25-node-core/src/ax25/xid/mod.rs b/crates/ax25-node-core/src/ax25/xid/mod.rs new file mode 100644 index 0000000..a9227a0 --- /dev/null +++ b/crates/ax25-node-core/src/ax25/xid/mod.rs @@ -0,0 +1,26 @@ +//! AX.25 v2.2 XID (Exchange Identification) information-field codec. +//! +//! Ports the `Packet.Ax25.Xid` namespace: the TLV parameter-negotiation payload +//! carried inside an XID U-frame (§4.3.3.7, Figure 4.5 / worked example Figure +//! 4.6) — the wire format the management data-link (MDL, App. C5) negotiates over. +//! +//! - [`info_field`] — the FI/GI/GL header + PI/PL/PV parameter codec +//! ([`info_field::encode`] / [`info_field::parse`]). +//! - [`parameters::XidParameters`] — the decoded, semantic parameter set. +//! - [`classes_of_procedures::ClassesOfProcedures`] — PI=2 (duplex). +//! - [`hdlc_optional_functions`] — PI=3 (reject scheme + modulo + segmenter) and +//! [`hdlc_optional_functions::RejectMode`]. +//! - [`parse_options::XidParseOptions`] — spec-strict-by-default parse leniency. +//! +//! Byte-for-byte with the C# codec; see the per-module docs for spec citations. + +pub mod classes_of_procedures; +pub mod hdlc_optional_functions; +pub mod info_field; +pub mod parameters; +pub mod parse_options; + +pub use classes_of_procedures::ClassesOfProcedures; +pub use hdlc_optional_functions::{HdlcOptionalFunctions, RejectMode}; +pub use parameters::XidParameters; +pub use parse_options::XidParseOptions; diff --git a/crates/ax25-node-core/src/ax25/xid/parameters.rs b/crates/ax25-node-core/src/ax25/xid/parameters.rs new file mode 100644 index 0000000..3a2575c --- /dev/null +++ b/crates/ax25-node-core/src/ax25/xid/parameters.rs @@ -0,0 +1,53 @@ +//! The decoded, semantic view of an XID information field's parameter set — +//! ports `Packet.Ax25.Xid.XidParameters`. +//! +//! Each field is `None` when the corresponding PI/PL/PV triple is *absent* from +//! the frame — which, per §4.3.3.7 ¶1024, means "use the currently-negotiated +//! value" rather than any particular default. This type is just the wire payload, +//! decoded; the negotiation (the `sdl` MDL machine) turns a command + response +//! pair into the agreed link parameters. +//! +//! Unit conventions match the wire format and the session context: +//! [`XidParameters::i_field_length_rx_bits`] is in **bits** (Figure 4.5's N1×8); +//! [`XidParameters::i_field_length_rx_octets`] converts to the N1 octet count. +//! [`XidParameters::ack_timer_millis`] is in milliseconds. +//! [`XidParameters::window_size_rx`] and [`XidParameters::retries`] are counts. + +use super::classes_of_procedures::ClassesOfProcedures; +use super::hdlc_optional_functions::HdlcOptionalFunctions; + +/// A decoded XID parameter set (§4.3.3.7, Figure 4.5). Byte-for-byte with C# +/// `XidParameters`; every field `None` ⇒ absent ("use current value"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct XidParameters { + /// Classes of Procedures (PI=2) — duplex selection. `None` if absent. + pub classes_of_procedures: Option, + /// HDLC Optional Functions (PI=3) — reject scheme + modulo + segmenter. + /// `None` if absent. + pub hdlc_optional_functions: Option, + /// I Field Length Receive (PI=6), in **bits** (the wire unit, N1×8). `None` + /// if absent. + pub i_field_length_rx_bits: Option, + /// Window Size Receive k (PI=8), in frames. `None` if absent. + pub window_size_rx: Option, + /// Acknowledge Timer T1 (PI=9), in milliseconds. `None` if absent. + pub ack_timer_millis: Option, + /// Retries N2 (PI=10), the retry count. `None` if absent. + pub retries: Option, +} + +impl XidParameters { + /// [`Self::i_field_length_rx_bits`] converted to octets (N1). `None` if the + /// field is absent. The wire value is bits; N1 in the session is octets, so + /// we divide by 8. + pub fn i_field_length_rx_octets(self) -> Option { + self.i_field_length_rx_bits.map(|bits| bits / 8) + } + + /// Build an N1 (I-field length, octets) value in the wire's bit unit — + /// convenience for callers that think in octets (as the session does). + /// Mirrors C# `XidParameters.OctetsToBits`. + pub fn octets_to_bits(octets: u32) -> u32 { + octets * 8 + } +} diff --git a/crates/ax25-node-core/src/ax25/xid/parse_options.rs b/crates/ax25-node-core/src/ax25/xid/parse_options.rs new file mode 100644 index 0000000..9022125 --- /dev/null +++ b/crates/ax25-node-core/src/ax25/xid/parse_options.rs @@ -0,0 +1,66 @@ +//! Leniency knobs for the XID info-field parse path — ports +//! `Packet.Ax25.Xid.XidParseOptions`. +//! +//! Mirrors the repo's spec-compliant-by-default philosophy (see +//! `docs/strict-vs-pragmatic-audit.md` and `CLAUDE.md`): the [`Strict`] default +//! ([`XidParseOptions::STRICT`]) rejects any malformed XID information field; each +//! accommodation for a non-conformant real-world peer is a named flag, defaulted +//! off. The outbound construction path ([`super::info_field::encode`]) has no +//! equivalent — it is unconditionally strict and never emits a malformed field. +//! +//! [`Strict`]: XidParseOptions::STRICT +//! +//! `no_std`, allocation-free: a `Copy` record of two flags. + +/// Strict-vs-lenient parser choices for the XID info-field decode. Both fields +/// default (via [`XidParseOptions::STRICT`] / [`Default`]) to spec-strict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct XidParseOptions { + /// Accept a Group Length that claims more parameter-field bytes than the + /// buffer actually contains, by clamping to the available bytes. Strict spec + /// (§4.3.3.7 ¶1021: GL is the exact parameter-field length) rejects this. + /// Default `false`. Mirrors C# `AllowGroupLengthOverrun`. + pub allow_group_length_overrun: bool, + + /// Accept a PI/PL whose PV runs past the end of the parameter field (a + /// trailing PI with no PL octet, or a PL larger than the remaining bytes), by + /// taking only the bytes that remain. Strict spec rejects this — a well-formed + /// parameter field is an exact run of complete PI/PL/PV triples. Default + /// `false`. Mirrors C# `AllowTruncatedParameter`. + pub allow_truncated_parameter: bool, +} + +impl XidParseOptions { + /// Spec-strict: reject any malformed XID information field. The default. + /// Mirrors C# `XidParseOptions.Strict`. + pub const STRICT: Self = Self { + allow_group_length_overrun: false, + allow_truncated_parameter: false, + }; + + /// Lenient: tolerate a short/over-claimed Group Length and a truncated + /// trailing parameter. Use for ingesting frames from peers that mis-size the + /// XID info field; never for outbound construction. Mirrors C# + /// `XidParseOptions.Lenient`. + pub const LENIENT: Self = Self { + allow_group_length_overrun: true, + allow_truncated_parameter: true, + }; + + /// The strict preset (spec-compliant). See [`Self::STRICT`]. + pub const fn strict() -> Self { + Self::STRICT + } + + /// The lenient preset. See [`Self::LENIENT`]. + pub const fn lenient() -> Self { + Self::LENIENT + } +} + +impl Default for XidParseOptions { + /// Strict — matches the C# `TryParse` parameterless default (spec-strict). + fn default() -> Self { + Self::STRICT + } +} diff --git a/crates/ax25-node-core/src/sdl/bridge.rs b/crates/ax25-node-core/src/sdl/bridge.rs index 69d3fe6..c0f23e2 100644 --- a/crates/ax25-node-core/src/sdl/bridge.rs +++ b/crates/ax25-node-core/src/sdl/bridge.rs @@ -48,6 +48,12 @@ mod uctl { pub const DM: u8 = 0x0F; /// UA — 0110_0011. pub const UA: u8 = 0x63; + /// FRMR — 1000_0111 (§4.3.3.6). + pub const FRMR: u8 = 0x87; + /// XID — 1010_1111 (§4.3.3.7). + pub const XID: u8 = 0xAF; + /// TEST — 1110_0011 (§4.3.4.2). + pub const TEST: u8 = 0xE3; } /// S-frame control low nibble (the N(R) goes in the high 3 bits, mod-8; P/F is @@ -158,6 +164,16 @@ impl WireSink { let control = ((nr & 0x07) << 5) | if *p { PF_BIT } else { 0 } | ((ns & 0x07) << 1); self.frame(true, control, Some(*pid), info.clone()) } + FrameSpec::Xid { + is_command, + pf, + info, + } => { + // XID U-frame (§4.3.3.7): base 0xAF | P/F, no PID; info carries the + // encoded parameter TLVs. Mirrors C# `Ax25Frame.Xid`. + let control = uctl::XID | if *pf { PF_BIT } else { 0 }; + self.frame(*is_command, control, None, info.clone()) + } } } @@ -318,6 +334,13 @@ pub fn classify_incoming_modulo(frame: &Frame, control_extension: Option) -> uctl::DISC => Event::DiscReceived(info), uctl::UA => Event::UaReceived(info), uctl::DM => Event::DmReceived(info), + // FRMR/XID/TEST are the info-bearing U-frames (§3.5). Mirrors the C# + // `Ax25FrameClassifier` U-frame switch. XID is always classified as the + // single `XidReceived` event — the command/response distinction rides in + // `FrameInfo::is_command`, which the MDL responder reads to decide routing. + uctl::FRMR => Event::FrmrReceived(info), + uctl::XID => Event::XidReceived(info), + uctl::TEST => Event::TestReceived(info), c if (c & 0xEF) == crate::ax25::frame::CONTROL_UI => Event::UiReceived(info), _ => return None, }) @@ -326,3 +349,141 @@ pub fn classify_incoming_modulo(frame: &Frame, control_extension: Option) -> /// PID used when an outbound spec carries none (UI/I always carry one in practice, /// but a defensive default keeps the encoder total). pub const DEFAULT_PID: u8 = PID_NO_LAYER3; + +#[cfg(test)] +mod tests { + use super::*; + use crate::ax25::xid::{info_field, HdlcOptionalFunctions, RejectMode, XidParameters}; + + fn call(s: &str) -> Callsign { + Callsign::parse(s).unwrap() + } + + /// A received XID command (0xAF, command C-bits, F=1) classifies as + /// `XidReceived` — not `ControlFieldError` — and its `FrameInfo` marks it a + /// command and carries the raw XID info bytes for the responder to parse. + #[test] + fn xid_command_classifies_as_xid_received_with_command_flag() { + let info = info_field::encode(&XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: RejectMode::SelectiveReject, + modulo128: false, + srej_multiframe: true, + segmenter_reassembler: false, + }), + ..Default::default() + }); + // dest C-bit set, source C-bit clear ⇒ command. + let frame = Frame { + destination: Address { + callsign: call("M0LTE"), + crh: true, + extension: false, + }, + source: Address { + callsign: call("G7XYZ"), + crh: false, + extension: false, + }, + digipeaters: Vec::new(), + control: uctl::XID | PF_BIT, // XID, P/F=1 + pid: None, + info: info.clone(), + }; + let event = classify_incoming(&frame).expect("XID must classify, not ControlFieldError"); + match event { + Event::XidReceived(fi) => { + assert!(fi.is_command, "command C-bits ⇒ is_command"); + assert!(fi.poll_final, "F=1 preserved"); + assert_eq!(fi.info, info, "raw XID info bytes carried through"); + // The carried info re-parses to the offered parameters. + let p = info_field::parse(&fi.info).expect("info round-trips"); + assert_eq!(p.hdlc_optional_functions.unwrap().reject, RejectMode::SelectiveReject); + } + other => panic!("expected XidReceived, got {other:?}"), + } + } + + /// An XID *response* (response C-bits) still classifies as `XidReceived` + /// (matching the single-event C# classifier); the response-ness is in the flag. + #[test] + fn xid_response_classifies_as_xid_received_with_response_flag() { + let frame = Frame { + destination: Address { + callsign: call("G7XYZ"), + crh: false, + extension: false, + }, + source: Address { + callsign: call("M0LTE"), + crh: true, + extension: false, + }, + digipeaters: Vec::new(), + control: uctl::XID | PF_BIT, + pid: None, + info: info_field::encode(&XidParameters::default()), + }; + match classify_incoming(&frame).expect("classifies") { + Event::XidReceived(fi) => assert!(!fi.is_command, "response C-bits ⇒ not command"), + other => panic!("expected XidReceived, got {other:?}"), + } + } + + #[test] + fn frmr_and_test_classify() { + let mk = |base: u8| Frame { + destination: Address { + callsign: call("M0LTE"), + crh: true, + extension: false, + }, + source: Address { + callsign: call("G7XYZ"), + crh: false, + extension: false, + }, + digipeaters: Vec::new(), + control: base, + pid: None, + info: Vec::new(), + }; + assert!(matches!( + classify_incoming(&mk(uctl::FRMR)), + Some(Event::FrmrReceived(_)) + )); + assert!(matches!( + classify_incoming(&mk(uctl::TEST)), + Some(Event::TestReceived(_)) + )); + } + + /// A built `FrameSpec::Xid` encodes to a real XID U-frame that decodes + + /// classifies back to `XidReceived` with the same info — the outbound→inbound + /// round-trip the MDL responder relies on. + #[test] + fn xid_frame_spec_builds_and_round_trips() { + let sink = WireSink::new(call("M0LTE"), call("G7XYZ"), Vec::new()); + let info = info_field::encode(&XidParameters { + window_size_rx: Some(7), + ..Default::default() + }); + let bytes = sink.encode_spec(&FrameSpec::Xid { + is_command: false, // response + pf: true, + info: info.clone(), + }); + let decoded = Frame::decode(&bytes).expect("XID frame decodes"); + assert_eq!(decoded.pid, None, "XID carries no PID"); + assert_eq!(decoded.control & 0xEF, uctl::XID, "XID control base"); + assert!(decoded.poll_final(), "F=1"); + assert!(decoded.is_response(), "built as a response"); + match classify_incoming(&decoded).expect("classifies") { + Event::XidReceived(fi) => { + assert!(!fi.is_command); + assert_eq!(fi.info, info); + } + other => panic!("expected XidReceived, got {other:?}"), + } + } +} diff --git a/crates/ax25-node-core/src/sdl/capability.rs b/crates/ax25-node-core/src/sdl/capability.rs new file mode 100644 index 0000000..4ba3d1d --- /dev/null +++ b/crates/ax25-node-core/src/sdl/capability.rs @@ -0,0 +1,445 @@ +//! Per-neighbour capability cache — ports `Packet.Node.Core.Capabilities` +//! (`PeerCapabilityCache` + `PeerCapabilityRecord` + `PeerDialPlan` / +//! `PeerDialPolicy`) as a fixed-capacity, `no_std`, alloc-free structure. +//! +//! Remembers, per (port, neighbour), whether it supports v2.2/SABME +//! ([`PeerCapabilityRecord::supports_extended`]) and whether it answers a +//! pre-session XID ([`PeerCapabilityRecord::supports_srej_via_xid`]), so a dial +//! can skip probes a known non-answerer would only stall on, and re-probe a +//! learned negative after ~30 days. The decision is [`PeerCapabilityCache::plan_dial`]; +//! the post-dial learning is [`PeerCapabilityCache::record_outcome`]. +//! +//! **Deviations from the C# original (all embedded-shape, behaviour-preserving):** +//! - No SQLite / [`store`](https://www.nuget.org/): in-memory only. On a Pico the +//! record set is tiny (research §6), so a fixed `[Option<_>; N]` replaces the +//! `ConcurrentDictionary`; when full, the least-recently-probed record is evicted. +//! - Port identity is a `u8` port index (the fw assigns each transport one) rather +//! than the desktop's `string PortId` — an alloc-free key for the fixed store. +//! - Time is an explicit `now_ms: u64` argument (a monotonic/RTC millisecond +//! stamp the caller supplies) rather than a `TimeProvider` — core has no wall +//! clock. The 30-day staleness window is [`STALE_AFTER_MS`]. + +use crate::ax25::Callsign; + +/// A learned negative is re-probed after this long (30 days, in ms), in case the +/// peer (or its firmware) changed. Mirrors C# `PeerCapabilityCache.StaleAfter`. +pub const STALE_AFTER_MS: u64 = 30 * 24 * 60 * 60 * 1000; + +/// How optimistic a dial should be before anything is learned about the peer. +/// Mirrors C# `PeerDialPolicy`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeerDialPolicy { + /// A node-to-node interlink: conservative — stay mod-8 until the peer is proven + /// extended, and probe SREJ via a pre-connect XID. + Interlink, + /// A user-initiated connect: optimistic — offer SABME by default. + UserConnect, +} + +/// The dial decision produced by [`PeerCapabilityCache::plan_dial`]. Mirrors C# +/// `PeerDialPlan`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeerDialPlan { + /// Offer SABME (mod-128) rather than SABM (mod-8). + pub extended: bool, + /// Send a pre-connect XID to probe / negotiate SREJ (moot on the extended path). + pub pre_connect_xid: bool, +} + +/// One learned (port, peer) capability record. `None` on a capability dimension +/// means "never probed". Mirrors C# `PeerCapabilityRecord`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeerCapabilityRecord { + /// The port (transport) index this record is keyed on. + pub port_id: u8, + /// The neighbour this record describes. + pub peer: Callsign, + /// `Some(true)` = speaks v2.2/SABME; `Some(false)` = refused / degraded to + /// mod-8; `None` = never probed. + pub supports_extended: Option, + /// `Some(true)` = answers a pre-session XID and negotiated SREJ; `Some(false)` + /// = does not answer XID; `None` = never probed. + pub supports_srej_via_xid: Option, + /// When this record was last probed (ms stamp from the caller's clock). + pub last_probed_ms: u64, + /// When an extended dial last degraded to mod-8 (ms stamp), if ever. + pub last_refused_ms: Option, +} + +/// A fixed-capacity, (port, peer)-keyed capability cache. `N` is the maximum number +/// of records; a full cache evicts the least-recently-probed record on insert. +#[derive(Debug)] +pub struct PeerCapabilityCache { + records: [Option; N], +} + +impl Default for PeerCapabilityCache { + fn default() -> Self { + Self::new() + } +} + +impl PeerCapabilityCache { + /// A fresh, empty cache. + pub fn new() -> Self { + Self { + records: core::array::from_fn(|_| None), + } + } + + /// Decide how to dial `peer` on `port_id`, given the caller's clock `now_ms`. + /// A miss or a stale record falls back to the optimistic `policy` default; a + /// fresh learned positive is honoured (offer SABME); a fresh learned negative + /// is skipped (mod-8, and skip the pre-connect XID for a known non-answerer). + /// Mirrors C# `PeerCapabilityCache.PlanDial`. + pub fn plan_dial( + &self, + port_id: u8, + peer: &Callsign, + policy: PeerDialPolicy, + now_ms: u64, + ) -> PeerDialPlan { + let rec = self.lookup(port_id, peer); + + // Extended: a fresh learned answer wins; else the policy's optimistic + // default (UserConnect offers SABME; Interlink stays mod-8). + let extended = if fresh(rec, rec.and_then(|r| r.supports_extended), now_ms) { + rec.unwrap().supports_extended.unwrap() + } else { + policy == PeerDialPolicy::UserConnect + }; + + // Pre-connect XID: moot on the extended path. Off it, send the XID unless we + // have freshly learned this peer does NOT answer it. + let known_non_answerer = fresh(rec, rec.and_then(|r| r.supports_srej_via_xid), now_ms) + && rec.and_then(|r| r.supports_srej_via_xid) == Some(false); + let pre_connect_xid = !extended && !known_non_answerer; + + PeerDialPlan { + extended, + pre_connect_xid, + } + } + + /// Record what a returned dial observed. **Plan-aware**: a dimension is learned + /// only when the dial actually probed it — a mod-8 dial proves nothing about + /// extended capability (leaves `supports_extended` untouched); a dial with no + /// pre-connect XID leaves `supports_srej_via_xid` untouched. Mirrors C# + /// `PeerCapabilityCache.RecordOutcome`. + #[allow(clippy::too_many_arguments)] + pub fn record_outcome( + &mut self, + port_id: u8, + peer: Callsign, + dialed_extended: bool, + observed_is_extended: bool, + dialed_pre_connect_xid: bool, + observed_srej_enabled: bool, + now_ms: u64, + ) { + let existing = self.lookup(port_id, &peer).copied(); + + // Only learn a dimension we actually probed; else carry the prior value. + let supports_extended = if dialed_extended { + Some(observed_is_extended) + } else { + existing.and_then(|r| r.supports_extended) + }; + let supports_srej_via_xid = if dialed_pre_connect_xid { + Some(observed_srej_enabled) + } else { + existing.and_then(|r| r.supports_srej_via_xid) + }; + + // LastRefused stamps an extended degrade (offered SABME, came back mod-8); + // else carry forward. + let last_refused_ms = if dialed_extended && !observed_is_extended { + Some(now_ms) + } else { + existing.and_then(|r| r.last_refused_ms) + }; + + self.upsert(PeerCapabilityRecord { + port_id, + peer, + supports_extended, + supports_srej_via_xid, + last_probed_ms: now_ms, + last_refused_ms, + }); + } + + /// Forget one (port, peer). Returns whether an entry was present. + pub fn forget(&mut self, port_id: u8, peer: &Callsign) -> bool { + if let Some(i) = self.index_of(port_id, peer) { + self.records[i] = None; + true + } else { + false + } + } + + /// Every cached record, in slot order. + pub fn iter(&self) -> impl Iterator { + self.records.iter().filter_map(|r| r.as_ref()) + } + + /// Number of records currently held. + pub fn len(&self) -> usize { + self.records.iter().filter(|r| r.is_some()).count() + } + + /// Whether the cache holds no records. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The record for (port, peer), if present. + pub fn lookup(&self, port_id: u8, peer: &Callsign) -> Option<&PeerCapabilityRecord> { + self.index_of(port_id, peer) + .and_then(|i| self.records[i].as_ref()) + } + + fn index_of(&self, port_id: u8, peer: &Callsign) -> Option { + self.records.iter().position(|r| { + r.as_ref() + .is_some_and(|rec| rec.port_id == port_id && rec.peer == *peer) + }) + } + + /// Insert or replace `rec` by its (port, peer) key. When the cache is full and + /// the key is new, evict the least-recently-probed record. + fn upsert(&mut self, rec: PeerCapabilityRecord) { + if let Some(i) = self.index_of(rec.port_id, &rec.peer) { + self.records[i] = Some(rec); + return; + } + if let Some(i) = self.records.iter().position(|r| r.is_none()) { + self.records[i] = Some(rec); + return; + } + // Full: evict the least-recently-probed record. + let mut victim = 0usize; + let mut oldest = u64::MAX; + for (i, slot) in self.records.iter().enumerate() { + if let Some(existing) = slot { + if existing.last_probed_ms <= oldest { + oldest = existing.last_probed_ms; + victim = i; + } + } + } + self.records[victim] = Some(rec); + } +} + +/// A learned dimension is fresh when the record exists, that dimension has a value, +/// and the record was probed within the staleness window. A never-probed (`None`) +/// dimension is never fresh. Mirrors C# `PeerCapabilityCache.Fresh`. +fn fresh(rec: Option<&PeerCapabilityRecord>, dimension: Option, now_ms: u64) -> bool { + match rec { + Some(r) => dimension.is_some() && now_ms.saturating_sub(r.last_probed_ms) < STALE_AFTER_MS, + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PORT: u8 = 0; // "vhf0" + const HF: u8 = 1; // "hf0" + const T0: u64 = 1_000_000; // arbitrary base ms stamp + + fn peer() -> Callsign { + Callsign::parse("GB7RDG-7").unwrap() + } + + fn cache() -> PeerCapabilityCache<8> { + PeerCapabilityCache::new() + } + + // ─── plan_dial: miss ⇒ optimistic policy default ───────────────────── + + #[test] + fn plan_dial_miss_user_connect_offers_sabme_and_no_xid() { + let plan = cache().plan_dial(PORT, &peer(), PeerDialPolicy::UserConnect, T0); + assert!(plan.extended); + assert!(!plan.pre_connect_xid); + } + + #[test] + fn plan_dial_miss_interlink_stays_mod8_and_sends_xid() { + let plan = cache().plan_dial(PORT, &peer(), PeerDialPolicy::Interlink, T0); + assert!(!plan.extended); + assert!(plan.pre_connect_xid); + } + + // ─── plan_dial: fresh learned answers ──────────────────────────────── + + #[test] + fn plan_dial_fresh_positive_extended_honoured_even_for_interlink() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, true, false, false, T0); + let plan = c.plan_dial(PORT, &peer(), PeerDialPolicy::Interlink, T0); + assert!(plan.extended); + assert!(!plan.pre_connect_xid); + } + + #[test] + fn plan_dial_fresh_negative_extended_skips_sabme_even_for_user_connect() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, false, false, false, T0); + let plan = c.plan_dial(PORT, &peer(), PeerDialPolicy::UserConnect, T0); + assert!(!plan.extended); + assert!(plan.pre_connect_xid, "unknown XID answerer ⇒ still probe"); + } + + #[test] + fn plan_dial_fresh_non_xid_answerer_skips_the_pre_connect_xid() { + let mut c = cache(); + c.record_outcome(PORT, peer(), false, false, true, false, T0); + let plan = c.plan_dial(PORT, &peer(), PeerDialPolicy::Interlink, T0); + assert!(!plan.extended); + assert!(!plan.pre_connect_xid, "known non-answerer ⇒ skip the stall"); + } + + #[test] + fn plan_dial_fresh_xid_answerer_still_sends_the_pre_connect_xid() { + let mut c = cache(); + c.record_outcome(PORT, peer(), false, false, true, true, T0); + let plan = c.plan_dial(PORT, &peer(), PeerDialPolicy::Interlink, T0); + assert!(!plan.extended); + assert!(plan.pre_connect_xid); + } + + // ─── plan_dial: staleness re-probe ─────────────────────────────────── + + #[test] + fn plan_dial_stale_negative_re_probes_with_the_policy_default() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, false, false, false, T0); + let later = T0 + STALE_AFTER_MS + 24 * 60 * 60 * 1000; // +1 day past window + assert!(c.plan_dial(PORT, &peer(), PeerDialPolicy::UserConnect, later).extended); + } + + #[test] + fn plan_dial_just_inside_the_window_is_still_fresh() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, false, false, false, T0); + let later = T0 + STALE_AFTER_MS - 60 * 1000; // 1 min inside the window + assert!(!c.plan_dial(PORT, &peer(), PeerDialPolicy::UserConnect, later).extended); + } + + // ─── record_outcome: plan-aware learning ───────────────────────────── + + #[test] + fn record_outcome_dialed_extended_sets_supports_extended() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, true, false, false, T0); + let rec = c.iter().next().unwrap(); + assert_eq!(rec.supports_extended, Some(true)); + assert_eq!(rec.supports_srej_via_xid, None, "never probed ⇒ null"); + } + + #[test] + fn record_outcome_dialed_mod8_does_not_touch_supports_extended() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, true, false, false, T0); + c.record_outcome(PORT, peer(), false, false, false, false, T0 + 300_000); + assert_eq!(c.iter().next().unwrap().supports_extended, Some(true)); + } + + #[test] + fn record_outcome_dialed_mod8_from_unknown_leaves_extended_null() { + let mut c = cache(); + c.record_outcome(PORT, peer(), false, false, false, false, T0); + assert_eq!(c.iter().next().unwrap().supports_extended, None); + } + + #[test] + fn record_outcome_dialed_xid_sets_supports_srej_via_xid() { + let mut c = cache(); + c.record_outcome(PORT, peer(), false, false, true, true, T0); + let rec = c.iter().next().unwrap(); + assert_eq!(rec.supports_srej_via_xid, Some(true)); + assert_eq!(rec.supports_extended, None); + } + + #[test] + fn record_outcome_no_xid_does_not_touch_supports_srej_via_xid() { + let mut c = cache(); + c.record_outcome(PORT, peer(), false, false, true, true, T0); + c.record_outcome(PORT, peer(), true, true, false, false, T0 + 300_000); + let rec = c.iter().next().unwrap(); + assert_eq!(rec.supports_srej_via_xid, Some(true), "preserved"); + assert_eq!(rec.supports_extended, Some(true), "the probed dimension"); + } + + #[test] + fn record_outcome_sets_last_refused_on_an_extended_degrade() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, false, false, false, T0); + assert_eq!(c.iter().next().unwrap().last_refused_ms, Some(T0)); + } + + #[test] + fn record_outcome_does_not_set_last_refused_on_a_clean_extended_dial() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, true, false, false, T0); + assert_eq!(c.iter().next().unwrap().last_refused_ms, None); + } + + #[test] + fn record_outcome_carries_last_refused_forward_on_a_non_degrade_dial() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, false, false, false, T0); + c.record_outcome(PORT, peer(), false, false, true, true, T0 + 300_000); + assert_eq!(c.iter().next().unwrap().last_refused_ms, Some(T0), "carried forward"); + } + + #[test] + fn record_outcome_stamps_last_probed_with_the_clock() { + let mut c = cache(); + let t = T0 + 3 * 60 * 60 * 1000; + c.record_outcome(PORT, peer(), true, true, false, false, t); + assert_eq!(c.iter().next().unwrap().last_probed_ms, t); + } + + // ─── per-link keying + forget + eviction ───────────────────────────── + + #[test] + fn records_are_keyed_per_port_and_peer() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, true, false, false, T0); + c.record_outcome(HF, peer(), true, false, false, false, T0); + assert_eq!(c.len(), 2); + assert!(c.plan_dial(PORT, &peer(), PeerDialPolicy::Interlink, T0).extended); + assert!(!c.plan_dial(HF, &peer(), PeerDialPolicy::UserConnect, T0).extended); + } + + #[test] + fn forget_removes_the_entry() { + let mut c = cache(); + c.record_outcome(PORT, peer(), true, true, false, false, T0); + assert!(c.forget(PORT, &peer())); + assert!(c.is_empty()); + assert!(!c.forget(PORT, &peer()), "already gone"); + } + + #[test] + fn full_cache_evicts_the_least_recently_probed() { + let mut c: PeerCapabilityCache<2> = PeerCapabilityCache::new(); + let a = Callsign::parse("G7AAA").unwrap(); + let b = Callsign::parse("G7BBB").unwrap(); + let d = Callsign::parse("G7DDD").unwrap(); + c.record_outcome(PORT, a, true, true, false, false, T0); // oldest + c.record_outcome(PORT, b, true, true, false, false, T0 + 1000); + // Cache full; inserting a third evicts A (oldest last_probed). + c.record_outcome(PORT, d, true, true, false, false, T0 + 2000); + assert_eq!(c.len(), 2); + assert!(c.lookup(PORT, &a).is_none(), "A evicted"); + assert!(c.lookup(PORT, &b).is_some()); + assert!(c.lookup(PORT, &d).is_some()); + } +} diff --git a/crates/ax25-node-core/src/sdl/manager.rs b/crates/ax25-node-core/src/sdl/manager.rs index 2568019..1fa7f44 100644 --- a/crates/ax25-node-core/src/sdl/manager.rs +++ b/crates/ax25-node-core/src/sdl/manager.rs @@ -21,6 +21,7 @@ use alloc::vec::Vec; use crate::ax25::Callsign; use super::bridge::WireSink; +use super::capability::PeerDialPlan; use super::carrier::CarrierSense; use super::event::Event; use super::session::Session; @@ -55,13 +56,15 @@ pub struct SessionManager { impl SessionManager { /// Build a manager for the node's own `local` callsign with all slots free. - /// A plain [`Self::connect`] dials mod-8 (SABM) by default; see + /// A plain [`Self::connect`] dials mod-128 (SABME) by default — matching C# + /// `Ax25ListenerOptions.PreferExtendedConnect = true` — with automatic degrade + /// to mod-8 SABM if the peer refuses (FRMR #45 or DM #48); see /// [`Self::with_prefer_extended_connect`]. No carrier-sense source is wired /// (always-clear); see [`Self::set_carrier_sense`]. pub fn new(local: Callsign) -> Self { Self { local, - prefer_extended_connect: false, + prefer_extended_connect: true, carrier: None, // `Option` isn't `Copy`, so build the array element-by-element. slots: core::array::from_fn(|_| None), @@ -96,13 +99,13 @@ impl SessionManager { /// Set whether a plain [`Self::connect`] prefers a mod-128 (SABME) dial with /// SABM/mod-8 fallback on refusal, returning `self` for chaining. Mirrors the - /// listener option `Ax25ListenerOptions.PreferExtendedConnect` (Ax25Listener.cs:1712) - /// — but where the C# default is `true`, pico defaults **false** to preserve the - /// historical mod-8 dial: the SABME→SABM degrade is only half-wired (FRMR - /// fallback #45 is present; the DM-refusal degrade #48 is owned by another track), - /// so a mod-128-preferred default could strand a connect to a DM-refusing peer - /// until #48 lands. Callers opt in per manager here, or per dial via - /// [`Self::connect_extended`]. + /// listener option `Ax25ListenerOptions.PreferExtendedConnect` (Ax25Listener.cs:1712), + /// and — now that both refusal degrades are present on the session (FRMR + /// fallback #45 and the DM-refusal degrade #48) — pico matches the C# default of + /// **true**: a v2.2-preferred dial that a pre-v2.2 peer refuses with FRMR or DM + /// degrades to a mod-8 SABM re-establishment instead of stranding. Pass `false` + /// here (or dial mod-8 explicitly via [`Self::connect_extended`]) to force the + /// historical mod-8 dial. pub fn with_prefer_extended_connect(mut self, prefer: bool) -> Self { self.prefer_extended_connect = prefer; self @@ -209,6 +212,39 @@ impl SessionManager { .as_mut() .expect("slot just ensured to be present"); slot.sink.sent.clear(); + + // Pre-session XID *command* responder (mirrors + // `Ax25Listener.HandleNoCachedSession`'s XID branch): a peer doing pre-SABM + // XID negotiation to us before any link exists — the PDN NET/ROM mod-8 + // interlink initiator opening with XID. §4.3.3.7 makes answering an XID + // command unconditional; the negotiated params stage on this cached slot's + // context so the *subsequent* SABM's figc4.1 t14 `Set Version 2.0` (which + // clears only `is_extended`) preserves the staged `srej_enabled` into the + // established link. We answer directly (connectionless — no LM-SEIZE), + // matching C# `RespondToXidCommand`; no ConnectIndication is raised (the + // following SABM raises it). Gated on `accept_incoming`, like SABM-accept. + if let Event::XidReceived(fi) = &event { + if fi.is_command + && slot.session.state == super::session::State::Disconnected + && slot.session.context.accept_incoming + { + let command_info = fi.info.clone(); + let response_info = super::mdl::respond_pre_session_xid( + &mut slot.session.context, + &command_info, + ); + // XID is a U-frame (1 octet in both modulos); modulo is immaterial. + slot.sink.extended = slot.session.context.is_extended; + let bytes = slot.sink.encode_spec(&super::signal::FrameSpec::Xid { + is_command: false, + pf: true, // F=1 so the initiator's figc5.2 F_eq_1 diamond fires + info: response_info, + }); + slot.sink.sent.push(bytes); + return core::mem::take(&mut slot.sink.sent); + } + } + // Track the link's negotiated modulo so the sink emits 2-octet extended // control on an I/S frame once the session is mod-128 (SABME-established). // is_extended is settled before any I/S frame is emitted (it is set on the @@ -285,6 +321,34 @@ impl SessionManager { self.post_with_local(local, peer, Event::DlConnectRequest, timers) } + /// Dial `peer` from `local` per a capability-cache [`PeerDialPlan`] — the + /// dial-time seam that supplies a peer's learned XID capabilities. The plan's + /// [`extended`](PeerDialPlan::extended) selects SABME vs SABM (via + /// [`Self::connect_extended`]). Pair with + /// [`PeerCapabilityCache::plan_dial`](super::capability::PeerCapabilityCache::plan_dial) + /// upstream and + /// [`PeerCapabilityCache::record_outcome`](super::capability::PeerCapabilityCache::record_outcome) + /// once the dial resolves (extended-vs-degraded observable from the session's + /// `is_extended`, SREJ from `srej_enabled`). + /// + /// NOTE: the plan's [`pre_connect_xid`](PeerDialPlan::pre_connect_xid) probe (an + /// *initiator* XID command sent before the SABM, then a bounded wait for the + /// response — the C# `NegotiateSrejBeforeConnectAsync` fast-probe) is NOT driven + /// here: it is an inherently async, multi-step flow above the synchronous + /// per-`post` core. The *responder* half is complete (see + /// [`super::mdl::respond_pre_session_xid`]), and the merge + /// ([`super::mdl::apply_negotiated`]) is available for a fw-side initiator MDL to + /// drive; this method honours the extended choice today. + pub fn connect_planned( + &mut self, + local: Callsign, + peer: Callsign, + plan: PeerDialPlan, + timers: &mut dyn TimerService, + ) -> Vec> { + self.connect_extended(local, peer, plan.extended, timers) + } + /// Drain the DL signals a peer's session has raised upward since the last call /// (for the console / app to consume). Empty if the peer has no slot. pub fn take_upward(&mut self, peer: &Callsign) -> Vec { @@ -448,18 +512,56 @@ mod tests { let mut t = MockTimerService::new(); let peer = call("G7XYZ"); - // Default (false) ⇒ mod-8 SABM — preserves historical pico behaviour. + // Default (true, matching C# PreferExtendedConnect) ⇒ mod-128 SABME. let mut mgr_default: SessionManager<2> = SessionManager::new(call("M0LTE-1")); - assert!(!mgr_default.prefer_extended_connect()); + assert!(mgr_default.prefer_extended_connect()); let out = mgr_default.connect(peer, &mut t); + assert!(matches!(classify(&out[0]), Event::SabmeReceived(_))); + + // Opt out ⇒ mod-8 SABM. + let mut mgr_m8: SessionManager<2> = + SessionManager::new(call("M0LTE-1")).with_prefer_extended_connect(false); + assert!(!mgr_m8.prefer_extended_connect()); + let out = mgr_m8.connect(peer, &mut t); assert!(matches!(classify(&out[0]), Event::SabmReceived(_))); + } + + /// The safety net that makes the SABME-first default safe: a plain + /// (default-preference) connect to a peer that refuses SABME with **DM** must + /// degrade to a mod-8 SABM re-establishment (#48 DM-degrade), not strand the + /// connect in Disconnected. This is the DM analogue of the FRMR-degrade test, + /// and the reason the default could be flipped to true. + #[test] + fn default_extended_connect_degrades_to_mod8_sabm_on_dm_refusal() { + let mut mgr: SessionManager<2> = SessionManager::new(call("M0LTE-1")); + let mut t = MockTimerService::new(); + let peer = call("G7XYZ"); - // Opt in ⇒ mod-128 SABME. - let mut mgr_ext: SessionManager<2> = - SessionManager::new(call("M0LTE-1")).with_prefer_extended_connect(true); - assert!(mgr_ext.prefer_extended_connect()); - let out = mgr_ext.connect(peer, &mut t); + // Plain connect uses the new default (SABME-first). + let out = mgr.connect(peer, &mut t); assert!(matches!(classify(&out[0]), Event::SabmeReceived(_))); + assert!(mgr.session_for(&peer).unwrap().context.is_extended); + assert_eq!( + mgr.session_for(&peer).unwrap().state, + State::AwaitingV22Connection + ); + + // Pre-v2.2 peer (XRouter-class) refuses SABME with DM (F=1). + let dm = Event::DmReceived(FrameInfo { + poll_final: true, + is_command: false, + ..Default::default() + }); + let out = mgr.post(peer, dm, &mut t); + + // #48: degraded to mod-8 and a SABM re-establishment emitted — NOT stranded. + let s = mgr.session_for(&peer).unwrap(); + assert!(!s.context.is_extended, "DM degraded the link to mod-8"); + assert_eq!(s.state, State::AwaitingConnection); + assert!( + out.iter().any(|b| matches!(classify(b), Event::SabmReceived(_))), + "expected a mod-8 SABM re-establishment after the DM: {out:02x?}" + ); } #[test] @@ -664,4 +766,152 @@ mod tests { ); assert!(!mgr.seize_pending(&peer)); } + + // ─── Pre-session XID responder (mirrors Ax25ListenerPreSessionXidTests) ── + + /// A mod-8 XID command offering SREJ (what a PDN interlink initiator sends + /// before its SABM), as a classified inbound event. + fn mod8_srej_xid_command() -> Event { + use crate::ax25::xid::{info_field, HdlcOptionalFunctions, RejectMode, XidParameters}; + let info = info_field::encode(&XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: RejectMode::SelectiveReject, + modulo128: false, + srej_multiframe: true, + segmenter_reassembler: false, + }), + ..Default::default() + }); + Event::XidReceived(FrameInfo { + poll_final: true, + is_command: true, + info, + ..Default::default() + }) + } + + /// A pre-session XID command from an unknown peer is answered with an XID + /// *response* (F=1) that advertises SREJ — NOT a DM, and NOT a connection. + #[test] + fn pre_session_xid_command_for_unknown_peer_is_answered_with_xid_response() { + use crate::ax25::xid::info_field; + use crate::ax25::Frame; + use crate::sdl::bridge::classify_incoming; + + let mut mgr: SessionManager<2> = SessionManager::new(call("M0LTE")); + let mut t = MockTimerService::new(); + let peer = call("G7XYZ"); + + let out = mgr.post(peer, mod8_srej_xid_command(), &mut t); + assert_eq!(out.len(), 1, "exactly one XID response on the wire"); + + let reply = Frame::decode(&out[0]).expect("XID reply decodes"); + assert!(reply.is_response(), "the answer is an XID *response*"); + assert!(reply.poll_final(), "F=1 so the initiator's F_eq_1 diamond fires"); + match classify_incoming(&reply) { + Some(Event::XidReceived(_)) => {} + other => panic!("expected an XID reply, got {other:?} (must not be a DM)"), + } + // The response advertises SREJ (both sides offered it). + let p = info_field::parse(&reply.info).expect("response info parses"); + assert_eq!( + p.hdlc_optional_functions.unwrap().reject, + crate::ax25::xid::RejectMode::SelectiveReject + ); + + // Answering an XID command is NOT a connection: the session stays + // Disconnected and no ConnectIndication was raised. + assert_eq!( + mgr.session_for(&peer).map(|s| s.state), + Some(State::Disconnected) + ); + assert!(!mgr + .take_upward(&peer) + .contains(&DataLinkSignal::ConnectIndication)); + } + + /// The SABM that follows the pre-session XID brings the session to Connected + /// with the XID-negotiated SREJ adopted (the staged SrejEnabled survives the + /// SABM's Set Version 2.0, which clears only is_extended). + #[test] + fn sabm_after_pre_session_xid_reaches_connected_with_srej_adopted() { + use crate::ax25::Frame; + use crate::sdl::bridge::classify_incoming; + + let mut mgr: SessionManager<2> = SessionManager::new(call("M0LTE")); + let mut t = MockTimerService::new(); + let peer = call("G7XYZ"); + + // 1) Pre-session XID → XID response; still Disconnected. + let xid_out = mgr.post(peer, mod8_srej_xid_command(), &mut t); + assert_eq!(xid_out.len(), 1); + assert!(matches!( + classify_incoming(&Frame::decode(&xid_out[0]).unwrap()), + Some(Event::XidReceived(_)) + )); + assert_eq!( + mgr.session_for(&peer).map(|s| s.state), + Some(State::Disconnected) + ); + + // 2) The peer now sends SABM → the link establishes, adopting SREJ. + let sabm = Event::SabmReceived(FrameInfo { + poll_final: true, + is_command: true, + ..Default::default() + }); + let ua_out = mgr.post(peer, sabm, &mut t); + + let s = mgr.session_for(&peer).expect("session exists"); + assert_eq!(s.state, State::Connected, "the SABM establishes the link"); + assert!( + s.context.srej_enabled, + "the XID-negotiated SREJ survives into the established session" + ); + assert!(!s.context.implicit_reject); + // The SABM is answered with a UA (not a DM). + assert!( + ua_out + .iter() + .any(|b| matches!(classify_incoming(&Frame::decode(b).unwrap()), Some(Event::UaReceived(_)))), + "the SABM must be acknowledged with a UA: {ua_out:02x?}" + ); + assert!(mgr + .take_upward(&peer) + .contains(&DataLinkSignal::ConnectIndication)); + } + + /// A `connect_planned` dial honours the capability plan's extended choice: + /// an extended plan dials SABME, a mod-8 plan dials SABM. + #[test] + fn connect_planned_honours_the_dial_plan_extended_choice() { + use crate::sdl::capability::PeerDialPlan; + + let peer = call("G7XYZ"); + let local = call("M0LTE-1"); + + let mut ext: SessionManager<2> = SessionManager::new(local); + let out = ext.connect_planned( + local, + peer, + PeerDialPlan { + extended: true, + pre_connect_xid: false, + }, + &mut MockTimerService::new(), + ); + assert!(matches!(classify(&out[0]), Event::SabmeReceived(_))); + + let mut m8: SessionManager<2> = SessionManager::new(local); + let out = m8.connect_planned( + local, + peer, + PeerDialPlan { + extended: false, + pre_connect_xid: true, + }, + &mut MockTimerService::new(), + ); + assert!(matches!(classify(&out[0]), Event::SabmReceived(_))); + } } diff --git a/crates/ax25-node-core/src/sdl/mdl.rs b/crates/ax25-node-core/src/sdl/mdl.rs new file mode 100644 index 0000000..e8c60a7 --- /dev/null +++ b/crates/ax25-node-core/src/sdl/mdl.rs @@ -0,0 +1,451 @@ +//! Management Data-Link (MDL) XID negotiation — ports the substantive logic of +//! `Packet.Ax25.Session.XidNegotiator` + `Ax25ManagementDataLink` + +//! `Ax25Listener.HandleNoCachedSession`'s pre-session XID branch. +//! +//! The AX.25 v2.2 MDL (Appendix C5) is the XID parameter-negotiation FSM that +//! turns SREJ / segmentation / modulo / window / T1 / N2 from forced establishment +//! defaults into *negotiated* link parameters. pico ports the two pieces that +//! matter on-air today: +//! +//! - The **§6.3.2 reverts-to merge** ([`apply_negotiated`]) that turns our offer +//! and the peer's XID into agreed link parameters, plus the §1436 version-2.0 +//! default set ([`apply_version_20_defaults`]) and the offer derivation +//! ([`default_offer_for`]). These mirror `XidNegotiator`. +//! - The **pre-session XID *command* responder** ([`respond_pre_session_xid`]) — +//! the un-transcribed figc5.1 responder path that answers an inbound XID command +//! *before* a session exists (a PDN NET/ROM mod-8 interlink initiator opening +//! with XID before its SABM). Mirrors `RespondToXidCommand` + +//! `HandleNoCachedSession`. The manager wires this in on the no-cached-session +//! path; the negotiated params stage on the cached context so the subsequent +//! SABM's `Set Version 2.0` (which clears only `is_extended`) preserves the +//! staged `srej_enabled` into the established link. +//! +//! `no_std` + `alloc`. + +extern crate alloc; +use alloc::vec::Vec; + +use crate::ax25::xid::{ + info_field, ClassesOfProcedures, HdlcOptionalFunctions, RejectMode, XidParameters, +}; + +use super::context::SessionContext; + +/// Derive a sensible offered XID parameter set from a session context — our +/// current modulo / SREJ capability, window k, N1, T1, N2. We advertise our +/// capability (mod-128 + SREJ when the context is extended / SREJ-enabled) so the +/// §6.3.2 merge can revert to the lesser against the peer. Mirrors +/// `Ax25ManagementDataLink.DefaultOfferFor`. +pub fn default_offer_for(context: &SessionContext) -> XidParameters { + XidParameters { + classes_of_procedures: Some(if context.half_duplex { + ClassesOfProcedures::HALF_DUPLEX_DEFAULT + } else { + ClassesOfProcedures::FULL_DUPLEX_CAPABLE + }), + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: if context.srej_enabled { + RejectMode::SelectiveReject + } else { + RejectMode::ImplicitReject + }, + modulo128: context.is_extended, + // Advertise SREJ-multiframe alongside SREJ — LinBPQ's XID responder + // REQUIRES the OPSREJMult bit or it rejects the whole XID and never + // negotiates SREJ. Only meaningful when we are actually offering SREJ. + srej_multiframe: context.srej_enabled, + segmenter_reassembler: context.segmenter_reassembler_enabled, + }), + i_field_length_rx_bits: Some(XidParameters::octets_to_bits(context.n1)), + window_size_rx: Some(context.k), + ack_timer_millis: Some(context.t1v_ms), + retries: Some(context.n2), + } +} + +/// Apply the §6.3.2 reverts-to merge of `offered` (what we sent / would send in an +/// XID command) and `response` (what the peer returned / offered) to `context`, +/// replacing the forced establishment defaults with the negotiated values. Each +/// parameter absent from *both* offers retains the context's current value +/// (§4.3.3.7 ¶1024). Mirrors `XidNegotiator.ApplyNegotiated`. +pub fn apply_negotiated( + context: &mut SessionContext, + offered: &XidParameters, + response: &XidParameters, +) { + // ─── HDLC Optional Functions (PI=3): reject scheme + modulo (§6.3.2 ¶1426) ── + // The agreed value is the LOWER of the two on each axis: SREJ survives only if + // BOTH offer it; mod-128 survives only if BOTH offer it. Absent from both → + // the defaults (SREJ, mod-128) via HdlcOptionalFunctions::DEFAULT. + let our_hdlc = offered + .hdlc_optional_functions + .unwrap_or(HdlcOptionalFunctions::DEFAULT); + let their_hdlc = response + .hdlc_optional_functions + .unwrap_or(HdlcOptionalFunctions::DEFAULT); + + let agreed_selective_reject = our_hdlc.reject == RejectMode::SelectiveReject + && their_hdlc.reject == RejectMode::SelectiveReject; + let agreed_modulo128 = our_hdlc.modulo128 && their_hdlc.modulo128; + // Segmenter/reassembler is a mutual-capability AND (§6.3.2 ¶1419). + let agreed_segmenter = + our_hdlc.segmenter_reassembler && their_hdlc.segmenter_reassembler; + + context.srej_enabled = agreed_selective_reject; + context.implicit_reject = !agreed_selective_reject; + context.is_extended = agreed_modulo128; + context.segmenter_reassembler_enabled = agreed_segmenter; + + // ─── Classes of Procedures (PI=2): duplex (§6.3.2 ¶1424) ──────────────────── + // Reverts to half-duplex unless BOTH offer full-duplex. + let our_cop = offered + .classes_of_procedures + .unwrap_or(ClassesOfProcedures::HALF_DUPLEX_DEFAULT); + let their_cop = response + .classes_of_procedures + .unwrap_or(ClassesOfProcedures::HALF_DUPLEX_DEFAULT); + let agreed_full_duplex = !our_cop.half_duplex && !their_cop.half_duplex; + context.half_duplex = !agreed_full_duplex; + + // ─── Window k (PI=8) + N1 (PI=6): notification / min (§6.3.2 ¶1430 / ¶1428) ─ + // A notification of the receiver's capacity; our send is bounded by the peer's + // advertised Rx, so take the min. Absent from both → retain current. + if let Some(k) = min_present(offered.window_size_rx, response.window_size_rx) { + context.k = k; + } + if let Some(n1) = min_present( + offered.i_field_length_rx_octets(), + response.i_field_length_rx_octets(), + ) { + context.n1 = n1; + } + + // ─── T1 (PI=9) + N2 (PI=10): greater (§6.3.2 ¶1432 / ¶1434) ────────────────── + // The more patient / safer choice on a slow/lossy link: both adopt the max. + if let Some(t1ms) = max_present(offered.ack_timer_millis, response.ack_timer_millis) { + context.t1v_ms = t1ms; + context.srt_ms = t1ms / 2; // keep T1V ≈ 2·SRT (integer §3 port) + } + if let Some(n2) = max_present(offered.retries, response.retries) { + context.n2 = n2; + } +} + +/// Install the complete AX.25 version-2.0 default parameter set per §6.3.2 ¶1 / +/// §1436 — used when a pre-v2.2 peer FRMRs our XID command. The FULL set, not +/// merely `is_extended = false`. Mirrors `XidNegotiator.ApplyVersion20Defaults`. +pub fn apply_version_20_defaults(context: &mut SessionContext) { + context.half_duplex = true; // Set Half Duplex + context.implicit_reject = true; // Set Implicit Reject + context.srej_enabled = false; // (REJ ⇒ no SREJ) + context.is_extended = false; // Modulo = 8 + context.n1 = 256; // 2048 bits = 256 octets + context.k = 7; // Window Size Receive = 7 (§1436, NOT the mod-8 XID default 4) + context.t1v_ms = 3000; // Acknowledge Timer + context.srt_ms = 1500; // keep T1V == 2·SRT + context.n2 = 10; // Retries + context.segmenter_reassembler_enabled = false; // v2.2-only (§1621) +} + +/// Handle an inbound XID *command* as the responder: merge the command's offered +/// parameters with our own offer per §6.3.2, apply the agreed values to `context`, +/// and return the *agreed* parameter set to echo back in the XID response. Placing +/// the agreed (post-merge) values guarantees both stations converge on the +/// identical reverts-to result. Mirrors `Ax25ManagementDataLink.RespondToXidCommand`. +pub fn respond_to_xid_command( + context: &mut SessionContext, + command: &XidParameters, +) -> XidParameters { + let offered = default_offer_for(context); + apply_negotiated(context, &offered, command); + // Echo the agreed values so the initiator's merge (its offer vs our response) + // lands on the identical result. + default_offer_for(context) +} + +/// The pre-session XID-command responder (mirrors `HandleNoCachedSession`'s XID +/// branch composed with `RespondToXidCommand`): seed `context` SREJ-capable so our +/// offer advertises SREJ, parse the command's offered parameters (strict; a +/// malformed / empty info ⇒ "no parameters offered", the merge falls through to the +/// §4.3.3.7 ¶1024 defaults), run the §6.3.2 merge into `context`, and return the +/// encoded XID *response* information field (an F=1 response carrying the agreed +/// values). The staged `srej_enabled` survives the subsequent SABM's `Set Version +/// 2.0` (which clears only `is_extended`), so the established link adopts SREJ when +/// both sides offered it. +pub fn respond_pre_session_xid(context: &mut SessionContext, command_info: &[u8]) -> Vec { + // Seed SREJ-capable so default_offer_for advertises SREJ; the lesser-of merge + // reverts this if the peer's offer lacked SREJ. + context.srej_enabled = true; + context.implicit_reject = false; + + let command = info_field::parse(command_info).unwrap_or_default(); + let agreed = respond_to_xid_command(context, &command); + info_field::encode(&agreed) +} + +/// Lesser of two notification values, treating absence as "no constraint". +fn min_present(a: Option, b: Option) -> Option { + match (a, b) { + (None, other) | (other, None) => other, + (Some(x), Some(y)) => Some(x.min(y)), + } +} + +/// Greater of two negotiated values, treating absence as "no preference". +fn max_present(a: Option, b: Option) -> Option { + match (a, b) { + (None, other) | (other, None) => other, + (Some(x), Some(y)) => Some(x.max(y)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> SessionContext { + SessionContext::new() + } + + fn hdlc(srej: bool, mod128: bool) -> HdlcOptionalFunctions { + HdlcOptionalFunctions { + reject: if srej { + RejectMode::SelectiveReject + } else { + RejectMode::ImplicitReject + }, + modulo128: mod128, + srej_multiframe: false, + segmenter_reassembler: false, + } + } + + // ─── §6.3.2 reverts-to merge (mirrors XidNegotiatorTests) ──────────────── + + #[test] + fn reject_scheme_is_the_lesser_of_the_two_offers() { + for (ours, theirs, expect) in [ + (true, true, true), + (true, false, false), + (false, true, false), + (false, false, false), + ] { + let mut c = ctx(); + let offered = XidParameters { + hdlc_optional_functions: Some(hdlc(ours, true)), + ..Default::default() + }; + let response = XidParameters { + hdlc_optional_functions: Some(hdlc(theirs, true)), + ..Default::default() + }; + apply_negotiated(&mut c, &offered, &response); + assert_eq!(c.srej_enabled, expect); + assert_eq!(c.implicit_reject, !expect); + } + } + + #[test] + fn modulo_is_the_lesser_of_the_two_offers() { + for (ours, theirs, expect) in [ + (true, true, true), + (true, false, false), + (false, true, false), + (false, false, false), + ] { + let mut c = ctx(); + let offered = XidParameters { + hdlc_optional_functions: Some(hdlc(true, ours)), + ..Default::default() + }; + let response = XidParameters { + hdlc_optional_functions: Some(hdlc(true, theirs)), + ..Default::default() + }; + apply_negotiated(&mut c, &offered, &response); + assert_eq!(c.is_extended, expect); + } + } + + #[test] + fn segmenter_enabled_only_when_both_advertise_it() { + let both_on = XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions { + segmenter_reassembler: true, + ..hdlc(true, true) + }), + ..Default::default() + }; + let one_off = XidParameters { + hdlc_optional_functions: Some(hdlc(true, true)), + ..Default::default() + }; + let mut c = ctx(); + apply_negotiated(&mut c, &both_on, &both_on); + assert!(c.segmenter_reassembler_enabled); + let mut c2 = ctx(); + apply_negotiated(&mut c2, &both_on, &one_off); + assert!(!c2.segmenter_reassembler_enabled); + } + + #[test] + fn window_k_is_the_min_and_n1_is_the_min() { + let mut c = ctx(); + apply_negotiated( + &mut c, + &XidParameters { + window_size_rx: Some(32), + i_field_length_rx_bits: Some(XidParameters::octets_to_bits(256)), + ..Default::default() + }, + &XidParameters { + window_size_rx: Some(10), + i_field_length_rx_bits: Some(XidParameters::octets_to_bits(128)), + ..Default::default() + }, + ); + assert_eq!(c.k, 10); + assert_eq!(c.n1, 128); + } + + #[test] + fn t1_and_n2_are_the_greater() { + let mut c = ctx(); + apply_negotiated( + &mut c, + &XidParameters { + ack_timer_millis: Some(1000), + retries: Some(8), + ..Default::default() + }, + &XidParameters { + ack_timer_millis: Some(4000), + retries: Some(20), + ..Default::default() + }, + ); + assert_eq!(c.t1v_ms, 4000); + assert_eq!(c.n2, 20); + } + + #[test] + fn absent_notification_fields_retain_current_values() { + let mut c = ctx(); + c.k = 5; + c.n1 = 200; + c.n2 = 7; + c.t1v_ms = 1234; + let offered = XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions::DEFAULT), + ..Default::default() + }; + apply_negotiated(&mut c, &offered, &offered); + assert_eq!(c.k, 5); + assert_eq!(c.n1, 200); + assert_eq!(c.n2, 7); + assert_eq!(c.t1v_ms, 1234); + } + + #[test] + fn absent_hdlc_selects_the_v22_defaults() { + let mut c = ctx(); + let empty = XidParameters::default(); + apply_negotiated(&mut c, &empty, &empty); + assert!(c.srej_enabled, "default selective reject"); + assert!(c.is_extended, "default modulo 128"); + } + + #[test] + fn version20_defaults_install_the_complete_1436_set() { + let mut c = ctx(); + c.is_extended = true; + c.srej_enabled = true; + c.segmenter_reassembler_enabled = true; + c.k = 32; + c.n1 = 512; + c.n2 = 20; + c.half_duplex = false; + c.t1v_ms = 500; + + apply_version_20_defaults(&mut c); + + assert!(c.half_duplex); + assert!(c.implicit_reject); + assert!(!c.srej_enabled); + assert!(!c.is_extended); + assert_eq!(c.n1, 256); + assert_eq!(c.k, 7); + assert_eq!(c.t1v_ms, 3000); + assert_eq!(c.n2, 10); + assert!(!c.segmenter_reassembler_enabled); + } + + // ─── Pre-session responder (mirrors Ax25ListenerPreSessionXidTests) ────── + + /// A mod-8 XID command offering SREJ (what a PDN interlink initiator sends + /// before its SABM) is answered with an XID response that advertises SREJ, and + /// the responder's context ends SREJ-enabled + mod-8. + #[test] + fn pre_session_xid_command_offering_srej_negotiates_srej() { + let command = info_field::encode(&XidParameters { + hdlc_optional_functions: Some(HdlcOptionalFunctions { + reject: RejectMode::SelectiveReject, + modulo128: false, // mod-8 + srej_multiframe: true, + segmenter_reassembler: false, + }), + ..Default::default() + }); + + let mut c = ctx(); + let response_info = respond_pre_session_xid(&mut c, &command); + + // Context adopted SREJ (both offered it) and stayed mod-8 (peer offered mod-8). + assert!(c.srej_enabled, "both sides offered SREJ ⇒ SREJ negotiated"); + assert!(!c.implicit_reject); + assert!(!c.is_extended, "peer offered mod-8 ⇒ link is mod-8"); + + // The response advertises SREJ. + let p = info_field::parse(&response_info).expect("response info parses"); + assert_eq!( + p.hdlc_optional_functions.unwrap().reject, + RejectMode::SelectiveReject + ); + assert!(!p.hdlc_optional_functions.unwrap().modulo128); + } + + /// A peer that offers REJ (no SREJ) makes the lesser-of merge revert our seeded + /// SREJ to go-back-N — we never end up SREJ-enabled unilaterally. + #[test] + fn pre_session_xid_command_offering_rej_reverts_to_go_back_n() { + let command = info_field::encode(&XidParameters { + hdlc_optional_functions: Some(hdlc(false, false)), // REJ, mod-8 + ..Default::default() + }); + let mut c = ctx(); + let response_info = respond_pre_session_xid(&mut c, &command); + assert!(!c.srej_enabled, "peer offered REJ ⇒ merge reverts SREJ off"); + assert!(c.implicit_reject); + let p = info_field::parse(&response_info).unwrap(); + assert_eq!( + p.hdlc_optional_functions.unwrap().reject, + RejectMode::ImplicitReject + ); + } + + /// An empty / malformed XID info field means "no parameters offered": the merge + /// falls through to the §6.3.2 defaults (SREJ, mod-128) against our SREJ-capable + /// seed — so we still answer with a well-formed XID response, and (our seeded + /// SREJ meeting the SREJ default) end SREJ-enabled. + #[test] + fn pre_session_xid_command_with_empty_info_falls_to_defaults() { + let mut c = ctx(); + let response_info = respond_pre_session_xid(&mut c, &[]); + assert!(c.srej_enabled, "seeded SREJ meets the SREJ default ⇒ SREJ negotiated"); + let p = info_field::parse(&response_info).expect("response is a well-formed XID info field"); + assert_eq!( + p.hdlc_optional_functions.unwrap().reject, + RejectMode::SelectiveReject + ); + } +} diff --git a/crates/ax25-node-core/src/sdl/mod.rs b/crates/ax25-node-core/src/sdl/mod.rs index 46474a8..ccc8aa4 100644 --- a/crates/ax25-node-core/src/sdl/mod.rs +++ b/crates/ax25-node-core/src/sdl/mod.rs @@ -37,6 +37,7 @@ //! runtime is host-tested with `cargo test` and is `no_std`-clean for the M0+. pub mod bridge; +pub mod capability; pub mod carrier; pub mod context; pub mod dispatch; @@ -44,6 +45,7 @@ pub mod event; pub mod guard; pub mod loop_exec; pub mod manager; +pub mod mdl; pub mod quirks; pub mod session; pub mod signal; @@ -52,11 +54,16 @@ pub mod timer; pub mod tx; pub use bridge::{classify_incoming, classify_incoming_modulo, WireSink}; +pub use capability::{PeerCapabilityCache, PeerCapabilityRecord, PeerDialPlan, PeerDialPolicy}; pub use carrier::{AlwaysClear, CarrierSense}; pub use context::{Payload, SessionContext}; pub use event::{Event, FrameInfo}; pub use loop_exec::{run_loop, MAX_ITERATIONS}; pub use manager::{SessionManager, Slot}; +pub use mdl::{ + apply_negotiated, apply_version_20_defaults, default_offer_for, respond_pre_session_xid, + respond_to_xid_command, +}; pub use quirks::Quirks; pub use session::{Session, State}; pub use signal::{ diff --git a/crates/ax25-node-core/src/sdl/signal.rs b/crates/ax25-node-core/src/sdl/signal.rs index f48cc30..c0d72f7 100644 --- a/crates/ax25-node-core/src/sdl/signal.rs +++ b/crates/ax25-node-core/src/sdl/signal.rs @@ -93,6 +93,17 @@ pub enum FrameSpec { /// Information field. info: Vec, }, + /// An XID (Exchange Identification) frame — the §4.3.3.7 parameter-negotiation + /// U-frame. Carries no PID; the info field is the encoded XID parameters (see + /// [`crate::ax25::xid`]). Emitted by the management data-link responder. + Xid { + /// Command (true) vs response (false). + is_command: bool, + /// Poll/final bit. + pf: bool, + /// Information field — the encoded XID parameter TLVs. + info: Vec, + }, } /// A signal raised to Layer 3 (the upper-layer service-access point). Ports the diff --git a/crates/ax25-node-core/src/sdl/tests.rs b/crates/ax25-node-core/src/sdl/tests.rs index 15afdc4..2ccd623 100644 --- a/crates/ax25-node-core/src/sdl/tests.rs +++ b/crates/ax25-node-core/src/sdl/tests.rs @@ -53,6 +53,15 @@ impl Recorder { }) .collect() } + fn supervisory(&self) -> Vec<(SupervisoryKind, u8)> { + self.frames + .iter() + .filter_map(|f| match f { + FrameSpec::Supervisory { kind, nr, .. } => Some((*kind, *nr)), + _ => None, + }) + .collect() + } } /// A frame-info for a received frame with the given P/F + command bit. @@ -575,6 +584,64 @@ fn go_back_n_window_not_capped_even_with_quirk_on() { assert_eq!(s.context.effective_window(), 7); } +// ─── SREJ activation via negotiated XID ───────────────────────────────────── + +/// The point of the XID/MDL work: once XID negotiation sets `srej_enabled` +/// (task 3's responder / `apply_negotiated`), the connected-mode machine actually +/// *uses* Selective Reject. An out-of-sequence I-frame on a negotiated-SREJ link +/// provokes an SREJ (requesting the single gap), where a go-back-N link falls back +/// to REJ. Before this work `srej_enabled` was never set, so the SREJ recovery code +/// — though present and correct — was dead on-air. +#[test] +fn negotiated_srej_link_emits_srej_on_out_of_sequence_i_frame() { + let mut s = connected_session(); + // As left by a successful XID negotiation (mdl::apply_negotiated / the + // pre-session responder): Selective Reject enabled on the link. + s.context.srej_enabled = true; + s.context.implicit_reject = false; + let mut t = MockTimerService::new(); + let mut r = Recorder::default(); + + // Expecting N(S)=V(R)=0; receive N(S)=1 — frame 0 is the gap. + s.post_event(Event::IReceived(rx_i(1, 0, false)), &mut t, &mut r); + + // A negotiated-SREJ link recovers with SREJ (not REJ), targeting the gap V(R)=0. + let sup = r.supervisory(); + assert!( + sup.iter().any(|(k, nr)| *k == SupervisoryKind::Srej && *nr == 0), + "negotiated SREJ link must emit an SREJ for the gap: {sup:?}" + ); + assert!( + !sup.iter().any(|(k, _)| *k == SupervisoryKind::Rej), + "a SREJ link must not fall back to REJ: {sup:?}" + ); + // The out-of-sequence frame is stored pending the retransmission of the gap. + assert!(s.context.stored_received_i_frames.contains_key(&1)); +} + +/// The contrast: with SREJ *not* negotiated (go-back-N, the default before XID), +/// the same out-of-sequence I-frame provokes REJ, not SREJ — proving the SREJ path +/// is genuinely gated on the XID-negotiated `srej_enabled`. +#[test] +fn go_back_n_link_emits_rej_not_srej_on_out_of_sequence_i_frame() { + let mut s = connected_session(); + assert!(!s.context.srej_enabled, "default: SREJ not negotiated"); + let mut t = MockTimerService::new(); + let mut r = Recorder::default(); + + s.post_event(Event::IReceived(rx_i(1, 0, false)), &mut t, &mut r); + + let sup = r.supervisory(); + assert!( + sup.iter().any(|(k, _)| *k == SupervisoryKind::Rej), + "a go-back-N link recovers with REJ: {sup:?}" + ); + assert!( + !sup.iter().any(|(k, _)| *k == SupervisoryKind::Srej), + "SREJ must stay dormant without XID negotiation: {sup:?}" + ); +} + // ─── Unhandled events are dropped (SDL semantics) ─────────────────────────── #[test] diff --git a/crates/ax25-node-core/tests/xid_golden_vectors.rs b/crates/ax25-node-core/tests/xid_golden_vectors.rs new file mode 100644 index 0000000..33d86a2 --- /dev/null +++ b/crates/ax25-node-core/tests/xid_golden_vectors.rs @@ -0,0 +1,160 @@ +//! Cross-stack golden-vector runner for the AX.25 v2.2 XID information-field codec. +//! +//! The XID leg of the three-stack parity contract (C# `Packet.Ax25.Xid` +//! authoritative <-> TS <-> Rust pico-node). It consumes `vectors/xid.json` — the +//! same *bytes* C#'s `XidInfoFieldTests` pins — and drives them through this +//! crate's `xid::info_field` codec, proving byte identity rather than re-deriving +//! expected values. +//! +//! Per vector: hex-decode `info_hex` -> `info_field::parse` -> assert the decoded +//! parameters -> when `roundtrip`, `info_field::encode` -> assert the bytes equal +//! `info_hex`. `serde_json` is a dev-dependency only; it never reaches the +//! `no_std` / firmware graph. + +use ax25_node_core::ax25::xid::{info_field, RejectMode}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct XidSet { + set: String, + vectors: Vec, +} + +#[derive(Deserialize)] +struct XidVector { + name: String, + info_hex: String, + #[serde(default)] + roundtrip: bool, + expect: XidExpect, +} + +/// The expected decode of a vector. Every field is optional — only the present +/// ones are asserted (a vector need not pin parameters it doesn't carry). +#[derive(Deserialize, Default)] +struct XidExpect { + half_duplex: Option, + /// `"srej"` or `"rej"`. + reject: Option, + modulo128: Option, + srej_multiframe: Option, + segmenter: Option, + i_field_length_rx_bits: Option, + window_size_rx: Option, + ack_timer_millis: Option, + retries: Option, + /// When true, assert every decoded parameter is absent (None). + #[serde(default)] + all_none: bool, +} + +fn hex_decode(s: &str) -> Vec { + let nibbles: Vec = s + .bytes() + .filter(|b| !b.is_ascii_whitespace() && *b != b'_') + .collect(); + assert!( + nibbles.len().is_multiple_of(2), + "hex string has an odd number of digits: {s:?}" + ); + nibbles + .chunks(2) + .map(|pair| (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1])) + .collect() +} + +fn hex_nibble(b: u8) -> u8 { + match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + other => panic!("not a hex digit: {:?}", other as char), + } +} + +const XID: &str = include_str!("../../../vectors/xid.json"); + +#[test] +fn xid_golden_vectors_round_trip() { + let set: XidSet = serde_json::from_str(XID).expect("vectors/xid.json must parse"); + assert_eq!(set.set, "xid", "unexpected set name in corpus"); + assert!(!set.vectors.is_empty(), "corpus must contain at least one vector"); + + for v in &set.vectors { + let wire = hex_decode(&v.info_hex); + let parsed = info_field::parse(&wire) + .unwrap_or_else(|| panic!("vector `{}` failed to parse", v.name)); + + if v.expect.all_none { + assert!(parsed.classes_of_procedures.is_none(), "vector `{}` classes", v.name); + assert!(parsed.hdlc_optional_functions.is_none(), "vector `{}` hdlc", v.name); + assert!(parsed.i_field_length_rx_bits.is_none(), "vector `{}` n1", v.name); + assert!(parsed.window_size_rx.is_none(), "vector `{}` k", v.name); + assert!(parsed.ack_timer_millis.is_none(), "vector `{}` t1", v.name); + assert!(parsed.retries.is_none(), "vector `{}` n2", v.name); + } + + if let Some(want) = v.expect.half_duplex { + assert_eq!( + parsed.classes_of_procedures.expect("classes present").half_duplex, + want, + "vector `{}` half_duplex", + v.name + ); + } + if let Some(want) = &v.expect.reject { + let got = parsed.hdlc_optional_functions.expect("hdlc present").reject; + let want = match want.as_str() { + "srej" => RejectMode::SelectiveReject, + "rej" => RejectMode::ImplicitReject, + other => panic!("vector `{}` bad reject `{other}`", v.name), + }; + assert_eq!(got, want, "vector `{}` reject", v.name); + } + if let Some(want) = v.expect.modulo128 { + assert_eq!( + parsed.hdlc_optional_functions.expect("hdlc present").modulo128, + want, + "vector `{}` modulo128", + v.name + ); + } + if let Some(want) = v.expect.srej_multiframe { + assert_eq!( + parsed.hdlc_optional_functions.expect("hdlc present").srej_multiframe, + want, + "vector `{}` srej_multiframe", + v.name + ); + } + if let Some(want) = v.expect.segmenter { + assert_eq!( + parsed.hdlc_optional_functions.expect("hdlc present").segmenter_reassembler, + want, + "vector `{}` segmenter", + v.name + ); + } + if let Some(want) = v.expect.i_field_length_rx_bits { + assert_eq!(parsed.i_field_length_rx_bits, Some(want), "vector `{}` n1 bits", v.name); + } + if let Some(want) = v.expect.window_size_rx { + assert_eq!(parsed.window_size_rx, Some(want), "vector `{}` window", v.name); + } + if let Some(want) = v.expect.ack_timer_millis { + assert_eq!(parsed.ack_timer_millis, Some(want), "vector `{}` t1", v.name); + } + if let Some(want) = v.expect.retries { + assert_eq!(parsed.retries, Some(want), "vector `{}` n2", v.name); + } + + if v.roundtrip { + let reencoded = info_field::encode(&parsed); + assert_eq!( + reencoded, wire, + "vector `{}` re-encode differs from info_hex (PARITY BREAK)", + v.name + ); + } + } +} diff --git a/vectors/xid.json b/vectors/xid.json new file mode 100644 index 0000000..b9e1adb --- /dev/null +++ b/vectors/xid.json @@ -0,0 +1,61 @@ +{ + "set": "xid", + "capabilities": ["xid"], + "reference": "packet-net/packet.net tests/Packet.Ax25.Tests/Xid/XidInfoFieldTests.cs", + "notes": "Shared cross-stack golden vectors for the AX.25 v2.2 XID information-field TLV codec (FI/GI/GL + PI/PL/PV, §4.3.3.7 / Figure 4.5-4.6). `info_hex` is the encoded XID information field (the bytes carried in an XID U-frame's info field) as hex; whitespace/underscores are ignored. Each vector is parsed, its decoded parameters asserted, and — when `roundtrip` is true — re-encoded and asserted byte-identical to `info_hex`. The `figure_4_6_*` vectors transcribe the exact bytes C#'s XidInfoFieldTests pins, so a green run proves the pico-node XID codec is byte-identical to Packet.Ax25. NOTE: `figure_4_6_literal` is the literal Figure 4.6 print whose Classes-of-Procedures byte carries the figure's documented ABM off-by-one (0x22); it is decode-only because both C# and pico re-encode ABM canonically at bit 0 (0x21) per the Figure 4.5 table — see figure_4_6_canonical.", + "vectors": [ + { + "name": "figure_4_6_canonical", + "description": "The Figure 4.6 worked example, canonically encoded (ABM at bit 0 ⇒ Classes byte 0x21, per the Figure 4.5 table; the HDLC PV is MSB-octet-first per §3.8). This is what both C# and pico Encode() produce for this parameter set, so it round-trips.", + "info_hex": "82 80 00 17 02 02 21 00 03 03 22 A8 82 06 02 04 00 08 01 02 09 02 10 00 0A 01 03", + "roundtrip": true, + "expect": { + "half_duplex": true, + "reject": "rej", + "modulo128": true, + "srej_multiframe": true, + "segmenter": false, + "i_field_length_rx_bits": 1024, + "window_size_rx": 2, + "ack_timer_millis": 4096, + "retries": 3 + } + }, + { + "name": "figure_4_6_literal", + "description": "The literal Figure 4.6 print, whose Classes-of-Procedures byte is 0x22 (the figure's ABM off-by-one). Decodes to the identical selection (duplex reads bit 5 either way); decode-only because canonical re-encode uses 0x21.", + "info_hex": "82 80 00 17 02 02 22 00 03 03 22 A8 82 06 02 04 00 08 01 02 09 02 10 00 0A 01 03", + "roundtrip": false, + "expect": { + "half_duplex": true, + "reject": "rej", + "modulo128": true, + "srej_multiframe": true, + "segmenter": false, + "i_field_length_rx_bits": 1024, + "window_size_rx": 2, + "ack_timer_millis": 4096, + "retries": 3 + } + }, + { + "name": "empty_header_all_defaults", + "description": "FI GI GL=0000 — the bare header with an empty parameter field (all fields absent ⇒ 'use current values').", + "info_hex": "82 80 00 00", + "roundtrip": true, + "expect": { "all_none": true } + }, + { + "name": "mod8_srej_offer", + "description": "The mod-8 XID command a PDN NET/ROM interlink initiator puts on the wire before its SABM: HDLC Optional Functions offering SREJ + SREJ-multiframe at modulo-8, PV 0x22 0xA4 0x84 (MSB-first, §3.8).", + "info_hex": "82 80 00 05 03 03 22 A4 84", + "roundtrip": true, + "expect": { + "reject": "srej", + "modulo128": false, + "srej_multiframe": true, + "segmenter": false + } + } + ] +}