openmoat_core/pattern/
literal.rs1use crate::lexer::{self, Token};
10use crate::policy::PolicyError;
11use crate::shell::tables::RESERVED_PREFIXES;
12
13pub fn literal_shell_pattern(command: &str) -> Result<String, PolicyError> {
21 let bad = || PolicyError::BadShellPattern {
22 pattern: command.to_owned(),
23 };
24 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 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
59fn 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 '\\' => "[\\\\]".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 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 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 #[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 assert_eq!(
194 literal_shell_pattern("git -c core.fsmonitor=x status").unwrap(),
195 "git -c core.fsmonitor=x status"
196 );
197 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}