use std::fmt;
use super::bash_class;
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Destination {
Local,
Remote(String),
}
impl fmt::Display for Destination {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local => f.write_str("this machine"),
Self::Remote(h) => f.write_str(h),
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Scope {
pub dest: Destination,
}
impl Scope {
pub fn key(&self) -> String {
match &self.dest {
Destination::Local => "local:ro".to_string(),
Destination::Remote(h) => format!("ssh:{h}:ro"),
}
}
pub fn label(&self) -> String {
match &self.dest {
Destination::Local => {
"Yes, and don't ask again for read-only commands on this machine this session"
.to_string()
}
Destination::Remote(h) => {
format!("Yes, and don't ask again for read-only commands on `{h}` this session")
}
}
}
}
const EXTRA_READONLY_HEADS: &[&str] = &[
"cd", "exit", "ps", "uptime", "free", "id", "groups", "vmstat", "iostat", "lsof", "netstat",
"ss", "last",
];
const AWK_WRITE_MARKERS: &[&str] = &["system(", ">", "close(", "|&", "ENVIRON["];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Word {
pub text: String,
pub quoted: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Token {
Word(Word),
Sep,
}
fn heredoc_marker(i: usize) -> String {
format!("\u{1}HEREDOC{i}\u{1}")
}
struct Heredoc {
body: String,
tag_quoted: bool,
}
fn strip_heredocs(cmd: &str) -> Option<(String, Vec<Heredoc>)> {
let mut line = String::new();
let mut docs: Vec<Heredoc> = Vec::new();
let mut rest = cmd.to_string();
loop {
let Some(pos) = find_heredoc_op(&rest) else {
line.push_str(&rest);
return Some((line, docs));
};
line.push_str(&rest[..pos]);
let after = &rest[pos + 2..];
let after = after.strip_prefix('-').unwrap_or(after);
let after = after.trim_start_matches(' ');
let (tag, tag_quoted, consumed) = read_heredoc_tag(after)?;
let body_start = &after[consumed..];
let nl = body_start.find('\n')?;
let (head_tail, body_and_rest) = body_start.split_at(nl + 1);
let (body, remainder) = split_at_tag(body_and_rest, &tag)?;
line.push_str(&heredoc_marker(docs.len()));
line.push_str(head_tail);
docs.push(Heredoc { body, tag_quoted });
rest = remainder;
}
}
fn find_heredoc_op(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < b.len() {
let c = b[i];
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => {
if c == b'\'' || c == b'"' {
quote = Some(c);
} else if c == b'<' && b.get(i + 1) == Some(&b'<') {
if b.get(i + 2) == Some(&b'<') {
return None; }
return Some(i);
}
}
}
i += 1;
}
None
}
fn read_heredoc_tag(s: &str) -> Option<(String, bool, usize)> {
let b = s.as_bytes();
let first = *b.first()?;
if first == b'\'' || first == b'"' {
let end = s[1..].find(first as char)? + 1;
return Some((s[1..end].to_string(), true, end + 1));
}
let end = s
.find(|c: char| c.is_whitespace() || c == ';' || c == '&' || c == '|')
.unwrap_or(s.len());
if end == 0 {
return None;
}
Some((s[..end].to_string(), false, end))
}
fn split_at_tag(s: &str, tag: &str) -> Option<(String, String)> {
let mut offset = 0usize;
for line in s.split_inclusive('\n') {
if line.trim() == tag {
let body = s[..offset].to_string();
let rest = s[offset + line.len()..].to_string();
return Some((body, rest));
}
offset += line.len();
}
None
}
pub fn tokenize(line: &str) -> Option<Vec<Token>> {
let mut out: Vec<Token> = Vec::new();
let mut cur = String::new();
let mut cur_quoted = false;
let mut have_cur = false;
let b: Vec<char> = line.chars().collect();
let mut i = 0usize;
macro_rules! flush {
() => {
if have_cur {
out.push(Token::Word(Word {
text: std::mem::take(&mut cur),
quoted: cur_quoted,
}));
#[allow(unused_assignments)]
{
cur_quoted = false;
have_cur = false;
}
}
};
}
while i < b.len() {
let c = b[i];
match c {
'\'' => {
let end = b[i + 1..].iter().position(|&x| x == '\'')? + i + 1;
cur.push_str(&b[i + 1..end].iter().collect::<String>());
cur_quoted = true;
have_cur = true;
i = end + 1;
}
'"' => {
let end = b[i + 1..].iter().position(|&x| x == '"')? + i + 1;
let inner: String = b[i + 1..end].iter().collect();
if inner.contains('`') || inner.contains("$(") {
return None;
}
cur.push_str(&inner);
cur_quoted = true;
have_cur = true;
i = end + 1;
}
'`' => return None,
'$' if b.get(i + 1) == Some(&'(') => return None,
'>' | '1' | '2' if !have_cur => match harmless_redirect_len(&b[i..]) {
Some(n) => i += n,
None if c == '>' => return None,
None => {
cur.push(c);
have_cur = true;
i += 1;
}
},
'>' | '<' => return None,
'(' | ')' => return None,
';' | '\n' => {
flush!();
out.push(Token::Sep);
i += 1;
}
'&' => {
if b.get(i + 1) == Some(&'&') {
flush!();
out.push(Token::Sep);
i += 2;
} else {
return None; }
}
'|' => {
flush!();
out.push(Token::Sep);
i += if b.get(i + 1) == Some(&'|') { 2 } else { 1 };
}
c if c.is_whitespace() => {
flush!();
i += 1;
}
c => {
cur.push(c);
have_cur = true;
i += 1;
}
}
}
flush!();
Some(out)
}
fn harmless_redirect_len(rest: &[char]) -> Option<usize> {
let mut i = usize::from(matches!(rest.first(), Some('1' | '2')));
if rest.get(i) != Some(&'>') {
return None;
}
i += 1;
let tail_ok = |j: usize| {
rest.get(j)
.is_none_or(|c| c.is_whitespace() || matches!(c, ';' | '|' | '&' | '\n'))
};
if rest.get(i) == Some(&'&') {
return (matches!(rest.get(i + 1), Some('1' | '2')) && tail_ok(i + 2)).then_some(i + 2);
}
while rest.get(i).is_some_and(|c| *c == ' ') {
i += 1;
}
let target: Vec<char> = DEV_NULL.chars().collect();
let end = i + target.len();
(rest.get(i..end) == Some(&target[..]) && tail_ok(end)).then_some(end)
}
const DEV_NULL: &str = "/dev/null";
pub fn segments(tokens: Vec<Token>) -> Vec<Vec<Word>> {
let mut out: Vec<Vec<Word>> = Vec::new();
let mut cur: Vec<Word> = Vec::new();
for t in tokens {
match t {
Token::Sep => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
Token::Word(w) => cur.push(w),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
const SSH_FLAGS_WITH_VALUE: &[&str] = &[
"-o", "-p", "-i", "-l", "-F", "-c", "-m", "-b", "-D", "-L", "-R", "-W", "-J", "-Q", "-E", "-S",
];
pub fn segment_is_readonly(seg: &[Word]) -> bool {
let Some(head) = seg.first() else {
return false;
};
let whole = seg
.iter()
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.join(" ");
match head.text.as_str() {
"awk" | "gawk" | "mawk" => !AWK_WRITE_MARKERS.iter().any(|m| whole.contains(m)),
"cd" => true,
other => {
if EXTRA_READONLY_HEADS.contains(&other) {
return true;
}
let words: Vec<(&str, bool)> =
seg.iter().map(|w| (w.text.as_str(), w.quoted)).collect();
bash_class::is_readonly_words(&words)
}
}
}
pub fn classify_bash(cmd: &str) -> Option<Scope> {
classify_inner(cmd, 0)
}
fn classify_inner(cmd: &str, depth: u8) -> Option<Scope> {
if depth > 1 {
return None;
}
let (line, docs) = strip_heredocs(cmd)?;
let segs = segments(tokenize(&line)?);
if segs.is_empty() {
return None;
}
let mut dest: Option<Destination> = None;
for seg in &segs {
let seg_dest = if seg[0].text == "ssh" {
let (host, payload) = ssh_target_and_payload(seg, &docs)?;
let inner = classify_inner(&payload, depth + 1)?;
if inner.dest != Destination::Local {
return None; }
Destination::Remote(host)
} else {
if !segment_is_readonly(seg) {
return None;
}
if seg.iter().any(|w| w.text.contains('\u{1}')) {
return None;
}
Destination::Local
};
match (&dest, &seg_dest) {
(None, _) => dest = Some(seg_dest),
(Some(Destination::Local), Destination::Remote(_)) => dest = Some(seg_dest),
(Some(Destination::Remote(_)), Destination::Local) => {}
(Some(a), b) if a == b => {}
_ => return None,
}
}
dest.map(|dest| Scope { dest })
}
fn ssh_target_and_payload(seg: &[Word], docs: &[Heredoc]) -> Option<(String, String)> {
let mut i = 1usize;
while i < seg.len() {
let t = seg[i].text.as_str();
if SSH_FLAGS_WITH_VALUE.contains(&t) {
i += 2;
continue;
}
if t.starts_with('-') {
i += 1;
continue;
}
break;
}
let target = seg.get(i)?.text.to_ascii_lowercase();
if target.is_empty() || target.starts_with('-') {
return None;
}
let rest = &seg[i + 1..];
for w in rest {
if let Some(body) = heredoc_for(&w.text, docs) {
return Some((target, body?));
}
}
if rest.is_empty() {
return None;
}
Some((
target,
rest.iter()
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.join(" "),
))
}
fn heredoc_for(word: &str, docs: &[Heredoc]) -> Option<Option<String>> {
if !word.contains('\u{1}') {
return None;
}
for (i, d) in docs.iter().enumerate() {
if word.contains(&heredoc_marker(i)) {
if !d.tag_quoted && (d.body.contains('$') || d.body.contains('`')) {
return Some(None);
}
return Some(Some(d.body.clone()));
}
}
Some(None)
}
#[cfg(test)]
mod tests;