Skip to main content

rns_net/interface/i2p/
sam.rs

1//! SAM v3.1 protocol client for I2P.
2//!
3//! Implements the Simple Anonymous Messaging protocol used to communicate
4//! with a local I2P router. All operations are blocking TCP-based.
5
6use std::fmt;
7use std::io::{self, Read, Write};
8use std::net::{SocketAddr, TcpStream};
9use std::time::Duration;
10
11/// SAM protocol version we speak.
12const SAM_VERSION: &str = "3.1";
13
14/// Default connection timeout for SAM sockets.
15const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
16
17/// Read timeout for SAM command responses.
18const READ_TIMEOUT: Duration = Duration::from_secs(30);
19
20// --- I2P Base64 ---
21
22/// I2P uses a non-standard base64 alphabet: `A-Za-z0-9-~` instead of `+/`.
23const I2P_BASE64_ALPHABET: &[u8; 64] =
24    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-~";
25
26/// Decode table: maps ASCII byte to 6-bit value (255 = invalid).
27fn i2p_base64_decode_table() -> [u8; 256] {
28    let mut table = [255u8; 256];
29    for (i, &ch) in I2P_BASE64_ALPHABET.iter().enumerate() {
30        table[ch as usize] = i as u8;
31    }
32    // Also accept '=' as padding (value irrelevant, handled separately)
33    table[b'=' as usize] = 0;
34    table
35}
36
37/// Encode binary data to I2P base64.
38pub fn i2p_base64_encode(data: &[u8]) -> String {
39    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
40    for chunk in data.chunks(3) {
41        let b0 = chunk[0] as u32;
42        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
43        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
44        let triple = (b0 << 16) | (b1 << 8) | b2;
45
46        out.push(I2P_BASE64_ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
47        out.push(I2P_BASE64_ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
48
49        if chunk.len() > 1 {
50            out.push(I2P_BASE64_ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
51        } else {
52            out.push('=');
53        }
54
55        if chunk.len() > 2 {
56            out.push(I2P_BASE64_ALPHABET[(triple & 0x3F) as usize] as char);
57        } else {
58            out.push('=');
59        }
60    }
61    out
62}
63
64/// Decode I2P base64 string to binary data.
65pub fn i2p_base64_decode(s: &str) -> Result<Vec<u8>, SamError> {
66    let table = i2p_base64_decode_table();
67    let bytes = s.as_bytes();
68
69    if !bytes.len().is_multiple_of(4) {
70        return Err(SamError::InvalidResponse(format!(
71            "invalid I2P base64 length: {}",
72            bytes.len()
73        )));
74    }
75
76    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
77
78    for chunk in bytes.chunks(4) {
79        let mut vals = [0u8; 4];
80        let mut pad_count = 0;
81        for (i, &b) in chunk.iter().enumerate() {
82            if b == b'=' {
83                pad_count += 1;
84                vals[i] = 0;
85            } else {
86                let v = table[b as usize];
87                if v == 255 {
88                    return Err(SamError::InvalidResponse(format!(
89                        "invalid I2P base64 character: {:?}",
90                        b as char
91                    )));
92                }
93                vals[i] = v;
94            }
95        }
96
97        let triple = (vals[0] as u32) << 18
98            | (vals[1] as u32) << 12
99            | (vals[2] as u32) << 6
100            | (vals[3] as u32);
101
102        out.push((triple >> 16) as u8);
103        if pad_count < 2 {
104            out.push((triple >> 8) as u8);
105        }
106        if pad_count < 1 {
107            out.push(triple as u8);
108        }
109    }
110
111    Ok(out)
112}
113
114// --- Error type ---
115
116/// Errors from SAM protocol operations.
117#[derive(Debug)]
118pub enum SamError {
119    /// Underlying I/O error.
120    Io(io::Error),
121    /// SAM router returned an error result (e.g., CANT_REACH_PEER).
122    Protocol(String),
123    /// Could not parse the SAM response.
124    InvalidResponse(String),
125}
126
127impl fmt::Display for SamError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            SamError::Io(e) => write!(f, "SAM I/O error: {}", e),
131            SamError::Protocol(msg) => write!(f, "SAM protocol error: {}", msg),
132            SamError::InvalidResponse(msg) => write!(f, "SAM invalid response: {}", msg),
133        }
134    }
135}
136
137impl From<io::Error> for SamError {
138    fn from(e: io::Error) -> Self {
139        SamError::Io(e)
140    }
141}
142
143// --- Types ---
144
145/// An I2P destination (public key, typically ~387 bytes raw).
146#[derive(Clone, Debug)]
147pub struct Destination {
148    /// Raw binary destination bytes.
149    pub data: Vec<u8>,
150}
151
152impl Destination {
153    /// Encode destination to I2P base64.
154    pub fn to_i2p_base64(&self) -> String {
155        i2p_base64_encode(&self.data)
156    }
157
158    /// Decode destination from I2P base64.
159    pub fn from_i2p_base64(s: &str) -> Result<Self, SamError> {
160        let data = i2p_base64_decode(s)?;
161        Ok(Destination { data })
162    }
163
164    /// Compute the .b32.i2p address from this destination.
165    /// SHA-256 of raw destination bytes, base32-encoded, lowercase + ".b32.i2p".
166    pub fn base32_address(&self) -> String {
167        let hash = rns_crypto::sha256::sha256(&self.data);
168        let encoded = base32_encode(&hash);
169        format!("{}.b32.i2p", encoded)
170    }
171}
172
173/// A generated keypair (destination + private key material).
174#[derive(Clone, Debug)]
175pub struct KeyPair {
176    pub destination: Destination,
177    /// Raw binary private key bytes.
178    pub private_key: Vec<u8>,
179}
180
181// --- Base32 encoding (RFC 4648, lowercase, no padding) ---
182
183const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
184
185/// Encode bytes to base32 (lowercase, no padding).
186fn base32_encode(data: &[u8]) -> String {
187    let mut out = String::with_capacity((data.len() * 8).div_ceil(5));
188    let mut buffer: u64 = 0;
189    let mut bits: u32 = 0;
190
191    for &byte in data {
192        buffer = (buffer << 8) | byte as u64;
193        bits += 8;
194        while bits >= 5 {
195            bits -= 5;
196            out.push(BASE32_ALPHABET[((buffer >> bits) & 0x1F) as usize] as char);
197        }
198    }
199    if bits > 0 {
200        out.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 0x1F) as usize] as char);
201    }
202    out
203}
204
205// --- SAM response parsing ---
206
207/// Parse a key=value pair from a SAM response token.
208fn parse_kv(token: &str) -> Option<(&str, &str)> {
209    let eq = token.find('=')?;
210    Some((&token[..eq], &token[eq + 1..]))
211}
212
213/// Read a single newline-terminated line from a SAM socket.
214///
215/// Reads byte-by-byte to avoid buffering past the newline.
216/// BufReader would consume and lose data beyond the line boundary,
217/// which is catastrophic for STREAM CONNECT/ACCEPT where the socket
218/// transitions to a raw data pipe after the response line.
219fn read_line(stream: &mut TcpStream) -> Result<String, SamError> {
220    let mut line = Vec::new();
221    let mut byte = [0u8; 1];
222    loop {
223        match stream.read_exact(&mut byte) {
224            Ok(()) => {
225                if byte[0] == b'\n' {
226                    break;
227                }
228                line.push(byte[0]);
229            }
230            Err(e) => return Err(SamError::Io(e)),
231        }
232    }
233    String::from_utf8(line)
234        .map_err(|e| SamError::InvalidResponse(format!("non-UTF8 SAM response: {}", e)))
235}
236
237/// Open a fresh TCP connection to the SAM bridge and perform HELLO handshake.
238fn hello_connect(sam_addr: &SocketAddr, underlay_mark: Option<u32>) -> Result<TcpStream, SamError> {
239    #[cfg(target_os = "linux")]
240    let mut stream = if underlay_mark.is_some() {
241        super::super::connect_tcp_with_options(sam_addr, None, underlay_mark, CONNECT_TIMEOUT)?
242    } else {
243        TcpStream::connect_timeout(sam_addr, CONNECT_TIMEOUT)?
244    };
245    #[cfg(not(target_os = "linux"))]
246    let mut stream = TcpStream::connect_timeout(sam_addr, CONNECT_TIMEOUT)?;
247    stream.set_read_timeout(Some(READ_TIMEOUT))?;
248    stream.set_write_timeout(Some(READ_TIMEOUT))?;
249
250    // Send HELLO
251    writeln!(stream, "HELLO VERSION MIN={v} MAX={v}", v = SAM_VERSION)?;
252    stream.flush()?;
253
254    // Read response
255    let line = read_line(&mut stream)?;
256    // Expected: "HELLO REPLY RESULT=OK VERSION=3.1"
257    let resp = parse_sam_response(&line)?;
258    if resp.command != "HELLO" || resp.subcommand != "REPLY" {
259        return Err(SamError::InvalidResponse(format!(
260            "expected HELLO REPLY, got: {}",
261            line
262        )));
263    }
264    check_result(&resp)?;
265
266    Ok(stream)
267}
268
269/// Parsed SAM response.
270struct SamResponse {
271    command: String,
272    subcommand: String,
273    params: Vec<(String, String)>,
274}
275
276impl SamResponse {
277    fn get(&self, key: &str) -> Option<&str> {
278        for (k, v) in &self.params {
279            if k == key {
280                return Some(v);
281            }
282        }
283        None
284    }
285}
286
287/// Parse a SAM response line into command, subcommand, and key=value params.
288/// Some values (like DESTINATION) can contain spaces if they are the last param,
289/// but SAM v3.1 generally uses space-separated KEY=VALUE pairs.
290fn parse_sam_response(line: &str) -> Result<SamResponse, SamError> {
291    if line.trim().is_empty() {
292        return Err(SamError::InvalidResponse("empty response".into()));
293    }
294    let mut parts = line.splitn(3, ' ');
295    let command = parts
296        .next()
297        .ok_or_else(|| SamError::InvalidResponse("empty response".into()))?
298        .to_string();
299    let subcommand = parts.next().unwrap_or("").to_string();
300    let rest = parts.next().unwrap_or("");
301
302    let mut params = Vec::new();
303    // Parse key=value pairs. Values can contain base64 which has no spaces,
304    // so simple space-splitting works.
305    for token in rest.split_whitespace() {
306        if let Some((k, v)) = parse_kv(token) {
307            params.push((k.to_string(), v.to_string()));
308        }
309    }
310
311    Ok(SamResponse {
312        command,
313        subcommand,
314        params,
315    })
316}
317
318/// Check if SAM response has RESULT=OK, return error otherwise.
319fn check_result(resp: &SamResponse) -> Result<(), SamError> {
320    match resp.get("RESULT") {
321        Some("OK") => Ok(()),
322        Some(result) => {
323            let message = resp.get("MESSAGE").unwrap_or("(no message)");
324            Err(SamError::Protocol(format!(
325                "RESULT={} MESSAGE={}",
326                result, message
327            )))
328        }
329        None => Ok(()), // Some responses don't have RESULT
330    }
331}
332
333// --- Public API ---
334
335/// Generate a new I2P destination keypair via SAM.
336/// Uses Ed25519 (SIGNATURE_TYPE=7).
337pub fn dest_generate(sam_addr: &SocketAddr) -> Result<KeyPair, SamError> {
338    dest_generate_with_mark(sam_addr, None)
339}
340
341pub(crate) fn dest_generate_with_mark(
342    sam_addr: &SocketAddr,
343    underlay_mark: Option<u32>,
344) -> Result<KeyPair, SamError> {
345    let mut stream = hello_connect(sam_addr, underlay_mark)?;
346
347    writeln!(stream, "DEST GENERATE SIGNATURE_TYPE=7")?;
348    stream.flush()?;
349
350    let line = read_line(&mut stream)?;
351    let resp = parse_sam_response(&line)?;
352
353    if resp.command != "DEST" || resp.subcommand != "REPLY" {
354        return Err(SamError::InvalidResponse(format!(
355            "expected DEST REPLY, got: {}",
356            line
357        )));
358    }
359
360    let pub_b64 = resp
361        .get("PUB")
362        .ok_or_else(|| SamError::InvalidResponse("DEST REPLY missing PUB".into()))?;
363    let priv_b64 = resp
364        .get("PRIV")
365        .ok_or_else(|| SamError::InvalidResponse("DEST REPLY missing PRIV".into()))?;
366
367    let dest_data = i2p_base64_decode(pub_b64)?;
368    let priv_data = i2p_base64_decode(priv_b64)?;
369
370    Ok(KeyPair {
371        destination: Destination { data: dest_data },
372        private_key: priv_data,
373    })
374}
375
376/// Create a STREAM session. Returns the control socket which must remain open
377/// for the session's lifetime.
378pub fn session_create(
379    sam_addr: &SocketAddr,
380    session_id: &str,
381    private_key_b64: &str,
382) -> Result<TcpStream, SamError> {
383    session_create_with_mark(sam_addr, session_id, private_key_b64, None)
384}
385
386pub(crate) fn session_create_with_mark(
387    sam_addr: &SocketAddr,
388    session_id: &str,
389    private_key_b64: &str,
390    underlay_mark: Option<u32>,
391) -> Result<TcpStream, SamError> {
392    let mut stream = hello_connect(sam_addr, underlay_mark)?;
393
394    writeln!(
395        stream,
396        "SESSION CREATE STYLE=STREAM ID={} DESTINATION={} SIGNATURE_TYPE=7",
397        session_id, private_key_b64,
398    )?;
399    stream.flush()?;
400
401    let line = read_line(&mut stream)?;
402    let resp = parse_sam_response(&line)?;
403
404    if resp.command != "SESSION" || resp.subcommand != "STATUS" {
405        return Err(SamError::InvalidResponse(format!(
406            "expected SESSION STATUS, got: {}",
407            line
408        )));
409    }
410    check_result(&resp)?;
411
412    // Control socket stays open
413    Ok(stream)
414}
415
416/// Connect to a remote I2P destination via STREAM CONNECT.
417/// Returns a bidirectional data stream.
418pub fn stream_connect(
419    sam_addr: &SocketAddr,
420    session_id: &str,
421    destination: &str,
422) -> Result<TcpStream, SamError> {
423    stream_connect_with_mark(sam_addr, session_id, destination, None)
424}
425
426pub(crate) fn stream_connect_with_mark(
427    sam_addr: &SocketAddr,
428    session_id: &str,
429    destination: &str,
430    underlay_mark: Option<u32>,
431) -> Result<TcpStream, SamError> {
432    let mut stream = hello_connect(sam_addr, underlay_mark)?;
433
434    writeln!(
435        stream,
436        "STREAM CONNECT ID={} DESTINATION={} SILENT=false",
437        session_id, destination,
438    )?;
439    stream.flush()?;
440
441    let line = read_line(&mut stream)?;
442    let resp = parse_sam_response(&line)?;
443
444    if resp.command != "STREAM" || resp.subcommand != "STATUS" {
445        return Err(SamError::InvalidResponse(format!(
446            "expected STREAM STATUS, got: {}",
447            line
448        )));
449    }
450    check_result(&resp)?;
451
452    // After RESULT=OK, the TCP socket becomes a raw data pipe
453    // Clear timeouts for the data phase
454    stream.set_read_timeout(None)?;
455    stream.set_write_timeout(None)?;
456
457    Ok(stream)
458}
459
460/// Accept an incoming connection on a session via STREAM ACCEPT.
461/// Returns the data stream and the remote peer's destination.
462pub fn stream_accept(
463    sam_addr: &SocketAddr,
464    session_id: &str,
465) -> Result<(TcpStream, Destination), SamError> {
466    stream_accept_with_mark(sam_addr, session_id, None)
467}
468
469pub(crate) fn stream_accept_with_mark(
470    sam_addr: &SocketAddr,
471    session_id: &str,
472    underlay_mark: Option<u32>,
473) -> Result<(TcpStream, Destination), SamError> {
474    let mut stream = hello_connect(sam_addr, underlay_mark)?;
475
476    writeln!(stream, "STREAM ACCEPT ID={} SILENT=false", session_id,)?;
477    stream.flush()?;
478
479    let line = read_line(&mut stream)?;
480    let resp = parse_sam_response(&line)?;
481
482    if resp.command != "STREAM" || resp.subcommand != "STATUS" {
483        return Err(SamError::InvalidResponse(format!(
484            "expected STREAM STATUS, got: {}",
485            line
486        )));
487    }
488    check_result(&resp)?;
489
490    // After RESULT=OK, the remote destination is sent as a line of base64 + newline
491    // before the data phase begins.
492    let dest_line = read_line(&mut stream)?;
493    let remote_dest = Destination::from_i2p_base64(dest_line.trim())?;
494
495    // Clear timeouts for the data phase
496    stream.set_read_timeout(None)?;
497    stream.set_write_timeout(None)?;
498
499    Ok((stream, remote_dest))
500}
501
502/// Look up a .b32.i2p name (or other I2P name) to a full destination.
503/// Opens a fresh SAM connection for the lookup.
504pub fn naming_lookup(sam_addr: &SocketAddr, name: &str) -> Result<Destination, SamError> {
505    naming_lookup_with_mark(sam_addr, name, None)
506}
507
508pub(crate) fn naming_lookup_with_mark(
509    sam_addr: &SocketAddr,
510    name: &str,
511    underlay_mark: Option<u32>,
512) -> Result<Destination, SamError> {
513    let mut stream = hello_connect(sam_addr, underlay_mark)?;
514    naming_lookup_on(&mut stream, name)
515}
516
517/// Perform a NAMING LOOKUP on an existing SAM socket.
518/// Use this for `NAME=ME` on a session control socket, since the `ME`
519/// name requires a session context on the same connection.
520pub fn naming_lookup_on(stream: &mut TcpStream, name: &str) -> Result<Destination, SamError> {
521    writeln!(stream, "NAMING LOOKUP NAME={}", name)?;
522    stream.flush()?;
523
524    let line = read_line(stream)?;
525    let resp = parse_sam_response(&line)?;
526
527    if resp.command != "NAMING" || resp.subcommand != "REPLY" {
528        return Err(SamError::InvalidResponse(format!(
529            "expected NAMING REPLY, got: {}",
530            line
531        )));
532    }
533    check_result(&resp)?;
534
535    let value = resp
536        .get("VALUE")
537        .ok_or_else(|| SamError::InvalidResponse("NAMING REPLY missing VALUE".into()))?;
538
539    Destination::from_i2p_base64(value)
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use std::net::TcpListener;
546    use std::thread;
547
548    fn read_command(stream: &mut TcpStream) {
549        let mut byte = [0u8; 1];
550        loop {
551            stream.read_exact(&mut byte).unwrap();
552            if byte[0] == b'\n' {
553                return;
554            }
555        }
556    }
557
558    fn mock_sam(
559        command_reply: &'static str,
560        data_after_reply: Option<&'static str>,
561    ) -> (SocketAddr, thread::JoinHandle<()>) {
562        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
563        let address = listener.local_addr().unwrap();
564        let handle = thread::spawn(move || {
565            let (mut stream, _) = listener.accept().unwrap();
566            read_command(&mut stream);
567            stream
568                .write_all(b"HELLO REPLY RESULT=OK VERSION=3.1\n")
569                .unwrap();
570            read_command(&mut stream);
571            stream.write_all(command_reply.as_bytes()).unwrap();
572            stream.write_all(b"\n").unwrap();
573            if let Some(data) = data_after_reply {
574                stream.write_all(data.as_bytes()).unwrap();
575                stream.write_all(b"\n").unwrap();
576            }
577            stream.flush().unwrap();
578            stream
579                .set_read_timeout(Some(Duration::from_secs(2)))
580                .unwrap();
581            let mut byte = [0u8; 1];
582            assert_eq!(stream.read(&mut byte).unwrap(), 0);
583        });
584        (address, handle)
585    }
586
587    // --- I2P base64 tests ---
588
589    #[test]
590    fn base64_encode_empty() {
591        assert_eq!(i2p_base64_encode(b""), "");
592    }
593
594    #[test]
595    fn base64_roundtrip() {
596        let data: Vec<u8> = (0..=255).collect();
597        let encoded = i2p_base64_encode(&data);
598        let decoded = i2p_base64_decode(&encoded).unwrap();
599        assert_eq!(decoded, data);
600    }
601
602    #[test]
603    fn base64_known_value() {
604        // "Hello" in standard base64 is "SGVsbG8=" using +/ alphabet
605        // In I2P base64 with -~ alphabet, same result since no +/ chars involved
606        let encoded = i2p_base64_encode(b"Hello");
607        assert_eq!(encoded, "SGVsbG8=");
608        let decoded = i2p_base64_decode(&encoded).unwrap();
609        assert_eq!(decoded, b"Hello");
610    }
611
612    #[test]
613    fn base64_i2p_specific_chars() {
614        // Test that -~ are used instead of +/
615        // Standard base64 of [0xFB, 0xEF, 0xBE] is "++++", which in I2P is "----"
616        let data = [0xFB, 0xEF, 0xBE];
617        let encoded = i2p_base64_encode(&data);
618        assert!(encoded.contains('-') || encoded.contains('~'));
619        // roundtrip
620        let decoded = i2p_base64_decode(&encoded).unwrap();
621        assert_eq!(decoded, data);
622    }
623
624    #[test]
625    fn base64_all_alphabet_chars_roundtrip() {
626        // Generate data that produces all 64 base64 characters
627        let data: Vec<u8> = (0..48).collect();
628        let encoded = i2p_base64_encode(&data);
629        let decoded = i2p_base64_decode(&encoded).unwrap();
630        assert_eq!(decoded, data);
631    }
632
633    #[test]
634    fn base64_padding_1() {
635        // 1 byte -> 4 chars with 2 padding
636        let encoded = i2p_base64_encode(&[0xFF]);
637        assert_eq!(encoded.len(), 4);
638        assert!(encoded.ends_with("=="));
639        let decoded = i2p_base64_decode(&encoded).unwrap();
640        assert_eq!(decoded, vec![0xFF]);
641    }
642
643    #[test]
644    fn base64_padding_2() {
645        // 2 bytes -> 4 chars with 1 padding
646        let encoded = i2p_base64_encode(&[0xFF, 0xFE]);
647        assert_eq!(encoded.len(), 4);
648        assert!(encoded.ends_with('='));
649        let decoded = i2p_base64_decode(&encoded).unwrap();
650        assert_eq!(decoded, vec![0xFF, 0xFE]);
651    }
652
653    #[test]
654    fn base64_no_padding() {
655        // 3 bytes -> 4 chars, no padding
656        let encoded = i2p_base64_encode(&[0xFF, 0xFE, 0xFD]);
657        assert_eq!(encoded.len(), 4);
658        assert!(!encoded.contains('='));
659        let decoded = i2p_base64_decode(&encoded).unwrap();
660        assert_eq!(decoded, vec![0xFF, 0xFE, 0xFD]);
661    }
662
663    #[test]
664    fn base64_decode_invalid_char() {
665        let result = i2p_base64_decode("!!!=");
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn base64_decode_invalid_length() {
671        let result = i2p_base64_decode("ABC");
672        assert!(result.is_err());
673    }
674
675    // --- Base32 tests ---
676
677    #[test]
678    fn base32_encode_empty() {
679        assert_eq!(base32_encode(&[]), "");
680    }
681
682    #[test]
683    fn base32_encode_known() {
684        // "Hello" -> base32 is "jbswy3dp" (lowercase)
685        let result = base32_encode(b"Hello");
686        assert_eq!(result, "jbswy3dp");
687    }
688
689    #[test]
690    fn base32_encode_sha256() {
691        // SHA256 of empty is known, just verify it produces a 52-char string
692        let hash = rns_crypto::sha256::sha256(b"");
693        let encoded = base32_encode(&hash);
694        // 32 bytes * 8 bits / 5 bits = 51.2 -> 52 chars
695        assert_eq!(encoded.len(), 52);
696        // All lowercase letters and digits 2-7
697        assert!(encoded
698            .chars()
699            .all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)));
700    }
701
702    // --- Destination tests ---
703
704    #[test]
705    fn destination_base32_address() {
706        let dest = Destination {
707            data: vec![0x42; 387], // dummy destination data
708        };
709        let addr = dest.base32_address();
710        assert!(addr.ends_with(".b32.i2p"));
711        // 52 chars of base32 + ".b32.i2p" = 60 chars
712        assert_eq!(addr.len(), 60);
713    }
714
715    #[test]
716    fn destination_roundtrip_base64() {
717        let data: Vec<u8> = (0..=255).cycle().take(387).collect();
718        let dest = Destination { data: data.clone() };
719        let b64 = dest.to_i2p_base64();
720        let dest2 = Destination::from_i2p_base64(&b64).unwrap();
721        assert_eq!(dest2.data, data);
722    }
723
724    // --- SAM response parsing tests ---
725
726    #[test]
727    fn parse_hello_reply() {
728        let line = "HELLO REPLY RESULT=OK VERSION=3.1";
729        let resp = parse_sam_response(line).unwrap();
730        assert_eq!(resp.command, "HELLO");
731        assert_eq!(resp.subcommand, "REPLY");
732        assert_eq!(resp.get("RESULT"), Some("OK"));
733        assert_eq!(resp.get("VERSION"), Some("3.1"));
734    }
735
736    #[test]
737    fn parse_rejects_empty_and_whitespace_only_replies() {
738        assert!(matches!(
739            parse_sam_response(""),
740            Err(SamError::InvalidResponse(_))
741        ));
742        assert!(matches!(
743            parse_sam_response("   "),
744            Err(SamError::InvalidResponse(_))
745        ));
746    }
747
748    #[test]
749    fn session_create_reports_duplicate_id_and_closes_socket() {
750        let (address, server) = mock_sam("SESSION STATUS RESULT=DUPLICATED_ID", None);
751        let error = session_create(&address, "duplicate", "AAAA").unwrap_err();
752        assert!(matches!(error, SamError::Protocol(message) if message.contains("DUPLICATED_ID")));
753        server.join().unwrap();
754    }
755
756    #[test]
757    fn stream_connect_reports_unreachable_peer_and_closes_socket() {
758        let (address, server) = mock_sam("STREAM STATUS RESULT=CANT_REACH_PEER", None);
759        let error = stream_connect(&address, "session", "AAAA").unwrap_err();
760        assert!(
761            matches!(error, SamError::Protocol(message) if message.contains("CANT_REACH_PEER"))
762        );
763        server.join().unwrap();
764    }
765
766    #[test]
767    fn stream_accept_rejects_invalid_remote_destination_and_closes_socket() {
768        let (address, server) = mock_sam("STREAM STATUS RESULT=OK", Some("not-base64"));
769        let error = stream_accept(&address, "session").unwrap_err();
770        assert!(matches!(error, SamError::InvalidResponse(_)));
771        server.join().unwrap();
772    }
773
774    #[test]
775    fn parse_session_status_ok() {
776        let line = "SESSION STATUS RESULT=OK DESTINATION=AAAA";
777        let resp = parse_sam_response(line).unwrap();
778        assert_eq!(resp.command, "SESSION");
779        assert_eq!(resp.subcommand, "STATUS");
780        assert_eq!(resp.get("RESULT"), Some("OK"));
781        assert_eq!(resp.get("DESTINATION"), Some("AAAA"));
782    }
783
784    #[test]
785    fn parse_session_status_error() {
786        let line = "SESSION STATUS RESULT=DUPLICATED_ID";
787        let resp = parse_sam_response(line).unwrap();
788        assert_eq!(resp.get("RESULT"), Some("DUPLICATED_ID"));
789        let err = check_result(&resp);
790        assert!(err.is_err());
791    }
792
793    #[test]
794    fn parse_stream_status_error() {
795        let line = "STREAM STATUS RESULT=CANT_REACH_PEER MESSAGE=unreachable";
796        let resp = parse_sam_response(line).unwrap();
797        assert_eq!(resp.get("RESULT"), Some("CANT_REACH_PEER"));
798        assert_eq!(resp.get("MESSAGE"), Some("unreachable"));
799        let err = check_result(&resp);
800        assert!(err.is_err());
801        if let Err(SamError::Protocol(msg)) = err {
802            assert!(msg.contains("CANT_REACH_PEER"));
803        }
804    }
805
806    #[test]
807    fn parse_naming_reply() {
808        let line = "NAMING REPLY RESULT=OK NAME=test.b32.i2p VALUE=AAAA";
809        let resp = parse_sam_response(line).unwrap();
810        assert_eq!(resp.command, "NAMING");
811        assert_eq!(resp.subcommand, "REPLY");
812        assert_eq!(resp.get("NAME"), Some("test.b32.i2p"));
813        assert_eq!(resp.get("VALUE"), Some("AAAA"));
814    }
815
816    #[test]
817    fn parse_naming_not_found() {
818        let line = "NAMING REPLY RESULT=KEY_NOT_FOUND";
819        let resp = parse_sam_response(line).unwrap();
820        let err = check_result(&resp);
821        assert!(err.is_err());
822    }
823
824    #[test]
825    fn parse_dest_reply() {
826        let line = "DEST REPLY PUB=AAAA PRIV=BBBB";
827        let resp = parse_sam_response(line).unwrap();
828        assert_eq!(resp.command, "DEST");
829        assert_eq!(resp.subcommand, "REPLY");
830        assert_eq!(resp.get("PUB"), Some("AAAA"));
831        assert_eq!(resp.get("PRIV"), Some("BBBB"));
832    }
833
834    #[test]
835    fn parse_stream_status_timeout() {
836        let line = "STREAM STATUS RESULT=TIMEOUT";
837        let resp = parse_sam_response(line).unwrap();
838        let err = check_result(&resp);
839        assert!(err.is_err());
840        if let Err(SamError::Protocol(msg)) = err {
841            assert!(msg.contains("TIMEOUT"));
842        }
843    }
844
845    #[test]
846    fn check_result_ok() {
847        let line = "TEST REPLY RESULT=OK";
848        let resp = parse_sam_response(line).unwrap();
849        assert!(check_result(&resp).is_ok());
850    }
851
852    #[test]
853    fn check_result_no_result_field() {
854        let line = "TEST REPLY FOO=BAR";
855        let resp = parse_sam_response(line).unwrap();
856        // No RESULT field is considered OK
857        assert!(check_result(&resp).is_ok());
858    }
859
860    #[test]
861    fn sam_error_display() {
862        let io_err = SamError::Io(io::Error::other("test"));
863        assert!(format!("{}", io_err).contains("test"));
864
865        let proto_err = SamError::Protocol("CANT_REACH_PEER".into());
866        assert!(format!("{}", proto_err).contains("CANT_REACH_PEER"));
867
868        let inv_err = SamError::InvalidResponse("bad".into());
869        assert!(format!("{}", inv_err).contains("bad"));
870    }
871}