const MAX_SRC: usize = 256 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Connector {
Pipe,
AndAnd,
OrOr,
Semi,
Amp,
}
impl Connector {
pub fn is_pipe(self) -> bool {
matches!(self, Connector::Pipe)
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct Word {
pub text: String,
pub raw: String,
pub quoted: bool,
pub expanded: bool,
pub at: usize,
}
#[derive(Debug, Clone, Default)]
pub struct Simple {
pub words: Vec<Word>,
pub prev: Option<Connector>,
pub next: Option<Connector>,
pub redirects: Vec<(String, Word)>,
pub at: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Opaque {
UnterminatedQuote,
UnterminatedSubstitution,
UnterminatedHeredoc,
IndirectExecution(&'static str),
TooLong,
}
impl Opaque {
pub fn why(&self) -> String {
match self {
Opaque::UnterminatedQuote => "an unterminated quote".into(),
Opaque::UnterminatedSubstitution => "an unbalanced substitution".into(),
Opaque::UnterminatedHeredoc => "a heredoc with no terminator".into(),
Opaque::IndirectExecution(w) => format!("`{w}` runs a command we cannot read"),
Opaque::TooLong => "a command too large to read".into(),
}
}
}
#[derive(Debug, Clone)]
pub enum Parsed {
Clear(Vec<Simple>),
Opaque(Opaque),
}
impl Parsed {
pub fn clauses(&self) -> &[Simple] {
match self {
Parsed::Clear(c) => c,
Parsed::Opaque(_) => &[],
}
}
}
const INDIRECT: &[&str] = &["eval", "xargs", "source", "."];
const SHELLS: &[&str] = &["sh", "bash", "zsh", "fish", "dash", "ksh"];
struct Build {
text: Vec<u8>,
raw: Vec<u8>,
quoted: bool,
expanded: bool,
at: usize,
}
impl Build {
fn new(at: usize) -> Self {
Build {
text: Vec::new(),
raw: Vec::new(),
quoted: false,
expanded: false,
at,
}
}
fn finish(self) -> Option<Word> {
if self.raw.is_empty() {
return None;
}
Some(Word {
text: String::from_utf8(self.text).ok()?,
raw: String::from_utf8(self.raw).ok()?,
quoted: self.quoted,
expanded: self.expanded,
at: self.at,
})
}
}
pub fn lex(src: &str) -> Parsed {
if src.len() > MAX_SRC {
return Parsed::Opaque(Opaque::TooLong);
}
let b = src.as_bytes();
let n = b.len();
let mut i = 0usize;
let mut out: Vec<Simple> = Vec::new();
let mut cur = Simple {
at: 0,
..Default::default()
};
let mut word: Option<Build> = None;
let mut redirect: Option<String> = None;
let mut heredocs: Vec<Vec<u8>> = Vec::new();
macro_rules! end_word {
() => {
if let Some(w) = word.take() {
if let Some(w) = w.finish() {
match redirect.take() {
Some(op) => cur.redirects.push((op, w)),
None => cur.words.push(w),
}
}
}
};
}
macro_rules! end_clause {
($conn:expr, $at:expr) => {{
end_word!();
cur.end = $at;
if !cur.words.is_empty() || !cur.redirects.is_empty() {
cur.next = $conn;
let prev = $conn;
out.push(std::mem::take(&mut cur));
cur.prev = prev;
} else {
cur.prev = $conn;
}
cur.at = $at;
}};
}
while i < n {
let c = b[i];
if c == b'#' && word.is_none() {
while i < n && b[i] != b'\n' {
i += 1;
}
continue;
}
match c {
b'\'' => {
let w = word.get_or_insert_with(|| Build::new(i));
w.quoted = true;
w.raw.push(c);
i += 1;
let start = i;
while i < n && b[i] != b'\'' {
i += 1;
}
if i >= n {
return Parsed::Opaque(Opaque::UnterminatedQuote);
}
w.text.extend_from_slice(&b[start..i]);
w.raw.extend_from_slice(&b[start..i]);
w.raw.push(b'\'');
i += 1;
}
b'"' => {
let w = word.get_or_insert_with(|| Build::new(i));
w.quoted = true;
w.raw.push(c);
i += 1;
let mut closed = false;
while i < n {
match b[i] {
b'"' => {
closed = true;
w.raw.push(b'"');
i += 1;
break;
}
b'\\' if i + 1 < n => {
w.raw.push(b'\\');
w.raw.push(b[i + 1]);
w.text.push(b[i + 1]);
i += 2;
}
b'$' if i + 1 < n && b[i + 1] == b'(' => {
let Some(close) = balanced(b, i + 1, b'(', b')') else {
return Parsed::Opaque(Opaque::UnterminatedSubstitution);
};
w.expanded = true;
w.raw.extend_from_slice(&b[i..=close]);
w.text.extend(std::iter::repeat_n(b' ', close - i + 1));
i = close + 1;
}
other => {
w.raw.push(other);
w.text.push(other);
i += 1;
}
}
}
if !closed {
return Parsed::Opaque(Opaque::UnterminatedQuote);
}
}
b'\\' if i + 1 < n => {
let w = word.get_or_insert_with(|| Build::new(i));
if b[i + 1] == b'\n' {
i += 2;
continue;
}
w.quoted = true;
w.raw.push(b'\\');
w.raw.push(b[i + 1]);
w.text.push(b[i + 1]);
i += 2;
}
b'`' => {
let w = word.get_or_insert_with(|| Build::new(i));
let mut j = i + 1;
while j < n && b[j] != b'`' {
j += 1;
}
if j >= n {
return Parsed::Opaque(Opaque::UnterminatedSubstitution);
}
w.expanded = true;
w.raw.extend_from_slice(&b[i..=j]);
w.text.extend(std::iter::repeat_n(b' ', j - i + 1));
i = j + 1;
}
b'$' if i + 1 < n && (b[i + 1] == b'(' || b[i + 1] == b'{') => {
let (open, close) = if b[i + 1] == b'(' {
(b'(', b')')
} else {
(b'{', b'}')
};
let Some(end) = balanced(b, i + 1, open, close) else {
return Parsed::Opaque(Opaque::UnterminatedSubstitution);
};
let w = word.get_or_insert_with(|| Build::new(i));
w.expanded = true;
w.raw.extend_from_slice(&b[i..=end]);
w.text.extend(std::iter::repeat_n(b' ', end - i + 1));
i = end + 1;
}
b'<' if i + 1 < n && b[i + 1] == b'<' => {
end_word!();
i += 2;
if i < n && b[i] == b'-' {
i += 1;
}
while i < n && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
let mut tag = Vec::new();
while i < n
&& (b[i].is_ascii_alphanumeric()
|| b[i] == b'_'
|| b[i] == b'\''
|| b[i] == b'"')
{
if b[i] != b'\'' && b[i] != b'"' {
tag.push(b[i]);
}
i += 1;
}
heredocs.push(tag);
}
b'>' | b'<' => {
end_word!();
let start = i;
i += 1;
if i < n && b[i] == b'>' {
i += 1;
}
if i < n && b[i] == b'&' {
i += 1;
while i < n && (b[i].is_ascii_digit() || b[i] == b'-') {
i += 1;
}
cur.redirects.push((
String::from_utf8_lossy(&b[start..i]).into_owned(),
Word {
text: String::new(),
raw: String::new(),
quoted: false,
expanded: false,
at: start,
},
));
continue;
}
redirect = Some(String::from_utf8_lossy(&b[start..i]).into_owned());
}
b'0'..=b'9'
if word.is_none()
&& i + 1 < n
&& (b[i + 1] == b'>' || b[i + 1] == b'<')
&& !matches!(b.get(i + 2), Some(b'<')) =>
{
let start = i;
i += 1;
i += 1;
if i < n && b[i] == b'>' {
i += 1;
}
if i < n && b[i] == b'&' {
i += 1;
while i < n && (b[i].is_ascii_digit() || b[i] == b'-') {
i += 1;
}
cur.redirects.push((
String::from_utf8_lossy(&b[start..i]).into_owned(),
Word {
text: String::new(),
raw: String::new(),
quoted: false,
expanded: false,
at: start,
},
));
continue;
}
redirect = Some(String::from_utf8_lossy(&b[start..i]).into_owned());
}
b'|' => {
let conn = if i + 1 < n && b[i + 1] == b'|' {
i += 2;
Connector::OrOr
} else {
i += 1;
if i < n && b[i] == b'&' {
i += 1;
}
Connector::Pipe
};
end_clause!(Some(conn), i);
}
b'&' => {
let conn = if i + 1 < n && b[i + 1] == b'&' {
i += 2;
Connector::AndAnd
} else {
i += 1;
Connector::Amp
};
end_clause!(Some(conn), i);
}
b';' => {
i += 1;
end_clause!(Some(Connector::Semi), i);
}
b'\n' => {
i += 1;
end_clause!(Some(Connector::Semi), i);
while let Some(tag) = heredocs.first().cloned() {
heredocs.remove(0);
match find_terminator(b, i, &tag) {
Some(next) => i = next,
None => return Parsed::Opaque(Opaque::UnterminatedHeredoc),
}
}
}
b'(' | b')' | b'{' | b'}' if word.is_none() => {
end_clause!(None, i);
i += 1;
}
b'{' | b'}' => {
let w = word.get_or_insert_with(|| Build::new(i));
w.raw.push(b[i]);
w.text.push(b[i]);
i += 1;
}
b' ' | b'\t' | b'\r' => {
end_word!();
i += 1;
}
other => {
let w = word.get_or_insert_with(|| Build::new(i));
w.raw.push(other);
w.text.push(other);
i += 1;
}
}
}
if !heredocs.is_empty() {
return Parsed::Opaque(Opaque::UnterminatedHeredoc);
}
end_clause!(None, n);
for cmd in &out {
if let Some(why) = indirect(cmd) {
return Parsed::Opaque(Opaque::IndirectExecution(why));
}
}
Parsed::Clear(out)
}
fn indirect(cmd: &Simple) -> Option<&'static str> {
let p = cmd.program()?;
if let Some(hit) = INDIRECT.iter().find(|k| **k == p) {
return Some(hit);
}
if SHELLS.contains(&p) && cmd.has_flag("-c") {
return SHELLS.iter().find(|s| **s == p).copied();
}
None
}
fn balanced(b: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
let mut depth = 0usize;
let mut i = from;
while i < b.len() {
if b[i] == open {
depth += 1;
} else if b[i] == close {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
i += 1;
}
None
}
fn find_terminator(b: &[u8], from: usize, tag: &[u8]) -> Option<usize> {
let mut line = from;
while line <= b.len() {
let end = b[line..]
.iter()
.position(|&c| c == b'\n')
.map(|p| line + p)
.unwrap_or(b.len());
let trimmed: &[u8] = {
let s = &b[line..end];
let a = s.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(0);
let z = s
.iter()
.rposition(|c| !c.is_ascii_whitespace())
.map(|p| p + 1)
.unwrap_or(a);
&s[a..z]
};
if trimmed == tag {
return Some(if end < b.len() { end + 1 } else { b.len() });
}
if end >= b.len() {
return None;
}
line = end + 1;
}
None
}
const WRAPPERS: &[&str] = &[
"sudo", "command", "builtin", "nice", "time", "timeout", "env",
];
const GIT_GLOBAL_VALUED: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--exec-path"];
const GIT_GLOBAL_BARE: &[&str] = &[
"--no-pager",
"--paginate",
"-p",
"--bare",
"--literal-pathspecs",
];
impl Simple {
pub fn program(&self) -> Option<&str> {
let mut idx = 0;
loop {
let w = self.words.get(idx)?;
let t = w.text.as_str();
if !w.quoted && t.contains('=') && !t.starts_with('-') {
let name = &t[..t.find('=').unwrap()];
if !name.is_empty() && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
{
idx += 1;
continue;
}
}
if WRAPPERS.contains(&t) {
idx += 1;
if t == "timeout" || t == "nice" {
while self
.words
.get(idx)
.is_some_and(|w| w.text.bytes().all(|c| c.is_ascii_digit() || c == b'.'))
&& self.words.get(idx).is_some_and(|w| !w.text.is_empty())
{
idx += 1;
}
}
continue;
}
return Some(t);
}
}
fn program_index(&self) -> Option<usize> {
let p = self.program()?;
self.words.iter().position(|w| w.text == p)
}
pub fn subcommand(&self) -> Option<&str> {
let mut idx = self.program_index()? + 1;
while let Some(w) = self.words.get(idx) {
let t = w.text.as_str();
if !t.starts_with('-') {
return Some(t);
}
if GIT_GLOBAL_BARE.contains(&t) {
idx += 1;
continue;
}
if let Some(flag) = GIT_GLOBAL_VALUED.iter().find(|f| t == **f) {
let _ = flag;
idx += 2;
continue;
}
if GIT_GLOBAL_VALUED
.iter()
.any(|f| t.starts_with(&format!("{f}=")))
{
idx += 1;
continue;
}
return None;
}
None
}
pub fn has_flag(&self, flag: &str) -> bool {
for w in &self.words {
if !w.quoted && w.text == "--" {
return false;
}
if w.quoted {
continue;
}
if w.text == flag {
return true;
}
}
false
}
pub fn has_short(&self, c: char) -> bool {
for w in &self.words {
if !w.quoted && w.text == "--" {
return false;
}
if w.quoted || w.text.len() < 2 {
continue;
}
let t = w.text.as_str();
if t.starts_with('-') && !t.starts_with("--") && t[1..].contains(c) {
return true;
}
}
false
}
#[allow(dead_code)]
pub fn flag_value(&self, flag: &str) -> Option<&str> {
let eq = format!("{flag}=");
for (i, w) in self.words.iter().enumerate() {
if !w.quoted && w.text == "--" {
return None;
}
if w.quoted {
continue;
}
if let Some(v) = w.text.strip_prefix(&eq) {
return Some(v);
}
if w.text == flag {
return self.words.get(i + 1).map(|w| w.text.as_str());
}
}
None
}
pub fn operands(&self) -> Vec<&Word> {
let Some(start) = self.program_index() else {
return Vec::new();
};
let mut out = Vec::new();
let mut after_ddash = false;
for w in self.words.iter().skip(start + 1) {
if !w.quoted && w.text == "--" {
after_ddash = true;
continue;
}
if after_ddash || !w.text.starts_with('-') {
out.push(w);
}
}
out
}
pub fn is_dry_run(&self) -> bool {
self.words
.iter()
.any(|w| !w.quoted && (w.text == "--dry-run" || w.text.starts_with("--dry-run=")))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clauses(src: &str) -> Vec<Simple> {
match lex(src) {
Parsed::Clear(c) => c,
Parsed::Opaque(o) => panic!("expected a readable command, got {o:?}"),
}
}
fn opaque(src: &str) -> Opaque {
match lex(src) {
Parsed::Opaque(o) => o,
Parsed::Clear(_) => panic!("expected opacity"),
}
}
#[test]
fn quotes_hide_operators_and_flags() {
let c = clauses(r#"pkill -f "git push origin v1 | tail""#);
assert_eq!(c.len(), 1, "the quoted pipe must not split the command");
assert_eq!(c[0].program(), Some("pkill"));
let c = clauses(r#"gh pr create --body "use --auto here""#);
assert!(!c[0].has_flag("--auto"), "a quoted --auto is not a flag");
assert!(c[0].has_flag("--body"), "an unquoted flag still is one");
}
#[test]
fn a_connector_starts_a_new_command() {
let c = clauses("git push origin main && echo done | tail -1");
assert_eq!(c.len(), 3);
assert_eq!(c[0].program(), Some("git"));
assert_eq!(c[0].next, Some(Connector::AndAnd));
assert_eq!(c[1].program(), Some("echo"));
assert_eq!(c[1].next, Some(Connector::Pipe));
assert_eq!(c[2].program(), Some("tail"));
}
#[test]
fn only_a_pipe_is_a_pipe() {
assert!(Connector::Pipe.is_pipe());
for c in [
Connector::AndAnd,
Connector::OrOr,
Connector::Semi,
Connector::Amp,
] {
assert!(!c.is_pipe(), "{c:?} is not a pipe");
}
let c = clauses("git push |& tail -2");
assert_eq!(c[0].next, Some(Connector::Pipe));
}
#[test]
fn a_heredoc_body_is_data_but_its_own_line_is_not() {
let c = clauses("git commit -F- <<'MSG' 2>&1 | tail -8\nsubject\nMSG\n");
assert_eq!(c[0].program(), Some("git"));
assert_eq!(c[0].subcommand(), Some("commit"));
assert_eq!(
c[0].next,
Some(Connector::Pipe),
"the pipe survives the heredoc"
);
assert!(
c.iter().all(|s| s.program() != Some("subject")),
"the body must not be read as commands"
);
}
#[test]
fn an_unterminated_heredoc_is_not_an_opinion() {
assert_eq!(
opaque("git commit -F- <<'MSG'\nbody\n"),
Opaque::UnterminatedHeredoc
);
}
#[test]
fn an_unterminated_quote_is_not_an_opinion() {
assert_eq!(opaque("git push \"origin"), Opaque::UnterminatedQuote);
assert_eq!(opaque("git push 'origin"), Opaque::UnterminatedQuote);
}
#[test]
fn indirect_execution_is_not_inspected() {
assert!(matches!(
opaque("eval \"$cmd\""),
Opaque::IndirectExecution(_)
));
assert!(matches!(
opaque("sh -c 'git push | tail'"),
Opaque::IndirectExecution(_)
));
assert_eq!(clauses("bash deploy.sh")[0].program(), Some("bash"));
}
#[test]
fn a_substitution_is_blanked_and_the_word_keeps_its_length() {
let c = clauses("echo $(git push | tail -1)");
assert_eq!(c.len(), 1, "a pipe inside a substitution is not our pipe");
let w = &c[0].words[1];
assert!(w.expanded);
assert_eq!(w.text.len(), w.raw.len(), "blanking preserves length");
assert!(w.text.trim().is_empty());
}
#[test]
fn a_comment_ends_the_command() {
let c = clauses("git push origin main # then | tail -5");
assert_eq!(c.len(), 1);
assert_eq!(c[0].operands().len(), 3);
}
#[test]
fn a_brace_attached_to_a_word_stays_in_the_word() {
let c = clauses("git stash pop stash@{2}");
assert_eq!(c.len(), 1);
let ops: Vec<&str> = c[0].operands().iter().map(|w| w.text.as_str()).collect();
assert_eq!(ops, vec!["stash", "pop", "stash@{2}"]);
let g = clauses("{ echo a; }");
assert_eq!(
g.iter().filter_map(|c| c.program()).collect::<Vec<_>>(),
vec!["echo"]
);
}
#[test]
fn a_redirect_target_is_not_argv() {
let c = clauses("git push > --force");
assert!(!c[0].has_flag("--force"));
assert_eq!(c[0].redirects.len(), 1);
let c = clauses("git push 2>&1 | tail -3");
assert_eq!(c.len(), 2);
assert_eq!(c[0].next, Some(Connector::Pipe));
}
#[test]
fn flags_stop_at_the_double_dash() {
let c = clauses("git add -- -A");
assert!(!c[0].has_short('A'));
assert!(c[0].operands().iter().any(|w| w.text == "-A"));
}
#[test]
fn short_clusters_are_searched_by_letter() {
let c = clauses("git add -Au");
assert!(c[0].has_short('A') && c[0].has_short('u'));
assert!(!c[0].has_short('p'));
}
#[test]
fn assignments_and_wrappers_are_not_the_program() {
for src in [
"GIT_SSH_COMMAND=ssh git push",
"sudo git push",
"timeout 90 git push",
"command git push",
] {
let c = clauses(src);
assert_eq!(c[0].program(), Some("git"), "{src}");
assert_eq!(c[0].subcommand(), Some("push"), "{src}");
}
}
#[test]
fn git_global_options_precede_the_subcommand() {
assert_eq!(clauses("git -C /tmp/x push")[0].subcommand(), Some("push"));
assert_eq!(clauses("git --no-pager log")[0].subcommand(), Some("log"));
assert_eq!(
clauses("git -c user.name=x commit")[0].subcommand(),
Some("commit")
);
assert_eq!(clauses("git --future-flag push")[0].subcommand(), None);
}
#[test]
fn a_flag_value_is_read_either_way_it_is_written() {
assert_eq!(
clauses("grep --include=*.py x")[0].flag_value("--include"),
Some("*.py")
);
assert_eq!(
clauses("grep --include *.py x")[0].flag_value("--include"),
Some("*.py")
);
}
#[test]
fn a_dry_run_is_recognised_in_both_forms() {
assert!(clauses("kubectl apply --dry-run=client -f x")[0].is_dry_run());
assert!(clauses("git push --dry-run")[0].is_dry_run());
assert!(!clauses("git push")[0].is_dry_run());
}
}