const MAX_COMMAND_BYTES: usize = 16 * 1024;
const BARE: &[u8] = b"_-./,:@%+=*?~^";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Tok {
Word(Word),
Pipe,
Sep,
AndIf,
StderrRedirect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum WordKind {
Lit,
Var(String),
Expanding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Word {
pub(super) text: String,
pub(super) bare: bool,
pub(super) kind: WordKind,
pub(super) pattern: bool,
}
impl Word {
pub(super) fn lit(&self) -> Option<&str> {
(self.kind == WordKind::Lit).then_some(self.text.as_str())
}
pub(super) fn is_keyword(&self, keyword: &str) -> bool {
self.bare && self.lit() == Some(keyword)
}
}
fn delimits(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\n' | b';' | b'|')
}
pub(super) fn lex(command: &str) -> Result<Vec<Tok>, String> {
let bytes = command.as_bytes();
if bytes.len() > MAX_COMMAND_BYTES {
return Err("a command longer than the guard reads".into());
}
if let Some(bad) = bytes
.iter()
.find(|&&b| !(b == b'\n' || b == b'\t' || (0x20..=0x7e).contains(&b)))
{
return Err(format!(
"the byte 0x{bad:02x}, which the guard does not read"
));
}
let mut toks = Vec::new();
let mut i = 0;
while let Some(&b) = bytes.get(i) {
match b {
b' ' | b'\t' => i += 1,
b'\n' | b';' => {
toks.push(Tok::Sep);
i += 1;
}
b'|' => {
if bytes.get(i + 1) == Some(&b'|') {
return Err("`||`".into());
}
toks.push(Tok::Pipe);
i += 1;
}
b'&' if bytes.get(i + 1) == Some(&b'&') => {
toks.push(Tok::AndIf);
i += 2;
}
_ => {
if let Some(len) = stderr_redirect_at(bytes, i) {
toks.push(Tok::StderrRedirect);
i += len;
continue;
}
let (word, next) = word_at(bytes, i)?;
toks.push(Tok::Word(word));
i = next;
}
}
}
Ok(toks)
}
fn stderr_redirect_at(bytes: &[u8], i: usize) -> Option<usize> {
[b"2>&1".as_slice(), b"2>/dev/null".as_slice()]
.into_iter()
.find(|t| bytes[i..].starts_with(t) && bytes.get(i + t.len()).is_none_or(|&b| delimits(b)))
.map(<[u8]>::len)
}
fn word_at(bytes: &[u8], start: usize) -> Result<(Word, usize), String> {
if let Some(var) = var_word_at(bytes, start) {
return Ok(var);
}
let mut text = String::new();
let mut bare = true;
let mut expands = false;
let mut pattern = false;
let mut j = start;
while let Some(&b) = bytes.get(j) {
if delimits(b) {
break;
}
match b {
b'\'' | b'"' => {
let (decoded, close, escaped) = quoted_span(bytes, j)?;
text.push_str(&decoded);
pattern |= escaped;
bare = false;
j = close + 1;
}
b'~' if j == start && bytes.get(j + 1) == Some(&b'/') => {
expands = true;
text.push('~');
j += 1;
}
b'~' if j == start || matches!(bytes[j - 1], b'=' | b':') => {
return Err("a `~` expansion".into());
}
b'=' | b'^' if j == start => {
return Err(format!("a word led by `{}`", char::from(b)));
}
b'^' if bytes[j - 1] == b'/' => return Err("a `/^` glob".into()),
b'*' | b'?' => {
expands = true;
text.push(char::from(b));
j += 1;
}
_ if b.is_ascii_alphanumeric() || BARE.contains(&b) => {
text.push(char::from(b));
j += 1;
}
_ => return Err(format!("`{}`", char::from(b))),
}
}
if !expands {
return Ok((
Word {
text,
bare,
kind: WordKind::Lit,
pattern,
},
j,
));
}
let path = text.starts_with('/') || text.starts_with("~/");
if !bare || !path {
return Err("a glob outside a bare absolute path".into());
}
Ok((
Word {
text,
bare,
kind: WordKind::Expanding,
pattern: false,
},
j,
))
}
fn quoted_span(bytes: &[u8], open: usize) -> Result<(String, usize, bool), String> {
let quote = bytes[open];
let mut text = String::new();
let mut pattern = false;
let mut k = open + 1;
while let Some(&b) = bytes.get(k) {
if b == quote {
return Ok((text, k, pattern));
}
if b == b'\n' {
return Err("a newline inside quotes".into());
}
if quote == b'"' {
match b {
b'`' | b'!' => return Err(format!("`{}` inside double quotes", char::from(b))),
b'\\' => {
let next = *bytes.get(k + 1).ok_or("an unclosed quote")?;
if matches!(next, b'\n' | b'!') {
return Err("a `\\` before a newline or `!` inside double quotes".into());
}
if !matches!(next, b'$' | b'`' | b'"' | b'\\') {
text.push('\\');
}
text.push(char::from(next));
pattern = true;
k += 2;
continue;
}
b'$' if matches!(bytes.get(k + 1), Some(b'"' | b'|' | b')')) => pattern = true,
b'$' => return Err("a `$` expansion inside double quotes".into()),
_ => {}
}
}
text.push(char::from(b));
k += 1;
}
Err("an unclosed quote".into())
}
fn var_word_at(bytes: &[u8], start: usize) -> Option<(Word, usize)> {
let rest = bytes.get(start..)?.strip_prefix(b"\"$")?;
let close = rest.iter().position(|&b| b == b'"')?;
let inner = &rest[..close];
let name = match inner.strip_prefix(b"{") {
Some(braced) => braced.strip_suffix(b"}")?,
None => inner,
};
let valid = name
.first()
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
&& name.iter().all(|b| b.is_ascii_alphanumeric() || *b == b'_');
let end = start + 2 + close + 1;
if !valid || bytes.get(end).is_some_and(|&b| !delimits(b)) {
return None;
}
let name = String::from_utf8_lossy(name).into_owned();
let text = String::from_utf8_lossy(&bytes[start..end]).into_owned();
Some((
Word {
text,
bare: false,
kind: WordKind::Var(name),
pattern: false,
},
end,
))
}