tor_config/
cmdline.rs

1//! Implement a configuration source based on command-line arguments.
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6/// A CmdLine holds a set of command-line arguments that augment a
7/// configuration.
8///
9/// These arguments are formatted in toml, and concatenated into a
10/// single toml object.  With arguments of the form "key=bareword",
11/// the bareword is quoted for convenience.
12#[derive(Debug, Clone)]
13pub struct CmdLine {
14    /// String for decorating Values.
15    //
16    // TODO(nickm): not yet used.
17    #[allow(dead_code)]
18    name: String,
19    /// List of toml lines as given on the command line.
20    contents: Vec<String>,
21}
22
23impl Default for CmdLine {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl CmdLine {
30    /// Make a new empty command-line
31    pub fn new() -> Self {
32        CmdLine {
33            name: "command line".to_string(),
34            contents: Vec::new(),
35        }
36    }
37    /// Add a single line of toml to the configuration.
38    pub fn push_toml_line(&mut self, line: String) {
39        self.contents.push(line);
40    }
41
42    /// Try to adjust the contents of a toml deserialization error so
43    /// that instead it refers to a single command-line argument.
44    fn convert_toml_error(
45        &self,
46        toml_str: &str,
47        error_message: &str,
48        span: &Option<std::ops::Range<usize>>,
49    ) -> String {
50        // Function to translate a string index to a 0-offset line number.
51        let linepos = |idx| toml_str.bytes().take(idx).filter(|b| *b == b'\n').count();
52
53        // Find the source position as a line within toml_str, and convert that
54        // to an index into self.contents.
55        let source_line = span
56            .as_ref()
57            .and_then(|range| {
58                let startline = linepos(range.start);
59                let endline = linepos(range.end);
60                (startline == endline).then_some(startline)
61            })
62            .and_then(|pos| self.contents.get(pos));
63
64        match (source_line, span.as_ref()) {
65            (Some(source), _) => {
66                format!("Couldn't parse command line: {error_message} in {source:?}")
67            }
68            (None, Some(range)) if toml_str.get(range.clone()).is_some() => format!(
69                "Couldn't parse command line: {error_message} within {:?}",
70                &toml_str[range.clone()]
71            ),
72            _ => format!("Couldn't parse command line: {error_message}"),
73        }
74    }
75
76    /// Compose elements of this cmdline into a single toml string.
77    fn build_toml(&self) -> String {
78        let mut toml_s = String::new();
79        for line in &self.contents {
80            toml_s.push_str(tweak_toml_bareword(line).as_ref().unwrap_or(line));
81            toml_s.push('\n');
82        }
83        toml_s
84    }
85}
86
87impl figment::Provider for CmdLine {
88    fn metadata(&self) -> figment::Metadata {
89        figment::Metadata::named("command line")
90    }
91
92    fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, figment::value::Dict>> {
93        let toml_str = self.build_toml();
94        let toml: toml::Value = toml::from_str(&toml_str).map_err(|toml_err| {
95            self.convert_toml_error(&toml_str, toml_err.message(), &toml_err.span())
96        })?;
97
98        figment::providers::Serialized::defaults(toml).data()
99    }
100}
101
102/// If `s` is a string of the form "keyword=bareword", return a new string
103/// where `bareword` is quoted. Otherwise return None.
104///
105/// This isn't a smart transformation outside the context of 'config',
106/// since many serde formats don't do so good a job when they get a
107/// string when they wanted a number or whatever.  But 'config' is
108/// pretty happy to convert strings to other stuff.
109fn tweak_toml_bareword(s: &str) -> Option<String> {
110    /// Regex to match a keyword=bareword item.
111    static RE: Lazy<Regex> = Lazy::new(|| {
112        Regex::new(
113            r#"(?x:
114               ^
115                [ \t]*
116                # first capture group: dotted barewords
117                ((?:[a-zA-Z0-9_\-]+\.)*
118                 [a-zA-Z0-9_\-]+)
119                [ \t]*=[ \t]*
120                # second group: one bareword without hyphens
121                ([a-zA-Z0-9_]+)
122                [ \t]*
123                $)"#,
124        )
125        .expect("Built-in regex compilation failed")
126    });
127
128    RE.captures(s).map(|c| format!("{}=\"{}\"", &c[1], &c[2]))
129}
130
131#[cfg(test)]
132mod test {
133    // @@ begin test lint list maintained by maint/add_warning @@
134    #![allow(clippy::bool_assert_comparison)]
135    #![allow(clippy::clone_on_copy)]
136    #![allow(clippy::dbg_macro)]
137    #![allow(clippy::mixed_attributes_style)]
138    #![allow(clippy::print_stderr)]
139    #![allow(clippy::print_stdout)]
140    #![allow(clippy::single_char_pattern)]
141    #![allow(clippy::unwrap_used)]
142    #![allow(clippy::unchecked_duration_subtraction)]
143    #![allow(clippy::useless_vec)]
144    #![allow(clippy::needless_pass_by_value)]
145    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
146    use super::*;
147    use figment::Provider as _;
148
149    #[test]
150    fn bareword_expansion() {
151        assert_eq!(tweak_toml_bareword("dsfklj"), None);
152        assert_eq!(tweak_toml_bareword("=99"), None);
153        assert_eq!(tweak_toml_bareword("=[1,2,3]"), None);
154        assert_eq!(tweak_toml_bareword("a=b-c"), None);
155
156        assert_eq!(tweak_toml_bareword("a=bc"), Some("a=\"bc\"".into()));
157        assert_eq!(tweak_toml_bareword("a=b_c"), Some("a=\"b_c\"".into()));
158        assert_eq!(
159            tweak_toml_bareword("hello.there.now=a_greeting"),
160            Some("hello.there.now=\"a_greeting\"".into())
161        );
162    }
163
164    #[test]
165    fn conv_toml_error() {
166        let mut cl = CmdLine::new();
167        cl.push_toml_line("Hello=world".to_string());
168        cl.push_toml_line("Hola=mundo".to_string());
169        cl.push_toml_line("Bonjour=monde".to_string());
170        let toml_s = cl.build_toml();
171
172        assert_eq!(
173            &cl.convert_toml_error(&toml_s, "Nice greeting", &Some(0..13)),
174            "Couldn't parse command line: Nice greeting in \"Hello=world\""
175        );
176
177        assert_eq!(
178            &cl.convert_toml_error(&toml_s, "Nice greeting", &Some(99..333)),
179            "Couldn't parse command line: Nice greeting"
180        );
181
182        assert_eq!(
183            &cl.convert_toml_error(&toml_s, "Nice greeting with a thing", &Some(0..13)),
184            "Couldn't parse command line: Nice greeting with a thing in \"Hello=world\""
185        );
186    }
187
188    #[test]
189    fn parse_good() {
190        let mut cl = CmdLine::default();
191        cl.push_toml_line("a=3".to_string());
192        cl.push_toml_line("bcd=hello".to_string());
193        cl.push_toml_line("ef=\"gh i\"".to_string());
194        cl.push_toml_line("w=[1,2,3]".to_string());
195
196        let v = cl
197            .data()
198            .unwrap()
199            .remove(&figment::Profile::Default)
200            .unwrap();
201
202        assert_eq!(v["a"], "3".into());
203        assert_eq!(v["bcd"], "hello".into());
204        assert_eq!(v["ef"], "gh i".into());
205        assert_eq!(v["w"], vec![1, 2, 3].into());
206    }
207
208    #[test]
209    fn parse_bad() {
210        let mut cl = CmdLine::default();
211        cl.push_toml_line("x=1 1 1 1 1".to_owned());
212        let v = cl.data();
213        assert!(v.is_err());
214    }
215}