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