Skip to main content

gix_command/
parse.rs

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