Skip to main content

dolang_winterop/
process.rs

1//! Process command-line argument encoding and parsing.
2//!
3//! Windows process creation receives one command-line string, not an argv
4//! array. [`join_arguments`] encodes an argv-like input using the convention
5//! understood by the MSVC runtime and Rust's standard library; pair it with
6//! [`split_arguments`] when parsing that same convention.
7
8use std::{error::Error, fmt};
9
10/// An argument contains a NUL character and cannot be represented in a
11/// Windows command line.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub struct NulError;
14
15impl fmt::Display for NulError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        f.write_str("Windows process arguments cannot contain NUL characters")
18    }
19}
20
21impl Error for NulError {}
22
23/// Appends one argument encoded with the quoting rules used by MSVC-compatible
24/// Windows command-line parsers and `std::process::Command`.
25///
26/// Callers that build a complete command line from untrusted arguments should
27/// prefer [`join_arguments`], which rejects NUL characters.
28///
29/// This low-level helper intentionally does not reject NUL characters because
30/// it appends into caller-owned output.
31pub fn quote_argument(argument: &str, output: &mut String) {
32    let quote = argument.is_empty() || argument.contains([' ', '\t', '"']);
33    if !quote {
34        output.push_str(argument);
35        return;
36    }
37
38    output.push('"');
39    let mut backslashes = 0;
40    for ch in argument.chars() {
41        if ch == '\\' {
42            backslashes += 1;
43        } else if ch == '"' {
44            output.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
45            output.push(ch);
46            backslashes = 0;
47        } else {
48            output.extend(std::iter::repeat_n('\\', backslashes));
49            output.push(ch);
50            backslashes = 0;
51        }
52    }
53    output.extend(std::iter::repeat_n('\\', backslashes * 2));
54    output.push('"');
55}
56
57/// Encodes arguments as one MSVC-compatible Windows command line.
58///
59/// Returns [`NulError`] when an argument contains a NUL character.
60///
61/// ```
62/// use dolang_winterop::process::{join_arguments, split_arguments};
63///
64/// let command_line = join_arguments(["tool", "two words", r#"a\"quote"#])?;
65/// assert_eq!(split_arguments(&command_line), ["tool", "two words", r#"a\"quote"#]);
66/// # Ok::<(), dolang_winterop::process::NulError>(())
67/// ```
68pub fn join_arguments<I, S>(arguments: I) -> std::result::Result<String, NulError>
69where
70    I: IntoIterator<Item = S>,
71    S: AsRef<str>,
72{
73    let mut command_line = String::new();
74    for argument in arguments {
75        let argument = argument.as_ref();
76        if argument.contains('\0') {
77            return Err(NulError);
78        }
79        if !command_line.is_empty() {
80            command_line.push(' ');
81        }
82        quote_argument(argument, &mut command_line);
83    }
84    Ok(command_line)
85}
86
87/// Parses an MSVC-compatible Windows command line into arguments.
88///
89/// This follows the convention used for process arguments by MSVC and Rust's
90/// `std::process`; it does not implement `CommandLineToArgvW`'s special
91/// treatment of the executable name.
92pub fn split_arguments(command_line: &str) -> Vec<String> {
93    let mut chars = command_line.chars().peekable();
94    let mut arguments = Vec::new();
95
96    loop {
97        while matches!(chars.peek(), Some(' ' | '\t')) {
98            chars.next();
99        }
100        if chars.peek().is_none() {
101            break;
102        }
103
104        let mut argument = String::new();
105        let mut in_quotes = false;
106        loop {
107            match chars.peek() {
108                None => break,
109                Some(' ' | '\t') if !in_quotes => break,
110                Some('\\') => {
111                    let mut backslashes = 0;
112                    while chars.next_if_eq(&'\\').is_some() {
113                        backslashes += 1;
114                    }
115                    if chars.next_if_eq(&'"').is_some() {
116                        argument.extend(std::iter::repeat_n('\\', backslashes / 2));
117                        let literal_quote =
118                            backslashes % 2 == 1 || (in_quotes && chars.next_if_eq(&'"').is_some());
119                        if literal_quote {
120                            argument.push('"');
121                        } else {
122                            in_quotes = !in_quotes;
123                        }
124                    } else {
125                        argument.extend(std::iter::repeat_n('\\', backslashes));
126                    }
127                }
128                Some('"') => {
129                    chars.next();
130                    if in_quotes && chars.next_if_eq(&'"').is_some() {
131                        argument.push('"');
132                    } else {
133                        in_quotes = !in_quotes;
134                    }
135                }
136                Some(_) => argument.push(chars.next().unwrap()),
137            }
138        }
139        arguments.push(argument);
140    }
141
142    arguments
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{NulError, join_arguments, quote_argument, split_arguments};
148
149    fn quote(argument: &str) -> String {
150        let mut result = String::new();
151        quote_argument(argument, &mut result);
152        result
153    }
154
155    #[test]
156    fn quotes_arguments() {
157        assert_eq!(quote(r"C:\plain"), r"C:\plain");
158        assert_eq!(quote(""), "\"\"");
159        assert_eq!(quote("two words"), r#""two words""#);
160        assert_eq!(quote("two\twords"), "\"two\twords\"");
161        assert_eq!(quote(r#"a\"b"#), r#""a\\\"b""#);
162        assert_eq!(
163            quote("C:\\path with space\\"),
164            "\"C:\\path with space\\\\\""
165        );
166    }
167
168    #[test]
169    fn splits_arguments() {
170        assert_eq!(split_arguments(""), Vec::<String>::new());
171        assert_eq!(split_arguments("a\tb"), ["a", "b"]);
172        assert_eq!(
173            split_arguments(r#""two words" a\\\"b"#),
174            ["two words", "a\\\"b"]
175        );
176        assert_eq!(
177            split_arguments(r#""C:\path with space\\""#),
178            ["C:\\path with space\\"]
179        );
180        assert_eq!(split_arguments(r#""a""b""#), ["a\"b"]);
181    }
182
183    #[test]
184    fn joins_and_splits_round_trip() {
185        let arguments = [
186            "",
187            "plain",
188            "two words",
189            "two\twords",
190            r#"a\"b"#,
191            r"trailing\\",
192        ];
193        let command_line = join_arguments(arguments).unwrap();
194        assert_eq!(split_arguments(&command_line), arguments);
195    }
196
197    #[test]
198    fn join_rejects_nul_arguments() {
199        assert_eq!(join_arguments(["a\0b"]), Err(NulError));
200    }
201}