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