stem_rs/response/
getconf.rs

1//! GETCONF response parsing.
2//!
3//! This module parses responses from the `GETCONF` command, which retrieves
4//! Tor configuration values. Configuration options can have single values,
5//! multiple values (like exit policies), or no value (unset options).
6//!
7//! # Response Format
8//!
9//! A successful GETCONF response contains key-value pairs:
10//!
11//! ```text
12//! 250-CookieAuthentication=0
13//! 250-ControlPort=9100
14//! 250-DataDirectory=/home/user/.tor
15//! 250 DirPort
16//! ```
17//!
18//! Options without values (like `DirPort` above) indicate the option is unset
19//! or using its default value.
20//!
21//! # Example
22//!
23//! ```rust
24//! use stem_rs::response::{ControlMessage, GetConfResponse};
25//!
26//! let response_text = "250-CookieAuthentication=0\r\n\
27//!                      250-ControlPort=9100\r\n\
28//!                      250 OK\r\n";
29//! let msg = ControlMessage::from_str(response_text, None, false).unwrap();
30//! let response = GetConfResponse::from_message(&msg).unwrap();
31//!
32//! // Single-value options return a Vec with one element
33//! assert_eq!(
34//!     response.entries.get("CookieAuthentication"),
35//!     Some(&vec!["0".to_string()])
36//! );
37//! ```
38//!
39//! # Multi-Value Options
40//!
41//! Some options like `ExitPolicy` can have multiple values:
42//!
43//! ```rust
44//! use stem_rs::response::{ControlMessage, GetConfResponse};
45//!
46//! let response_text = "250-ExitPolicy=accept *:80\r\n\
47//!                      250-ExitPolicy=accept *:443\r\n\
48//!                      250-ExitPolicy=reject *:*\r\n\
49//!                      250 OK\r\n";
50//! let msg = ControlMessage::from_str(response_text, None, false).unwrap();
51//! let response = GetConfResponse::from_message(&msg).unwrap();
52//!
53//! let policies = response.entries.get("ExitPolicy").unwrap();
54//! assert_eq!(policies.len(), 3);
55//! ```
56//!
57//! # See Also
58//!
59//! - [`crate::Controller::get_conf`]: High-level API for getting configuration
60//! - [`GetInfoResponse`]: For querying runtime information
61//! - [Tor Control Protocol: GETCONF](https://spec.torproject.org/control-spec/commands.html#getconf)
62
63use std::collections::HashMap;
64
65use super::ControlMessage;
66use crate::Error;
67
68/// Parsed response from the GETCONF command.
69///
70/// Contains a mapping of configuration option names to their values.
71/// Options can have zero, one, or multiple values.
72///
73/// # Value Semantics
74///
75/// - **Empty Vec**: Option is unset or using default value
76/// - **Single element**: Option has one value
77/// - **Multiple elements**: Option has multiple values (e.g., ExitPolicy)
78///
79/// # Example
80///
81/// ```rust
82/// use stem_rs::response::{ControlMessage, GetConfResponse};
83///
84/// let msg = ControlMessage::from_str(
85///     "250-ControlPort=9051\r\n\
86///      250-DirPort\r\n\
87///      250 OK\r\n",
88///     None,
89///     false
90/// ).unwrap();
91///
92/// let response = GetConfResponse::from_message(&msg).unwrap();
93///
94/// // ControlPort has a value
95/// assert_eq!(
96///     response.entries.get("ControlPort"),
97///     Some(&vec!["9051".to_string()])
98/// );
99///
100/// // DirPort is unset (empty Vec)
101/// assert_eq!(response.entries.get("DirPort"), Some(&vec![]));
102/// ```
103#[derive(Debug, Clone)]
104pub struct GetConfResponse {
105    /// Mapping of configuration option names to their values.
106    ///
107    /// Each key is a configuration option name (e.g., "ControlPort").
108    /// Each value is a Vec of strings:
109    /// - Empty Vec: option is unset
110    /// - Single element: option has one value
111    /// - Multiple elements: option has multiple values
112    pub entries: HashMap<String, Vec<String>>,
113}
114
115impl GetConfResponse {
116    /// Parses a GETCONF response from a control message.
117    ///
118    /// Extracts configuration option names and their values from the response.
119    ///
120    /// # Arguments
121    ///
122    /// * `message` - The control message to parse
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if:
127    /// - [`Error::InvalidArguments`](crate::Error::InvalidArguments): One or more
128    ///   requested configuration options were not recognized by Tor
129    /// - [`Error::Protocol`](crate::Error::Protocol): The response had a non-OK
130    ///   status code for other reasons
131    ///
132    /// # Example
133    ///
134    /// ```rust
135    /// use stem_rs::response::{ControlMessage, GetConfResponse};
136    ///
137    /// // Successful response
138    /// let msg = ControlMessage::from_str(
139    ///     "250-SocksPort=9050\r\n250 OK\r\n",
140    ///     None,
141    ///     false
142    /// ).unwrap();
143    /// let response = GetConfResponse::from_message(&msg).unwrap();
144    /// assert_eq!(
145    ///     response.entries.get("SocksPort"),
146    ///     Some(&vec!["9050".to_string()])
147    /// );
148    ///
149    /// // Empty response (no options requested)
150    /// let msg = ControlMessage::from_str("250 OK\r\n", None, false).unwrap();
151    /// let response = GetConfResponse::from_message(&msg).unwrap();
152    /// assert!(response.entries.is_empty());
153    /// ```
154    pub fn from_message(message: &ControlMessage) -> Result<Self, Error> {
155        let mut entries: HashMap<String, Vec<String>> = HashMap::new();
156
157        let content = message.content();
158
159        if content == vec![("250".to_string(), ' ', "OK".to_string())] {
160            return Ok(Self { entries });
161        }
162
163        if !message.is_ok() {
164            let mut unrecognized_keywords = Vec::new();
165
166            for (code, _, line) in &content {
167                if code == "552"
168                    && line.starts_with("Unrecognized configuration key \"")
169                    && line.ends_with('"')
170                {
171                    let keyword = &line[32..line.len() - 1];
172                    unrecognized_keywords.push(keyword.to_string());
173                }
174            }
175
176            if !unrecognized_keywords.is_empty() {
177                return Err(Error::InvalidArguments(format!(
178                    "GETCONF request contained unrecognized keywords: {}",
179                    unrecognized_keywords.join(", ")
180                )));
181            }
182
183            return Err(Error::Protocol(format!(
184                "GETCONF response contained a non-OK status code:\n{}",
185                message
186            )));
187        }
188
189        for line in message.iter() {
190            let line_str = line.to_string();
191
192            let (key, value) = if let Some(eq_pos) = line_str.find('=') {
193                let k = line_str[..eq_pos].to_string();
194                let v = line_str[eq_pos + 1..].to_string();
195                let v = if v.is_empty() { None } else { Some(v) };
196                (k, v)
197            } else {
198                (line_str.trim().to_string(), None)
199            };
200
201            if key.is_empty() || key == "OK" {
202                continue;
203            }
204
205            let key_clone = key.clone();
206            entries.entry(key).or_default();
207            if let Some(v) = value {
208                entries.get_mut(&key_clone).unwrap().push(v);
209            }
210        }
211
212        Ok(Self { entries })
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn create_message(lines: Vec<&str>) -> ControlMessage {
221        let parsed: Vec<(String, char, Vec<u8>)> = lines
222            .iter()
223            .enumerate()
224            .map(|(i, line)| {
225                let divider = if i == lines.len() - 1 { ' ' } else { '-' };
226                ("250".to_string(), divider, line.as_bytes().to_vec())
227            })
228            .collect();
229        let raw = lines.join("\r\n");
230        ControlMessage::new(parsed, raw.into_bytes(), None).unwrap()
231    }
232
233    fn create_error_message(code: &str, lines: Vec<&str>) -> ControlMessage {
234        let parsed: Vec<(String, char, Vec<u8>)> = lines
235            .iter()
236            .enumerate()
237            .map(|(i, line)| {
238                let divider = if i == lines.len() - 1 { ' ' } else { '-' };
239                (code.to_string(), divider, line.as_bytes().to_vec())
240            })
241            .collect();
242        let raw = lines.join("\r\n");
243        ControlMessage::new(parsed, raw.into_bytes(), None).unwrap()
244    }
245
246    #[test]
247    fn test_getconf_single_value() {
248        let msg = create_message(vec!["CookieAuthentication=0", "OK"]);
249        let response = GetConfResponse::from_message(&msg).unwrap();
250        assert_eq!(
251            response.entries.get("CookieAuthentication"),
252            Some(&vec!["0".to_string()])
253        );
254    }
255
256    #[test]
257    fn test_getconf_multiple_values() {
258        let msg = create_message(vec![
259            "CookieAuthentication=0",
260            "ControlPort=9100",
261            "DataDirectory=/home/user/.tor",
262            "OK",
263        ]);
264        let response = GetConfResponse::from_message(&msg).unwrap();
265        assert_eq!(
266            response.entries.get("CookieAuthentication"),
267            Some(&vec!["0".to_string()])
268        );
269        assert_eq!(
270            response.entries.get("ControlPort"),
271            Some(&vec!["9100".to_string()])
272        );
273        assert_eq!(
274            response.entries.get("DataDirectory"),
275            Some(&vec!["/home/user/.tor".to_string()])
276        );
277    }
278
279    #[test]
280    fn test_getconf_key_without_value() {
281        let msg = create_message(vec!["DirPort", "OK"]);
282        let response = GetConfResponse::from_message(&msg).unwrap();
283        assert_eq!(response.entries.get("DirPort"), Some(&vec![]));
284    }
285
286    #[test]
287    fn test_getconf_multiple_values_same_key() {
288        let msg = create_message(vec![
289            "ExitPolicy=accept *:80",
290            "ExitPolicy=accept *:443",
291            "ExitPolicy=reject *:*",
292            "OK",
293        ]);
294        let response = GetConfResponse::from_message(&msg).unwrap();
295        let policies = response.entries.get("ExitPolicy").unwrap();
296        assert_eq!(policies.len(), 3);
297        assert_eq!(policies[0], "accept *:80");
298        assert_eq!(policies[1], "accept *:443");
299        assert_eq!(policies[2], "reject *:*");
300    }
301
302    #[test]
303    fn test_getconf_empty_response() {
304        let msg = create_message(vec!["OK"]);
305        let response = GetConfResponse::from_message(&msg).unwrap();
306        assert!(response.entries.is_empty());
307    }
308
309    #[test]
310    fn test_getconf_unrecognized_key() {
311        let msg =
312            create_error_message("552", vec!["Unrecognized configuration key \"InvalidKey\""]);
313        let result = GetConfResponse::from_message(&msg);
314        assert!(result.is_err());
315        if let Err(Error::InvalidArguments(msg)) = result {
316            assert!(msg.contains("InvalidKey"));
317        } else {
318            panic!("Expected InvalidArguments error");
319        }
320    }
321
322    #[test]
323    fn test_getconf_empty_value_bug() {
324        let msg = create_message(vec!["SomeOption=", "OK"]);
325        let response = GetConfResponse::from_message(&msg).unwrap();
326        assert_eq!(response.entries.get("SomeOption"), Some(&vec![]));
327    }
328
329    #[test]
330    fn test_getconf_multiple_unrecognized_keys() {
331        let parsed = vec![
332            (
333                "552".to_string(),
334                '-',
335                "Unrecognized configuration key \"brickroad\""
336                    .as_bytes()
337                    .to_vec(),
338            ),
339            (
340                "552".to_string(),
341                ' ',
342                "Unrecognized configuration key \"submarine\""
343                    .as_bytes()
344                    .to_vec(),
345            ),
346        ];
347        let msg = ControlMessage::new(parsed, "552 error".into(), None).unwrap();
348        let result = GetConfResponse::from_message(&msg);
349        assert!(result.is_err());
350        if let Err(Error::InvalidArguments(msg)) = result {
351            assert!(msg.contains("brickroad"));
352            assert!(msg.contains("submarine"));
353        } else {
354            panic!("Expected InvalidArguments error");
355        }
356    }
357
358    #[test]
359    fn test_getconf_value_with_spaces() {
360        let msg = create_message(vec!["DataDirectory=/tmp/fake dir", "OK"]);
361        let response = GetConfResponse::from_message(&msg).unwrap();
362        assert_eq!(
363            response.entries.get("DataDirectory"),
364            Some(&vec!["/tmp/fake dir".to_string()])
365        );
366    }
367
368    #[test]
369    fn test_getconf_batch_response() {
370        let msg = create_message(vec![
371            "CookieAuthentication=0",
372            "ControlPort=9100",
373            "DataDirectory=/tmp/fake dir",
374            "DirPort",
375            "OK",
376        ]);
377        let response = GetConfResponse::from_message(&msg).unwrap();
378        assert_eq!(
379            response.entries.get("CookieAuthentication"),
380            Some(&vec!["0".to_string()])
381        );
382        assert_eq!(
383            response.entries.get("ControlPort"),
384            Some(&vec!["9100".to_string()])
385        );
386        assert_eq!(
387            response.entries.get("DataDirectory"),
388            Some(&vec!["/tmp/fake dir".to_string()])
389        );
390        assert_eq!(response.entries.get("DirPort"), Some(&vec![]));
391    }
392
393    #[test]
394    fn test_getconf_invalid_response_code() {
395        let parsed = vec![
396            ("123".to_string(), '-', "FOO".as_bytes().to_vec()),
397            ("532".to_string(), ' ', "BAR".as_bytes().to_vec()),
398        ];
399        let msg = ControlMessage::new(parsed, "invalid".into(), None).unwrap();
400        let result = GetConfResponse::from_message(&msg);
401        assert!(result.is_err());
402    }
403}