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