safe-chains 0.219.1

Auto-allow safe bash commands in agentic coding tools
Documentation
use std::fmt;
use super::*;

fn write_sep(f: &mut fmt::Formatter<'_>, trailing_op: Option<ListOp>) -> fmt::Result {
    if !matches!(trailing_op, Some(ListOp::Semi)) {
        f.write_str(";")?;
    }
    Ok(())
}

fn write_case_arm(f: &mut fmt::Formatter<'_>, arm: &CaseArm) -> fmt::Result {
    f.write_str(" ")?;
    for (i, p) in arm.patterns.iter().enumerate() {
        if i > 0 {
            f.write_str("|")?;
        }
        write!(f, "{p}")?;
    }
    f.write_str(") ")?;
    // `Script`'s Display terminates a trailing `Semi` itself, and `;;` is a token rather than a
    // separator, so it is written detached: `write_body` here would render `a;` + `;;` = `a;;;`,
    // which is not the same tree.
    write!(f, "{}", arm.body)?;
    f.write_str(" ;;")
}

fn write_redirs(f: &mut fmt::Formatter<'_>, redirs: &[Redir]) -> fmt::Result {
    for r in redirs {
        write!(f, " {r}")?;
    }
    Ok(())
}

fn write_body(f: &mut fmt::Formatter<'_>, script: &Script) -> fmt::Result {
    for (i, stmt) in script.0.iter().enumerate() {
        if i > 0 {
            f.write_str(" ")?;
        }
        write!(f, "{}", stmt.pipeline)?;
        match &stmt.op {
            Some(ListOp::Semi) | None => f.write_str(";")?,
            Some(op) => write!(f, " {op}")?,
        }
    }
    Ok(())
}

impl fmt::Display for Script {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for (i, stmt) in self.0.iter().enumerate() {
            if i > 0 {
                f.write_str(" ")?;
            }
            write!(f, "{}", stmt.pipeline)?;
            match &stmt.op {
                Some(ListOp::Semi) => f.write_str(";")?,
                Some(op) => write!(f, " {op}")?,
                None => {}
            }
        }
        Ok(())
    }
}

impl fmt::Display for ListOp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ListOp::And => f.write_str("&&"),
            ListOp::Or => f.write_str("||"),
            ListOp::Semi => f.write_str(";"),
            ListOp::Amp => f.write_str("&"),
        }
    }
}

impl fmt::Display for Pipeline {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.bang {
            f.write_str("! ")?;
        }
        for (i, cmd) in self.commands.iter().enumerate() {
            if i > 0 {
                f.write_str(" | ")?;
            }
            write!(f, "{cmd}")?;
        }
        Ok(())
    }
}

impl fmt::Display for Cmd {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Cmd::Simple(s) => write!(f, "{s}"),
            Cmd::Subshell { body, redirs } => {
                write!(f, "({body})")?;
                for r in redirs {
                    write!(f, " {r}")?;
                }
                Ok(())
            }
            // `Script`'s Display already terminates a trailing `Semi` statement, so `write_sep`
            // supplies the `;` only when it is missing. Appending one unconditionally emitted
            // `{ echo hi;; }`, which re-parsed as the same tree only while `;;` meant nothing.
            Cmd::BraceGroup { body, redirs } => {
                write!(f, "{{ {body}")?;
                write_sep(f, body.0.last().and_then(|s| s.op))?;
                f.write_str(" }")?;
                for r in redirs {
                    write!(f, " {r}")?;
                }
                Ok(())
            }
            Cmd::FunctionDef { name, body } => {
                write!(f, "{name}() {{ {body}")?;
                write_sep(f, body.0.last().and_then(|s| s.op))?;
                f.write_str(" }")
            }
            Cmd::For { var, items, body, redirs } => {
                write!(f, "for {var}")?;
                if !items.is_empty() {
                    f.write_str(" in")?;
                    for item in items {
                        write!(f, " {item}")?;
                    }
                }
                write_sep(f, None)?;
                write!(f, " do ")?;
                write_body(f, body)?;
                f.write_str(" done")?;
                write_redirs(f, redirs)
            }
            Cmd::While { cond, body, redirs } => {
                write!(f, "while {cond}")?;
                write_sep(f, cond.0.last().and_then(|s| s.op))?;
                write!(f, " do ")?;
                write_body(f, body)?;
                f.write_str(" done")?;
                write_redirs(f, redirs)
            }
            Cmd::Until { cond, body, redirs } => {
                write!(f, "until {cond}")?;
                write_sep(f, cond.0.last().and_then(|s| s.op))?;
                write!(f, " do ")?;
                write_body(f, body)?;
                f.write_str(" done")?;
                write_redirs(f, redirs)
            }
            Cmd::If { branches, else_body, redirs } => {
                for (i, branch) in branches.iter().enumerate() {
                    if i == 0 {
                        write!(f, "if {}", branch.cond)?;
                    } else {
                        write!(f, " elif {}", branch.cond)?;
                    }
                    write_sep(f, branch.cond.0.last().and_then(|s| s.op))?;
                    write!(f, " then ")?;
                    write_body(f, &branch.body)?;
                    f.write_str("")?;
                }
                if let Some(eb) = else_body {
                    write!(f, " else ")?;
                    write_body(f, eb)?;
                }
                f.write_str(" fi")?;
                write_redirs(f, redirs)
            }
            Cmd::Case { subject, arms, redirs } => {
                write!(f, "case {subject} in")?;
                for arm in arms {
                    write_case_arm(f, arm)?;
                }
                f.write_str(" esac")?;
                write_redirs(f, redirs)
            }
            Cmd::DoubleBracket { words, redirs } => {
                f.write_str("[[")?;
                for w in words {
                    write!(f, " {w}")?;
                }
                f.write_str(" ]]")?;
                for r in redirs {
                    write!(f, " {r}")?;
                }
                Ok(())
            }
        }
    }
}

impl fmt::Display for SimpleCmd {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut first = true;
        for (name, val) in &self.env {
            if !first { f.write_str(" ")?; }
            first = false;
            write!(f, "{name}={val}")?;
        }
        for w in &self.words {
            if !first { f.write_str(" ")?; }
            first = false;
            write!(f, "{w}")?;
        }
        for r in &self.redirs {
            if !first { f.write_str(" ")?; }
            first = false;
            write!(f, "{r}")?;
        }
        Ok(())
    }
}

impl fmt::Display for Word {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for part in &self.0 {
            write!(f, "{part}")?;
        }
        Ok(())
    }
}

impl fmt::Display for WordPart {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            WordPart::Lit(s) => f.write_str(s),
            WordPart::Escape(c) => write!(f, "\\{c}"),
            WordPart::SQuote(s) => write!(f, "'{s}'"),
            WordPart::DQuote(w) => write!(f, "\"{w}\""),
            WordPart::CmdSub(s) => {
                let rendered = s.to_string();
                if rendered.starts_with('(') {
                    write!(f, "$( {rendered})")
                } else {
                    write!(f, "$({rendered})")
                }
            }
            WordPart::ProcSub(s) => write!(f, "<({s})"),
            WordPart::Backtick(s) => write!(f, "`{s}`"),
            WordPart::Arith(s) => write!(f, "$(({s}))"),
        }
    }
}

impl fmt::Display for Redir {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Redir::Write { fd, target, mode } => {
                // `&>`/`&>>` name both streams by construction and take no descriptor prefix —
                // `2&> f` is not a redirect, and rendering one would not re-parse.
                let both = matches!(mode, WriteMode::TruncateBoth | WriteMode::AppendBoth);
                if *fd != 1 && !both { write!(f, "{fd}")?; }
                let op = match mode {
                    WriteMode::Truncate => ">",
                    WriteMode::Append => ">>",
                    WriteMode::Clobber => ">|",
                    WriteMode::TruncateBoth => "&>",
                    WriteMode::AppendBoth => "&>>",
                };
                write!(f, "{op} {target}")
            }
            Redir::Read { fd, target } => {
                if *fd != 0 { write!(f, "{fd}")?; }
                write!(f, "< {target}")
            }
            Redir::ReadWrite { fd, target } => {
                if *fd != 0 { write!(f, "{fd}")?; }
                write!(f, "<> {target}")
            }
            Redir::HereStr(w) => write!(f, "<<< {w}"),
            Redir::HereDoc { delimiter, strip_tabs, .. } => {
                if *strip_tabs { write!(f, "<<-{delimiter}") } else { write!(f, "<<{delimiter}") }
            }
            Redir::DupFd { src, dst } => {
                if *src != 1 { write!(f, "{src}")?; }
                write!(f, ">&{dst}")
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::cst::parse;

    #[test]
    fn display_simple() {
        let s = parse("echo hello").unwrap();
        assert_eq!(s.to_string(), "echo hello");
    }

    #[test]
    fn display_pipeline() {
        let s = parse("grep foo | head -5").unwrap();
        assert_eq!(s.to_string(), "grep foo | head -5");
    }

    #[test]
    fn display_sequence() {
        let s = parse("ls && echo done").unwrap();
        assert_eq!(s.to_string(), "ls && echo done");
    }

    #[test]
    fn display_single_quoted() {
        let s = parse("echo 'hello world'").unwrap();
        assert_eq!(s.to_string(), "echo 'hello world'");
    }

    #[test]
    fn display_double_quoted() {
        let s = parse("echo \"hello world\"").unwrap();
        assert_eq!(s.to_string(), "echo \"hello world\"");
    }

    #[test]
    fn display_redirect() {
        let s = parse("echo hello > /dev/null").unwrap();
        assert_eq!(s.to_string(), "echo hello > /dev/null");
    }

    #[test]
    fn display_fd_redirect() {
        let s = parse("echo hello 2>&1").unwrap();
        assert_eq!(s.to_string(), "echo hello 2>&1");
    }

    #[test]
    fn display_cmd_sub() {
        let s = parse("echo $(ls)").unwrap();
        assert_eq!(s.to_string(), "echo $(ls)");
    }

    #[test]
    fn display_for() {
        let s = parse("for x in 1 2 3; do echo $x; done").unwrap();
        assert_eq!(s.to_string(), "for x in 1 2 3; do echo $x; done");
    }

    #[test]
    fn display_if() {
        let s = parse("if true; then echo yes; else echo no; fi").unwrap();
        assert_eq!(s.to_string(), "if true; then echo yes; else echo no; fi");
    }

    #[test]
    fn display_for_with_redirect() {
        let s = parse("for x in 1 2; do echo $x; done 2>/dev/null").unwrap();
        assert_eq!(s.to_string(), "for x in 1 2; do echo $x; done 2> /dev/null");
    }

    #[test]
    fn display_if_with_redirect() {
        let s = parse("if true; then echo yes; fi 2>&1").unwrap();
        assert_eq!(s.to_string(), "if true; then echo yes; fi 2>&1");
    }

    #[test]
    fn display_env_prefix() {
        let s = parse("FOO=bar ls").unwrap();
        assert_eq!(s.to_string(), "FOO=bar ls");
    }

    #[test]
    fn display_subshell() {
        let s = parse("(echo hello)").unwrap();
        assert_eq!(s.to_string(), "(echo hello)");
    }

    #[test]
    fn display_negation() {
        let s = parse("! echo hello").unwrap();
        assert_eq!(s.to_string(), "! echo hello");
    }
}