openmoat_core/pattern/
literal.rs1use crate::lexer::{self, Token};
10use crate::policy::PolicyError;
11
12pub fn literal_shell_pattern(command: &str) -> Result<String, PolicyError> {
20 let bad = || PolicyError::BadShellPattern {
21 pattern: command.to_owned(),
22 };
23 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
47fn 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 '\\' => "[\\\\]".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 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 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 #[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 assert_eq!(
171 literal_shell_pattern("git -c core.fsmonitor=x status").unwrap(),
172 "git -c core.fsmonitor=x status"
173 );
174 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}