Skip to main content

rns_net/interface/i2p/
mod.rs

1//! I2P interface using the SAM v3.1 protocol.
2//!
3//! Provides anonymous transport over the I2P network. The interface manages
4//! both outbound peer connections and inbound connection acceptance.
5//! Each peer connection becomes a dynamic interface with HDLC framing,
6//! similar to TCP server client connections.
7//!
8//! Matches Python `I2PInterface` from `I2PInterface.py`.
9
10pub mod sam;
11
12use std::io::{self, Read, Write};
13use std::net::SocketAddr;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::{Arc, Mutex};
17use std::thread;
18use std::time::Duration;
19
20use rns_core::constants;
21use rns_core::transport::types::{InterfaceId, InterfaceInfo};
22
23use crate::event::{Event, EventSender};
24use crate::hdlc;
25use crate::interface::{lock_or_recover, Writer};
26
27use self::sam::{Destination, SamError};
28
29/// Hardware MTU for I2P streams (matches Python I2PInterface).
30const HW_MTU: usize = 1064;
31
32/// Estimated bitrate for I2P tunnels (256 kbps, matches Python).
33const BITRATE_GUESS: u64 = 256_000;
34
35/// Wait time before reconnecting to an outbound peer.
36const RECONNECT_WAIT: Duration = Duration::from_secs(15);
37
38/// Configuration for the I2P interface.
39#[derive(Debug, Clone)]
40pub struct I2pConfig {
41    pub name: String,
42    pub interface_id: InterfaceId,
43    /// SAM bridge host (default "127.0.0.1").
44    pub sam_host: String,
45    /// SAM bridge port (default 7656).
46    pub sam_port: u16,
47    /// List of .b32.i2p peer addresses (or full base64 destinations) to connect to.
48    pub peers: Vec<String>,
49    /// Whether to accept inbound connections.
50    pub connectable: bool,
51    /// Directory for key persistence (typically `{config_dir}/storage`).
52    pub storage_dir: PathBuf,
53    pub ingress_control: rns_core::transport::types::IngressControlConfig,
54    pub underlay_mark: Option<u32>,
55    pub runtime: Arc<Mutex<I2pRuntime>>,
56}
57
58#[derive(Debug, Clone)]
59pub struct I2pRuntime {
60    pub reconnect_wait: Duration,
61}
62
63impl I2pRuntime {
64    pub fn from_config(_config: &I2pConfig) -> Self {
65        Self {
66            reconnect_wait: RECONNECT_WAIT,
67        }
68    }
69}
70
71#[derive(Debug, Clone)]
72pub struct I2pRuntimeConfigHandle {
73    pub interface_name: String,
74    pub runtime: Arc<Mutex<I2pRuntime>>,
75    pub startup: I2pRuntime,
76}
77
78impl Default for I2pConfig {
79    fn default() -> Self {
80        let mut config = I2pConfig {
81            name: String::new(),
82            interface_id: InterfaceId(0),
83            sam_host: "127.0.0.1".into(),
84            sam_port: 7656,
85            peers: Vec::new(),
86            connectable: false,
87            storage_dir: PathBuf::from("."),
88            ingress_control: rns_core::transport::types::IngressControlConfig::enabled(),
89            underlay_mark: None,
90            runtime: Arc::new(Mutex::new(I2pRuntime {
91                reconnect_wait: RECONNECT_WAIT,
92            })),
93        };
94        let startup = I2pRuntime::from_config(&config);
95        config.runtime = Arc::new(Mutex::new(startup));
96        config
97    }
98}
99
100/// Writer that sends HDLC-framed data over an I2P SAM stream.
101struct I2pWriter {
102    stream: std::net::TcpStream,
103}
104
105impl Writer for I2pWriter {
106    fn send_frame(&mut self, data: &[u8]) -> io::Result<()> {
107        self.stream.write_all(&hdlc::frame(data))
108    }
109}
110
111/// Sanitize a name for use in filenames and SAM session IDs.
112fn sanitize_name(name: &str) -> String {
113    name.chars()
114        .map(|c| {
115            if c.is_alphanumeric() || c == '-' || c == '_' {
116                c
117            } else {
118                '_'
119            }
120        })
121        .collect()
122}
123
124/// Key file path for the given interface name.
125fn key_file_path(storage_dir: &Path, name: &str) -> PathBuf {
126    storage_dir.join(format!("i2p_{}.key", sanitize_name(name)))
127}
128
129/// Load or generate an I2P keypair. Persists the private key to disk
130/// so the node keeps a stable I2P address across restarts.
131fn load_or_generate_keypair(
132    sam_addr: &SocketAddr,
133    storage_dir: &Path,
134    name: &str,
135    underlay_mark: Option<u32>,
136) -> Result<sam::KeyPair, SamError> {
137    let key_path = key_file_path(storage_dir, name);
138
139    if key_path.exists() {
140        // Load existing private key
141        let priv_data = std::fs::read(&key_path).map_err(SamError::Io)?;
142
143        // We need the public destination too. Create a temporary session to extract it,
144        // or we can derive it by creating a session and reading back the destination.
145        // Simpler: store the full keypair. Let's re-generate from the private key
146        // by creating a session (the destination is embedded in the private key).
147        // Actually, the I2P private key blob contains the destination as a prefix.
148        // We can extract the destination from the private key.
149        // The destination is the first portion of the private key blob.
150        // For simplicity, store the full PRIV blob (which includes the destination).
151
152        // The PRIV blob from DEST GENERATE contains both the destination and private keys.
153        // The destination is extractable as the first ~387 bytes, but the exact size
154        // depends on the key type. For Ed25519 (sig type 7), the destination is:
155        //   256 bytes (encryption public key) + 128 bytes (signing public key) + 3 bytes (certificate)
156        // = 387 bytes. But this can vary with certificate content.
157
158        // Rather than parsing, we pass the full PRIV to SESSION CREATE (which accepts it)
159        // and then do a NAMING LOOKUP on "ME" to get our destination.
160
161        Ok(sam::KeyPair {
162            destination: Destination { data: Vec::new() }, // filled in later
163            private_key: priv_data,
164        })
165    } else {
166        // Generate new keypair
167        log::info!("[{}] generating new I2P destination keypair", name);
168        let keypair = sam::dest_generate_with_mark(sam_addr, underlay_mark)?;
169
170        // Save private key to disk
171        if let Some(parent) = key_path.parent() {
172            std::fs::create_dir_all(parent).map_err(SamError::Io)?;
173        }
174        std::fs::write(&key_path, &keypair.private_key).map_err(SamError::Io)?;
175        log::info!("[{}] saved I2P key to {:?}", name, key_path);
176
177        Ok(keypair)
178    }
179}
180
181/// Start the I2P interface coordinator. All peer connections are registered
182/// as dynamic interfaces via InterfaceUp events.
183pub fn start(config: I2pConfig, tx: EventSender, next_id: Arc<AtomicU64>) -> io::Result<()> {
184    start_with_template(config, tx, next_id, None)
185}
186
187fn start_with_template(
188    config: I2pConfig,
189    tx: EventSender,
190    next_id: Arc<AtomicU64>,
191    dynamic_template: Option<super::DynamicInterfaceTemplate>,
192) -> io::Result<()> {
193    let name = config.name.clone();
194
195    thread::Builder::new()
196        .name(format!("i2p-coord-{}", config.interface_id.0))
197        .spawn(move || {
198            if let Err(e) = coordinator(config, tx, next_id, dynamic_template) {
199                log::error!("[{}] I2P coordinator failed: {}", name, e);
200            }
201        })?;
202
203    Ok(())
204}
205
206/// Coordinator thread: sets up SAM sessions and spawns peer threads.
207fn coordinator(
208    config: I2pConfig,
209    tx: EventSender,
210    next_id: Arc<AtomicU64>,
211    dynamic_template: Option<super::DynamicInterfaceTemplate>,
212) -> Result<(), SamError> {
213    let ingress_control = config.ingress_control;
214    let sam_addr: SocketAddr = format!("{}:{}", config.sam_host, config.sam_port)
215        .parse()
216        .map_err(|e| SamError::Io(io::Error::new(io::ErrorKind::InvalidInput, e)))?;
217
218    // Load or generate keypair
219    let keypair = load_or_generate_keypair(
220        &sam_addr,
221        &config.storage_dir,
222        &config.name,
223        config.underlay_mark,
224    )?;
225    let priv_b64 = sam::i2p_base64_encode(&keypair.private_key);
226
227    // We use a single session for all streams (outbound + inbound).
228    // Session ID must be unique; use the interface name sanitized.
229    let session_id = sanitize_name(&config.name);
230
231    log::info!("[{}] creating SAM session (id={})", config.name, session_id);
232    let mut control_socket =
233        sam::session_create_with_mark(&sam_addr, &session_id, &priv_b64, config.underlay_mark)?;
234
235    // Look up our own destination via NAMING LOOKUP "ME" on the session control socket.
236    // "ME" requires a session context on the same connection.
237    match sam::naming_lookup_on(&mut control_socket, "ME") {
238        Ok(our_dest) => {
239            let b32 = our_dest.base32_address();
240            log::info!("[{}] I2P address: {}", config.name, b32);
241        }
242        Err(e) => {
243            log::warn!("[{}] could not look up own destination: {}", config.name, e);
244        }
245    }
246
247    // Spawn outbound peer threads
248    for peer_addr in &config.peers {
249        let peer_addr = peer_addr.trim().to_string();
250        if peer_addr.is_empty() {
251            continue;
252        }
253
254        let tx2 = tx.clone();
255        let next_id2 = next_id.clone();
256        let sam_addr2 = sam_addr;
257        let session_id2 = session_id.clone();
258        let iface_name = config.name.clone();
259        let runtime = Arc::clone(&config.runtime);
260        let dynamic_template = dynamic_template.clone();
261
262        thread::Builder::new()
263            .name(format!("i2p-out-{}", peer_addr))
264            .spawn(move || {
265                outbound_peer_loop(OutboundPeerContext {
266                    sam_addr: sam_addr2,
267                    session_id: session_id2,
268                    peer_addr,
269                    iface_name,
270                    tx2,
271                    next_id2,
272                    runtime,
273                    ingress_control,
274                    dynamic_template,
275                    underlay_mark: config.underlay_mark,
276                });
277            })
278            .ok();
279    }
280
281    // Spawn acceptor thread if connectable
282    if config.connectable {
283        let tx2 = tx.clone();
284        let next_id2 = next_id.clone();
285        let sam_addr2 = sam_addr;
286        let session_id2 = session_id.clone();
287        let iface_name = config.name.clone();
288        let dynamic_template = dynamic_template.clone();
289
290        thread::Builder::new()
291            .name("i2p-acceptor".into())
292            .spawn(move || {
293                acceptor_loop(AcceptorContext {
294                    sam_addr: sam_addr2,
295                    session_id: session_id2,
296                    iface_name,
297                    tx: tx2,
298                    next_id: next_id2,
299                    ingress_control,
300                    dynamic_template,
301                    underlay_mark: config.underlay_mark,
302                });
303            })
304            .ok();
305    }
306
307    // The control socket (and this thread) must remain alive for the session's lifetime.
308    // We park the coordinator thread here. If control_socket drops, the session ends.
309    let _keep_alive = control_socket;
310    loop {
311        thread::sleep(Duration::from_secs(3600));
312    }
313}
314
315/// Outbound peer thread: connects to a remote I2P destination, runs HDLC reader loop.
316/// Reconnects on failure.
317struct OutboundPeerContext {
318    sam_addr: SocketAddr,
319    session_id: String,
320    peer_addr: String,
321    iface_name: String,
322    tx2: EventSender,
323    next_id2: Arc<AtomicU64>,
324    runtime: Arc<Mutex<I2pRuntime>>,
325    ingress_control: rns_core::transport::types::IngressControlConfig,
326    dynamic_template: Option<super::DynamicInterfaceTemplate>,
327    underlay_mark: Option<u32>,
328}
329
330fn outbound_peer_loop(context: OutboundPeerContext) {
331    let OutboundPeerContext {
332        sam_addr,
333        session_id,
334        peer_addr,
335        iface_name,
336        tx2: tx,
337        next_id2: next_id,
338        runtime,
339        ingress_control,
340        dynamic_template,
341        underlay_mark,
342    } = context;
343    loop {
344        log::info!("[{}] connecting to I2P peer {}", iface_name, peer_addr);
345
346        // Resolve .b32.i2p address if needed
347        let destination = if peer_addr.ends_with(".i2p") {
348            match sam::naming_lookup_with_mark(&sam_addr, &peer_addr, underlay_mark) {
349                Ok(dest) => dest.to_i2p_base64(),
350                Err(e) => {
351                    log::warn!("[{}] failed to resolve {}: {}", iface_name, peer_addr, e);
352                    thread::sleep(lock_or_recover(&runtime, "i2p runtime").reconnect_wait);
353                    continue;
354                }
355            }
356        } else {
357            // Assume it's already a full base64 destination
358            peer_addr.to_string()
359        };
360
361        // Connect via SAM
362        match sam::stream_connect_with_mark(&sam_addr, &session_id, &destination, underlay_mark) {
363            Ok(stream) => {
364                let client_id = InterfaceId(next_id.fetch_add(1, Ordering::Relaxed));
365
366                log::info!(
367                    "[{}] connected to I2P peer {} → id {}",
368                    iface_name,
369                    peer_addr,
370                    client_id.0
371                );
372
373                // Clone stream for writer/reader split
374                let writer_stream = match stream.try_clone() {
375                    Ok(s) => s,
376                    Err(e) => {
377                        log::warn!("[{}] failed to clone stream: {}", iface_name, e);
378                        thread::sleep(lock_or_recover(&runtime, "i2p runtime").reconnect_wait);
379                        continue;
380                    }
381                };
382
383                let writer: Box<dyn Writer> = Box::new(I2pWriter {
384                    stream: writer_stream,
385                });
386
387                let info = InterfaceInfo {
388                    id: client_id,
389                    name: format!("I2PInterface/{}", peer_addr),
390                    mode: constants::MODE_FULL,
391                    gravity: 0,
392                    recursive_prs: false,
393                    announces_from_internal: true,
394                    announces_to_internal: None,
395                    out_capable: true,
396                    in_capable: true,
397                    bitrate: Some(BITRATE_GUESS),
398                    airtime_profile: None,
399                    announce_rate_target: None,
400                    announce_rate_grace: 0,
401                    announce_rate_penalty: 0.0,
402                    announce_cap: constants::ANNOUNCE_CAP,
403                    is_local_client: false,
404                    wants_tunnel: false,
405                    tunnel_id: None,
406                    mtu: 65535,
407                    ia_freq: 0.0,
408                    ip_freq: 0.0,
409                    op_freq: 0.0,
410                    op_samples: 0,
411                    started: 0.0,
412                    ingress_control,
413                };
414
415                // Register dynamic interface
416                let event = if let Some(template) = &dynamic_template {
417                    Event::DynamicInterfaceUp {
418                        id: client_id,
419                        writer,
420                        registration: Box::new(template.registration(info)),
421                    }
422                } else {
423                    Event::InterfaceUp(client_id, Some(writer), Some(Box::new(info)))
424                };
425                if tx.send(event).is_err() {
426                    return; // Driver shut down
427                }
428
429                // Run HDLC reader loop (blocks until disconnect)
430                peer_reader_loop(
431                    stream,
432                    client_id,
433                    &iface_name,
434                    &tx,
435                    template_ifac_size(dynamic_template.as_ref()),
436                );
437
438                // Disconnected
439                let _ = tx.send(Event::InterfaceDown(client_id));
440                log::warn!(
441                    "[{}] I2P peer {} disconnected, reconnecting in {}s",
442                    iface_name,
443                    peer_addr,
444                    lock_or_recover(&runtime, "i2p runtime")
445                        .reconnect_wait
446                        .as_secs()
447                );
448            }
449            Err(e) => {
450                log::warn!(
451                    "[{}] failed to connect to I2P peer {}: {}",
452                    iface_name,
453                    peer_addr,
454                    e
455                );
456            }
457        }
458
459        thread::sleep(lock_or_recover(&runtime, "i2p runtime").reconnect_wait);
460    }
461}
462
463/// Acceptor thread: loops accepting inbound connections on the session.
464struct AcceptorContext {
465    sam_addr: SocketAddr,
466    session_id: String,
467    iface_name: String,
468    tx: EventSender,
469    next_id: Arc<AtomicU64>,
470    ingress_control: rns_core::transport::types::IngressControlConfig,
471    dynamic_template: Option<super::DynamicInterfaceTemplate>,
472    underlay_mark: Option<u32>,
473}
474
475fn acceptor_loop(context: AcceptorContext) {
476    let AcceptorContext {
477        sam_addr,
478        session_id,
479        iface_name,
480        tx,
481        next_id,
482        ingress_control,
483        dynamic_template,
484        underlay_mark,
485    } = context;
486
487    loop {
488        match sam::stream_accept_with_mark(&sam_addr, &session_id, underlay_mark) {
489            Ok((stream, remote_dest)) => {
490                let client_id = InterfaceId(next_id.fetch_add(1, Ordering::Relaxed));
491                let remote_b32 = remote_dest.base32_address();
492
493                log::info!(
494                    "[{}] accepted I2P connection from {} → id {}",
495                    iface_name,
496                    remote_b32,
497                    client_id.0
498                );
499
500                // Clone stream for writer/reader split
501                let writer_stream = match stream.try_clone() {
502                    Ok(s) => s,
503                    Err(e) => {
504                        log::warn!("[{}] failed to clone accepted stream: {}", iface_name, e);
505                        continue;
506                    }
507                };
508
509                let writer: Box<dyn Writer> = Box::new(I2pWriter {
510                    stream: writer_stream,
511                });
512
513                let info = InterfaceInfo {
514                    id: client_id,
515                    name: format!("I2PInterface/{}", remote_b32),
516                    mode: constants::MODE_FULL,
517                    gravity: 0,
518                    recursive_prs: false,
519                    announces_from_internal: true,
520                    announces_to_internal: None,
521                    out_capable: true,
522                    in_capable: true,
523                    bitrate: Some(BITRATE_GUESS),
524                    airtime_profile: None,
525                    announce_rate_target: None,
526                    announce_rate_grace: 0,
527                    announce_rate_penalty: 0.0,
528                    announce_cap: constants::ANNOUNCE_CAP,
529                    is_local_client: false,
530                    wants_tunnel: false,
531                    tunnel_id: None,
532                    mtu: 65535,
533                    ia_freq: 0.0,
534                    ip_freq: 0.0,
535                    op_freq: 0.0,
536                    op_samples: 0,
537                    started: 0.0,
538                    ingress_control,
539                };
540
541                // Register dynamic interface
542                let event = if let Some(template) = &dynamic_template {
543                    Event::DynamicInterfaceUp {
544                        id: client_id,
545                        writer,
546                        registration: Box::new(template.registration(info)),
547                    }
548                } else {
549                    Event::InterfaceUp(client_id, Some(writer), Some(Box::new(info)))
550                };
551                if tx.send(event).is_err() {
552                    return; // Driver shut down
553                }
554
555                // Spawn per-client reader thread (accepted peers don't reconnect)
556                let client_tx = tx.clone();
557                let client_name = iface_name.to_string();
558                let ifac_size = template_ifac_size(dynamic_template.as_ref());
559                thread::Builder::new()
560                    .name(format!("i2p-client-{}", client_id.0))
561                    .spawn(move || {
562                        peer_reader_loop(stream, client_id, &client_name, &client_tx, ifac_size);
563                        let _ = client_tx.send(Event::InterfaceDown(client_id));
564                    })
565                    .ok();
566            }
567            Err(e) => {
568                log::warn!("[{}] I2P accept failed: {}, retrying", iface_name, e);
569                thread::sleep(Duration::from_secs(1));
570            }
571        }
572    }
573}
574
575/// IFAC bytes a spawned peer connection may carry on the wire.
576fn template_ifac_size(template: Option<&super::DynamicInterfaceTemplate>) -> usize {
577    template
578        .and_then(|template| template.ifac.as_ref())
579        .map_or(0, |ifac| ifac.size)
580}
581
582/// HDLC decoder bounded by the I2P hardware MTU plus on-wire IFAC bytes.
583fn frame_decoder(ifac_size: usize) -> hdlc::Decoder {
584    hdlc::Decoder::reticulum(HW_MTU, ifac_size)
585}
586
587/// Per-peer HDLC reader loop. Reads from the SAM data stream, decodes HDLC
588/// frames, and sends them to the driver. Returns when the stream is closed.
589fn peer_reader_loop(
590    mut stream: std::net::TcpStream,
591    id: InterfaceId,
592    name: &str,
593    tx: &EventSender,
594    ifac_size: usize,
595) {
596    let mut decoder = frame_decoder(ifac_size);
597    let mut buf = [0u8; 4096];
598
599    loop {
600        match stream.read(&mut buf) {
601            Ok(0) => {
602                log::info!("[{}] I2P peer {} disconnected", name, id.0);
603                return;
604            }
605            Ok(n) => {
606                for frame in decoder.feed(&buf[..n]) {
607                    if tx
608                        .send(Event::Frame {
609                            interface_id: id,
610                            data: frame,
611                            rssi: None,
612                            snr: None,
613                        })
614                        .is_err()
615                    {
616                        return; // Driver shut down
617                    }
618                }
619            }
620            Err(e) => {
621                log::warn!("[{}] I2P peer {} read error: {}", name, id.0, e);
622                return;
623            }
624        }
625    }
626}
627
628// --- Factory implementation ---
629
630use super::{InterfaceConfigData, InterfaceFactory, StartContext, StartResult};
631use std::collections::HashMap;
632
633/// Factory for `I2PInterface`.
634pub struct I2pFactory;
635
636impl InterfaceFactory for I2pFactory {
637    fn type_name(&self) -> &str {
638        "I2PInterface"
639    }
640
641    fn parse_config(
642        &self,
643        name: &str,
644        id: InterfaceId,
645        params: &HashMap<String, String>,
646    ) -> Result<Box<dyn InterfaceConfigData>, String> {
647        let sam_host = params
648            .get("sam_host")
649            .cloned()
650            .unwrap_or_else(|| "127.0.0.1".into());
651
652        let sam_port = params
653            .get("sam_port")
654            .and_then(|v| v.parse::<u16>().ok())
655            .unwrap_or(7656);
656
657        let connectable = params
658            .get("connectable")
659            .and_then(|v| crate::config::parse_bool_pub(v))
660            .unwrap_or(false);
661
662        let peers = params
663            .get("peers")
664            .map(|v| {
665                v.split(',')
666                    .map(|s| s.trim().to_string())
667                    .filter(|s| !s.is_empty())
668                    .collect::<Vec<String>>()
669            })
670            .unwrap_or_default();
671
672        let storage_dir = params
673            .get("storage_dir")
674            .map(PathBuf::from)
675            .unwrap_or_else(|| PathBuf::from("/tmp/rns-i2p"));
676
677        Ok(Box::new(I2pConfig {
678            name: name.to_string(),
679            interface_id: id,
680            sam_host,
681            sam_port,
682            connectable,
683            peers,
684            storage_dir,
685            ingress_control: rns_core::transport::types::IngressControlConfig::enabled(),
686            underlay_mark: None,
687            runtime: Arc::new(Mutex::new(I2pRuntime {
688                reconnect_wait: RECONNECT_WAIT,
689            })),
690        }))
691    }
692
693    fn start(
694        &self,
695        config: Box<dyn InterfaceConfigData>,
696        ctx: StartContext,
697    ) -> io::Result<StartResult> {
698        let mut cfg = *config
699            .into_any()
700            .downcast::<I2pConfig>()
701            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "wrong config type"))?;
702        cfg.ingress_control = ctx.ingress_control;
703        cfg.underlay_mark = ctx.underlay_mark;
704        let parent_id = cfg.interface_id;
705        start_with_template(
706            cfg,
707            ctx.tx,
708            ctx.next_dynamic_id,
709            Some(super::DynamicInterfaceTemplate {
710                parent_id,
711                interface_type: "I2PInterfacePeer".into(),
712                ifac: ctx.ifac,
713                mode: ctx.mode,
714                gravity: ctx.gravity,
715                recursive_prs: ctx.recursive_prs,
716                announces_from_internal: ctx.announces_from_internal,
717                announces_to_internal: ctx.announces_to_internal,
718            }),
719        )?;
720        Ok(StartResult::Listener { control: None })
721    }
722}
723
724pub(crate) fn i2p_runtime_handle_from_config(config: &I2pConfig) -> I2pRuntimeConfigHandle {
725    I2pRuntimeConfigHandle {
726        interface_name: config.name.clone(),
727        runtime: Arc::clone(&config.runtime),
728        startup: I2pRuntime::from_config(config),
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn frame_decoder_accepts_frames_up_to_hw_mtu_plus_ifac() {
738        let ifac_size = 16;
739        let mut decoder = frame_decoder(ifac_size);
740        let payload = vec![0x5a; HW_MTU + ifac_size];
741        let decoded = decoder.feed_with_diagnostics(&hdlc::frame(&payload));
742        assert_eq!(decoded.frames, vec![payload]);
743        assert!(decoded.invalid_frame_lengths.is_empty());
744    }
745
746    #[test]
747    fn frame_decoder_drops_frame_above_hw_mtu_as_invalid() {
748        let mut decoder = frame_decoder(0);
749        let decoded = decoder.feed_with_diagnostics(&hdlc::frame(&vec![0x5a; HW_MTU + 1]));
750        assert!(decoded.frames.is_empty());
751        assert_eq!(decoded.invalid_frame_lengths, vec![HW_MTU + 1]);
752    }
753}