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() + 2) / 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() % 4 != 0 {
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 + 4) / 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) -> Result<TcpStream, SamError> {
239    let mut stream = TcpStream::connect_timeout(sam_addr, CONNECT_TIMEOUT)?;
240    stream.set_read_timeout(Some(READ_TIMEOUT))?;
241    stream.set_write_timeout(Some(READ_TIMEOUT))?;
242
243    // Send HELLO
244    write!(stream, "HELLO VERSION MIN={v} MAX={v}\n", v = SAM_VERSION)?;
245    stream.flush()?;
246
247    // Read response
248    let line = read_line(&mut stream)?;
249    // Expected: "HELLO REPLY RESULT=OK VERSION=3.1"
250    let resp = parse_sam_response(&line)?;
251    if resp.command != "HELLO" || resp.subcommand != "REPLY" {
252        return Err(SamError::InvalidResponse(format!(
253            "expected HELLO REPLY, got: {}",
254            line
255        )));
256    }
257    check_result(&resp)?;
258
259    Ok(stream)
260}
261
262/// Parsed SAM response.
263struct SamResponse {
264    command: String,
265    subcommand: String,
266    params: Vec<(String, String)>,
267}
268
269impl SamResponse {
270    fn get(&self, key: &str) -> Option<&str> {
271        for (k, v) in &self.params {
272            if k == key {
273                return Some(v);
274            }
275        }
276        None
277    }
278}
279
280/// Parse a SAM response line into command, subcommand, and key=value params.
281/// Some values (like DESTINATION) can contain spaces if they are the last param,
282/// but SAM v3.1 generally uses space-separated KEY=VALUE pairs.
283fn parse_sam_response(line: &str) -> Result<SamResponse, SamError> {
284    let mut parts = line.splitn(3, ' ');
285    let command = parts
286        .next()
287        .ok_or_else(|| SamError::InvalidResponse("empty response".into()))?
288        .to_string();
289    let subcommand = parts
290        .next()
291        .unwrap_or("")
292        .to_string();
293    let rest = parts.next().unwrap_or("");
294
295    let mut params = Vec::new();
296    // Parse key=value pairs. Values can contain base64 which has no spaces,
297    // so simple space-splitting works.
298    for token in rest.split_whitespace() {
299        if let Some((k, v)) = parse_kv(token) {
300            params.push((k.to_string(), v.to_string()));
301        }
302    }
303
304    Ok(SamResponse {
305        command,
306        subcommand,
307        params,
308    })
309}
310
311/// Check if SAM response has RESULT=OK, return error otherwise.
312fn check_result(resp: &SamResponse) -> Result<(), SamError> {
313    match resp.get("RESULT") {
314        Some("OK") => Ok(()),
315        Some(result) => {
316            let message = resp.get("MESSAGE").unwrap_or("(no message)");
317            Err(SamError::Protocol(format!(
318                "RESULT={} MESSAGE={}",
319                result, message
320            )))
321        }
322        None => Ok(()), // Some responses don't have RESULT
323    }
324}
325
326// --- Public API ---
327
328/// Generate a new I2P destination keypair via SAM.
329/// Uses Ed25519 (SIGNATURE_TYPE=7).
330pub fn dest_generate(sam_addr: &SocketAddr) -> Result<KeyPair, SamError> {
331    let mut stream = hello_connect(sam_addr)?;
332
333    write!(stream, "DEST GENERATE SIGNATURE_TYPE=7\n")?;
334    stream.flush()?;
335
336    let line = read_line(&mut stream)?;
337    let resp = parse_sam_response(&line)?;
338
339    if resp.command != "DEST" || resp.subcommand != "REPLY" {
340        return Err(SamError::InvalidResponse(format!(
341            "expected DEST REPLY, got: {}",
342            line
343        )));
344    }
345
346    let pub_b64 = resp.get("PUB").ok_or_else(|| {
347        SamError::InvalidResponse("DEST REPLY missing PUB".into())
348    })?;
349    let priv_b64 = resp.get("PRIV").ok_or_else(|| {
350        SamError::InvalidResponse("DEST REPLY missing PRIV".into())
351    })?;
352
353    let dest_data = i2p_base64_decode(pub_b64)?;
354    let priv_data = i2p_base64_decode(priv_b64)?;
355
356    Ok(KeyPair {
357        destination: Destination { data: dest_data },
358        private_key: priv_data,
359    })
360}
361
362/// Create a STREAM session. Returns the control socket which must remain open
363/// for the session's lifetime.
364pub fn session_create(
365    sam_addr: &SocketAddr,
366    session_id: &str,
367    private_key_b64: &str,
368) -> Result<TcpStream, SamError> {
369    let mut stream = hello_connect(sam_addr)?;
370
371    write!(
372        stream,
373        "SESSION CREATE STYLE=STREAM ID={} DESTINATION={} SIGNATURE_TYPE=7\n",
374        session_id, private_key_b64,
375    )?;
376    stream.flush()?;
377
378    let line = read_line(&mut stream)?;
379    let resp = parse_sam_response(&line)?;
380
381    if resp.command != "SESSION" || resp.subcommand != "STATUS" {
382        return Err(SamError::InvalidResponse(format!(
383            "expected SESSION STATUS, got: {}",
384            line
385        )));
386    }
387    check_result(&resp)?;
388
389    // Control socket stays open
390    Ok(stream)
391}
392
393/// Connect to a remote I2P destination via STREAM CONNECT.
394/// Returns a bidirectional data stream.
395pub fn stream_connect(
396    sam_addr: &SocketAddr,
397    session_id: &str,
398    destination: &str,
399) -> Result<TcpStream, SamError> {
400    let mut stream = hello_connect(sam_addr)?;
401
402    write!(
403        stream,
404        "STREAM CONNECT ID={} DESTINATION={} SILENT=false\n",
405        session_id, destination,
406    )?;
407    stream.flush()?;
408
409    let line = read_line(&mut stream)?;
410    let resp = parse_sam_response(&line)?;
411
412    if resp.command != "STREAM" || resp.subcommand != "STATUS" {
413        return Err(SamError::InvalidResponse(format!(
414            "expected STREAM STATUS, got: {}",
415            line
416        )));
417    }
418    check_result(&resp)?;
419
420    // After RESULT=OK, the TCP socket becomes a raw data pipe
421    // Clear timeouts for the data phase
422    stream.set_read_timeout(None)?;
423    stream.set_write_timeout(None)?;
424
425    Ok(stream)
426}
427
428/// Accept an incoming connection on a session via STREAM ACCEPT.
429/// Returns the data stream and the remote peer's destination.
430pub fn stream_accept(
431    sam_addr: &SocketAddr,
432    session_id: &str,
433) -> Result<(TcpStream, Destination), SamError> {
434    let mut stream = hello_connect(sam_addr)?;
435
436    write!(
437        stream,
438        "STREAM ACCEPT ID={} SILENT=false\n",
439        session_id,
440    )?;
441    stream.flush()?;
442
443    let line = read_line(&mut stream)?;
444    let resp = parse_sam_response(&line)?;
445
446    if resp.command != "STREAM" || resp.subcommand != "STATUS" {
447        return Err(SamError::InvalidResponse(format!(
448            "expected STREAM STATUS, got: {}",
449            line
450        )));
451    }
452    check_result(&resp)?;
453
454    // After RESULT=OK, the remote destination is sent as a line of base64 + newline
455    // before the data phase begins.
456    let dest_line = read_line(&mut stream)?;
457    let remote_dest = Destination::from_i2p_base64(dest_line.trim())?;
458
459    // Clear timeouts for the data phase
460    stream.set_read_timeout(None)?;
461    stream.set_write_timeout(None)?;
462
463    Ok((stream, remote_dest))
464}
465
466/// Look up a .b32.i2p name (or other I2P name) to a full destination.
467/// Opens a fresh SAM connection for the lookup.
468pub fn naming_lookup(
469    sam_addr: &SocketAddr,
470    name: &str,
471) -> Result<Destination, SamError> {
472    let mut stream = hello_connect(sam_addr)?;
473    naming_lookup_on(&mut stream, name)
474}
475
476/// Perform a NAMING LOOKUP on an existing SAM socket.
477/// Use this for `NAME=ME` on a session control socket, since the `ME`
478/// name requires a session context on the same connection.
479pub fn naming_lookup_on(
480    stream: &mut TcpStream,
481    name: &str,
482) -> Result<Destination, SamError> {
483    write!(stream, "NAMING LOOKUP NAME={}\n", name)?;
484    stream.flush()?;
485
486    let line = read_line(stream)?;
487    let resp = parse_sam_response(&line)?;
488
489    if resp.command != "NAMING" || resp.subcommand != "REPLY" {
490        return Err(SamError::InvalidResponse(format!(
491            "expected NAMING REPLY, got: {}",
492            line
493        )));
494    }
495    check_result(&resp)?;
496
497    let value = resp.get("VALUE").ok_or_else(|| {
498        SamError::InvalidResponse("NAMING REPLY missing VALUE".into())
499    })?;
500
501    Destination::from_i2p_base64(value)
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    // --- I2P base64 tests ---
509
510    #[test]
511    fn base64_encode_empty() {
512        assert_eq!(i2p_base64_encode(b""), "");
513    }
514
515    #[test]
516    fn base64_roundtrip() {
517        let data: Vec<u8> = (0..=255).collect();
518        let encoded = i2p_base64_encode(&data);
519        let decoded = i2p_base64_decode(&encoded).unwrap();
520        assert_eq!(decoded, data);
521    }
522
523    #[test]
524    fn base64_known_value() {
525        // "Hello" in standard base64 is "SGVsbG8=" using +/ alphabet
526        // In I2P base64 with -~ alphabet, same result since no +/ chars involved
527        let encoded = i2p_base64_encode(b"Hello");
528        assert_eq!(encoded, "SGVsbG8=");
529        let decoded = i2p_base64_decode(&encoded).unwrap();
530        assert_eq!(decoded, b"Hello");
531    }
532
533    #[test]
534    fn base64_i2p_specific_chars() {
535        // Test that -~ are used instead of +/
536        // Standard base64 of [0xFB, 0xEF, 0xBE] is "++++", which in I2P is "----"
537        let data = [0xFB, 0xEF, 0xBE];
538        let encoded = i2p_base64_encode(&data);
539        assert!(encoded.contains('-') || encoded.contains('~'));
540        // roundtrip
541        let decoded = i2p_base64_decode(&encoded).unwrap();
542        assert_eq!(decoded, data);
543    }
544
545    #[test]
546    fn base64_all_alphabet_chars_roundtrip() {
547        // Generate data that produces all 64 base64 characters
548        let data: Vec<u8> = (0..48).collect();
549        let encoded = i2p_base64_encode(&data);
550        let decoded = i2p_base64_decode(&encoded).unwrap();
551        assert_eq!(decoded, data);
552    }
553
554    #[test]
555    fn base64_padding_1() {
556        // 1 byte -> 4 chars with 2 padding
557        let encoded = i2p_base64_encode(&[0xFF]);
558        assert_eq!(encoded.len(), 4);
559        assert!(encoded.ends_with("=="));
560        let decoded = i2p_base64_decode(&encoded).unwrap();
561        assert_eq!(decoded, vec![0xFF]);
562    }
563
564    #[test]
565    fn base64_padding_2() {
566        // 2 bytes -> 4 chars with 1 padding
567        let encoded = i2p_base64_encode(&[0xFF, 0xFE]);
568        assert_eq!(encoded.len(), 4);
569        assert!(encoded.ends_with('='));
570        let decoded = i2p_base64_decode(&encoded).unwrap();
571        assert_eq!(decoded, vec![0xFF, 0xFE]);
572    }
573
574    #[test]
575    fn base64_no_padding() {
576        // 3 bytes -> 4 chars, no padding
577        let encoded = i2p_base64_encode(&[0xFF, 0xFE, 0xFD]);
578        assert_eq!(encoded.len(), 4);
579        assert!(!encoded.contains('='));
580        let decoded = i2p_base64_decode(&encoded).unwrap();
581        assert_eq!(decoded, vec![0xFF, 0xFE, 0xFD]);
582    }
583
584    #[test]
585    fn base64_decode_invalid_char() {
586        let result = i2p_base64_decode("!!!=");
587        assert!(result.is_err());
588    }
589
590    #[test]
591    fn base64_decode_invalid_length() {
592        let result = i2p_base64_decode("ABC");
593        assert!(result.is_err());
594    }
595
596    // --- Base32 tests ---
597
598    #[test]
599    fn base32_encode_empty() {
600        assert_eq!(base32_encode(&[]), "");
601    }
602
603    #[test]
604    fn base32_encode_known() {
605        // "Hello" -> base32 is "jbswy3dp" (lowercase)
606        let result = base32_encode(b"Hello");
607        assert_eq!(result, "jbswy3dp");
608    }
609
610    #[test]
611    fn base32_encode_sha256() {
612        // SHA256 of empty is known, just verify it produces a 52-char string
613        let hash = rns_crypto::sha256::sha256(b"");
614        let encoded = base32_encode(&hash);
615        // 32 bytes * 8 bits / 5 bits = 51.2 -> 52 chars
616        assert_eq!(encoded.len(), 52);
617        // All lowercase letters and digits 2-7
618        assert!(encoded.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)));
619    }
620
621    // --- Destination tests ---
622
623    #[test]
624    fn destination_base32_address() {
625        let dest = Destination {
626            data: vec![0x42; 387], // dummy destination data
627        };
628        let addr = dest.base32_address();
629        assert!(addr.ends_with(".b32.i2p"));
630        // 52 chars of base32 + ".b32.i2p" = 60 chars
631        assert_eq!(addr.len(), 60);
632    }
633
634    #[test]
635    fn destination_roundtrip_base64() {
636        let data: Vec<u8> = (0..=255).cycle().take(387).collect();
637        let dest = Destination { data: data.clone() };
638        let b64 = dest.to_i2p_base64();
639        let dest2 = Destination::from_i2p_base64(&b64).unwrap();
640        assert_eq!(dest2.data, data);
641    }
642
643    // --- SAM response parsing tests ---
644
645    #[test]
646    fn parse_hello_reply() {
647        let line = "HELLO REPLY RESULT=OK VERSION=3.1";
648        let resp = parse_sam_response(line).unwrap();
649        assert_eq!(resp.command, "HELLO");
650        assert_eq!(resp.subcommand, "REPLY");
651        assert_eq!(resp.get("RESULT"), Some("OK"));
652        assert_eq!(resp.get("VERSION"), Some("3.1"));
653    }
654
655    #[test]
656    fn parse_session_status_ok() {
657        let line = "SESSION STATUS RESULT=OK DESTINATION=AAAA";
658        let resp = parse_sam_response(line).unwrap();
659        assert_eq!(resp.command, "SESSION");
660        assert_eq!(resp.subcommand, "STATUS");
661        assert_eq!(resp.get("RESULT"), Some("OK"));
662        assert_eq!(resp.get("DESTINATION"), Some("AAAA"));
663    }
664
665    #[test]
666    fn parse_session_status_error() {
667        let line = "SESSION STATUS RESULT=DUPLICATED_ID";
668        let resp = parse_sam_response(line).unwrap();
669        assert_eq!(resp.get("RESULT"), Some("DUPLICATED_ID"));
670        let err = check_result(&resp);
671        assert!(err.is_err());
672    }
673
674    #[test]
675    fn parse_stream_status_error() {
676        let line = "STREAM STATUS RESULT=CANT_REACH_PEER MESSAGE=unreachable";
677        let resp = parse_sam_response(line).unwrap();
678        assert_eq!(resp.get("RESULT"), Some("CANT_REACH_PEER"));
679        assert_eq!(resp.get("MESSAGE"), Some("unreachable"));
680        let err = check_result(&resp);
681        assert!(err.is_err());
682        if let Err(SamError::Protocol(msg)) = err {
683            assert!(msg.contains("CANT_REACH_PEER"));
684        }
685    }
686
687    #[test]
688    fn parse_naming_reply() {
689        let line = "NAMING REPLY RESULT=OK NAME=test.b32.i2p VALUE=AAAA";
690        let resp = parse_sam_response(line).unwrap();
691        assert_eq!(resp.command, "NAMING");
692        assert_eq!(resp.subcommand, "REPLY");
693        assert_eq!(resp.get("NAME"), Some("test.b32.i2p"));
694        assert_eq!(resp.get("VALUE"), Some("AAAA"));
695    }
696
697    #[test]
698    fn parse_naming_not_found() {
699        let line = "NAMING REPLY RESULT=KEY_NOT_FOUND";
700        let resp = parse_sam_response(line).unwrap();
701        let err = check_result(&resp);
702        assert!(err.is_err());
703    }
704
705    #[test]
706    fn parse_dest_reply() {
707        let line = "DEST REPLY PUB=AAAA PRIV=BBBB";
708        let resp = parse_sam_response(line).unwrap();
709        assert_eq!(resp.command, "DEST");
710        assert_eq!(resp.subcommand, "REPLY");
711        assert_eq!(resp.get("PUB"), Some("AAAA"));
712        assert_eq!(resp.get("PRIV"), Some("BBBB"));
713    }
714
715    #[test]
716    fn parse_stream_status_timeout() {
717        let line = "STREAM STATUS RESULT=TIMEOUT";
718        let resp = parse_sam_response(line).unwrap();
719        let err = check_result(&resp);
720        assert!(err.is_err());
721        if let Err(SamError::Protocol(msg)) = err {
722            assert!(msg.contains("TIMEOUT"));
723        }
724    }
725
726    #[test]
727    fn check_result_ok() {
728        let line = "TEST REPLY RESULT=OK";
729        let resp = parse_sam_response(line).unwrap();
730        assert!(check_result(&resp).is_ok());
731    }
732
733    #[test]
734    fn check_result_no_result_field() {
735        let line = "TEST REPLY FOO=BAR";
736        let resp = parse_sam_response(line).unwrap();
737        // No RESULT field is considered OK
738        assert!(check_result(&resp).is_ok());
739    }
740
741    #[test]
742    fn sam_error_display() {
743        let io_err = SamError::Io(io::Error::new(io::ErrorKind::Other, "test"));
744        assert!(format!("{}", io_err).contains("test"));
745
746        let proto_err = SamError::Protocol("CANT_REACH_PEER".into());
747        assert!(format!("{}", proto_err).contains("CANT_REACH_PEER"));
748
749        let inv_err = SamError::InvalidResponse("bad".into());
750        assert!(format!("{}", inv_err).contains("bad"));
751    }
752}