use std::ffi::OsString; use bstr::{BStr, BString}; /// The result of [`command_line()`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Outcome { /// Leading environment assignments, without the separating `=`. Names are ASCII shell identifiers. pub env: Vec<(String, OsString)>, /// The command to execute. pub command: OsString, /// The arguments to pass to the command. pub args: Vec, } /// The error returned when a command line cannot be parsed into a command. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Error { /// A quote was opened but never closed. MissingClosingQuote, /// An unquoted backslash was not followed by a byte to escape. MissingEscapedByte, /// The input contains no command to execute. MissingCommand, /// The command, an argument, or an environment value cannot be represented as an OS string on this platform. UnrepresentableOsString, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Error::MissingClosingQuote => "missing closing quote", Error::MissingEscapedByte => "missing byte after escape", Error::MissingCommand => "missing command", Error::UnrepresentableOsString => { "command, argument, or environment value cannot be represented as an OS string" } }) } } impl std::error::Error for Error {} #[derive(Clone, Copy, PartialEq, Eq)] enum Quote { Single, Double, } /// A parsed shell word. struct Word { value: BString, /// The byte offset of the unquoted `=` within `value`, if it follows a valid shell identifier. assignment_separator: Option, } /// Split `input` into leading environment assignments and the command with its arguments. /// /// Whitespace, quotes, escapes, line continuations, and comments follow POSIX `sh`ell word-splitting rules. Shell /// expansions and operators are not interpreted. An assignment is recognized only when its name is an unquoted /// shell identifier. Assignment-only input is rejected because it contains no command to execute. Environment /// assignment names are strings, while their values, the command, and arguments are converted losslessly to OS /// strings or rejected if the platform cannot represent them. pub fn command_line(input: &BStr) -> Result { let mut words = parse_words(input)?; let assignment_count = words .iter() .take_while(|word| word.assignment_separator.is_some()) .count(); let mut args = words.split_off(assignment_count).into_iter().map(|word| word.value); let command = into_os_string(args.next().ok_or(Error::MissingCommand)?)?; let env = words .into_iter() .map(|word| { let separator = word.assignment_separator.expect("only recognized assignments remain"); Ok(( String::from_utf8(word.value[..separator].to_owned()) .expect("shell assignment names contain only ASCII bytes"), into_os_string(word.value[separator + 1..].to_owned().into())?, )) }) .collect::>()?; Ok(Outcome { env, command, args: args.map(into_os_string).collect::>()?, }) } pub(crate) fn arguments(input: &BStr) -> Result, Error> { parse_words(input)? .into_iter() .map(|word| into_os_string(word.value)) .collect() } fn parse_words(input: &BStr) -> Result, Error> { let mut words = Vec::new(); let mut value = BString::default(); let mut assignment_possible = true; let mut assignment_separator = None; let mut word_started = false; let mut quote = None; let mut bytes = input.iter().copied(); while let Some(byte) = bytes.next() { match quote { Some(Quote::Single) => { if byte == b'\'' { quote = None; } else { value.push(byte); } } Some(Quote::Double) => match byte { b'"' => quote = None, b'\\' => match bytes.next() { Some(b'\n') => {} Some(next @ (b'$' | b'`' | b'"' | b'\\')) => value.push(next), Some(next) => { value.push(b'\\'); value.push(next); } None => return Err(Error::MissingClosingQuote), }, _ => value.push(byte), }, None => match byte { b' ' | b'\t' | b'\n' => { if word_started { words.push(Word { value: std::mem::take(&mut value), assignment_separator, }); (assignment_possible, assignment_separator, word_started) = (true, None, false); } } b'#' if !word_started => { bytes.by_ref().find(|byte| *byte == b'\n'); } b'\'' => (assignment_possible, quote, word_started) = (false, Some(Quote::Single), true), b'"' => (assignment_possible, quote, word_started) = (false, Some(Quote::Double), true), b'\\' => match bytes.next() { Some(b'\n') => {} Some(next) => { (assignment_possible, word_started) = (false, true); value.push(next); } None => return Err(Error::MissingEscapedByte), }, _ => { word_started = true; push_unquoted(&mut value, &mut assignment_possible, &mut assignment_separator, byte); } }, } } if quote.is_some() { return Err(Error::MissingClosingQuote); } if word_started { words.push(Word { value, assignment_separator, }); } Ok(words) } /// Append an unquoted byte while tracking whether the word starts with a valid shell /// assignment name and where its `=` occurs. fn push_unquoted( value: &mut BString, assignment_possible: &mut bool, assignment_separator: &mut Option, byte: u8, ) { if assignment_separator.is_none() && *assignment_possible { if value.is_empty() { *assignment_possible = byte == b'_' || byte.is_ascii_alphabetic(); } else if byte == b'=' { *assignment_separator = Some(value.len()); } else if byte != b'_' && !byte.is_ascii_alphanumeric() { *assignment_possible = false; } } value.push(byte); } fn into_os_string(value: BString) -> Result { gix_path::try_from_bstring(value) .map(std::path::PathBuf::into_os_string) .map_err(|_| Error::UnrepresentableOsString) }