stem_rs/protocol.rs
1//! Control protocol message parsing for Tor control protocol.
2//!
3//! This module provides parsing utilities for control protocol responses,
4//! including single-line, multi-line, and data responses. It implements
5//! the message format defined in section 2.3 of the Tor control protocol
6//! specification.
7//!
8//! # Conceptual Role
9//!
10//! The protocol module handles the low-level parsing of control protocol
11//! messages. It sits between the raw socket communication ([`crate::socket`])
12//! and the high-level response types ([`crate::response`]).
13//!
14//! # Protocol Format
15//!
16//! The Tor control protocol uses a text-based format with the following structure:
17//!
18//! ## Request Format
19//!
20//! ```text
21//! COMMAND [ARGS]\r\n
22//! ```
23//!
24//! ## Response Format
25//!
26//! Each response line has the format:
27//!
28//! ```text
29//! STATUS DIVIDER MESSAGE\r\n
30//! ```
31//!
32//! Where:
33//! - `STATUS` is a 3-digit status code (e.g., 250 for success, 5xx for errors)
34//! - `DIVIDER` indicates the line type:
35//! - ` ` (space): Final line of the response
36//! - `-`: Continuation line (more lines follow)
37//! - `+`: Data line (multi-line data block follows)
38//! - `MESSAGE` is the response content
39//!
40//! ## Status Codes
41//!
42//! | Code | Meaning |
43//! |------|---------|
44//! | 250 | Success |
45//! | 251 | Operation unnecessary |
46//! | 5xx | Error (various types) |
47//! | 650 | Asynchronous event |
48//!
49//! # Single-Line Response Example
50//!
51//! ```text
52//! 250 OK
53//! ```
54//!
55//! # Multi-Line Response Example
56//!
57//! ```text
58//! 250-version=0.4.7.1
59//! 250-config-file=/etc/tor/torrc
60//! 250 OK
61//! ```
62//!
63//! # Data Response Example
64//!
65//! ```text
66//! 250+info/names=
67//! desc/id/* -- Router descriptors by ID.
68//! desc/name/* -- Router descriptors by nickname.
69//! .
70//! 250 OK
71//! ```
72//!
73//! # Example
74//!
75//! ```rust
76//! use stem_rs::protocol::{ParsedLine, ControlLine, format_command};
77//!
78//! // Parse a response line
79//! let line = ParsedLine::parse("250 OK").unwrap();
80//! assert_eq!(line.status_code, 250);
81//! assert!(line.is_final());
82//!
83//! // Parse key=value content
84//! let mut ctrl = ControlLine::new("key1=value1 key2=\"quoted value\"");
85//! let (k, v) = ctrl.pop_mapping(false, false).unwrap();
86//! assert_eq!(k, "key1");
87//! assert_eq!(v, "value1");
88//!
89//! // Format a command
90//! let cmd = format_command("GETINFO", &["version"]);
91//! assert_eq!(cmd, "GETINFO version\r\n");
92//! ```
93//!
94//! # Thread Safety
95//!
96//! [`ParsedLine`] is `Send` and `Sync` as it contains only owned data.
97//! [`ControlLine`] is `Send` but not `Sync` due to internal mutable state
98//! for tracking parse position.
99//!
100//! # See Also
101//!
102//! - [`crate::socket`]: Low-level socket communication
103//! - [`crate::response`]: High-level response parsing
104//! - [`crate::controller`]: High-level controller API
105
106use crate::Error;
107
108/// A parsed line from a Tor control protocol response.
109///
110/// Represents a single line of a control protocol response, broken down into
111/// its component parts: status code, divider character, and content.
112///
113/// # Protocol Format
114///
115/// Each response line has the format: `STATUS DIVIDER CONTENT`
116///
117/// - `status_code`: 3-digit numeric code indicating success/failure
118/// - `divider`: Single character indicating line type
119/// - `content`: The actual message content
120///
121/// # Divider Types
122///
123/// | Divider | Method | Meaning |
124/// |---------|--------|---------|
125/// | ` ` (space) | [`is_final()`](Self::is_final) | Final line of response |
126/// | `-` | [`is_continuation()`](Self::is_continuation) | More lines follow |
127/// | `+` | [`is_data()`](Self::is_data) | Multi-line data block follows |
128///
129/// # Example
130///
131/// ```rust
132/// use stem_rs::protocol::ParsedLine;
133///
134/// // Parse a success response
135/// let line = ParsedLine::parse("250 OK").unwrap();
136/// assert_eq!(line.status_code, 250);
137/// assert_eq!(line.divider, ' ');
138/// assert_eq!(line.content, "OK");
139/// assert!(line.is_final());
140///
141/// // Parse a continuation line
142/// let cont = ParsedLine::parse("250-version=0.4.7.1").unwrap();
143/// assert!(cont.is_continuation());
144///
145/// // Parse a data line
146/// let data = ParsedLine::parse("250+getinfo").unwrap();
147/// assert!(data.is_data());
148///
149/// // Parse an error response
150/// let err = ParsedLine::parse("515 Authentication failed").unwrap();
151/// assert_eq!(err.status_code, 515);
152/// ```
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct ParsedLine {
155 /// The 3-digit status code from the response.
156 ///
157 /// Common codes:
158 /// - `250`: Success
159 /// - `251`: Operation unnecessary
160 /// - `5xx`: Various error conditions
161 /// - `650`: Asynchronous event notification
162 pub status_code: u16,
163
164 /// The divider character indicating the line type.
165 ///
166 /// - `' '` (space): Final line of the response
167 /// - `'-'`: Continuation line (more lines follow)
168 /// - `'+'`: Data line (multi-line data block follows)
169 pub divider: char,
170
171 /// The content of the response line after the status code and divider.
172 ///
173 /// For success responses, this is typically "OK".
174 /// For error responses, this contains the error description.
175 /// For data responses, this may contain key=value pairs or other data.
176 pub content: String,
177}
178
179impl ParsedLine {
180 /// Parses a raw control protocol response line into its components.
181 ///
182 /// Takes a line from the control socket and extracts the status code,
183 /// divider character, and content. The line may optionally include
184 /// trailing `\r\n` characters which are stripped.
185 ///
186 /// # Arguments
187 ///
188 /// * `line` - The raw line to parse, with or without trailing CRLF
189 ///
190 /// # Returns
191 ///
192 /// A `ParsedLine` containing the extracted components.
193 ///
194 /// # Errors
195 ///
196 /// Returns [`Error::Protocol`] if:
197 /// - The line is shorter than 3 characters
198 /// - The first 3 characters are not a valid numeric status code
199 ///
200 /// # Example
201 ///
202 /// ```rust
203 /// use stem_rs::protocol::ParsedLine;
204 ///
205 /// // Parse with CRLF (as received from socket)
206 /// let line = ParsedLine::parse("250 OK\r\n").unwrap();
207 /// assert_eq!(line.status_code, 250);
208 /// assert_eq!(line.content, "OK");
209 ///
210 /// // Parse without CRLF
211 /// let line = ParsedLine::parse("250-version=0.4.7.1").unwrap();
212 /// assert_eq!(line.content, "version=0.4.7.1");
213 ///
214 /// // Error: line too short
215 /// assert!(ParsedLine::parse("25").is_err());
216 ///
217 /// // Error: invalid status code
218 /// assert!(ParsedLine::parse("abc OK").is_err());
219 /// ```
220 pub fn parse(line: &str) -> Result<Self, Error> {
221 let line = line.trim_end_matches(['\r', '\n']);
222 if line.len() < 3 {
223 return Err(Error::Protocol(format!("line too short: {}", line)));
224 }
225
226 let status_code: u16 = line[..3]
227 .parse()
228 .map_err(|_| Error::Protocol(format!("invalid status code: {}", &line[..3])))?;
229
230 let divider = if line.len() > 3 {
231 line.chars().nth(3).unwrap_or(' ')
232 } else {
233 ' '
234 };
235
236 let content = if line.len() > 4 {
237 line[4..].to_string()
238 } else {
239 String::new()
240 };
241
242 Ok(Self {
243 status_code,
244 divider,
245 content,
246 })
247 }
248
249 /// Returns `true` if this is the final line of a response.
250 ///
251 /// A final line has a space character as its divider, indicating
252 /// no more lines follow in this response.
253 ///
254 /// # Example
255 ///
256 /// ```rust
257 /// use stem_rs::protocol::ParsedLine;
258 ///
259 /// let final_line = ParsedLine::parse("250 OK").unwrap();
260 /// assert!(final_line.is_final());
261 ///
262 /// let cont_line = ParsedLine::parse("250-more data").unwrap();
263 /// assert!(!cont_line.is_final());
264 /// ```
265 pub fn is_final(&self) -> bool {
266 self.divider == ' '
267 }
268
269 /// Returns `true` if this is a continuation line.
270 ///
271 /// A continuation line has a `-` character as its divider, indicating
272 /// more lines follow in this response.
273 ///
274 /// # Example
275 ///
276 /// ```rust
277 /// use stem_rs::protocol::ParsedLine;
278 ///
279 /// let cont_line = ParsedLine::parse("250-version=0.4.7.1").unwrap();
280 /// assert!(cont_line.is_continuation());
281 ///
282 /// let final_line = ParsedLine::parse("250 OK").unwrap();
283 /// assert!(!final_line.is_continuation());
284 /// ```
285 pub fn is_continuation(&self) -> bool {
286 self.divider == '-'
287 }
288
289 /// Returns `true` if this is a data line.
290 ///
291 /// A data line has a `+` character as its divider, indicating a
292 /// multi-line data block follows. The data block is terminated
293 /// by a line containing only a period (`.`).
294 ///
295 /// # Example
296 ///
297 /// ```rust
298 /// use stem_rs::protocol::ParsedLine;
299 ///
300 /// let data_line = ParsedLine::parse("250+getinfo").unwrap();
301 /// assert!(data_line.is_data());
302 ///
303 /// let final_line = ParsedLine::parse("250 OK").unwrap();
304 /// assert!(!data_line.is_final());
305 /// ```
306 pub fn is_data(&self) -> bool {
307 self.divider == '+'
308 }
309}
310
311/// A parser for space-delimited control protocol response content.
312///
313/// `ControlLine` provides methods for parsing the content portion of control
314/// protocol responses, which often contain space-separated values and
315/// `KEY=VALUE` mappings. It maintains an internal position to track parsing
316/// progress.
317///
318/// # Conceptual Role
319///
320/// After extracting the content from a [`ParsedLine`], `ControlLine` is used
321/// to parse individual entries from that content. It supports:
322///
323/// - Unquoted values: `value1 value2 value3`
324/// - Quoted values: `"value with spaces"`
325/// - Key-value mappings: `key=value` or `key="quoted value"`
326/// - Escaped strings: `"value with \"quotes\" and \\backslashes"`
327///
328/// # What This Type Does NOT Do
329///
330/// - Parse the status code or divider (use [`ParsedLine`] for that)
331/// - Handle multi-line data blocks
332/// - Validate semantic correctness of values
333///
334/// # Thread Safety
335///
336/// `ControlLine` is `Send` but not `Sync` due to internal mutable state
337/// for tracking the parse position. For concurrent access, create separate
338/// `ControlLine` instances.
339///
340/// # Example
341///
342/// ```rust
343/// use stem_rs::protocol::ControlLine;
344///
345/// // Parse space-separated values
346/// let mut line = ControlLine::new("hello world test");
347/// assert_eq!(line.pop(false, false).unwrap(), "hello");
348/// assert_eq!(line.pop(false, false).unwrap(), "world");
349/// assert_eq!(line.pop(false, false).unwrap(), "test");
350/// assert!(line.is_empty());
351///
352/// // Parse quoted values
353/// let mut line = ControlLine::new("\"hello world\" test");
354/// assert_eq!(line.pop(true, false).unwrap(), "hello world");
355/// assert_eq!(line.pop(false, false).unwrap(), "test");
356///
357/// // Parse key=value mappings
358/// let mut line = ControlLine::new("key=value other=\"quoted\"");
359/// let (k, v) = line.pop_mapping(false, false).unwrap();
360/// assert_eq!(k, "key");
361/// assert_eq!(v, "value");
362/// let (k2, v2) = line.pop_mapping(true, false).unwrap();
363/// assert_eq!(k2, "other");
364/// assert_eq!(v2, "quoted");
365/// ```
366pub struct ControlLine {
367 /// The full content string being parsed.
368 content: String,
369 /// Current position in the content string.
370 position: usize,
371}
372
373impl ControlLine {
374 /// Creates a new `ControlLine` parser for the given content.
375 ///
376 /// The parser starts at the beginning of the content string.
377 ///
378 /// # Arguments
379 ///
380 /// * `content` - The content string to parse
381 ///
382 /// # Example
383 ///
384 /// ```rust
385 /// use stem_rs::protocol::ControlLine;
386 ///
387 /// let line = ControlLine::new("key=value other=data");
388 /// assert!(!line.is_empty());
389 /// ```
390 pub fn new(content: &str) -> Self {
391 Self {
392 content: content.to_string(),
393 position: 0,
394 }
395 }
396
397 /// Returns the unparsed remainder of the content.
398 ///
399 /// Leading whitespace is trimmed from the returned string.
400 ///
401 /// # Example
402 ///
403 /// ```rust
404 /// use stem_rs::protocol::ControlLine;
405 ///
406 /// let mut line = ControlLine::new("hello world");
407 /// assert_eq!(line.remainder(), "hello world");
408 /// line.pop(false, false).unwrap();
409 /// assert_eq!(line.remainder(), "world");
410 /// ```
411 pub fn remainder(&self) -> &str {
412 self.content[self.position..].trim_start()
413 }
414
415 /// Returns `true` if there is no more content to parse.
416 ///
417 /// # Example
418 ///
419 /// ```rust
420 /// use stem_rs::protocol::ControlLine;
421 ///
422 /// let mut line = ControlLine::new("hello");
423 /// assert!(!line.is_empty());
424 /// line.pop(false, false).unwrap();
425 /// assert!(line.is_empty());
426 ///
427 /// let empty = ControlLine::new("");
428 /// assert!(empty.is_empty());
429 /// ```
430 pub fn is_empty(&self) -> bool {
431 self.remainder().is_empty()
432 }
433
434 /// Returns `true` if the next entry is a quoted value.
435 ///
436 /// Checks if the next non-whitespace character is a double quote (`"`).
437 ///
438 /// # Example
439 ///
440 /// ```rust
441 /// use stem_rs::protocol::ControlLine;
442 ///
443 /// let line = ControlLine::new("\"quoted\" unquoted");
444 /// assert!(line.is_next_quoted());
445 ///
446 /// let line2 = ControlLine::new("unquoted \"quoted\"");
447 /// assert!(!line2.is_next_quoted());
448 /// ```
449 pub fn is_next_quoted(&self) -> bool {
450 self.remainder().starts_with('"')
451 }
452
453 /// Returns `true` if the next entry is a `KEY=VALUE` mapping.
454 ///
455 /// Optionally checks that the key matches a specific value and/or
456 /// that the value is quoted.
457 ///
458 /// # Arguments
459 ///
460 /// * `key` - If `Some`, checks that the key matches this value
461 /// * `quoted` - If `true`, checks that the value is quoted
462 ///
463 /// # Example
464 ///
465 /// ```rust
466 /// use stem_rs::protocol::ControlLine;
467 ///
468 /// let line = ControlLine::new("key=value");
469 /// assert!(line.is_next_mapping(None, false));
470 /// assert!(line.is_next_mapping(Some("key"), false));
471 /// assert!(!line.is_next_mapping(Some("other"), false));
472 ///
473 /// let quoted = ControlLine::new("key=\"value\"");
474 /// assert!(quoted.is_next_mapping(None, true));
475 /// assert!(quoted.is_next_mapping(Some("key"), true));
476 /// ```
477 pub fn is_next_mapping(&self, key: Option<&str>, quoted: bool) -> bool {
478 let rest = self.remainder();
479 if let Some(eq_pos) = rest.find('=') {
480 if let Some(expected_key) = key {
481 let actual_key = &rest[..eq_pos];
482 if actual_key != expected_key {
483 return false;
484 }
485 }
486 if quoted {
487 rest.get(eq_pos + 1..).is_some_and(|s| s.starts_with('"'))
488 } else {
489 true
490 }
491 } else {
492 false
493 }
494 }
495
496 /// Returns the key of the next entry if it's a `KEY=VALUE` mapping.
497 ///
498 /// Returns `None` if the next entry is not a mapping.
499 ///
500 /// # Example
501 ///
502 /// ```rust
503 /// use stem_rs::protocol::ControlLine;
504 ///
505 /// let line = ControlLine::new("mykey=myvalue");
506 /// assert_eq!(line.peek_key(), Some("mykey"));
507 ///
508 /// let no_mapping = ControlLine::new("just a value");
509 /// assert_eq!(no_mapping.peek_key(), None);
510 /// ```
511 pub fn peek_key(&self) -> Option<&str> {
512 let rest = self.remainder();
513 rest.find('=').map(|pos| &rest[..pos])
514 }
515
516 /// Removes and returns the next space-separated entry.
517 ///
518 /// Advances the internal position past the extracted entry.
519 ///
520 /// # Arguments
521 ///
522 /// * `quoted` - If `true`, expects and removes surrounding quotes
523 /// * `escaped` - If `true`, processes escape sequences in the value
524 ///
525 /// # Escape Sequences
526 ///
527 /// When `escaped` is `true`, the following sequences are processed:
528 /// - `\\n` → newline
529 /// - `\\r` → carriage return
530 /// - `\\t` → tab
531 /// - `\\\\` → backslash
532 /// - `\\"` → double quote
533 ///
534 /// # Errors
535 ///
536 /// Returns [`Error::Protocol`] if:
537 /// - No more content to parse
538 /// - `quoted` is `true` but the next entry doesn't start with a quote
539 /// - A quoted string is not properly terminated
540 ///
541 /// # Example
542 ///
543 /// ```rust
544 /// use stem_rs::protocol::ControlLine;
545 ///
546 /// // Unquoted values
547 /// let mut line = ControlLine::new("hello world");
548 /// assert_eq!(line.pop(false, false).unwrap(), "hello");
549 /// assert_eq!(line.pop(false, false).unwrap(), "world");
550 ///
551 /// // Quoted values
552 /// let mut line = ControlLine::new("\"hello world\" test");
553 /// assert_eq!(line.pop(true, false).unwrap(), "hello world");
554 ///
555 /// // Escaped values
556 /// let mut line = ControlLine::new("\"hello\\nworld\"");
557 /// assert_eq!(line.pop(true, true).unwrap(), "hello\nworld");
558 /// ```
559 pub fn pop(&mut self, quoted: bool, escaped: bool) -> Result<String, Error> {
560 let rest = self.remainder();
561 if rest.is_empty() {
562 return Err(Error::Protocol("no more content to pop".to_string()));
563 }
564
565 if quoted {
566 if !rest.starts_with('"') {
567 return Err(Error::Protocol("expected quoted string".to_string()));
568 }
569 let after_quote = &rest[1..];
570 let end_pos = find_closing_quote(after_quote, escaped)?;
571 let value = if escaped {
572 unescape_string(&after_quote[..end_pos])
573 } else {
574 after_quote[..end_pos].to_string()
575 };
576 self.position = self.content.len() - rest.len() + 2 + end_pos;
577 if self.position < self.content.len() && self.content.as_bytes()[self.position] == b' '
578 {
579 self.position += 1;
580 }
581 Ok(value)
582 } else {
583 let end_pos = rest.find(' ').unwrap_or(rest.len());
584 let value = rest[..end_pos].to_string();
585 self.position = self.content.len() - rest.len() + end_pos;
586 if self.position < self.content.len() {
587 self.position += 1;
588 }
589 Ok(value)
590 }
591 }
592
593 /// Removes and returns the next `KEY=VALUE` mapping.
594 ///
595 /// Parses the next entry as a key-value pair separated by `=`.
596 /// Advances the internal position past the extracted mapping.
597 ///
598 /// # Arguments
599 ///
600 /// * `quoted` - If `true`, expects the value to be quoted
601 /// * `escaped` - If `true`, processes escape sequences in the value
602 ///
603 /// # Errors
604 ///
605 /// Returns [`Error::Protocol`] if:
606 /// - The next entry is not a `KEY=VALUE` mapping
607 /// - `quoted` is `true` but the value is not quoted
608 /// - A quoted string is not properly terminated
609 ///
610 /// # Example
611 ///
612 /// ```rust
613 /// use stem_rs::protocol::ControlLine;
614 ///
615 /// // Simple mapping
616 /// let mut line = ControlLine::new("key=value other=data");
617 /// let (k, v) = line.pop_mapping(false, false).unwrap();
618 /// assert_eq!(k, "key");
619 /// assert_eq!(v, "value");
620 ///
621 /// // Quoted mapping
622 /// let mut line = ControlLine::new("key=\"hello world\"");
623 /// let (k, v) = line.pop_mapping(true, false).unwrap();
624 /// assert_eq!(k, "key");
625 /// assert_eq!(v, "hello world");
626 ///
627 /// // Error: not a mapping
628 /// let mut line = ControlLine::new("not_a_mapping");
629 /// assert!(line.pop_mapping(false, false).is_err());
630 /// ```
631 pub fn pop_mapping(&mut self, quoted: bool, escaped: bool) -> Result<(String, String), Error> {
632 let rest = self.remainder();
633 let eq_pos = rest
634 .find('=')
635 .ok_or_else(|| Error::Protocol(format!("expected key=value mapping in: {}", rest)))?;
636
637 let key = rest[..eq_pos].to_string();
638 self.position = self.content.len() - rest.len() + eq_pos + 1;
639
640 let value = self.pop(quoted, escaped)?;
641 Ok((key, value))
642 }
643}
644
645/// Finds the position of the closing quote in a string.
646///
647/// Searches for the closing `"` character, optionally handling escape
648/// sequences where `\"` does not count as a closing quote.
649///
650/// # Arguments
651///
652/// * `s` - The string to search (should not include the opening quote)
653/// * `escaped` - If `true`, `\"` sequences are skipped
654///
655/// # Errors
656///
657/// Returns [`Error::Protocol`] if no closing quote is found.
658fn find_closing_quote(s: &str, escaped: bool) -> Result<usize, Error> {
659 let mut pos = 0;
660 let bytes = s.as_bytes();
661 while pos < bytes.len() {
662 if bytes[pos] == b'"' {
663 return Ok(pos);
664 }
665 if escaped && bytes[pos] == b'\\' && pos + 1 < bytes.len() {
666 pos += 2;
667 } else {
668 pos += 1;
669 }
670 }
671 Err(Error::Protocol("unterminated quoted string".to_string()))
672}
673
674/// Processes escape sequences in a string.
675///
676/// Converts escape sequences to their actual characters:
677/// - `\n` → newline
678/// - `\r` → carriage return
679/// - `\t` → tab
680/// - `\\` → backslash
681/// - `\"` → double quote
682///
683/// Unknown escape sequences are preserved as-is (e.g., `\x` → `\x`).
684fn unescape_string(s: &str) -> String {
685 let mut result = String::with_capacity(s.len());
686 let mut chars = s.chars().peekable();
687 while let Some(c) = chars.next() {
688 if c == '\\' {
689 if let Some(&next) = chars.peek() {
690 chars.next();
691 match next {
692 'n' => result.push('\n'),
693 'r' => result.push('\r'),
694 't' => result.push('\t'),
695 '\\' => result.push('\\'),
696 '"' => result.push('"'),
697 _ => {
698 result.push('\\');
699 result.push(next);
700 }
701 }
702 } else {
703 result.push('\\');
704 }
705 } else {
706 result.push(c);
707 }
708 }
709 result
710}
711
712/// Formats a control protocol command with arguments.
713///
714/// Creates a properly formatted command string ready to send to the
715/// control socket. The command is terminated with `\r\n` as required
716/// by the protocol.
717///
718/// # Arguments
719///
720/// * `command` - The command name (e.g., "GETINFO", "SETCONF")
721/// * `args` - Slice of argument strings to append to the command
722///
723/// # Returns
724///
725/// A formatted command string ending with `\r\n`.
726///
727/// # Example
728///
729/// ```rust
730/// use stem_rs::protocol::format_command;
731///
732/// // Command without arguments
733/// let cmd = format_command("AUTHENTICATE", &[]);
734/// assert_eq!(cmd, "AUTHENTICATE\r\n");
735///
736/// // Command with single argument
737/// let cmd = format_command("GETINFO", &["version"]);
738/// assert_eq!(cmd, "GETINFO version\r\n");
739///
740/// // Command with multiple arguments
741/// let cmd = format_command("SETCONF", &["key1=value1", "key2=value2"]);
742/// assert_eq!(cmd, "SETCONF key1=value1 key2=value2\r\n");
743/// ```
744pub fn format_command(command: &str, args: &[&str]) -> String {
745 if args.is_empty() {
746 format!("{}\r\n", command)
747 } else {
748 format!("{} {}\r\n", command, args.join(" "))
749 }
750}
751
752/// Quotes a string for use in control protocol commands.
753///
754/// Wraps the string in double quotes and escapes special characters:
755/// - `"` → `\"`
756/// - `\` → `\\`
757/// - newline → `\n`
758/// - carriage return → `\r`
759/// - tab → `\t`
760///
761/// This is the inverse of the unescaping performed by [`ControlLine::pop`]
762/// with `escaped = true`.
763///
764/// # Arguments
765///
766/// * `s` - The string to quote
767///
768/// # Returns
769///
770/// A quoted and escaped string.
771///
772/// # Example
773///
774/// ```rust
775/// use stem_rs::protocol::quote_string;
776///
777/// // Simple string
778/// assert_eq!(quote_string("hello"), "\"hello\"");
779///
780/// // String with special characters
781/// assert_eq!(quote_string("hello\nworld"), "\"hello\\nworld\"");
782/// assert_eq!(quote_string("say \"hi\""), "\"say \\\"hi\\\"\"");
783/// assert_eq!(quote_string("path\\to\\file"), "\"path\\\\to\\\\file\"");
784/// ```
785///
786/// # Round-Trip Property
787///
788/// For any string `s`, quoting and then unquoting should return the
789/// original string:
790///
791/// ```rust
792/// use stem_rs::protocol::{quote_string, ControlLine};
793///
794/// let original = "hello\nworld";
795/// let quoted = quote_string(original);
796/// let mut line = ControlLine::new("ed);
797/// let unquoted = line.pop(true, true).unwrap();
798/// assert_eq!(original, unquoted);
799/// ```
800pub fn quote_string(s: &str) -> String {
801 let mut result = String::with_capacity(s.len() + 2);
802 result.push('"');
803 for c in s.chars() {
804 match c {
805 '"' => result.push_str("\\\""),
806 '\\' => result.push_str("\\\\"),
807 '\n' => result.push_str("\\n"),
808 '\r' => result.push_str("\\r"),
809 '\t' => result.push_str("\\t"),
810 _ => result.push(c),
811 }
812 }
813 result.push('"');
814 result
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn test_parse_line_simple() {
823 let parsed = ParsedLine::parse("250 OK").unwrap();
824 assert_eq!(parsed.status_code, 250);
825 assert_eq!(parsed.divider, ' ');
826 assert_eq!(parsed.content, "OK");
827 assert!(parsed.is_final());
828 }
829
830 #[test]
831 fn test_parse_line_continuation() {
832 let parsed = ParsedLine::parse("250-version=0.4.7.1").unwrap();
833 assert_eq!(parsed.status_code, 250);
834 assert_eq!(parsed.divider, '-');
835 assert_eq!(parsed.content, "version=0.4.7.1");
836 assert!(parsed.is_continuation());
837 }
838
839 #[test]
840 fn test_parse_line_data() {
841 let parsed = ParsedLine::parse("250+getinfo").unwrap();
842 assert_eq!(parsed.status_code, 250);
843 assert_eq!(parsed.divider, '+');
844 assert_eq!(parsed.content, "getinfo");
845 assert!(parsed.is_data());
846 }
847
848 #[test]
849 fn test_parse_line_error() {
850 let parsed = ParsedLine::parse("515 Authentication failed").unwrap();
851 assert_eq!(parsed.status_code, 515);
852 assert_eq!(parsed.divider, ' ');
853 assert_eq!(parsed.content, "Authentication failed");
854 }
855
856 #[test]
857 fn test_parse_line_too_short() {
858 assert!(ParsedLine::parse("25").is_err());
859 }
860
861 #[test]
862 fn test_parse_line_invalid_code() {
863 assert!(ParsedLine::parse("abc OK").is_err());
864 }
865
866 #[test]
867 fn test_control_line_pop_unquoted() {
868 let mut line = ControlLine::new("hello world test");
869 assert_eq!(line.pop(false, false).unwrap(), "hello");
870 assert_eq!(line.pop(false, false).unwrap(), "world");
871 assert_eq!(line.pop(false, false).unwrap(), "test");
872 assert!(line.is_empty());
873 }
874
875 #[test]
876 fn test_control_line_pop_quoted() {
877 let mut line = ControlLine::new("\"hello world\" test");
878 assert_eq!(line.pop(true, false).unwrap(), "hello world");
879 assert_eq!(line.pop(false, false).unwrap(), "test");
880 }
881
882 #[test]
883 fn test_control_line_pop_escaped() {
884 let mut line = ControlLine::new("\"hello\\nworld\" test");
885 assert_eq!(line.pop(true, true).unwrap(), "hello\nworld");
886 }
887
888 #[test]
889 fn test_control_line_pop_mapping() {
890 let mut line = ControlLine::new("key=value other=data");
891 let (k, v) = line.pop_mapping(false, false).unwrap();
892 assert_eq!(k, "key");
893 assert_eq!(v, "value");
894 let (k2, v2) = line.pop_mapping(false, false).unwrap();
895 assert_eq!(k2, "other");
896 assert_eq!(v2, "data");
897 }
898
899 #[test]
900 fn test_control_line_pop_mapping_quoted() {
901 let mut line = ControlLine::new("key=\"hello world\"");
902 let (k, v) = line.pop_mapping(true, false).unwrap();
903 assert_eq!(k, "key");
904 assert_eq!(v, "hello world");
905 }
906
907 #[test]
908 fn test_control_line_is_next_mapping() {
909 let line = ControlLine::new("key=value");
910 assert!(line.is_next_mapping(None, false));
911 assert!(line.is_next_mapping(Some("key"), false));
912 assert!(!line.is_next_mapping(Some("other"), false));
913 }
914
915 #[test]
916 fn test_control_line_peek_key() {
917 let line = ControlLine::new("mykey=myvalue");
918 assert_eq!(line.peek_key(), Some("mykey"));
919 }
920
921 #[test]
922 fn test_format_command_no_args() {
923 assert_eq!(format_command("AUTHENTICATE", &[]), "AUTHENTICATE\r\n");
924 }
925
926 #[test]
927 fn test_format_command_with_args() {
928 assert_eq!(
929 format_command("GETINFO", &["version", "config-file"]),
930 "GETINFO version config-file\r\n"
931 );
932 }
933
934 #[test]
935 fn test_quote_string_simple() {
936 assert_eq!(quote_string("hello"), "\"hello\"");
937 }
938
939 #[test]
940 fn test_quote_string_with_escapes() {
941 assert_eq!(quote_string("hello\nworld"), "\"hello\\nworld\"");
942 assert_eq!(quote_string("say \"hi\""), "\"say \\\"hi\\\"\"");
943 assert_eq!(quote_string("path\\to\\file"), "\"path\\\\to\\\\file\"");
944 }
945
946 #[test]
947 fn test_unescape_string() {
948 assert_eq!(unescape_string("hello\\nworld"), "hello\nworld");
949 assert_eq!(unescape_string("say \\\"hi\\\""), "say \"hi\"");
950 assert_eq!(unescape_string("path\\\\to"), "path\\to");
951 assert_eq!(unescape_string("tab\\there"), "tab\there");
952 }
953
954 #[test]
955 fn test_parse_line_with_crlf() {
956 let parsed = ParsedLine::parse("250 OK\r\n").unwrap();
957 assert_eq!(parsed.status_code, 250);
958 assert_eq!(parsed.content, "OK");
959 }
960
961 #[test]
962 fn test_control_line_empty() {
963 let line = ControlLine::new("");
964 assert!(line.is_empty());
965 }
966
967 #[test]
968 fn test_control_line_whitespace_handling() {
969 let mut line = ControlLine::new(" hello world ");
970 assert_eq!(line.pop(false, false).unwrap(), "hello");
971 assert_eq!(line.pop(false, false).unwrap(), "world");
972 }
973
974 #[test]
975 fn test_parse_line_status_codes() {
976 let codes = [200, 250, 251, 500, 510, 515, 550, 650];
977 for code in codes {
978 let line = format!("{} Test message", code);
979 let parsed = ParsedLine::parse(&line).unwrap();
980 assert_eq!(parsed.status_code, code);
981 }
982 }
983
984 #[test]
985 fn test_control_line_complex_mapping() {
986 let mut line = ControlLine::new("key1=value1 key2=\"quoted value\" key3=value3");
987
988 let (k1, v1) = line.pop_mapping(false, false).unwrap();
989 assert_eq!(k1, "key1");
990 assert_eq!(v1, "value1");
991
992 let (k2, v2) = line.pop_mapping(true, false).unwrap();
993 assert_eq!(k2, "key2");
994 assert_eq!(v2, "quoted value");
995
996 let (k3, v3) = line.pop_mapping(false, false).unwrap();
997 assert_eq!(k3, "key3");
998 assert_eq!(v3, "value3");
999 }
1000
1001 #[test]
1002 fn test_control_line_escaped_quotes() {
1003 let mut line = ControlLine::new("\"hello \\\"world\\\"\"");
1004 let value = line.pop(true, true).unwrap();
1005 assert_eq!(value, "hello \"world\"");
1006 }
1007
1008 #[test]
1009 fn test_control_line_escaped_backslash() {
1010 let mut line = ControlLine::new("\"path\\\\to\\\\file\"");
1011 let value = line.pop(true, true).unwrap();
1012 assert_eq!(value, "path\\to\\file");
1013 }
1014
1015 #[test]
1016 fn test_control_line_all_escape_sequences() {
1017 let mut line = ControlLine::new("\"\\n\\r\\t\\\\\\\"\"");
1018 let value = line.pop(true, true).unwrap();
1019 assert_eq!(value, "\n\r\t\\\"");
1020 }
1021
1022 #[test]
1023 fn test_quote_string_all_special_chars() {
1024 let input = "hello\nworld\r\ttab\\backslash\"quote";
1025 let quoted = quote_string(input);
1026 assert_eq!(quoted, "\"hello\\nworld\\r\\ttab\\\\backslash\\\"quote\"");
1027 }
1028
1029 #[test]
1030 fn test_format_command_single_arg() {
1031 assert_eq!(
1032 format_command("GETINFO", &["version"]),
1033 "GETINFO version\r\n"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_format_command_multiple_args() {
1039 assert_eq!(
1040 format_command("SETCONF", &["key1=value1", "key2=value2"]),
1041 "SETCONF key1=value1 key2=value2\r\n"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_parsed_line_divider_types() {
1047 let final_line = ParsedLine::parse("250 OK").unwrap();
1048 assert!(final_line.is_final());
1049 assert!(!final_line.is_continuation());
1050 assert!(!final_line.is_data());
1051
1052 let cont_line = ParsedLine::parse("250-More data").unwrap();
1053 assert!(!cont_line.is_final());
1054 assert!(cont_line.is_continuation());
1055 assert!(!cont_line.is_data());
1056
1057 let data_line = ParsedLine::parse("250+Data block").unwrap();
1058 assert!(!data_line.is_final());
1059 assert!(!data_line.is_continuation());
1060 assert!(data_line.is_data());
1061 }
1062
1063 #[test]
1064 fn test_control_line_is_next_quoted() {
1065 let line = ControlLine::new("\"quoted\" unquoted");
1066 assert!(line.is_next_quoted());
1067
1068 let line2 = ControlLine::new("unquoted \"quoted\"");
1069 assert!(!line2.is_next_quoted());
1070 }
1071
1072 #[test]
1073 fn test_control_line_pop_error_on_empty() {
1074 let mut line = ControlLine::new("");
1075 assert!(line.pop(false, false).is_err());
1076 }
1077
1078 #[test]
1079 fn test_control_line_pop_error_on_missing_quote() {
1080 let mut line = ControlLine::new("unquoted");
1081 assert!(line.pop(true, false).is_err());
1082 }
1083}
1084
1085#[cfg(test)]
1086mod proptests {
1087 use super::*;
1088 use proptest::char::range as char_range;
1089 use proptest::prelude::*;
1090
1091 fn valid_status_code() -> impl Strategy<Value = u16> {
1092 prop_oneof![
1093 Just(200u16),
1094 Just(250u16),
1095 Just(251u16),
1096 Just(500u16),
1097 Just(510u16),
1098 Just(515u16),
1099 Just(550u16),
1100 Just(650u16),
1101 ]
1102 }
1103
1104 fn safe_content_char() -> impl Strategy<Value = char> {
1105 prop_oneof![
1106 char_range('a', 'z'),
1107 char_range('A', 'Z'),
1108 char_range('0', '9'),
1109 Just(' '),
1110 Just('='),
1111 Just('-'),
1112 Just('_'),
1113 Just('.'),
1114 Just('/'),
1115 ]
1116 }
1117
1118 fn safe_content_string() -> impl Strategy<Value = String> {
1119 proptest::collection::vec(safe_content_char(), 0..50)
1120 .prop_map(|chars| chars.into_iter().collect())
1121 }
1122
1123 proptest! {
1124 #![proptest_config(ProptestConfig::with_cases(100))]
1125
1126 #[test]
1127 fn prop_parsed_line_roundtrip(
1128 status_code in valid_status_code(),
1129 content in safe_content_string()
1130 ) {
1131 let line = format!("{} {}", status_code, content);
1132 let parsed = ParsedLine::parse(&line).expect("should parse");
1133 prop_assert_eq!(parsed.status_code, status_code);
1134 prop_assert_eq!(parsed.divider, ' ');
1135 prop_assert_eq!(parsed.content, content);
1136 }
1137
1138 #[test]
1139 fn prop_parsed_line_continuation_roundtrip(
1140 status_code in valid_status_code(),
1141 content in safe_content_string()
1142 ) {
1143 let line = format!("{}-{}", status_code, content);
1144 let parsed = ParsedLine::parse(&line).expect("should parse");
1145 prop_assert_eq!(parsed.status_code, status_code);
1146 prop_assert_eq!(parsed.divider, '-');
1147 prop_assert!(parsed.is_continuation());
1148 }
1149
1150 #[test]
1151 fn prop_quote_unquote_roundtrip(content in safe_content_string()) {
1152 let quoted = quote_string(&content);
1153 prop_assert!(quoted.starts_with('"'));
1154 prop_assert!(quoted.ends_with('"'));
1155 let inner = "ed[1..quoted.len()-1];
1156 let unquoted = unescape_string(inner);
1157 prop_assert_eq!(content, unquoted);
1158 }
1159
1160 #[test]
1161 fn prop_format_command_ends_with_crlf(
1162 cmd in "[A-Z]{3,10}",
1163 args in proptest::collection::vec("[a-z0-9]{1,10}", 0..3)
1164 ) {
1165 let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1166 let formatted = format_command(&cmd, &args_refs);
1167 prop_assert!(formatted.ends_with("\r\n"));
1168 prop_assert!(formatted.starts_with(&cmd));
1169 }
1170
1171 #[test]
1172 fn prop_control_line_pop_preserves_content(
1173 word1 in "[a-z]{1,10}",
1174 word2 in "[a-z]{1,10}",
1175 word3 in "[a-z]{1,10}"
1176 ) {
1177 let content = format!("{} {} {}", word1, word2, word3);
1178 let mut line = ControlLine::new(&content);
1179 let popped1 = line.pop(false, false).expect("should pop");
1180 let popped2 = line.pop(false, false).expect("should pop");
1181 let popped3 = line.pop(false, false).expect("should pop");
1182 prop_assert_eq!(popped1, word1);
1183 prop_assert_eq!(popped2, word2);
1184 prop_assert_eq!(popped3, word3);
1185 prop_assert!(line.is_empty());
1186 }
1187
1188 #[test]
1189 fn prop_control_line_mapping_roundtrip(
1190 key in "[a-z]{1,10}",
1191 value in "[a-z0-9]{1,10}"
1192 ) {
1193 let content = format!("{}={}", key, value);
1194 let mut line = ControlLine::new(&content);
1195 let (k, v) = line.pop_mapping(false, false).expect("should pop mapping");
1196 prop_assert_eq!(k, key);
1197 prop_assert_eq!(v, value);
1198 }
1199 }
1200}