Skip to main content

openmoat_core/pattern/
literal.rs

1//! Shell patterns that match one command literally.
2//!
3//! A person who approves a command (`moat allow --always`) approves exactly
4//! that command. Written as a rule, glob characters would become wildcards
5//! (`cat *` would allow every `cat`), a leading `!` an exclusion and a
6//! trailing `$` an end anchor, so each word is escaped before it becomes a
7//! pattern.
8
9use crate::lexer::{self, Token};
10use crate::policy::PolicyError;
11use crate::shell::tables::RESERVED_PREFIXES;
12
13/// A shell pattern that matches `command` token for token and nothing wider.
14///
15/// Glob characters and `$` are put in one-character classes (`*` → `[*]`).
16/// Words the pattern lexer would split or reinterpret (whitespace, quotes,
17/// operators, a leading `!`) are single-quoted. Operators (`|`, `&&`, …) stay
18/// operators. Shell rules are prefixes, so arguments appended after the
19/// approved command still match.
20pub fn literal_shell_pattern(command: &str) -> Result<String, PolicyError> {
21    let bad = || PolicyError::BadShellPattern {
22        pattern: command.to_owned(),
23    };
24    // The classifier sees the words braces make (`{cat,.env}` runs `cat .env`),
25    // so the approval names them too.
26    let (mut tokens, too_many) =
27        lexer::expand_braces(lexer::lex(command.trim()).map_err(|_| bad())?);
28    if too_many.is_some() {
29        return Err(bad());
30    }
31    if tokens.is_empty() {
32        return Err(PolicyError::EmptyPattern);
33    }
34    // `! git diff --quiet`, `do make test`: the classifier reports the command
35    // after a reserved word, so the approval names that command.
36    let reserved = tokens
37        .iter()
38        .take_while(|t| {
39            matches!(t, Token::Word(w) if !w.quoted && RESERVED_PREFIXES.contains(&w.text.as_str()))
40        })
41        .count();
42    if reserved < tokens.len() {
43        tokens.drain(..reserved);
44    }
45    if let Some(subcommand) = git_subcommand(&tokens) {
46        tokens = subcommand;
47    }
48    tokens
49        .iter()
50        .map(|token| match token {
51            Token::Word(w) => Ok(literal_word(&w.text)),
52            Token::Operator(op) => Ok(op.symbol().to_owned()),
53            Token::HereDoc { .. } => Err(bad()),
54        })
55        .collect::<Result<Vec<_>, _>>()
56        .map(|parts| parts.join(" "))
57}
58
59/// The classifier reports `git -C dir status` as `git status` (and reads `dir`),
60/// so an approval of a plain git command line names the subcommand the same way;
61/// otherwise the approved rule could never match. When an option can run a
62/// program the command line as written is judged too, so it stays as written.
63fn git_subcommand(tokens: &[Token]) -> Option<Vec<Token>> {
64    let argv = tokens
65        .iter()
66        .map(|t| match t {
67            Token::Word(w) => Some(w.text.clone()),
68            _ => None,
69        })
70        .collect::<Option<Vec<_>>>()?;
71    if argv.first().map(|p| crate::shell::tokens::basename(p)) != Some("git") {
72        return None;
73    }
74    let git = crate::shell::git::parse(&argv);
75    if git.keep_original || git.subcommand.len() == argv.len() {
76        return None;
77    }
78    let keep = git.subcommand.len();
79    let mut out = tokens[..1].to_vec();
80    out.extend_from_slice(&tokens[tokens.len() - (keep - 1)..]);
81    Some(out)
82}
83
84fn literal_word(text: &str) -> String {
85    let escaped: String = text
86        .chars()
87        .map(|c| match c {
88            '*' | '?' | '[' | ']' | '{' | '}' | '$' => format!("[{c}]"),
89            // A backslash escapes the next character in a shell pattern; as
90            // a one-character class it is literal.
91            '\\' => "[\\\\]".to_owned(),
92            c => c.to_string(),
93        })
94        .collect();
95    let needs_quotes = escaped.is_empty()
96        || escaped.starts_with('!')
97        || escaped
98            .chars()
99            .any(|c| c.is_whitespace() || "'\"\\|&;<>()`#".contains(c));
100    if needs_quotes {
101        format!("'{}'", escaped.replace('\'', "'\\''"))
102    } else {
103        escaped
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::pattern::ShellPattern;
111
112    fn argv(s: &[&str]) -> Vec<String> {
113        s.iter().map(|w| (*w).to_owned()).collect()
114    }
115
116    fn compiled(command: &str) -> ShellPattern {
117        ShellPattern::compile(&literal_shell_pattern(command).unwrap()).unwrap()
118    }
119
120    #[test]
121    fn glob_characters_and_anchors_stay_literal() {
122        assert_eq!(
123            literal_shell_pattern("npm install left-pad").unwrap(),
124            "npm install left-pad"
125        );
126        assert_eq!(literal_shell_pattern("cat *").unwrap(), "cat [*]");
127        let cat = compiled("cat *");
128        assert!(cat.is_match(&argv(&["cat", "*"])));
129        assert!(
130            !cat.is_match(&argv(&["cat", "~/.ssh/id_rsa"])),
131            "`*` is not a wildcard"
132        );
133        let q = compiled("ls file?.[ch] '{a,b}'");
134        assert!(q.is_match(&argv(&["ls", "file?.[ch]", "{a,b}"])));
135        assert!(!q.is_match(&argv(&["ls", "file1.c", "a"])));
136        // Unquoted braces are the words bash makes of them, as classified.
137        assert_eq!(
138            literal_shell_pattern("{cat,.env} x{1..2}").unwrap(),
139            "cat .env x1 x2"
140        );
141        assert!(literal_shell_pattern("x{1..300}").is_err());
142        let bang = compiled("!echo hi $");
143        assert!(!bang.negated, "a leading `!` stays literal");
144        assert!(
145            bang.is_match(&argv(&["!echo", "hi", "$", "more"])),
146            "`$` is not an anchor"
147        );
148    }
149
150    #[test]
151    fn a_leading_reserved_word_is_not_part_of_the_command() {
152        assert_eq!(
153            literal_shell_pattern("! git diff --quiet").unwrap(),
154            "git diff --quiet"
155        );
156        assert_eq!(literal_shell_pattern("do make test").unwrap(), "make test");
157        assert_eq!(literal_shell_pattern("'do' x").unwrap(), "do x");
158        assert_eq!(literal_shell_pattern("do").unwrap(), "do");
159    }
160
161    #[test]
162    fn backslashes_stay_literal() {
163        // `\` left a dangling
164        // escape in the glob, so the approved rule broke the whole policy.
165        for command in ["++\\\\", "printf 'a\\nb'", "echo C:\\\\temp"] {
166            let pattern = literal_shell_pattern(command).unwrap();
167            let compiled = ShellPattern::compile(&pattern)
168                .unwrap_or_else(|e| panic!("{command:?} -> {pattern:?}: {e}"));
169            let words: Vec<String> = crate::lexer::lex(command)
170                .unwrap()
171                .into_iter()
172                .map(|t| t.to_string())
173                .collect();
174            assert!(compiled.is_match(&words), "{command:?} -> {pattern:?}");
175        }
176    }
177
178    /// The classifier strips git's global options (`shell/git.rs`); an approval
179    /// that kept them could never match (found by the `literal_pattern` fuzz target).
180    #[test]
181    fn git_global_options_are_approved_the_way_they_are_classified() {
182        assert_eq!(
183            literal_shell_pattern("git -C crates/x status").unwrap(),
184            "git status"
185        );
186        assert_eq!(
187            literal_shell_pattern("/usr/bin/git --no-pager log -1").unwrap(),
188            "/usr/bin/git log -1"
189        );
190        assert!(compiled("git -C crates/x status").is_match(&argv(&["git", "status"])));
191        // An option that can run a program keeps the command line as written,
192        // which the classifier also reports.
193        assert_eq!(
194            literal_shell_pattern("git -c core.fsmonitor=x status").unwrap(),
195            "git -c core.fsmonitor=x status"
196        );
197        // Not plain words: left alone.
198        assert_eq!(
199            literal_shell_pattern("git -C x status | head").unwrap(),
200            "git -C x status | head"
201        );
202    }
203
204    #[test]
205    fn quotes_and_operators_round_trip() {
206        assert!(compiled("echo \"it's\"").is_match(&argv(&["echo", "it's"])));
207        let piped = compiled("npm test | tee out.log");
208        assert!(piped.is_match(&argv(&["npm", "test", "|", "tee", "out.log"])));
209    }
210
211    #[test]
212    fn unusable_commands_are_errors() {
213        assert!(matches!(
214            literal_shell_pattern("echo 'unterminated"),
215            Err(PolicyError::BadShellPattern { .. })
216        ));
217        assert!(matches!(
218            literal_shell_pattern("   "),
219            Err(PolicyError::EmptyPattern)
220        ));
221        assert!(literal_shell_pattern("cat <<EOF\nx\nEOF\n").is_err());
222    }
223}