diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a0d4d9..afc14ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,17 @@ jobs: - name: Test run: cargo test -p ax25-node-core + # The `netrom-compress` feature (LinBPQ L4Compress interop: the zlib/DEFLATE + # codec + the NET/ROM L4 negotiation/compress/inflate path) is OFF by default, + # so the default Test step above never exercises it. Run its suite explicitly + # here so this gated path can't silently rot (a prior enum break slipped + # through exactly this way). Clippy the feature too, warnings-as-errors. + - name: Clippy — netrom-compress feature (warnings are errors) + run: cargo clippy -p ax25-node-core --all-targets --features netrom-compress -- -D warnings + + - name: Test — netrom-compress feature + run: cargo test -p ax25-node-core --features netrom-compress + - name: no_std build (firmware crate's library shape — default features off, alloc on) run: cargo build -p ax25-node-core --no-default-features --features alloc diff --git a/crates/ax25-node-core/Cargo.toml b/crates/ax25-node-core/Cargo.toml index 8e76f21..be23a4c 100644 --- a/crates/ax25-node-core/Cargo.toml +++ b/crates/ax25-node-core/Cargo.toml @@ -39,6 +39,16 @@ std = ["alloc", "ax25sdl/std"] # fixed-capacity buffers — see the module docs). Kept as a separate feature so the # heap dependency is explicit. alloc = [] +# `netrom-compress` gates the NET/ROM L4 payload (de)compression codec +# (`netrom::transport::deflate`) — a compact, self-contained zlib/DEFLATE +# (RFC 1950 + RFC 1951) implementation for BPQ `L4Compress` interop. It is OFF by +# default so the on-target firmware carries ZERO compression code (a flash win) +# unless a deployment actually peers with a compressing BPQ node. It pulls +# `alloc` because the codec's growable output buffers use `alloc::vec::Vec` +# (following the crate's existing streaming-buffer pattern); no external crate is +# added to the shipped graph — the codec is hand-rolled, and its miniz_oxide +# TEST ORACLE lives in `[dev-dependencies]` only (never shipped). +netrom-compress = ["alloc"] [dependencies] # The generated AX.25 v2.2 SDL state-machine tables + typed closed sets, consumed @@ -61,3 +71,10 @@ ax25sdl = { path = "../../../ax25sdl/spec/rust", default-features = false } # the test still runs fully offline. serde = { version = "1", features = ["derive"] } serde_json = "1" +# HOST-TEST-ONLY oracle for the `netrom-compress` codec: the pure-Rust reference +# zlib/DEFLATE implementation (flate2's backend). Used ONLY by the codec's inline +# `#[cfg(test)]` oracle tests to prove our encoder emits valid zlib that a real +# inflater accepts, and that our inflater reads real zlib (incl. dynamic Huffman). +# `default-features = false, features = ["with-alloc"]` keeps it no_std+alloc. As a +# dev-dependency it never reaches the shipped/firmware graph (like serde above). +miniz_oxide = { version = "0.8", default-features = false, features = ["with-alloc"] } diff --git a/crates/ax25-node-core/src/netrom/transport/circuit.rs b/crates/ax25-node-core/src/netrom/transport/circuit.rs index 75eeec0..2e2395d 100644 --- a/crates/ax25-node-core/src/netrom/transport/circuit.rs +++ b/crates/ax25-node-core/src/netrom/transport/circuit.rs @@ -27,6 +27,8 @@ use alloc::vec::Vec; use super::circuit_options::NetRomCircuitOptions; use super::circuit_state::{NetRomCircuitCloseReason, NetRomCircuitState}; use crate::ax25::Callsign; +#[cfg(feature = "netrom-compress")] +use crate::netrom::wire::{ConnectAckInfo, CONNECT_REQUEST_INFO_EXTENDED_LEN, FLAG_COMPRESSED}; use crate::netrom::wire::{ ConnectRequestInfo, NetRomNetworkHeader, NetRomOpcode, NetRomPacket, NetRomTransportHeader, FLAG_CHOKE, FLAG_MORE_FOLLOWS, FLAG_NAK, @@ -73,6 +75,10 @@ struct Unacked { sequence: u8, payload: Vec, more_follows: bool, + /// This fragment's payload is part of a compressed logical frame — carries the + /// [`FLAG_COMPRESSED`] flag on (re)transmit. Gated behind `netrom-compress`. + #[cfg(feature = "netrom-compress")] + compressed: bool, sent_at: u64, retries: u8, } @@ -81,6 +87,9 @@ struct Unacked { struct Fragment { bytes: Vec, more_follows: bool, + /// This fragment belongs to a compressed logical send — see [`Unacked::compressed`]. + #[cfg(feature = "netrom-compress")] + compressed: bool, } /// One end of a NET/ROM L4 virtual circuit. @@ -108,6 +117,17 @@ pub struct NetRomCircuit { vr: u8, reassembly: Vec, + // Compression negotiation (BPQ L4Compress). `compression_enabled` is the settled + // per-circuit result — true only when BOTH ends advertised compression at connect + // time; until then it is false (send raw, the always-safe path). + // `reassembly_compressed` tracks whether the more-follows fragments currently + // being accumulated were flagged compressed, so the whole logical frame is + // inflated exactly once at the end. Gated behind `netrom-compress`. + #[cfg(feature = "netrom-compress")] + compression_enabled: bool, + #[cfg(feature = "netrom-compress")] + reassembly_compressed: bool, + // Flow control. peer_choked: bool, local_choked: bool, @@ -150,6 +170,10 @@ impl NetRomCircuit { unacked: Vec::new(), vr: 0, reassembly: Vec::new(), + #[cfg(feature = "netrom-compress")] + compression_enabled: false, + #[cfg(feature = "netrom-compress")] + reassembly_compressed: false, peer_choked: false, local_choked: false, pending_deliveries: 0, @@ -188,6 +212,15 @@ impl NetRomCircuit { pub fn peer_choked(&self) -> bool { self.peer_choked } + /// True once the circuit is connected and *both* ends negotiated LinBPQ-style L4 + /// payload compression — i.e. outbound data is being zlib-compressed and flagged + /// [`FLAG_COMPRESSED`]. False (the safe default) when either end declined, in + /// which case data is sent raw. Gated behind `netrom-compress`. Mirrors C# + /// `NetRomCircuit.CompressionNegotiated`. + #[cfg(feature = "netrom-compress")] + pub fn compression_negotiated(&self) -> bool { + self.compression_enabled + } /// Send-side V(s): the next send sequence to allocate (mod 256). pub fn send_state(&self) -> u8 { self.vs @@ -238,14 +271,43 @@ impl NetRomCircuit { if self.state != NetRomCircuitState::Connected || data.is_empty() { return; } + + // When compression is negotiated on this circuit, compress the WHOLE logical + // send into one zlib stream, then fragment that stream. Every fragment of a + // compressed frame carries the Compressed flag; the receiver reassembles all + // its more-follows fragments and inflates the concatenation once. Falls back + // to raw when compression would not shrink the data (BPQ does the same: + // "if complen >= dataLen … just send") — no point paying the zlib header for + // an expansion, and raw is always decodable (the flag is per-frame). + #[cfg(feature = "netrom-compress")] + let compressed_buf: Option> = if self.compression_enabled { + let z = super::compression::compress(data); + if z.len() < data.len() { + Some(z) + } else { + None + } + } else { + None + }; + #[cfg(feature = "netrom-compress")] + let (body, compressed): (&[u8], bool) = match &compressed_buf { + Some(z) => (z.as_slice(), true), + None => (data, false), + }; + #[cfg(not(feature = "netrom-compress"))] + let body: &[u8] = data; + let frag = self.options.fragment_size.max(1); let mut offset = 0; - while offset < data.len() { - let take = frag.min(data.len() - offset); - let more = offset + take < data.len(); + while offset < body.len() { + let take = frag.min(body.len() - offset); + let more = offset + take < body.len(); self.send_queue.push_back(Fragment { - bytes: data[offset..offset + take].to_vec(), + bytes: body[offset..offset + take].to_vec(), more_follows: more, + #[cfg(feature = "netrom-compress")] + compressed, }); offset += take; } @@ -279,7 +341,12 @@ impl NetRomCircuit { let t = packet.transport; match NetRomOpcode::from_nibble(t.opcode) { Some(NetRomOpcode::ConnectRequest) => self.on_connect_request(), - Some(NetRomOpcode::ConnectAcknowledge) => self.on_connect_acknowledge(&t, now_ms), + Some(NetRomOpcode::ConnectAcknowledge) => self.on_connect_acknowledge( + &t, + #[cfg(feature = "netrom-compress")] + packet.payload, + now_ms, + ), Some(NetRomOpcode::DisconnectRequest) => self.on_disconnect_request(), Some(NetRomOpcode::DisconnectAcknowledge) => self.on_disconnect_acknowledge(), Some(NetRomOpcode::Information) => self.on_information(&t, packet.payload, now_ms), @@ -293,7 +360,13 @@ impl NetRomCircuit { /// Accept an inbound circuit: adopt the peer's index/id + proposed window, move /// to Connected, and send the Connect Acknowledge. (Owner-driven, for an /// incoming connect.) - pub fn accept_inbound(&mut self, peer_index: u8, peer_id: u8, proposed_window: u8) { + pub fn accept_inbound( + &mut self, + peer_index: u8, + peer_id: u8, + proposed_window: u8, + #[cfg(feature = "netrom-compress")] peer_offers_compression: bool, + ) { self.remote_index = peer_index; self.remote_id = peer_id; let proposed = if proposed_window == 0 { @@ -302,6 +375,15 @@ impl NetRomCircuit { proposed_window }; self.window = proposed.min(self.options.window_size).clamp(1, 127); + + // Compression is enabled on this circuit only if BOTH ends advertised it: + // the peer's Connect Request carried the offer AND our options enable it. The + // Connect Acknowledge mirrors the agreement back so the originator knows. + #[cfg(feature = "netrom-compress")] + { + self.compression_enabled = self.options.compression_enabled && peer_offers_compression; + } + self.state = NetRomCircuitState::Connected; self.send_connect_acknowledge(false); self.fire_connected(); @@ -347,18 +429,25 @@ impl NetRomCircuit { return; } // Go-back style: retransmit every in-flight frame, bumping timers. - let frames: Vec<(u8, Vec, bool)> = self - .unacked - .iter() - .map(|u| (u.sequence, u.payload.clone(), u.more_follows)) - .collect(); - for (seq, payload, more) in &frames { - self.send_information(*seq, payload, *more); + // Take the list out so each frame can be borrowed while calling + // `&mut self.send_information`, then put it back with bumped timers + // (behaviour-identical to the prior clone-into-tuple, and it avoids + // cloning every payload on each retransmit). + let mut frames = core::mem::take(&mut self.unacked); + for u in &frames { + self.send_information( + u.sequence, + &u.payload, + u.more_follows, + #[cfg(feature = "netrom-compress")] + u.compressed, + ); } - for u in &mut self.unacked { + for u in &mut frames { u.sent_at = now_ms; u.retries += 1; } + self.unacked = frames; } } } @@ -376,7 +465,12 @@ impl NetRomCircuit { // ─── FSM handlers ─────────────────────────────────────────────────── - fn on_connect_acknowledge(&mut self, t: &NetRomTransportHeader, now_ms: u64) { + fn on_connect_acknowledge( + &mut self, + t: &NetRomTransportHeader, + #[cfg(feature = "netrom-compress")] info: &[u8], + now_ms: u64, + ) { if self.state != NetRomCircuitState::Connecting { return; } @@ -390,6 +484,16 @@ impl NetRomCircuit { return; } + // Compression negotiation: enable only if WE offered (options.compression_enabled) + // AND the peer's Connect Acknowledge mirrored the agreement back. A peer that + // ignored our offer (or that we never offered to) replies with the vanilla + // empty/short ack ⇒ compression_enabled stays false ⇒ we send raw, always safe. + #[cfg(feature = "netrom-compress")] + { + self.compression_enabled = + self.options.compression_enabled && ConnectAckInfo::agrees_compression(info); + } + self.state = NetRomCircuitState::Connected; self.fire_connected(); self.pump_send_queue(now_ms); @@ -427,10 +531,38 @@ impl NetRomCircuit { if t.tx_sequence == self.vr { self.vr = self.vr.wrapping_add(1); if !payload.is_empty() { + // Track whether this logical frame is a compressed stream. BPQ sets + // the Compressed flag on every fragment, so the FIRST fragment is + // authoritative; read it at the start of accumulation and hold it + // until the frame completes. + #[cfg(feature = "netrom-compress")] + if self.reassembly.is_empty() { + self.reassembly_compressed = t.compressed(); + } self.reassembly.extend_from_slice(payload); } if !t.more_follows() && !self.reassembly.is_empty() { let whole = core::mem::take(&mut self.reassembly); + + // Inflate first if the logical frame was sent compressed. A + // corrupt/undecodable stream is dropped (fail closed) — but still + // acked so the sender advances (a NAK can't recover a bad zlib + // stream), never delivered as garbage and never panicking. + #[cfg(feature = "netrom-compress")] + let whole = if self.reassembly_compressed { + self.reassembly_compressed = false; + match super::compression::try_decompress(&whole) { + Some(w) => w, + None => { + self.send_information_acknowledge(false); + self.pump_send_queue(now_ms); + return; + } + } + } else { + whole + }; + if self.options.choke_threshold > 0 { self.pending_deliveries += 1; } @@ -481,12 +613,27 @@ impl NetRomCircuit { Some(u) if !u.base().is_empty() => u, _ => self.local_node, }; - let mut info = [0u8; crate::netrom::wire::CONNECT_REQUEST_INFO_LEN]; let cri = ConnectRequestInfo { proposed_window: self.options.window_size.clamp(1, 127), originating_user: user, originating_node: self.local_node, }; + + // When compression is enabled we OFFER it via the LinBPQ extended-connect form + // (canonical 15 octets + a 2-octet timer trailer carrying the compress bit). A + // peer that ignores the trailer just sees a normal Connect Request, so offering + // is interop-safe; we only actually compress once the peer's Connect + // Acknowledge confirms it agreed. Compression off ⇒ canonical 15-octet form. + #[cfg(feature = "netrom-compress")] + if self.options.compression_enabled { + let mut info = [0u8; CONNECT_REQUEST_INFO_EXTENDED_LEN]; + cri.encode_extended(&mut info, self.options.proposed_timer_seconds, true) + .expect("17-byte buffer"); + self.emit(t, &info); + return; + } + + let mut info = [0u8; crate::netrom::wire::CONNECT_REQUEST_INFO_LEN]; cri.encode(&mut info).expect("15-byte buffer"); self.emit(t, &info); } @@ -500,6 +647,21 @@ impl NetRomCircuit { opcode: NetRomOpcode::ConnectAcknowledge.as_u8(), flags: if refused { FLAG_CHOKE } else { 0 }, }; + + // Mirror the compression agreement back to the originator (LinBPQ extended + // Connect Acknowledge) only when compression was actually agreed; otherwise + // the canonical empty-info Connect Acknowledge is sent, so a non-compressing + // circuit is byte-for-byte vanilla NET/ROM. + #[cfg(feature = "netrom-compress")] + if !refused && self.compression_enabled { + if let Some(info) = + ConnectAckInfo::encode(self.window, self.options.time_to_live, true) + { + self.emit(t, &info); + return; + } + } + self.emit(t, &[]); } @@ -527,11 +689,21 @@ impl NetRomCircuit { self.emit(t, &[]); } - fn send_information(&mut self, seq: u8, payload: &[u8], more_follows: bool) { + fn send_information( + &mut self, + seq: u8, + payload: &[u8], + more_follows: bool, + #[cfg(feature = "netrom-compress")] compressed: bool, + ) { let mut flags = 0u8; if more_follows { flags |= FLAG_MORE_FOLLOWS; } + #[cfg(feature = "netrom-compress")] + if compressed { + flags |= FLAG_COMPRESSED; + } if self.local_choked { flags |= FLAG_CHOKE; } @@ -588,11 +760,19 @@ impl NetRomCircuit { let fragment = self.send_queue.pop_front().unwrap(); let seq = self.vs; self.vs = self.vs.wrapping_add(1); - self.send_information(seq, &fragment.bytes, fragment.more_follows); + self.send_information( + seq, + &fragment.bytes, + fragment.more_follows, + #[cfg(feature = "netrom-compress")] + fragment.compressed, + ); self.unacked.push(Unacked { sequence: seq, payload: fragment.bytes, more_follows: fragment.more_follows, + #[cfg(feature = "netrom-compress")] + compressed: fragment.compressed, sent_at: now_ms, retries: 0, }); @@ -615,21 +795,28 @@ impl NetRomCircuit { } fn retransmit_from(&mut self, seq: u8, now_ms: u64) { - let to_send: Vec<(u8, Vec, bool)> = self - .unacked - .iter() - .filter(|u| u.sequence == seq || mod256_after(u.sequence, seq)) - .map(|u| (u.sequence, u.payload.clone(), u.more_follows)) - .collect(); - for (s, payload, more) in &to_send { - self.send_information(*s, payload, *more); + // Take the in-flight list out so each matching frame can be borrowed while + // calling `&mut self.send_information`, then put it back with bumped timers + // (behaviour-identical to the prior clone-into-tuple). + let mut frames = core::mem::take(&mut self.unacked); + for u in &frames { + if u.sequence == seq || mod256_after(u.sequence, seq) { + self.send_information( + u.sequence, + &u.payload, + u.more_follows, + #[cfg(feature = "netrom-compress")] + u.compressed, + ); + } } - for u in &mut self.unacked { + for u in &mut frames { if u.sequence == seq || mod256_after(u.sequence, seq) { u.sent_at = now_ms; u.retries += 1; } } + self.unacked = frames; } // ─── Choke ────────────────────────────────────────────────────────── @@ -677,6 +864,11 @@ impl NetRomCircuit { self.unacked.clear(); self.send_queue.clear(); self.reassembly.clear(); + #[cfg(feature = "netrom-compress")] + { + self.reassembly_compressed = false; + self.compression_enabled = false; + } self.fire_closed(reason); } @@ -755,6 +947,8 @@ mod tests { req.transport.circuit_index, req.transport.circuit_id, cri.proposed_window, + #[cfg(feature = "netrom-compress")] + false, ); assert_eq!(b.state(), NetRomCircuitState::Connected); assert!(b.take_events().contains(&CircuitEvent::Connected)); @@ -788,3 +982,307 @@ mod tests { .contains(&CircuitEvent::Closed(NetRomCircuitCloseReason::Normal))); } } + +// ─── L4 compression (BPQ L4Compress) — negotiation + send/recv, feature-gated ─── +#[cfg(all(test, feature = "netrom-compress"))] +mod compression_tests { + use super::*; + use crate::ax25::Callsign; + use crate::netrom::transport::compression; + use crate::netrom::wire::{ + ConnectAckInfo, ConnectRequestInfo, CONNECT_REQUEST_INFO_EXTENDED_LEN, + CONNECT_REQUEST_INFO_LEN, FLAG_COMPRESSED, MAX_PAYLOAD, + }; + + const NOW: u64 = 1_000; + + fn cs(b: &[u8]) -> Callsign { + Callsign::new(b, 0).unwrap() + } + + fn on() -> NetRomCircuitOptions { + NetRomCircuitOptions { + compression_enabled: true, + ..Default::default() + } + } + + fn off() -> NetRomCircuitOptions { + NetRomCircuitOptions::default() + } + + /// Feed a batch of a peer's outbound datagrams into `to`. + fn feed(pkts: &[OutboundPacket], to: &mut NetRomCircuit) { + for p in pkts { + let pkt = NetRomPacket { + network: p.network, + transport: p.transport, + payload: &p.payload, + }; + to.on_packet(&pkt, NOW); + } + } + + /// Bring up an A→B circuit under the given options, running the full connect + /// handshake (offer/agree wired through as A's Connect Request advertised). + /// Returns both connected ends. A = index 1/id 7, B = index 2/id 9. + fn connected_pair( + opts_a: NetRomCircuitOptions, + opts_b: NetRomCircuitOptions, + ) -> (NetRomCircuit, NetRomCircuit) { + let (na, nb) = (cs(b"GB7AAA"), cs(b"GB7BBB")); + let mut a = NetRomCircuit::new(1, 7, na, nb, opts_a); + let mut b = NetRomCircuit::new(2, 9, nb, na, opts_b); + + a.connect(cs(b"M0LTE"), NOW); + let creq = a.take_outbox().remove(0); + let cri = ConnectRequestInfo::decode(&creq.payload).unwrap(); + let offered = ConnectRequestInfo::offers_compression(&creq.payload); + b.accept_inbound( + creq.transport.circuit_index, + creq.transport.circuit_id, + cri.proposed_window, + offered, + ); + // Deliver B's Connect Acknowledge back to A so A settles its side. + let cack = b.take_outbox(); + feed(&cack, &mut a); + (a, b) + } + + // ── Negotiation ──────────────────────────────────────────────────── + + #[test] + fn offers_via_extended_connect_when_enabled() { + let mut a = NetRomCircuit::new(1, 7, cs(b"GB7AAA"), cs(b"GB7BBB"), on()); + a.connect(cs(b"M0LTE"), NOW); + let creq = a.take_outbox().remove(0); + assert_eq!(creq.payload.len(), CONNECT_REQUEST_INFO_EXTENDED_LEN); + assert!(ConnectRequestInfo::offers_compression(&creq.payload)); + } + + #[test] + fn sends_canonical_connect_when_disabled() { + // Feature compiled in, but the option off ⇒ byte-identical plain NET/ROM. + let mut a = NetRomCircuit::new(1, 7, cs(b"GB7AAA"), cs(b"GB7BBB"), off()); + a.connect(cs(b"M0LTE"), NOW); + let creq = a.take_outbox().remove(0); + assert_eq!(creq.payload.len(), CONNECT_REQUEST_INFO_LEN); + assert!(!ConnectRequestInfo::offers_compression(&creq.payload)); + } + + #[test] + fn both_ends_offer_and_agree_enables_compression() { + let (a, b) = connected_pair(on(), on()); + assert_eq!(a.state(), NetRomCircuitState::Connected); + assert_eq!(b.state(), NetRomCircuitState::Connected); + assert!(a.compression_negotiated(), "originator settled compression on"); + assert!(b.compression_negotiated(), "acceptor settled compression on"); + } + + #[test] + fn responder_declining_leaves_both_ends_plain() { + // A offers, B has compression disabled ⇒ B replies with the vanilla empty + // Connect Acknowledge, and neither end compresses. + let (na, nb) = (cs(b"GB7AAA"), cs(b"GB7BBB")); + let mut a = NetRomCircuit::new(1, 7, na, nb, on()); + let mut b = NetRomCircuit::new(2, 9, nb, na, off()); + a.connect(cs(b"M0LTE"), NOW); + let creq = a.take_outbox().remove(0); + let cri = ConnectRequestInfo::decode(&creq.payload).unwrap(); + b.accept_inbound( + creq.transport.circuit_index, + creq.transport.circuit_id, + cri.proposed_window, + ConnectRequestInfo::offers_compression(&creq.payload), + ); + let cack = b.take_outbox(); + assert_eq!(cack.len(), 1); + assert!( + cack[0].payload.is_empty(), + "a declining Connect Acknowledge is the vanilla empty-info form" + ); + assert!(!ConnectAckInfo::agrees_compression(&cack[0].payload)); + feed(&cack, &mut a); + assert!(!a.compression_negotiated()); + assert!(!b.compression_negotiated()); + } + + #[test] + fn initiator_declining_leaves_both_ends_plain() { + // A never offers (canonical connect); B is willing but has nothing to agree + // to ⇒ both stay plain. + let (a, b) = connected_pair(off(), on()); + assert!(!a.compression_negotiated()); + assert!(!b.compression_negotiated()); + } + + // ── Send / receive ───────────────────────────────────────────────── + + /// A moderately compressible ~4 KiB payload (repeated text + a little entropy) + /// whose zlib stream exceeds the 236-byte fragment size, forcing a multi-fragment + /// compressed logical send. Stays under the 8 KiB decompress cap. + fn multi_fragment_payload() -> Vec { + let mut rng: u32 = 0x1234_5678; + let mut data = Vec::new(); + for _ in 0..70 { + data.extend_from_slice(b"GB7RDG NET/ROM node broadcast quality 192 via GB7RDG-7 "); + for _ in 0..8 { + rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + data.push((rng >> 24) as u8); + } + } + data + } + + #[test] + fn compressed_logical_send_round_trips_through_fragment_reassemble_inflate() { + let original = multi_fragment_payload(); + let z = compression::compress(&original); + assert!(z.len() < original.len(), "payload must actually compress"); + assert!( + z.len() > MAX_PAYLOAD, + "compressed stream must span >1 fragment (got {})", + z.len() + ); + + // A window wide enough to emit every fragment in one burst (so the whole + // logical frame is in the outbox to inspect + deliver at once). + let opts = NetRomCircuitOptions { + compression_enabled: true, + window_size: 32, + ..Default::default() + }; + let (mut a, mut b) = connected_pair(opts, opts); + a.send(&original, NOW); + let frames = a.take_outbox(); + + // Every fragment of a compressed logical send carries the Compressed flag; + // all but the last carry more-follows. + assert!(frames.len() >= 2, "expected multiple fragments"); + for (i, f) in frames.iter().enumerate() { + assert_eq!( + NetRomOpcode::from_nibble(f.transport.opcode), + Some(NetRomOpcode::Information) + ); + assert!(f.transport.compressed(), "fragment {i} lacks the Compressed flag"); + let last = i == frames.len() - 1; + assert_eq!(f.transport.more_follows(), !last, "more-follows on fragment {i}"); + } + + feed(&frames, &mut b); + let events = b.take_events(); + let received: Vec> = events + .into_iter() + .filter_map(|e| match e { + CircuitEvent::DataReceived(d) => Some(d), + _ => None, + }) + .collect(); + assert_eq!(received.len(), 1, "reassembled to a single logical frame"); + assert_eq!(received[0], original, "inflated payload matches the original"); + } + + #[test] + fn incompressible_payload_uses_the_raw_per_send_fallback() { + // High-entropy data zlib cannot shrink ⇒ sent raw, Compressed flag clear, + // even though the circuit negotiated compression. + let mut rng: u32 = 0xDEAD_BEEF; + let mut payload = Vec::new(); + for _ in 0..48 { + rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + payload.push((rng >> 24) as u8); + } + assert!( + compression::compress(&payload).len() >= payload.len(), + "test precondition: payload must not compress" + ); + + let (mut a, mut b) = connected_pair(on(), on()); + assert!(a.compression_negotiated()); + a.send(&payload, NOW); + let frames = a.take_outbox(); + assert_eq!(frames.len(), 1); + assert!( + !frames[0].transport.compressed(), + "raw fallback must leave the Compressed flag clear" + ); + + feed(&frames, &mut b); + let received: Vec> = b + .take_events() + .into_iter() + .filter_map(|e| match e { + CircuitEvent::DataReceived(d) => Some(d), + _ => None, + }) + .collect(); + assert_eq!(received, alloc::vec![payload]); + } + + #[test] + fn disabled_circuit_never_sets_the_compressed_flag() { + // Feature compiled in, both ends' option off ⇒ behaves like plain NET/ROM: + // no negotiation, no Compressed flag, data flows raw. + let (mut a, mut b) = connected_pair(off(), off()); + assert!(!a.compression_negotiated()); + let payload = b"plain netrom data, no compression on this circuit"; + a.send(payload, NOW); + let frames = a.take_outbox(); + assert_eq!(frames.len(), 1); + assert!( + !frames[0].transport.compressed(), + "a disabled circuit must not compress" + ); + feed(&frames, &mut b); + let received: Vec> = b + .take_events() + .into_iter() + .filter_map(|e| match e { + CircuitEvent::DataReceived(d) => Some(d), + _ => None, + }) + .collect(); + assert_eq!(received, alloc::vec![payload.to_vec()]); + } + + #[test] + fn a_corrupt_compressed_frame_is_dropped_but_still_acked() { + // Fail-closed: an undecodable zlib payload flagged Compressed must not be + // delivered as garbage nor panic — it is dropped, and still acked so the + // sender advances (a NAK can't recover a bad zlib stream). + let (_a, mut b) = connected_pair(on(), on()); + // Craft an Information frame addressed to B (its local key 2/9), sequence 0, + // flagged Compressed, with a payload that is not a valid zlib stream. + let garbage = OutboundPacket { + network: NetRomNetworkHeader { + origin: cs(b"GB7AAA"), + destination: cs(b"GB7BBB"), + time_to_live: 10, + }, + transport: NetRomTransportHeader { + circuit_index: 2, + circuit_id: 9, + tx_sequence: 0, + rx_sequence: 0, + opcode: NetRomOpcode::Information.as_u8(), + flags: FLAG_COMPRESSED, + }, + payload: alloc::vec![0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], + }; + feed(&[garbage], &mut b); + + // No data delivered upward… + let delivered = b + .take_events() + .into_iter() + .any(|e| matches!(e, CircuitEvent::DataReceived(_))); + assert!(!delivered, "a corrupt compressed frame must not be delivered"); + // …but an Information Acknowledge was still emitted. + let acked = b.take_outbox().into_iter().any(|p| { + NetRomOpcode::from_nibble(p.transport.opcode) + == Some(NetRomOpcode::InformationAcknowledge) + }); + assert!(acked, "the dropped frame is still acked so the sender advances"); + } +} diff --git a/crates/ax25-node-core/src/netrom/transport/circuit_manager.rs b/crates/ax25-node-core/src/netrom/transport/circuit_manager.rs index 9eb7df0..446ef37 100644 --- a/crates/ax25-node-core/src/netrom/transport/circuit_manager.rs +++ b/crates/ax25-node-core/src/netrom/transport/circuit_manager.rs @@ -50,6 +50,13 @@ pub struct IncomingCircuit { pub peer_id: u8, /// The window size the peer proposed in its Connect Request. pub proposed_window: u8, + /// Whether the peer's Connect Request offered LinBPQ L4 compression (the + /// extended-connect compress bit). Passed to + /// [`NetRomCircuit::accept_inbound`](super::circuit::NetRomCircuit::accept_inbound) + /// so compression enables only when both ends advertise. Gated behind + /// `netrom-compress`. + #[cfg(feature = "netrom-compress")] + pub offered_compression: bool, } struct Managed { @@ -204,6 +211,8 @@ impl CircuitManager { incoming.peer_index, incoming.peer_id, incoming.proposed_window, + #[cfg(feature = "netrom-compress")] + incoming.offered_compression, ); } } @@ -264,6 +273,10 @@ impl CircuitManager { proposed_window = info.proposed_window; originating_user = info.originating_user; } + // Read the peer's compression offer off the (extended) Connect Request. The + // canonical decode above ignores the trailer, so this is a separate read. + #[cfg(feature = "netrom-compress")] + let offered_compression = ConnectRequestInfo::offers_compression(request.payload); let peer_key = (remote_node, t.circuit_index, t.circuit_id); let (index, id) = self.allocate_key(); @@ -279,6 +292,8 @@ impl CircuitManager { peer_index: t.circuit_index, peer_id: t.circuit_id, proposed_window, + #[cfg(feature = "netrom-compress")] + offered_compression, }); } @@ -1009,4 +1024,72 @@ mod tests { Some(NetRomOpcode::DisconnectAcknowledge) ); } + + // ─── L4 compression negotiation through the manager (feature-gated) ── + + /// A compressible multi-fragment payload (compresses under the fragment size + /// yet stays under the 8 KiB decompress cap). + #[cfg(feature = "netrom-compress")] + fn compressible_payload() -> Vec { + let mut data = Vec::new(); + for _ in 0..60 { + data.extend_from_slice(b"GB7RDG NET/ROM node broadcast quality 192 via GB7RDG-7 more follows\n"); + } + data + } + + #[cfg(feature = "netrom-compress")] + #[test] + fn both_ends_negotiate_and_a_compressed_send_round_trips_through_the_manager() { + let mut h = Harness::with_both(NetRomCircuitOptions { + compression_enabled: true, + window_size: 8, + ..Default::default() + }); + h.auto_accept_on_b(); + let a = h.open_from_a(); + h.connect_a(a, user()); + h.pump(); + let b = h.accepted(0); + + assert!( + h.a.circuit_mut(a).unwrap().compression_negotiated(), + "originator negotiated compression via the manager accept path" + ); + assert!( + h.b.circuit_mut(b).unwrap().compression_negotiated(), + "acceptor negotiated compression" + ); + + // End-to-end: a compressed logical send fragments, reassembles, inflates. + let payload = compressible_payload(); + h.send_a(a, &payload); + h.pump(); + assert_eq!(h.cap_b(b).received_bytes(), payload); + } + + #[cfg(feature = "netrom-compress")] + #[test] + fn either_end_declining_leaves_the_manager_circuit_plain() { + // A offers, B declines (compression off) ⇒ neither compresses; data still flows. + let mut h = Harness::with_options( + NetRomCircuitOptions { + compression_enabled: true, + ..Default::default() + }, + NetRomCircuitOptions::default(), + ); + h.auto_accept_on_b(); + let a = h.open_from_a(); + h.connect_a(a, user()); + h.pump(); + let b = h.accepted(0); + + assert!(!h.a.circuit_mut(a).unwrap().compression_negotiated()); + assert!(!h.b.circuit_mut(b).unwrap().compression_negotiated()); + + h.send_a(a, b"still flows uncompressed"); + h.pump(); + assert_eq!(h.cap_b(b).received_bytes(), b"still flows uncompressed"); + } } diff --git a/crates/ax25-node-core/src/netrom/transport/circuit_options.rs b/crates/ax25-node-core/src/netrom/transport/circuit_options.rs index 5826e34..7abb939 100644 --- a/crates/ax25-node-core/src/netrom/transport/circuit_options.rs +++ b/crates/ax25-node-core/src/netrom/transport/circuit_options.rs @@ -35,6 +35,25 @@ pub struct NetRomCircuitOptions { /// *choke*. Default 0 — the receiver never self-chokes (it drains promptly); a /// host that can stall its reader sets this so backpressure reaches the wire. pub choke_threshold: usize, + /// Offer (and accept) LinBPQ-style negotiated NET/ROM L4 payload compression on + /// circuits this node originates or accepts (BPQ `L4Compress`). **Default + /// `false`** (decline) — a circuit then runs uncompressed, which every NET/ROM + /// peer can read. When `true`, the circuit advertises compression in its Connect + /// Request / Acknowledge and only actually compresses outbound data when the + /// *other end* also agreed. Gated behind `netrom-compress`. Mirrors C# + /// `NetRomCircuitOptions.CompressionEnabled`. + #[cfg(feature = "netrom-compress")] + pub compression_enabled: bool, + /// The proposed session timer (T1, whole seconds) carried in the trailing 2 + /// octets of a LinBPQ extended Connect Request — the carrier for the + /// compression-supported bit. Only emitted when [`compression_enabled`] is set. + /// Default 60 s; the high nibble is reserved for the compress flag so the value + /// is masked to the low 12 bits on the wire. Gated behind `netrom-compress`. + /// Mirrors C# `NetRomCircuitOptions.ProposedTimerSeconds`. + /// + /// [`compression_enabled`]: Self::compression_enabled + #[cfg(feature = "netrom-compress")] + pub proposed_timer_seconds: u16, } impl Default for NetRomCircuitOptions { @@ -46,6 +65,10 @@ impl Default for NetRomCircuitOptions { time_to_live: DEFAULT_TIME_TO_LIVE, fragment_size: MAX_PAYLOAD, choke_threshold: 0, + #[cfg(feature = "netrom-compress")] + compression_enabled: false, + #[cfg(feature = "netrom-compress")] + proposed_timer_seconds: 60, } } } diff --git a/crates/ax25-node-core/src/netrom/transport/compression.rs b/crates/ax25-node-core/src/netrom/transport/compression.rs new file mode 100644 index 0000000..840b145 --- /dev/null +++ b/crates/ax25-node-core/src/netrom/transport/compression.rs @@ -0,0 +1,72 @@ +//! The payload (de)compressor for a compression-negotiated NET/ROM L4 circuit — +//! the thin, parity-named adapter the circuit calls, over the [`deflate`] zlib +//! codec. It mirrors the C# `Packet.NetRom.Transport.NetRomCompression` surface +//! (`Compress` / `TryDecompress`) and fixes the decompress cap at the BPQ-matching +//! 8 KiB so the circuit hook points read cleanly. +//! +//! The circuit performs the *framing* (compress the whole logical send as one +//! zlib stream, then fragment at 236 bytes with the [`FLAG_COMPRESSED`] flag on +//! every fragment; reassemble all more-follows fragments, then inflate the +//! concatenation once). This module is just the codec seam: `compress` deflates, +//! `try_decompress` inflates fail-closed (a corrupt / oversized stream returns +//! `None` so the circuit drops the frame rather than crashing). +//! +//! Gated behind the `netrom-compress` feature (via the parent module). +//! +//! [`deflate`]: super::deflate +//! [`FLAG_COMPRESSED`]: crate::netrom::wire::FLAG_COMPRESSED + +use alloc::vec::Vec; + +use super::deflate::{self, DEFAULT_MAX_INFLATE}; + +/// The cap on a single decompressed logical frame — generous headroom over the +/// 236-byte fragment size, matching LinBPQ's 8 KiB inflate buffer and the C# +/// `NetRomCircuit.MaxDecompressedFrame`. A compressed frame that expands past this +/// is treated as corrupt and dropped. +pub const MAX_DECOMPRESSED_FRAME: usize = DEFAULT_MAX_INFLATE; // 8192 + +/// Compress `data` into a zlib stream (RFC 1950) that LinBPQ's `doinflate` +/// accepts. Mirrors C# `NetRomCompression.Compress`. +pub fn compress(data: &[u8]) -> Vec { + deflate::zlib_compress(data) +} + +/// Decompress a zlib stream produced by LinBPQ (or by [`compress`]) back to the +/// original bytes, capped at [`MAX_DECOMPRESSED_FRAME`]. Returns `None` (never +/// panics) if `data` is not a valid zlib stream or expands past the cap — a +/// corrupt or truncated compressed frame must fail closed, not crash the circuit. +/// Mirrors C# `NetRomCompression.TryDecompress` (the `out`/`bool` shape becomes an +/// `Option`). +pub fn try_decompress(data: &[u8]) -> Option> { + deflate::zlib_decompress(data, MAX_DECOMPRESSED_FRAME).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_a_realistic_payload() { + let data = b"GB7RDG NET/ROM node; connect from M0LTE-7; more follows. ".repeat(20); + let z = compress(&data); + assert!(z.len() < data.len(), "realistic text should shrink"); + assert_eq!(try_decompress(&z).unwrap(), data); + } + + #[test] + fn corrupt_stream_fails_closed() { + let mut z = compress(b"the quick brown fox jumps over the lazy dog"); + let n = z.len(); + z[n - 1] ^= 0xFF; // clobber the Adler-32 trailer + assert!(try_decompress(&z).is_none()); + } + + #[test] + fn oversized_stream_fails_closed_at_the_cap() { + // >8 KiB of highly compressible data inflates past MAX_DECOMPRESSED_FRAME. + let big = alloc::vec![b'Z'; MAX_DECOMPRESSED_FRAME + 1]; + let z = compress(&big); + assert!(try_decompress(&z).is_none()); + } +} diff --git a/crates/ax25-node-core/src/netrom/transport/deflate.rs b/crates/ax25-node-core/src/netrom/transport/deflate.rs new file mode 100644 index 0000000..3573381 --- /dev/null +++ b/crates/ax25-node-core/src/netrom/transport/deflate.rs @@ -0,0 +1,968 @@ +//! Compact, correctness-critical zlib / DEFLATE codec for NET/ROM L4 payload +//! compression (BPQ `L4Compress` interop). +//! +//! This is the Rust port of the C# `Packet.NetRom.Transport.NetRomCompression` +//! reference (`src/Packet.NetRom/Transport/NetRomCompression.cs`). BPQ's L4 +//! compression puts the user-data body on the wire as a **zlib stream (RFC 1950)** +//! — a 2-octet zlib header, a raw DEFLATE (RFC 1951) body, and an Adler-32 +//! trailer — NOT raw deflate. LinBPQ inflates with a plain `inflateInit`/`inflate` +//! (default window bits, so it expects the zlib wrapper) and deflates with +//! `deflateInit(Z_BEST_COMPRESSION)`. So we must read/write exactly that framing. +//! +//! ## What lives here +//! +//! - [`zlib_decompress`] — full RFC-1951 inflate (stored + fixed-Huffman + +//! dynamic-Huffman blocks), wrapped per RFC-1950 (parse + verify the 2-octet +//! zlib header, verify the trailing Adler-32). A caller-supplied output cap +//! ([`DEFAULT_MAX_INFLATE`] = 8 KiB, matching BPQ's inflate buffer). It is +//! **fail-closed**: any malformed input, bad Adler-32, or cap-exceed returns +//! `Err` — a corrupt compressed frame must never crash the circuit. +//! - [`zlib_compress`] — a compact greedy LZ77 (hash-chain match finder) emitting +//! a single **fixed-Huffman** block (valid RFC-1951 that any zlib inflater — +//! miniz, zlib, BPQ's `doinflate` — accepts), wrapped in the zlib header + +//! Adler-32. The ratio need only be "useful", not optimal; a fixed-Huffman +//! encoder is far smaller than a dynamic-Huffman one, which is the whole point +//! of hand-rolling this rather than pulling in a full deflate crate. +//! +//! Both directions are proven against the `miniz_oxide` reference in the inline +//! test module (a `[dev-dependencies]` test oracle that never ships). +//! +//! `no_std`; the growable output buffers use `alloc::vec::Vec` (the crate's +//! existing streaming-buffer pattern). Integer-only, no `unsafe`, no panics on +//! any input. + +use alloc::vec; +use alloc::vec::Vec; + +/// Default cap on inflate output (octets). Matches BPQ's 8 KiB inflate buffer and +/// the C# `NetRomCircuit.MaxDecompressedFrame = 8192`. A stream that expands past +/// the caller's cap fails closed. +pub const DEFAULT_MAX_INFLATE: usize = 8192; + +/// The failure modes of [`zlib_decompress`]. All are fail-closed: the caller drops +/// the frame. The variants are informational (useful in tests / logging); the +/// circuit treats any `Err` identically (drop, still ack so the sender advances). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZlibError { + /// The 2-octet zlib header is missing, not DEFLATE/CM=8, has an out-of-range + /// window (CINFO > 7), fails the mod-31 check, or requests a preset dictionary + /// (FDICT) we cannot supply. + BadHeader, + /// The stream ended before a complete DEFLATE structure could be decoded + /// (truncated input, or a trailer shorter than the 4-octet Adler-32). + Truncated, + /// The DEFLATE body is structurally invalid (reserved block type, bad Huffman + /// tables, a back-reference distance pointing before the output start, …). + Malformed, + /// Decoding would exceed the caller-supplied output cap. + CapExceeded, + /// The stream decoded, but its trailing Adler-32 does not match the checksum + /// of the produced output — corrupt payload. + BadChecksum, +} + +// --------------------------------------------------------------------------- +// Adler-32 (RFC 1950 §9). +// --------------------------------------------------------------------------- + +const ADLER_MOD: u32 = 65_521; + +/// The Adler-32 checksum of `data` (the zlib trailer over the *uncompressed* +/// bytes). Integer-only; each step is reduced mod 65521 so no overflow occurs. +fn adler32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + for &byte in data { + a = (a + byte as u32) % ADLER_MOD; + b = (b + a) % ADLER_MOD; + } + (b << 16) | a +} + +// --------------------------------------------------------------------------- +// RFC 1951 constants (length / distance base + extra-bit tables). +// --------------------------------------------------------------------------- + +/// Base length for length symbols 257..=285 (index = symbol - 257). +const LENGTH_BASE: [u16; 29] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, + 163, 195, 227, 258, +]; +/// Extra bits for length symbols 257..=285. +const LENGTH_EXTRA: [u8; 29] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, +]; +/// Base distance for distance symbols 0..=29. +const DIST_BASE: [u16; 30] = [ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, + 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, +]; +/// Extra bits for distance symbols 0..=29. +const DIST_EXTRA: [u8; 30] = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, + 13, +]; + +/// The order in which the 19 code-length-code lengths appear in a dynamic block +/// header (RFC 1951 §3.2.7). +const CODE_LENGTH_ORDER: [usize; 19] = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, +]; + +const MAX_BITS: usize = 15; +const MAX_LCODES: usize = 286; +const MAX_DCODES: usize = 30; +const FIX_LCODES: usize = 288; + +// =========================================================================== +// INFLATE +// =========================================================================== + +/// A canonical Huffman decode table, built from a per-symbol code-length array +/// using the counts + sorted-symbols method (as in Mark Adler's `puff.c`). Small +/// and allocation-light; `symbols` is the only heap use. +struct Huffman { + /// `count[len]` = number of codes of bit-length `len` (index 1..=MAX_BITS). + count: [u16; MAX_BITS + 1], + /// Symbols, ordered by (bit-length, symbol value) — the canonical order. + symbols: Vec, +} + +impl Huffman { + /// Build a Huffman table from `lengths` (bit-length per symbol; 0 = unused). + /// + /// Returns `Ok(left)` where `left` is the "slack" in the code space: + /// `left == 0` is a complete code, `left > 0` is an incomplete code (some code + /// space unused), and an **over-subscribed** code (`left` would go negative) + /// is rejected as [`ZlibError::Malformed`]. The caller decides whether an + /// incomplete code is acceptable in its context. + fn build(lengths: &[u16]) -> Result<(Self, i32), ZlibError> { + let mut count = [0u16; MAX_BITS + 1]; + for &len in lengths { + // A length > MAX_BITS is structurally impossible from a valid stream; + // guard anyway so indexing can never panic. + if len as usize > MAX_BITS { + return Err(ZlibError::Malformed); + } + count[len as usize] += 1; + } + + // Compute how much of the code space is left over (the completeness check). + let mut left: i32 = 1; + for &c in &count[1..=MAX_BITS] { + left <<= 1; + left -= c as i32; + if left < 0 { + // Over-subscribed: more codes of this length than the space allows. + return Err(ZlibError::Malformed); + } + } + + // Offsets into the sorted symbol table for each length. + let mut offsets = [0u16; MAX_BITS + 2]; + for len in 1..=MAX_BITS { + offsets[len + 1] = offsets[len] + count[len]; + } + + // Place symbols into the table in canonical order. + let total: usize = lengths.iter().filter(|&&l| l != 0).count(); + let mut symbols = vec![0u16; total]; + for (symbol, &len) in lengths.iter().enumerate() { + if len != 0 { + symbols[offsets[len as usize] as usize] = symbol as u16; + offsets[len as usize] += 1; + } + } + + Ok((Huffman { count, symbols }, left)) + } +} + +/// The streaming inflate state: an LSB-first bit reader over `data` plus the +/// growable output buffer and its cap. +struct Inflater<'a> { + data: &'a [u8], + /// Index of the next byte not yet pulled into `bit_buf`. + pos: usize, + /// Bit accumulator (LSB-first): the low `bit_cnt` bits are pending. + bit_buf: u32, + /// Number of valid bits currently in `bit_buf` (always < 8 after any `bits`). + bit_cnt: u32, + out: Vec, + cap: usize, +} + +impl<'a> Inflater<'a> { + fn new(data: &'a [u8], cap: usize) -> Self { + Inflater { + data, + pos: 0, + bit_buf: 0, + bit_cnt: 0, + out: Vec::new(), + cap, + } + } + + /// Pull `need` bits (0..=15) LSB-first from the stream. `Err(Truncated)` if the + /// input runs out. Extra bits (length/distance) are read in this natural order; + /// Huffman codes are read one bit at a time via this same path. + fn bits(&mut self, need: u32) -> Result { + while self.bit_cnt < need { + if self.pos >= self.data.len() { + return Err(ZlibError::Truncated); + } + self.bit_buf |= (self.data[self.pos] as u32) << self.bit_cnt; + self.pos += 1; + self.bit_cnt += 8; + } + let mask = if need == 0 { 0 } else { (1u32 << need) - 1 }; + let val = self.bit_buf & mask; + self.bit_buf >>= need; + self.bit_cnt -= need; + Ok(val) + } + + /// Decode one symbol using Huffman table `h`. Reads bits one at a time, MSB of + /// the code first (the DEFLATE convention), and returns the symbol once the + /// accumulated code falls inside a length's range. `Err(Malformed)` if the + /// code runs past `MAX_BITS` without matching (an invalid / incomplete code). + fn decode(&mut self, h: &Huffman) -> Result { + let mut code: i32 = 0; + let mut first: i32 = 0; + let mut index: i32 = 0; + for len in 1..=MAX_BITS { + code |= self.bits(1)? as i32; + let count = h.count[len] as i32; + if code - count < first { + let sym_index = (index + (code - first)) as usize; + // sym_index is provably < symbols.len() for a valid table, but + // guard so a malformed table can never panic. + return h + .symbols + .get(sym_index) + .copied() + .ok_or(ZlibError::Malformed); + } + index += count; + first += count; + first <<= 1; + code <<= 1; + } + Err(ZlibError::Malformed) + } + + /// Append a byte to the output, enforcing the cap. + fn push(&mut self, byte: u8) -> Result<(), ZlibError> { + if self.out.len() >= self.cap { + return Err(ZlibError::CapExceeded); + } + self.out.push(byte); + Ok(()) + } + + /// A stored (uncompressed) block: byte-align, read LEN/NLEN, copy LEN octets. + fn stored(&mut self) -> Result<(), ZlibError> { + // Discard the partial byte in the accumulator to reach a byte boundary. + // `bit_cnt` is always < 8 here, and those bits belong to the byte just + // before `pos`, so restarting from `pos` is byte-aligned. + self.bit_buf = 0; + self.bit_cnt = 0; + + if self.pos + 4 > self.data.len() { + return Err(ZlibError::Truncated); + } + let len = self.data[self.pos] as usize | ((self.data[self.pos + 1] as usize) << 8); + let nlen = self.data[self.pos + 2] as usize | ((self.data[self.pos + 3] as usize) << 8); + if nlen != (!len & 0xffff) { + return Err(ZlibError::Malformed); + } + self.pos += 4; + if self.pos + len > self.data.len() { + return Err(ZlibError::Truncated); + } + if self.out.len() + len > self.cap { + return Err(ZlibError::CapExceeded); + } + self.out + .extend_from_slice(&self.data[self.pos..self.pos + len]); + self.pos += len; + Ok(()) + } + + /// Decode literal/length + distance codes until the end-of-block symbol (256). + fn codes(&mut self, lencode: &Huffman, distcode: &Huffman) -> Result<(), ZlibError> { + loop { + let symbol = self.decode(lencode)?; + if symbol < 256 { + self.push(symbol as u8)?; + } else if symbol == 256 { + return Ok(()); + } else { + // Length symbol (257..=285). + let idx = (symbol - 257) as usize; + if idx >= LENGTH_BASE.len() { + // 286/287 are invalid length codes (only reachable via the + // fixed table, which defines them but they never legally occur). + return Err(ZlibError::Malformed); + } + let extra = self.bits(LENGTH_EXTRA[idx] as u32)?; + let length = LENGTH_BASE[idx] as usize + extra as usize; + + let dsym = self.decode(distcode)? as usize; + if dsym >= DIST_BASE.len() { + return Err(ZlibError::Malformed); + } + let dextra = self.bits(DIST_EXTRA[dsym] as u32)?; + let dist = DIST_BASE[dsym] as usize + dextra as usize; + + if dist > self.out.len() { + // Back-reference points before the start of the output. + return Err(ZlibError::Malformed); + } + if self.out.len() + length > self.cap { + return Err(ZlibError::CapExceeded); + } + // Copy byte-by-byte: overlapping copies (dist < length) are legal + // and must read freshly-written bytes (RLE-style runs). + let start = self.out.len() - dist; + for k in 0..length { + let b = self.out[start + k]; + self.out.push(b); + } + } + } + } + + /// A dynamic-Huffman block: read the code-length code, expand it into the + /// literal/length + distance code lengths, build both tables, decode the body. + fn dynamic(&mut self) -> Result<(), ZlibError> { + let nlen = self.bits(5)? as usize + 257; + let ndist = self.bits(5)? as usize + 1; + let ncode = self.bits(4)? as usize + 4; + if nlen > MAX_LCODES || ndist > MAX_DCODES { + return Err(ZlibError::Malformed); + } + + // Read the code-length code lengths (3 bits each) in the permuted order. + let mut cl_lengths = [0u16; 19]; + for i in 0..ncode { + cl_lengths[CODE_LENGTH_ORDER[i]] = self.bits(3)? as u16; + } + // Remaining entries stay 0 (already initialised). + let (clcode, left) = Huffman::build(&cl_lengths)?; + // The code-length code must be complete. + if left != 0 { + return Err(ZlibError::Malformed); + } + + // Expand into nlen + ndist code lengths. + let total = nlen + ndist; + let mut lengths = [0u16; MAX_LCODES + MAX_DCODES]; + let mut index = 0usize; + while index < total { + let symbol = self.decode(&clcode)?; + if symbol < 16 { + lengths[index] = symbol; + index += 1; + } else { + let (repeat, value) = match symbol { + 16 => { + // Copy the previous code length 3..=6 times. + if index == 0 { + return Err(ZlibError::Malformed); + } + (3 + self.bits(2)? as usize, lengths[index - 1]) + } + 17 => (3 + self.bits(3)? as usize, 0), // repeat zero 3..=10 + 18 => (11 + self.bits(7)? as usize, 0), // repeat zero 11..=138 + _ => return Err(ZlibError::Malformed), + }; + if index + repeat > total { + return Err(ZlibError::Malformed); + } + for _ in 0..repeat { + lengths[index] = value; + index += 1; + } + } + } + + // A block with no end-of-block code (256) is malformed. + if lengths[256] == 0 { + return Err(ZlibError::Malformed); + } + + let (lencode, lleft) = Huffman::build(&lengths[..nlen])?; + // The literal/length code must be complete (no legal incomplete case). + if lleft != 0 { + return Err(ZlibError::Malformed); + } + + let (distcode, dleft) = Huffman::build(&lengths[nlen..total])?; + // A distance code may legally be incomplete ONLY in the single-distance + // special case: at most one code, of length 1 (all other symbols length 0). + if dleft != 0 { + let nonzero = distcode.symbols.len(); + let ones = distcode.count[1] as usize; + if !(nonzero == ones && nonzero <= 1) { + return Err(ZlibError::Malformed); + } + } + + self.codes(&lencode, &distcode) + } + + /// Inflate the whole DEFLATE stream (a sequence of blocks) into `self.out`. + fn inflate(&mut self) -> Result<(), ZlibError> { + loop { + let last = self.bits(1)?; + let btype = self.bits(2)?; + match btype { + 0 => self.stored()?, + 1 => { + let (lencode, distcode) = fixed_tables()?; + self.codes(&lencode, &distcode)?; + } + 2 => self.dynamic()?, + _ => return Err(ZlibError::Malformed), // reserved block type (3) + } + if last == 1 { + return Ok(()); + } + } + } +} + +/// Build the fixed-Huffman literal/length and distance tables (RFC 1951 §3.2.6). +/// Rebuilt per fixed block; fixed blocks are rare in real BPQ/zlib output, so the +/// simplicity is worth more than caching. +fn fixed_tables() -> Result<(Huffman, Huffman), ZlibError> { + let mut ll = [0u16; FIX_LCODES]; + for (sym, slot) in ll.iter_mut().enumerate() { + *slot = match sym { + 0..=143 => 8, + 144..=255 => 9, + 256..=279 => 7, + _ => 8, // 280..=287 + }; + } + let (lencode, _) = Huffman::build(&ll)?; + + // 30 distance codes, all length 5 (symbols 30/31 are absent — an incomplete + // code, which is fine: they never legally occur). + let dl = [5u16; MAX_DCODES]; + let (distcode, _) = Huffman::build(&dl)?; + Ok((lencode, distcode)) +} + +/// Decompress a zlib stream (RFC 1950: 2-octet header + DEFLATE body + Adler-32) +/// back to the original bytes. Mirrors the C# `NetRomCompression.TryDecompress`. +/// +/// **Fail-closed:** returns `Err` (never panics) on a bad zlib header, a malformed +/// or truncated DEFLATE body, output exceeding `max_output`, or a trailing +/// Adler-32 that does not match — a corrupt compressed frame must not crash the +/// circuit. `max_output` is the hard cap on produced octets (use +/// [`DEFAULT_MAX_INFLATE`] for the BPQ-matching 8 KiB). +pub fn zlib_decompress(data: &[u8], max_output: usize) -> Result, ZlibError> { + // Smallest possible zlib stream: 2 header + >=2 body + 4 Adler. + if data.len() < 6 { + return Err(ZlibError::Truncated); + } + + // ---- RFC 1950 header ---- + let cmf = data[0]; + let flg = data[1]; + let cm = cmf & 0x0f; + let cinfo = cmf >> 4; + if cm != 8 || cinfo > 7 { + // Not DEFLATE, or a window larger than the 32 KiB we (and zlib) support. + return Err(ZlibError::BadHeader); + } + let header = ((cmf as u16) << 8) | flg as u16; + if !header.is_multiple_of(31) { + return Err(ZlibError::BadHeader); + } + if (flg & 0x20) != 0 { + // FDICT: a preset dictionary we cannot supply — refuse rather than + // silently mis-decode. (BPQ/miniz never set it.) + return Err(ZlibError::BadHeader); + } + + // The DEFLATE body sits between the 2-octet header and the 4-octet Adler-32 + // trailer. This layout is exact for any well-formed zlib stream; trailing + // garbage simply makes the Adler-32 check fail (still fail-closed). + let body = &data[2..data.len() - 4]; + let trailer = &data[data.len() - 4..]; + + let mut inflater = Inflater::new(body, max_output); + inflater.inflate()?; + let out = inflater.out; + + // ---- RFC 1950 Adler-32 trailer (big-endian, over the uncompressed data) ---- + let expected = ((trailer[0] as u32) << 24) + | ((trailer[1] as u32) << 16) + | ((trailer[2] as u32) << 8) + | (trailer[3] as u32); + if adler32(&out) != expected { + return Err(ZlibError::BadChecksum); + } + + Ok(out) +} + +// =========================================================================== +// DEFLATE (compact greedy LZ77 + fixed Huffman) +// =========================================================================== + +/// An LSB-first bit writer. DEFLATE packs the bit stream LSB-first within each +/// octet; Huffman codes are packed MSB-first, so [`Self::write_code`] reverses the +/// code's bits before emitting them LSB-first. +struct BitWriter { + out: Vec, + bit_buf: u32, + bit_cnt: u32, +} + +impl BitWriter { + fn new() -> Self { + BitWriter { + out: Vec::new(), + bit_buf: 0, + bit_cnt: 0, + } + } + + /// Write the low `n` bits of `val` (0..=16 bits), LSB-first. + fn write_bits(&mut self, val: u32, n: u32) { + self.bit_buf |= (val & if n == 0 { 0 } else { (1u32 << n) - 1 }) << self.bit_cnt; + self.bit_cnt += n; + while self.bit_cnt >= 8 { + self.out.push((self.bit_buf & 0xff) as u8); + self.bit_buf >>= 8; + self.bit_cnt -= 8; + } + } + + /// Write a canonical Huffman `code` of `len` bits. Canonical codes are defined + /// MSB-first, but the stream is LSB-first, so reverse the `len` bits. + fn write_code(&mut self, code: u32, len: u32) { + self.write_bits(reverse_bits(code, len), len); + } + + /// Flush any partial final octet (zero-padded) and return the buffer. + fn finish(mut self) -> Vec { + if self.bit_cnt > 0 { + self.out.push((self.bit_buf & 0xff) as u8); + } + self.out + } +} + +/// Reverse the low `len` bits of `code`. +fn reverse_bits(mut code: u32, len: u32) -> u32 { + let mut r = 0u32; + for _ in 0..len { + r = (r << 1) | (code & 1); + code >>= 1; + } + r +} + +/// The fixed-Huffman literal/length code for symbol `sym` (0..=287), as +/// `(code, bit_length)` with `code` in canonical MSB-first form. Computed directly +/// from the RFC 1951 §3.2.6 length assignment. +fn fixed_ll_code(sym: u16) -> (u32, u32) { + match sym { + // 7-bit codes, values 0b0000000..0b0010111. + 256..=279 => ((sym - 256) as u32, 7), + // 8-bit codes, values 0b00110000..0b10111111. + 0..=143 => (0x30 + sym as u32, 8), + // 8-bit codes, values 0b11000000..0b11000111. + 280..=287 => (0xc0 + (sym - 280) as u32, 8), + // 9-bit codes, values 0b110010000..0b111111111. + _ => (0x190 + (sym - 144) as u32, 9), // 144..=255 + } +} + +/// Emit one literal byte using the fixed-Huffman literal/length code. +fn emit_literal(w: &mut BitWriter, byte: u8) { + let (code, len) = fixed_ll_code(byte as u16); + w.write_code(code, len); +} + +/// Emit a length/distance back-reference: length symbol + extra bits, then +/// distance symbol (fixed 5-bit code) + extra bits. +fn emit_match(w: &mut BitWriter, length: usize, dist: usize) { + // Length symbol: largest base <= length. + let mut li = LENGTH_BASE.len() - 1; + while LENGTH_BASE[li] as usize > length { + li -= 1; + } + let (code, len) = fixed_ll_code(257 + li as u16); + w.write_code(code, len); + w.write_bits((length - LENGTH_BASE[li] as usize) as u32, LENGTH_EXTRA[li] as u32); + + // Distance symbol: largest base <= dist. Fixed distance codes are the 5-bit + // canonical codes, i.e. code == symbol. + let mut di = DIST_BASE.len() - 1; + while DIST_BASE[di] as usize > dist { + di -= 1; + } + w.write_code(di as u32, 5); + w.write_bits((dist - DIST_BASE[di] as usize) as u32, DIST_EXTRA[di] as u32); +} + +// ---- Greedy LZ77 hash-chain match finder ---- + +const MIN_MATCH: usize = 3; +const MAX_MATCH: usize = 258; +/// The DEFLATE 32 KiB sliding window. +const WINDOW: usize = 32_768; +/// Hash table size (2^13). A modest table: collisions only cost a little ratio, +/// and this keeps the transient encode allocation to ~32 KiB. +const HASH_BITS: u32 = 13; +const HASH_SIZE: usize = 1 << HASH_BITS; +/// Cap on hash-chain traversal per position (bounds worst-case encode time). +const MAX_CHAIN: usize = 128; +/// Sentinel for "no position" in the hash head/prev tables. +const NIL: u32 = u32::MAX; + +/// Hash three bytes starting at `data[i]` into the hash table index. +#[inline] +fn hash3(data: &[u8], i: usize) -> usize { + let h = ((data[i] as u32) << 10) ^ ((data[i + 1] as u32) << 5) ^ (data[i + 2] as u32); + (h & (HASH_SIZE as u32 - 1)) as usize +} + +/// Compress `data` into a zlib stream (RFC 1950) that any zlib inflater — miniz, +/// zlib, BPQ's `doinflate` — accepts. Mirrors the C# `NetRomCompression.Compress`. +/// +/// Greedy LZ77 with a hash-chain match finder, emitting a single fixed-Huffman +/// block. The ratio is "useful", not optimal; the encoder is deliberately compact. +pub fn zlib_compress(data: &[u8]) -> Vec { + // ---- RFC 1950 header: CM=8 (DEFLATE), CINFO=7 (32 KiB window), FDICT=0. ---- + // FLG = 0x01 makes (0x78<<8 | 0x01) % 31 == 0 with FLEVEL=0; the same header + // shape the C# reference asserts (first byte 0x78, header % 31 == 0). + let mut out: Vec = vec![0x78, 0x01]; + + let deflate_body = deflate_fixed(data); + out.extend_from_slice(&deflate_body); + + // ---- RFC 1950 trailer: Adler-32 of the uncompressed data, big-endian. ---- + let checksum = adler32(data); + out.push((checksum >> 24) as u8); + out.push((checksum >> 16) as u8); + out.push((checksum >> 8) as u8); + out.push(checksum as u8); + + out +} + +/// Produce a single fixed-Huffman DEFLATE block (BFINAL=1) for `data`. +fn deflate_fixed(data: &[u8]) -> Vec { + let mut w = BitWriter::new(); + // Block header: BFINAL=1, BTYPE=01 (fixed Huffman), LSB-first. + w.write_bits(1, 1); + w.write_bits(1, 2); + + let n = data.len(); + if n == 0 { + // Just the end-of-block symbol. + let (code, len) = fixed_ll_code(256); + w.write_code(code, len); + return w.finish(); + } + + // Hash chains: `head[h]` = most recent position with hash h; `prev[p]` = the + // position with the same hash immediately before p. + let mut head = vec![NIL; HASH_SIZE]; + let mut prev = vec![NIL; n]; + + let mut i = 0usize; + while i < n { + let (mut best_len, mut best_dist) = (0usize, 0usize); + + if i + MIN_MATCH <= n { + let h = hash3(data, i); + let window_start = i.saturating_sub(WINDOW); + let max_len = core::cmp::min(MAX_MATCH, n - i); + let mut cur = head[h]; + let mut chain = MAX_CHAIN; + while cur != NIL { + let j = cur as usize; + if j < window_start { + break; // older than the window; chain is strictly decreasing + } + // Extend the match at j vs i. + let mut l = 0usize; + while l < max_len && data[j + l] == data[i + l] { + l += 1; + } + if l > best_len { + best_len = l; + best_dist = i - j; + if l >= max_len { + break; // can't do better than the maximum + } + } + chain -= 1; + if chain == 0 { + break; + } + cur = prev[j]; + } + } + + if best_len >= MIN_MATCH { + emit_match(&mut w, best_len, best_dist); + // Insert every position the match covers so later matches can find them. + let end = i + best_len; + while i < end { + if i + MIN_MATCH <= n { + let h = hash3(data, i); + prev[i] = head[h]; + head[h] = i as u32; + } + i += 1; + } + } else { + emit_literal(&mut w, data[i]); + if i + MIN_MATCH <= n { + let h = hash3(data, i); + prev[i] = head[h]; + head[h] = i as u32; + } + i += 1; + } + } + + // End-of-block. + let (code, len) = fixed_ll_code(256); + w.write_code(code, len); + w.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + + // --- Corpus of inputs exercised by the round-trip / oracle tests. --- + fn corpus() -> Vec> { + // Realistic NET/ROM-ish text (repeats compress well). + let text = b"GB7RDG:G8PZT-1} NET/ROM node broadcast: nodes RDGBBS via GB7RDG-7 \ + quality 192, connect request from M0LTE-7 to G8PZT, more follows. "; + let mut realistic = Vec::new(); + for _ in 0..40 { + realistic.extend_from_slice(text); + } + // Pseudo-random / incompressible-ish (deterministic LCG). + let mut rng: u32 = 0x1234_5678; + let mut random = Vec::new(); + for _ in 0..2000 { + rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + random.push((rng >> 24) as u8); + } + // Mixed: text + run + text (forces multiple match/literal transitions). + let mut mixed = text.to_vec(); + mixed.extend_from_slice(&vec![0x7Eu8; 500]); + mixed.extend_from_slice(text); + + vec![ + Vec::new(), // empty + vec![0x42], // one byte + vec![0xAA; 1], // one byte, different value + vec![b'A'; 5000], // highly repetitive (long RLE runs) + vec![0u8; 300], // zeros + realistic, // long repeated realistic text + text.to_vec(), // short realistic single copy + random, // incompressible-ish + mixed, // mixed match/literal transitions + ] + } + + // --------------------------------------------------------------- + // 1. Self round-trip: inflate(deflate(x)) == x. + // --------------------------------------------------------------- + #[test] + fn self_round_trip() { + for input in corpus() { + let compressed = zlib_compress(&input); + let restored = zlib_decompress(&compressed, 1 << 20) + .expect("our inflate must read our own deflate"); + assert_eq!(restored, input, "round-trip mismatch (len {})", input.len()); + } + } + + #[test] + fn our_output_has_zlib_framing() { + // Matches the C# NetRomCompressionTests framing assertions. + let c = zlib_compress(b"hello world hello world"); + assert_eq!(c[0], 0x78, "CMF must be 0x78 (CM=8, CINFO=7)"); + assert_eq!( + (((c[0] as u16) << 8) | c[1] as u16) % 31, + 0, + "zlib header must satisfy the mod-31 check" + ); + } + + // --------------------------------------------------------------- + // 2. Oracle A: miniz_oxide inflates OUR deflate output. + // --------------------------------------------------------------- + #[test] + fn oracle_miniz_reads_our_output() { + for input in corpus() { + let ours = zlib_compress(&input); + let via_miniz = miniz_oxide::inflate::decompress_to_vec_zlib(&ours) + .expect("miniz must inflate our zlib output"); + assert_eq!(via_miniz, input, "miniz round-trip mismatch (len {})", input.len()); + } + } + + // --------------------------------------------------------------- + // 3. Oracle B: OUR inflate reads miniz_oxide's deflate output, + // including dynamic-Huffman blocks (levels 6..=10) and stored. + // --------------------------------------------------------------- + #[test] + fn our_inflate_reads_miniz_output() { + for input in corpus() { + // Level 0 tends to stored blocks; 6/9/10 exercise dynamic Huffman. + for level in [0u8, 1, 6, 9, 10] { + let miniz = miniz_oxide::deflate::compress_to_vec_zlib(&input, level); + let ours = zlib_decompress(&miniz, 1 << 20) + .unwrap_or_else(|e| panic!("our inflate failed on miniz L{level}: {e:?}")); + assert_eq!(ours, input, "mismatch inflating miniz L{level} (len {})", input.len()); + } + } + } + + #[test] + fn our_inflate_reads_dynamic_huffman() { + // Explicitly assert the miniz stream we read back is a DYNAMIC block + // (BTYPE=10), so this test genuinely covers the dynamic decoder. + let text = b"the quick brown fox jumps over the lazy dog; \ + the quick brown fox jumps over the lazy dog; pack my box."; + let mut input = Vec::new(); + for _ in 0..30 { + input.extend_from_slice(text); + } + let miniz = miniz_oxide::deflate::compress_to_vec_zlib(&input, 9); + // Inspect first block type: header byte 2 is the first DEFLATE byte; + // bit0 = BFINAL, bits1-2 = BTYPE (LSB-first). + let first = miniz[2]; + let btype = (first >> 1) & 0b11; + assert_eq!(btype, 0b10, "expected miniz L9 to emit a dynamic-Huffman block"); + let ours = zlib_decompress(&miniz, 1 << 20).expect("inflate dynamic block"); + assert_eq!(ours, input); + } + + // --------------------------------------------------------------- + // 4a. Adler-32 known vectors. + // --------------------------------------------------------------- + #[test] + fn adler32_vectors() { + assert_eq!(adler32(b""), 1); + assert_eq!(adler32(b"a"), 0x0062_0062); + assert_eq!(adler32(b"abc"), 0x024D_0127); + assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398); + } + + // --------------------------------------------------------------- + // 4b. Fail-closed: corrupt / bad-header / bad-checksum / cap. + // --------------------------------------------------------------- + #[test] + fn rejects_too_short() { + assert_eq!(zlib_decompress(&[], DEFAULT_MAX_INFLATE), Err(ZlibError::Truncated)); + assert_eq!(zlib_decompress(&[0x78], DEFAULT_MAX_INFLATE), Err(ZlibError::Truncated)); + } + + #[test] + fn rejects_bad_header() { + // CM != 8. + let mut s = zlib_compress(b"data data data"); + s[0] = 0x77; // CM=7 + assert_eq!(zlib_decompress(&s, DEFAULT_MAX_INFLATE), Err(ZlibError::BadHeader)); + + // Broken mod-31 check. + let mut s2 = zlib_compress(b"data data data"); + s2[1] = s2[1].wrapping_add(1); + assert_eq!(zlib_decompress(&s2, DEFAULT_MAX_INFLATE), Err(ZlibError::BadHeader)); + + // CINFO > 7 (window too large). + let mut s3 = zlib_compress(b"data data data"); + s3[0] = 0x88; // CINFO=8, CM=8 + // (may fail either the CINFO check or the mod-31 check — both are BadHeader) + assert_eq!(zlib_decompress(&s3, DEFAULT_MAX_INFLATE), Err(ZlibError::BadHeader)); + + // FDICT set. + let mut s4 = zlib_compress(b"data data data"); + // Set FDICT (bit 5 of FLG) and fix the mod-31 check. + s4[1] |= 0x20; + // Recompute FCHECK so the header still passes mod-31, isolating FDICT. + let base = ((s4[0] as u16) << 8) | (s4[1] as u16 & 0xE0); // keep CM/CINFO + FLEVEL+FDICT + let rem = base % 31; + let fcheck = if rem == 0 { 0 } else { 31 - rem }; + s4[1] = (s4[1] & 0xE0) | fcheck as u8; + assert_eq!(zlib_decompress(&s4, DEFAULT_MAX_INFLATE), Err(ZlibError::BadHeader)); + } + + #[test] + fn rejects_bad_checksum() { + let mut s = zlib_compress(b"the quick brown fox"); + let n = s.len(); + s[n - 1] ^= 0xFF; // corrupt the Adler-32 trailer + assert_eq!(zlib_decompress(&s, DEFAULT_MAX_INFLATE), Err(ZlibError::BadChecksum)); + } + + #[test] + fn rejects_corrupt_body() { + let s = zlib_compress(b"the quick brown fox jumps over the lazy dog"); + // Flip bits in the DEFLATE body (byte 4, past the header). This should + // produce a malformed stream or a checksum failure — never a panic, never Ok. + let mut corrupt = s.clone(); + corrupt[4] ^= 0xFF; + let r = zlib_decompress(&corrupt, DEFAULT_MAX_INFLATE); + assert!(r.is_err(), "corrupt body must fail closed, got {r:?}"); + } + + #[test] + fn cap_exceeded_fails_closed() { + // > 8 KiB of highly compressible data: compresses tiny, inflates past cap. + let big = vec![b'Z'; 20_000]; + let compressed = zlib_compress(&big); + assert!(compressed.len() < big.len()); + // With the 8 KiB cap it must fail closed... + assert_eq!( + zlib_decompress(&compressed, DEFAULT_MAX_INFLATE), + Err(ZlibError::CapExceeded) + ); + // ...but a generous cap decodes it fine (proves cap is the only reason). + let ok = zlib_decompress(&compressed, 50_000).expect("decodes under a large cap"); + assert_eq!(ok, big); + } + + #[test] + fn cap_boundary_exact() { + // Exactly-cap-sized output must succeed; one over must fail. + let data = vec![b'q'; 1000]; + let c = zlib_compress(&data); + assert!(zlib_decompress(&c, 1000).is_ok(), "exact cap must succeed"); + assert_eq!(zlib_decompress(&c, 999), Err(ZlibError::CapExceeded)); + } + + #[test] + fn miniz_stored_incompressible_via_our_inflate() { + // Random data at level 1 often lands in stored blocks; assert we read it. + let mut rng: u32 = 0xDEAD_BEEF; + let mut random = Vec::new(); + for _ in 0..4096 { + rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + random.push((rng >> 16) as u8); + } + let miniz = miniz_oxide::deflate::compress_to_vec_zlib(&random, 0); + let ours = zlib_decompress(&miniz, 1 << 20).expect("read miniz stored blocks"); + assert_eq!(ours, random); + } +} diff --git a/crates/ax25-node-core/src/netrom/transport/mod.rs b/crates/ax25-node-core/src/netrom/transport/mod.rs index c15a4ba..617fa07 100644 --- a/crates/ax25-node-core/src/netrom/transport/mod.rs +++ b/crates/ax25-node-core/src/netrom/transport/mod.rs @@ -10,6 +10,17 @@ pub mod circuit; pub mod circuit_manager; pub mod circuit_options; pub mod circuit_state; +/// The parity-named (de)compressor the circuit calls over [`deflate`] — the +/// compress / try_decompress seam for BPQ `L4Compress` interop, with the +/// 8 KiB fail-closed cap. Gated behind `netrom-compress`. Mirrors C# +/// `Packet.NetRom.Transport.NetRomCompression`. +#[cfg(feature = "netrom-compress")] +pub mod compression; +/// The NET/ROM L4 payload (de)compression codec (zlib / RFC 1950 + DEFLATE / +/// RFC 1951), for BPQ `L4Compress` interop. Gated behind the `netrom-compress` +/// cargo feature so the default on-target build carries no compression code. +#[cfg(feature = "netrom-compress")] +pub mod deflate; pub mod inp3_engine; pub mod inp3_update_scheduler; diff --git a/crates/ax25-node-core/src/netrom/wire/connect_ack_info.rs b/crates/ax25-node-core/src/netrom/wire/connect_ack_info.rs new file mode 100644 index 0000000..3d26bab --- /dev/null +++ b/crates/ax25-node-core/src/netrom/wire/connect_ack_info.rs @@ -0,0 +1,81 @@ +//! Codec for the information field of a NET/ROM L4 Connect Acknowledge (opcode +//! 0x02) in the LinBPQ **extended** form. Vanilla NET/ROM sends a Connect +//! Acknowledge with an empty info field; LinBPQ, when the Connect Request came +//! from a BPQ node that offered compression, replies with two octets — the +//! accepted send-window and a time-to-live/flags octet — and folds its +//! compression-agreed bit into the latter. +//! +//! Wire layout (LinBPQ `L4Code.c` Connect Acknowledge build), 2 octets: +//! ```text +//! [1] accepted send-window size +//! [1] TTL byte; bit 0x80 = "compression agreed" (L4DATA[1] |= 0x80) +//! ``` +//! +//! The bit is only ever set when *both* ends offered compression. On receipt the +//! originator masks it off before reading the TTL (`L4DATA[1] &= 0x7f`), so it is +//! harmless to a peer that ignores it. A **declining** acknowledge is the vanilla +//! empty-info form (byte-for-byte plain NET/ROM), so a non-compressing circuit +//! never emits this extension. +//! +//! Ports `Packet.NetRom.Wire.ConnectAckInfo`. `no_std`, allocation-free (the +//! 2-octet extension is returned by value). Gated behind the `netrom-compress` +//! feature so the default on-target build carries no compression surface. + +/// Octets in the LinBPQ extended Connect Acknowledge info field. +pub const CONNECT_ACK_INFO_EXTENDED_LEN: usize = 2; + +/// The "compression agreed" bit, OR-ed into the TTL octet of an extended Connect +/// Acknowledge (LinBPQ `L4Code.c`: `L3MSG->L4DATA[1] |= 0x80`). Mirrors C# +/// `ConnectAckInfo.CompressBit`. +pub const CONNECT_ACK_COMPRESS_BIT: u8 = 0x80; + +/// Codec for the extended Connect Acknowledge info field. A unit type carrying the +/// two associated functions, mirroring the C# `static class ConnectAckInfo`. +pub struct ConnectAckInfo; + +impl ConnectAckInfo { + /// Build the extended Connect Acknowledge info field when + /// `agree_compression` is set: `[accepted_window, time_to_live | 0x80]`. + /// Returns `None` for the vanilla (declining) form so the caller emits an + /// empty info field — a circuit that did not negotiate compression stays + /// byte-for-byte the plain NET/ROM Connect Acknowledge. Mirrors C# + /// `ConnectAckInfo.Build` (which returns `[]` when not agreeing). + pub fn encode( + accepted_window: u8, + time_to_live: u8, + agree_compression: bool, + ) -> Option<[u8; CONNECT_ACK_INFO_EXTENDED_LEN]> { + if !agree_compression { + return None; + } + Some([accepted_window, time_to_live | CONNECT_ACK_COMPRESS_BIT]) + } + + /// Read the BPQ compression-agreed bit from a Connect Acknowledge info field. + /// Returns `false` for the empty / short (vanilla) form. Mirrors C# + /// `ConnectAckInfo.AgreesCompression`. + pub fn agrees_compression(info: &[u8]) -> bool { + info.len() >= CONNECT_ACK_INFO_EXTENDED_LEN && (info[1] & CONNECT_ACK_COMPRESS_BIT) != 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agreeing_ack_carries_window_and_ttl_with_the_bit() { + let info = ConnectAckInfo::encode(4, 10, true).unwrap(); + assert_eq!(info[0], 4); + assert_eq!(info[1] & 0x7F, 10); // TTL survives under the masked-off bit + assert!(ConnectAckInfo::agrees_compression(&info)); + } + + #[test] + fn declining_ack_is_the_empty_vanilla_form() { + assert!(ConnectAckInfo::encode(4, 10, false).is_none()); + // The vanilla empty info field agrees to nothing. + assert!(!ConnectAckInfo::agrees_compression(&[])); + assert!(!ConnectAckInfo::agrees_compression(&[4])); // too short + } +} diff --git a/crates/ax25-node-core/src/netrom/wire/connect_request_info.rs b/crates/ax25-node-core/src/netrom/wire/connect_request_info.rs index 5b64c96..a60be9d 100644 --- a/crates/ax25-node-core/src/netrom/wire/connect_request_info.rs +++ b/crates/ax25-node-core/src/netrom/wire/connect_request_info.rs @@ -25,6 +25,20 @@ use crate::ax25::Callsign; /// callsigns). A peer may append extension octets after these. pub const CONNECT_REQUEST_INFO_LEN: usize = 1 + SHIFTED_LENGTH + SHIFTED_LENGTH; // 15 +/// Octets in the LinBPQ "extended connect" form: the canonical 15 plus a 2-octet +/// trailer carrying the proposed session timer (T1, little-endian) — and, in the +/// high byte of that timer, the BPQ compression-supported bit. Gated behind +/// `netrom-compress`. Mirrors C# `ConnectRequestInfo.ExtendedLength`. +#[cfg(feature = "netrom-compress")] +pub const CONNECT_REQUEST_INFO_EXTENDED_LEN: usize = CONNECT_REQUEST_INFO_LEN + 2; // 17 + +/// The BPQ "compression supported" bit, OR-ed into the **high** byte of the +/// trailing T1 timer of an extended Connect Request (LinBPQ `L4Code.c`: +/// `MSG->L4DATA[16] |= 0x40`). The receiver masks it off (`BPQPARAMS[1] &= 0xf`) +/// before reading the timer. Mirrors C# `ConnectRequestInfo.CompressBit`. +#[cfg(feature = "netrom-compress")] +pub const CONNECT_REQUEST_COMPRESS_BIT: u8 = 0x40; + /// The parsed Connect Request info: the proposed window + the originating /// user/node callsigns. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -51,6 +65,47 @@ impl ConnectRequestInfo { Some(()) } + /// Build the LinBPQ **extended** Connect Request info field into the front of + /// `dst` (≥ [`CONNECT_REQUEST_INFO_EXTENDED_LEN`]): the canonical 15 octets + /// followed by the 2-octet T1 timer trailer, with the compression-supported bit + /// ([`CONNECT_REQUEST_COMPRESS_BIT`]) OR-ed into the timer's high byte when + /// `offer_compression` is set. This is the exact shape LinBPQ both originates and + /// parses. A peer that ignores the trailer (vanilla NET/ROM, or pico with + /// compression off) simply sees a normal Connect Request (the trailer is beyond + /// the 15 octets [`decode`](Self::decode) reads). Returns `None` only if `dst` is + /// too short. Mirrors C# `ConnectRequestInfo.BuildExtended`. + #[cfg(feature = "netrom-compress")] + pub fn encode_extended( + &self, + dst: &mut [u8], + timer_seconds: u16, + offer_compression: bool, + ) -> Option<()> { + if dst.len() < CONNECT_REQUEST_INFO_EXTENDED_LEN { + return None; + } + dst[0] = self.proposed_window; + write_shifted(&self.originating_user, &mut dst[1..])?; + write_shifted(&self.originating_node, &mut dst[1 + SHIFTED_LENGTH..])?; + dst[CONNECT_REQUEST_INFO_LEN] = (timer_seconds & 0xFF) as u8; // T1 low + let mut hi = ((timer_seconds >> 8) & 0x0F) as u8; // T1 high — only low nibble is the timer + if offer_compression { + hi |= CONNECT_REQUEST_COMPRESS_BIT; + } + dst[CONNECT_REQUEST_INFO_LEN + 1] = hi; + Some(()) + } + + /// Read the BPQ compression-supported bit from a Connect Request info field, if + /// the peer sent the extended (≥ 17-octet) form. Returns `false` for the + /// canonical 15-octet form (no trailer ⇒ no offer). Mirrors C# + /// `ConnectRequestInfo.OffersCompression`. + #[cfg(feature = "netrom-compress")] + pub fn offers_compression(info: &[u8]) -> bool { + info.len() >= CONNECT_REQUEST_INFO_EXTENDED_LEN + && (info[CONNECT_REQUEST_INFO_LEN + 1] & CONNECT_REQUEST_COMPRESS_BIT) != 0 + } + /// Parse the proposed window + originating user/node from a Connect Request /// info field. Total: returns `None` if the field is shorter than the 15-octet /// canonical layout or a callsign is undecodable. Trailing octets beyond the @@ -68,3 +123,60 @@ impl ConnectRequestInfo { }) } } + +#[cfg(all(test, feature = "netrom-compress"))] +mod extended_tests { + use super::*; + use crate::ax25::Callsign; + + fn cs(b: &[u8]) -> Callsign { + Callsign::new(b, 0).unwrap() + } + + #[test] + fn extended_form_is_17_octets_and_offers_when_asked() { + let cri = ConnectRequestInfo { + proposed_window: 4, + originating_user: cs(b"M0LTE"), + originating_node: cs(b"GB7RDG"), + }; + let mut buf = [0u8; CONNECT_REQUEST_INFO_EXTENDED_LEN]; + cri.encode_extended(&mut buf, 60, true).unwrap(); + + // Canonical 15-octet prefix round-trips through the (unchanged) decoder. + let parsed = ConnectRequestInfo::decode(&buf).unwrap(); + assert_eq!(parsed, cri); + + // T1 trailer: low byte = 60, high byte's low nibble = 0, compress bit set. + assert_eq!(buf[CONNECT_REQUEST_INFO_LEN], 60); + assert_eq!(buf[CONNECT_REQUEST_INFO_LEN + 1] & 0x0F, 0); + assert!(ConnectRequestInfo::offers_compression(&buf)); + } + + #[test] + fn extended_without_offer_clears_the_bit() { + let cri = ConnectRequestInfo { + proposed_window: 4, + originating_user: cs(b"M0LTE"), + originating_node: cs(b"GB7RDG"), + }; + let mut buf = [0u8; CONNECT_REQUEST_INFO_EXTENDED_LEN]; + cri.encode_extended(&mut buf, 0x123, false).unwrap(); + // Timer 0x123 → low nibble of high byte carries 0x1, no compress bit. + assert_eq!(buf[CONNECT_REQUEST_INFO_LEN], 0x23); + assert_eq!(buf[CONNECT_REQUEST_INFO_LEN + 1], 0x01); + assert!(!ConnectRequestInfo::offers_compression(&buf)); + } + + #[test] + fn canonical_15_octet_form_offers_nothing() { + let cri = ConnectRequestInfo { + proposed_window: 4, + originating_user: cs(b"M0LTE"), + originating_node: cs(b"GB7RDG"), + }; + let mut buf = [0u8; CONNECT_REQUEST_INFO_LEN]; + cri.encode(&mut buf).unwrap(); + assert!(!ConnectRequestInfo::offers_compression(&buf)); + } +} diff --git a/crates/ax25-node-core/src/netrom/wire/mod.rs b/crates/ax25-node-core/src/netrom/wire/mod.rs index ed440bf..335a9df 100644 --- a/crates/ax25-node-core/src/netrom/wire/mod.rs +++ b/crates/ax25-node-core/src/netrom/wire/mod.rs @@ -18,6 +18,11 @@ pub mod broadcast; pub mod callsign; +/// The LinBPQ extended Connect Acknowledge info field (window / TTL + the +/// compression-agreed bit). Gated behind `netrom-compress`. Mirrors C# +/// `Packet.NetRom.Wire.ConnectAckInfo`. +#[cfg(feature = "netrom-compress")] +pub mod connect_ack_info; pub mod connect_request_info; pub mod entry; pub mod inp3_l3rtt; @@ -33,6 +38,10 @@ pub use broadcast::NodesBroadcast; pub use callsign::{ read_alias, try_read_shifted, write_alias, write_shifted, Alias, ALIAS_LENGTH, SHIFTED_LENGTH, }; +#[cfg(feature = "netrom-compress")] +pub use connect_ack_info::{ConnectAckInfo, CONNECT_ACK_INFO_EXTENDED_LEN}; +#[cfg(feature = "netrom-compress")] +pub use connect_request_info::CONNECT_REQUEST_INFO_EXTENDED_LEN; pub use connect_request_info::{ConnectRequestInfo, CONNECT_REQUEST_INFO_LEN}; pub use entry::NodesRoutingEntry; pub use network_header::{NetRomNetworkHeader, DEFAULT_TIME_TO_LIVE, NETWORK_HEADER_LEN}; @@ -41,6 +50,8 @@ pub use nodes_broadcast_builder::{ }; pub use options::NetRomParseOptions; pub use packet::{NetRomPacket, MAX_PAYLOAD, PACKET_HEADER_LEN}; +#[cfg(feature = "netrom-compress")] +pub use transport_header::FLAG_COMPRESSED; pub use transport_header::{ NetRomOpcode, NetRomTransportHeader, FLAGS_MASK, FLAG_CHOKE, FLAG_MORE_FOLLOWS, FLAG_NAK, OPCODE_MASK, TRANSPORT_HEADER_LEN, diff --git a/crates/ax25-node-core/src/netrom/wire/transport_header.rs b/crates/ax25-node-core/src/netrom/wire/transport_header.rs index 5f9b6f5..a7c88fc 100644 --- a/crates/ax25-node-core/src/netrom/wire/transport_header.rs +++ b/crates/ax25-node-core/src/netrom/wire/transport_header.rs @@ -28,6 +28,17 @@ pub const OPCODE_MASK: u8 = 0x0F; /// The high bits of the opcode-and-flags byte (the flow-control flags). pub const FLAGS_MASK: u8 = 0xF0; +/// Compressed (bit 4): a **BPQ-specific** extension flag marking an Information +/// message whose payload is a zlib / RFC 1950 compressed stream rather than raw +/// user data — LinBPQ `L4COMP` in `asmstrucs.h`. Only ever set on a circuit where +/// both ends negotiated compression at connect time (the `L4Compress` capability +/// handshake), so a peer that did not agree never receives it. Reassemble all the +/// [`FLAG_MORE_FOLLOWS`] fragments first, then inflate the concatenation as one +/// stream. Gated behind the `netrom-compress` feature so the default on-target +/// build carries no compression surface. Mirrors C# `NetRomTransportFlags.Compressed`. +#[cfg(feature = "netrom-compress")] +pub const FLAG_COMPRESSED: u8 = 0x10; + /// More-follows (bit 5): this Information message is a non-final fragment of a /// logical frame larger than one 236-byte payload. pub const FLAG_MORE_FOLLOWS: u8 = 0x20; @@ -121,6 +132,13 @@ impl NetRomTransportHeader { self.flags & FLAG_MORE_FOLLOWS != 0 } + /// True if the compressed flag (bit 4) is set — the payload is a zlib stream + /// (only on a compression-negotiated circuit). Gated behind `netrom-compress`. + #[cfg(feature = "netrom-compress")] + pub const fn compressed(&self) -> bool { + self.flags & FLAG_COMPRESSED != 0 + } + /// The raw opcode-and-flags byte (opcode nibble OR-ed with the flag bits). pub const fn opcode_and_flags(&self) -> u8 { (self.opcode & OPCODE_MASK) | (self.flags & FLAGS_MASK)