Skip to main content

gix_command/
parse.rs

1use std::ffi::OsString;
2
3use bstr::{BStr, BString};
4use gix_error::{ExnResult, Result, 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::<ExnResult<_, Error>>()?,
96    })
97}
98
99pub(crate) fn arguments(input: &BStr) -> Result<Vec<OsString>> {
100    parse_words(input)
101        .or_error()?
102        .into_iter()
103        .map(|word| into_os_string(word.value).or_error())
104        .collect()
105}
106
107fn parse_words(input: &BStr) -> std::result::Result<Vec<Word>, Error> {
108    let mut words = Vec::new();
109    let mut value = BString::default();
110    let mut assignment_possible = true;
111    let mut assignment_separator = None;
112    let mut word_started = false;
113    let mut quote = None;
114    let mut bytes = input.iter().copied();
115
116    while let Some(byte) = bytes.next() {
117        match quote {
118            Some(Quote::Single) => {
119                if byte == b'\'' {
120                    quote = None;
121                } else {
122                    value.push(byte);
123                }
124            }
125            Some(Quote::Double) => match byte {
126                b'"' => quote = None,
127                b'\\' => match bytes.next() {
128                    Some(b'\n') => {}
129                    Some(next @ (b'$' | b'`' | b'"' | b'\\')) => value.push(next),
130                    Some(next) => {
131                        value.push(b'\\');
132                        value.push(next);
133                    }
134                    None => return Err(Error::MissingClosingQuote),
135                },
136                _ => value.push(byte),
137            },
138            None => match byte {
139                b' ' | b'\t' | b'\n' => {
140                    if word_started {
141                        words.push(Word {
142                            value: std::mem::take(&mut value),
143                            assignment_separator,
144                        });
145                        (assignment_possible, assignment_separator, word_started) = (true, None, false);
146                    }
147                }
148                b'#' if !word_started => {
149                    bytes.by_ref().find(|byte| *byte == b'\n');
150                }
151                b'\'' => (assignment_possible, quote, word_started) = (false, Some(Quote::Single), true),
152                b'"' => (assignment_possible, quote, word_started) = (false, Some(Quote::Double), true),
153                b'\\' => match bytes.next() {
154                    Some(b'\n') => {}
155                    Some(next) => {
156                        (assignment_possible, word_started) = (false, true);
157                        value.push(next);
158                    }
159                    None => return Err(Error::MissingEscapedByte),
160                },
161                _ => {
162                    word_started = true;
163                    push_unquoted(&mut value, &mut assignment_possible, &mut assignment_separator, byte);
164                }
165            },
166        }
167    }
168    if quote.is_some() {
169        return Err(Error::MissingClosingQuote);
170    }
171    if word_started {
172        words.push(Word {
173            value,
174            assignment_separator,
175        });
176    }
177    Ok(words)
178}
179
180/// Append an unquoted byte while tracking whether the word starts with a valid shell
181/// assignment name and where its `=` occurs.
182fn push_unquoted(
183    value: &mut BString,
184    assignment_possible: &mut bool,
185    assignment_separator: &mut Option<usize>,
186    byte: u8,
187) {
188    if assignment_separator.is_none() && *assignment_possible {
189        if value.is_empty() {
190            *assignment_possible = byte == b'_' || byte.is_ascii_alphabetic();
191        } else if byte == b'=' {
192            *assignment_separator = Some(value.len());
193        } else if byte != b'_' && !byte.is_ascii_alphanumeric() {
194            *assignment_possible = false;
195        }
196    }
197    value.push(byte);
198}
199
200fn into_os_string(value: BString) -> ExnResult<OsString, Error> {
201    gix_path::from_bstring(value)
202        .map(std::path::PathBuf::into_os_string)
203        .or_raise_typed(|| Error::UnrepresentableOsString)
204}