use std::ffi::OsStr;
use std::fmt;
use std::path::{Path, PathBuf};
use winnow::stream::BStr;
pub const MAX_DEPTH: usize = 10;
pub const MAX_FILES: usize = 4096;
#[derive(Default, Debug)]
pub struct ResponseFiles {
files: Vec<Vec<u8>>,
owned: Vec<Vec<u8>>,
}
#[derive(Debug)]
pub struct ResponseError {
pub path: PathBuf,
pub kind: ResponseErrorKind,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ResponseErrorKind {
Io(std::io::Error),
PrematureEnd,
TooDeep,
TooMany,
}
impl fmt::Display for ResponseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let path = self.path.display();
match &self.kind {
ResponseErrorKind::Io(error) => write!(f, "{path}: {error}"),
ResponseErrorKind::PrematureEnd => write!(f, "{path}: premature end of input"),
ResponseErrorKind::TooDeep => write!(f, "{path}: response file nesting too deep"),
ResponseErrorKind::TooMany => write!(f, "{path}: too many response files"),
}
}
}
impl std::error::Error for ResponseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.kind {
ResponseErrorKind::Io(error) => Some(error),
_ => None,
}
}
}
#[derive(Clone, Copy)]
enum Word {
Arg(usize),
File {
file: usize,
start: usize,
end: usize,
},
Owned(usize),
}
pub fn expand<'a, S: AsRef<OsStr>>(
args: &'a [S],
files: &'a mut ResponseFiles,
) -> Result<Vec<&'a BStr>, ResponseError> {
let mut words = Vec::with_capacity(args.len());
for (index, arg) in args.iter().enumerate() {
match arg.as_ref().as_encoded_bytes().strip_prefix(b"@") {
Some(path) => read(files, path_of(path)?, 1, &mut words)?,
None => words.push(Word::Arg(index)),
}
}
let files: &'a ResponseFiles = files;
Ok(words
.into_iter()
.map(|word| {
BStr::new(match word {
Word::Arg(index) => args[index].as_ref().as_encoded_bytes(),
Word::File { file, start, end } => &files.files[file][start..end],
Word::Owned(index) => &files.owned[index],
})
})
.collect())
}
fn read(
files: &mut ResponseFiles,
path: &Path,
depth: usize,
words: &mut Vec<Word>,
) -> Result<(), ResponseError> {
let error = |kind| ResponseError {
path: path.to_owned(),
kind,
};
if depth > MAX_DEPTH {
return Err(error(ResponseErrorKind::TooDeep));
}
if files.files.len() >= MAX_FILES {
return Err(error(ResponseErrorKind::TooMany));
}
let data = std::fs::read(path).map_err(|e| error(ResponseErrorKind::Io(e)))?;
let file = files.files.len();
let tokens = tokenize(&data).ok_or_else(|| error(ResponseErrorKind::PrematureEnd))?;
files.files.push(data);
for token in tokens {
let bytes = match &token {
Token::Range(start, end) => &files.files[file][*start..*end],
Token::Owned(bytes) => bytes,
};
if let Some(nested) = bytes.strip_prefix(b"@") {
let nested = path_of(nested)?.to_owned();
read(files, &nested, depth + 1, words)?;
continue;
}
words.push(match token {
Token::Range(start, end) => Word::File { file, start, end },
Token::Owned(bytes) => {
files.owned.push(bytes);
Word::Owned(files.owned.len() - 1)
}
});
}
Ok(())
}
#[cfg(unix)]
fn path_of(bytes: &[u8]) -> Result<&Path, ResponseError> {
use std::os::unix::ffi::OsStrExt;
Ok(Path::new(OsStr::from_bytes(bytes)))
}
#[cfg(not(unix))]
fn path_of(bytes: &[u8]) -> Result<&Path, ResponseError> {
unicode_path(bytes)
}
#[cfg(any(test, not(unix)))]
fn unicode_path(bytes: &[u8]) -> Result<&Path, ResponseError> {
match std::str::from_utf8(bytes) {
Ok(text) => Ok(Path::new(text)),
Err(error) => Err(ResponseError {
path: PathBuf::from(String::from_utf8_lossy(bytes).into_owned()),
kind: ResponseErrorKind::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
error,
)),
}),
}
}
enum Token {
Range(usize, usize),
Owned(Vec<u8>),
}
fn is_space(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r')
}
fn tokenize(data: &[u8]) -> Option<Vec<Token>> {
let mut tokens = Vec::new();
let mut i = 0;
while i < data.len() {
if is_space(data[i]) {
i += 1;
continue;
}
let start = i;
while i < data.len() && !is_space(data[i]) && !matches!(data[i], b'\\' | b'\'' | b'"') {
i += 1;
}
if i == data.len() || is_space(data[i]) {
tokens.push(Token::Range(start, i));
continue;
}
let mut word = data[start..i].to_vec();
let mut quote = None;
while i < data.len() {
let c = data[i];
if c == b'\\' {
word.push(*data.get(i + 1)?);
i += 2;
} else if let Some(q) = quote {
if c == q {
quote = None;
} else {
word.push(c);
}
i += 1;
} else if c == b'\'' || c == b'"' {
quote = Some(c);
i += 1;
} else if is_space(c) {
break;
} else {
word.push(c);
i += 1;
}
}
if quote.is_some() {
return None;
}
tokens.push(Token::Owned(word));
}
Some(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
fn words(data: &str) -> Option<Vec<String>> {
let tokens = tokenize(data.as_bytes())?;
Some(
tokens
.into_iter()
.map(|token| match token {
Token::Range(start, end) => data[start..end].to_owned(),
Token::Owned(bytes) => String::from_utf8(bytes).unwrap(),
})
.collect(),
)
}
#[test]
fn splits_on_whitespace() {
assert_eq!(words(" a\tb\n\nc \r\n").unwrap(), ["a", "b", "c"]);
assert!(words("").unwrap().is_empty());
}
#[test]
fn quotes_and_backslashes() {
assert_eq!(
words(r#"'a b' "c d" e\ f g'h i'j "it's" 'say "hi"' '' \\"#).unwrap(),
["a b", "c d", "e f", "gh ij", "it's", "say \"hi\"", "", "\\"]
);
}
#[test]
fn plain_words_borrow() {
let tokens = tokenize(b"plain 'quoted'").unwrap();
assert!(matches!(tokens[0], Token::Range(0, 5)));
assert!(matches!(tokens[1], Token::Owned(_)));
}
#[test]
fn premature_end() {
assert!(words("'open").is_none());
assert!(words("trailing\\").is_none());
}
#[test]
fn a_non_utf8_path_is_not_an_empty_name() {
let error = unicode_path(b"\xff").unwrap_err();
assert!(matches!(error.kind, ResponseErrorKind::Io(_)));
assert!(!error.path.as_os_str().is_empty());
}
}