Skip to main content

gix_command/
parse.rs

1use std::ffi::OsString;
2
3use bstr::{BStr, BString};
4
5/// The result of [`command_line()`].
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Outcome {
8    /// Leading environment assignments, without the separating `=`. Names are ASCII shell identifiers.
9    pub env: Vec<(String, OsString)>,
10    /// The command to execute.
11    pub command: OsString,
12    /// The arguments to pass to the command.
13    pub args: Vec<OsString>,
14}
15
16/// The error returned when a command line cannot be parsed into a command.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Error {
19    /// A quote was opened but never closed.
20    MissingClosingQuote,
21    /// An unquoted backslash was not followed by a byte to escape.
22    MissingEscapedByte,
23    /// The input contains no command to execute.
24    MissingCommand,
25    /// The command, an argument, or an environment value cannot be represented as an OS string on this platform.
26    UnrepresentableOsString,
27}
28
29impl std::fmt::Display for Error {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.write_str(match self {
32            Error::MissingClosingQuote => "missing closing quote",
33            Error::MissingEscapedByte => "missing byte after escape",
34            Error::MissingCommand => "missing command",
35            Error::UnrepresentableOsString => {
36                "command, argument, or environment value cannot be represented as an OS string"
37            }
38        })
39    }
40}
41
42impl std::error::Error for Error {}
43
44#[derive(Clone, Copy, PartialEq, Eq)]
45enum Quote {
46    Single,
47    Double,
48}
49
50/// A parsed shell word.
51struct Word {
52    value: BString,
53    /// The byte offset of the unquoted `=` within `value`, if it follows a valid shell identifier.
54    assignment_separator: Option<usize>,
55}
56
57/// Split `input` into leading environment assignments and the command with its arguments.
58///
59/// Whitespace, quotes, escapes, line continuations, and comments follow POSIX `sh`ell word-splitting rules. Shell
60/// expansions and operators are not interpreted. An assignment is recognized only when its name is an unquoted
61/// shell identifier. Assignment-only input is rejected because it contains no command to execute. Environment
62/// assignment names are strings, while their values, the command, and arguments are converted losslessly to OS
63/// strings or rejected if the platform cannot represent them.
64pub fn command_line(input: &BStr) -> Result<Outcome, Error> {
65    let mut words = parse_words(input)?;
66    let assignment_count = words
67        .iter()
68        .take_while(|word| word.assignment_separator.is_some())
69        .count();
70    let mut args = words.split_off(assignment_count).into_iter().map(|word| word.value);
71    let command = into_os_string(args.next().ok_or(Error::MissingCommand)?)?;
72    let env = words
73        .into_iter()
74        .map(|word| {
75            let separator = word.assignment_separator.expect("only recognized assignments remain");
76            Ok((
77                String::from_utf8(word.value[..separator].to_owned())
78                    .expect("shell assignment names contain only ASCII bytes"),
79                into_os_string(word.value[separator + 1..].to_owned().into())?,
80            ))
81        })
82        .collect::<Result<_, Error>>()?;
83    Ok(Outcome {
84        env,
85        command,
86        args: args.map(into_os_string).collect::<Result<_, _>>()?,
87    })
88}
89
90pub(crate) fn arguments(input: &BStr) -> Result<Vec<OsString>, Error> {
91    parse_words(input)?
92        .into_iter()
93        .map(|word| into_os_string(word.value))
94        .collect()
95}
96
97fn parse_words(input: &BStr) -> Result<Vec<Word>, Error> {
98    let mut words = Vec::new();
99    let mut value = BString::default();
100    let mut assignment_possible = true;
101    let mut assignment_separator = None;
102    let mut word_started = false;
103    let mut quote = None;
104    let mut bytes = input.iter().copied();
105
106    while let Some(byte) = bytes.next() {
107        match quote {
108            Some(Quote::Single) => {
109                if byte == b'\'' {
110                    quote = None;
111                } else {
112                    value.push(byte);
113                }
114            }
115            Some(Quote::Double) => match byte {
116                b'"' => quote = None,
117                b'\\' => match bytes.next() {
118                    Some(b'\n') => {}
119                    Some(next @ (b'$' | b'`' | b'"' | b'\\')) => value.push(next),
120                    Some(next) => {
121                        value.push(b'\\');
122                        value.push(next);
123                    }
124                    None => return Err(Error::MissingClosingQuote),
125                },
126                _ => value.push(byte),
127            },
128            None => match byte {
129                b' ' | b'\t' | b'\n' => {
130                    if word_started {
131                        words.push(Word {
132                            value: std::mem::take(&mut value),
133                            assignment_separator,
134                        });
135                        (assignment_possible, assignment_separator, word_started) = (true, None, false);
136                    }
137                }
138                b'#' if !word_started => {
139                    bytes.by_ref().find(|byte| *byte == b'\n');
140                }
141                b'\'' => (assignment_possible, quote, word_started) = (false, Some(Quote::Single), true),
142                b'"' => (assignment_possible, quote, word_started) = (false, Some(Quote::Double), true),
143                b'\\' => match bytes.next() {
144                    Some(b'\n') => {}
145                    Some(next) => {
146                        (assignment_possible, word_started) = (false, true);
147                        value.push(next);
148                    }
149                    None => return Err(Error::MissingEscapedByte),
150                },
151                _ => {
152                    word_started = true;
153                    push_unquoted(&mut value, &mut assignment_possible, &mut assignment_separator, byte);
154                }
155            },
156        }
157    }
158    if quote.is_some() {
159        return Err(Error::MissingClosingQuote);
160    }
161    if word_started {
162        words.push(Word {
163            value,
164            assignment_separator,
165        });
166    }
167    Ok(words)
168}
169
170/// Append an unquoted byte while tracking whether the word starts with a valid shell
171/// assignment name and where its `=` occurs.
172fn push_unquoted(
173    value: &mut BString,
174    assignment_possible: &mut bool,
175    assignment_separator: &mut Option<usize>,
176    byte: u8,
177) {
178    if assignment_separator.is_none() && *assignment_possible {
179        if value.is_empty() {
180            *assignment_possible = byte == b'_' || byte.is_ascii_alphabetic();
181        } else if byte == b'=' {
182            *assignment_separator = Some(value.len());
183        } else if byte != b'_' && !byte.is_ascii_alphanumeric() {
184            *assignment_possible = false;
185        }
186    }
187    value.push(byte);
188}
189
190fn into_os_string(value: BString) -> Result<OsString, Error> {
191    gix_path::try_from_bstring(value)
192        .map(std::path::PathBuf::into_os_string)
193        .map_err(|_| Error::UnrepresentableOsString)
194}