openmoat-core 0.6.0

Policy model and decision engine for OpenMoat. Pure: no I/O, no OS calls.
Documentation
//! What a command writes to a pipe, when the command line shows it.
//!
//! A shell reading stdin after the pipe runs that text as its program, so
//! `cat <<'EOF' | sh` is classified like `sh <<'EOF'`. `printf` and some
//! `echo`s expand escapes and formats (`printf '\x63at .env'` prints
//! `cat .env`); only `\n` is read, and any other escape or a `printf` format
//! makes the text unknown, so a shell that runs it asks.

use super::SimpleCommand;
use crate::shell::tokens::basename;

/// Text a command passes on through a pipe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Piped {
    /// The text, as the shell after the pipe reads it.
    Text(String),
    /// `echo` or `printf` print text the command line does not show (an escape
    /// or a format the classifier does not read).
    Unknown(String),
    /// Any other program: its output is not on the command line (`rev`, `tr`,
    /// `cat FILE`, a download already judged on its own).
    Output(String),
}

impl Piped {
    pub(super) fn text(&self) -> Option<&str> {
        match self {
            Self::Text(text) => Some(text),
            Self::Unknown(_) | Self::Output(_) => None,
        }
    }

    pub(super) fn unknown(&self) -> Option<&str> {
        match self {
            Self::Unknown(program) => Some(program),
            Self::Text(_) | Self::Output(_) => None,
        }
    }

    /// True when a shell reading this runs text the command line does not show.
    pub(super) fn is_hidden(&self) -> bool {
        !matches!(self, Self::Text(_))
    }
}

/// What `cmd` writes to a pipe: a bare `cat` passes on its here-document or
/// here-string, `echo` and `printf` their arguments.
pub(super) fn data(cmd: &SimpleCommand) -> Option<Piped> {
    let argv: Vec<&str> = cmd.words.iter().map(|w| w.text.as_str()).collect();
    match argv.as_slice() {
        [cat] | [cat, "-"] if basename(cat) == "cat" && cmd.reads.is_empty() => Some(Piped::Text(
            cmd.stdin_texts().collect::<Vec<_>>().join("\n"),
        )),
        [program, operands @ ..] if matches!(basename(program), "echo" | "printf") => {
            let printed: Vec<&str> = operands
                .iter()
                .copied()
                .skip_while(|a| matches!(*a, "-n" | "-e" | "-E" | "--"))
                .collect();
            let format = basename(program) == "printf" && printed.iter().any(|a| a.contains('%'));
            let text = expand_newlines(&printed.join(" ")).filter(|_| !format);
            Some(text.map_or_else(|| Piped::Unknown(basename(program).to_owned()), Piped::Text))
        }
        // Any other program prints what the command line does not show (`rev`, `tr`,
        // `cat FILE`): a shell after the pipe runs text no rule saw.
        [program, ..] => Some(Piped::Output(basename(program).to_owned())),
        [] => None,
    }
}

/// `text` with each `\n` turned into a newline; `None` when it holds any
/// other backslash, whose meaning differs between `printf` and the `echo`s.
fn expand_newlines(text: &str) -> Option<String> {
    let mut out = String::with_capacity(text.len());
    let mut chars = text.chars();
    while let Some(c) = chars.next() {
        match c {
            '\\' if chars.next()? == 'n' => out.push('\n'),
            '\\' => return None,
            _ => out.push(c),
        }
    }
    Some(out)
}

#[cfg(test)]
mod tests {
    use super::expand_newlines;

    #[test]
    fn only_newline_escapes_are_read() {
        assert_eq!(expand_newlines("ls\\npwd").as_deref(), Some("ls\npwd"));
        assert_eq!(expand_newlines("plain").as_deref(), Some("plain"));
        for text in ["\\x63at .env", "\\\\n", "\\101", "\\t", "end\\"] {
            assert_eq!(expand_newlines(text), None, "{text}");
        }
    }
}