Skip to main content

guardrail/engine/
mod.rs

1mod prefilter;
2mod sql;
3
4use std::fmt;
5
6pub use hayai::engine::{
7    ChainedNormalizer, IdentityNormalizer, MatchEngine, Normalizer, NullPrefilter, PathNormalizer,
8    Prefilter, contains_ascii_ci,
9};
10use hayai::engine::RegexMatcher;
11
12pub use self::prefilter::PrefixPrefilter;
13pub use self::sql::SqlCommentStripper;
14
15use crate::model::{Decision, Rule, Severity};
16
17// ═══════════════════════════════════════════════════════════════════
18// Domain-specific trait: RuleEngine (returns Decision, not indices)
19// ═══════════════════════════════════════════════════════════════════
20
21/// Trait for domain-specific rule matching that returns Decisions.
22pub trait RuleEngine {
23    #[must_use]
24    fn check(&self, command: &str) -> Decision;
25    #[must_use]
26    fn rules(&self) -> &[Rule];
27    #[must_use]
28    fn rule_count(&self) -> usize {
29        self.rules().len()
30    }
31}
32
33/// Production normalizer: `PathNormalizer` then `SqlCommentStripper`.
34pub type ProductionNormalizer = ChainedNormalizer<PathNormalizer, SqlCommentStripper>;
35
36/// Backward-compatible alias for `PathNormalizer`.
37pub type NixStoreNormalizer = PathNormalizer;
38
39// ═══════════════════════════════════════════════════════════════════
40// RegexEngine -- wraps hayai::RegexMatcher, adds Decision logic
41// ═══════════════════════════════════════════════════════════════════
42
43/// Production rule engine: pluggable normalizer + prefilter + `RegexSet` DFA.
44///
45/// Default type parameters give zero-cost production behavior via
46/// monomorphization. Tests can substitute `IdentityNormalizer` and/or
47/// `NullPrefilter` for isolation.
48///
49/// # Examples
50///
51/// ```no_run
52/// use guardrail::engine::*;
53/// # fn main() -> anyhow::Result<()> {
54/// let rules = vec![]; // load from config
55/// // Production (default):
56/// let engine = RegexEngine::new(rules.clone())?;
57/// // Testing (no normalization, no prefilter):
58/// let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter)?;
59/// # Ok(())
60/// # }
61/// ```
62pub struct RegexEngine<N: Normalizer = ProductionNormalizer, P: Prefilter = PrefixPrefilter> {
63    matcher: RegexMatcher<N, P>,
64    rules: Vec<Rule>,
65}
66
67impl<N: Normalizer + fmt::Debug, P: Prefilter + fmt::Debug> fmt::Debug for RegexEngine<N, P> {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.debug_struct("RegexEngine")
70            .field("rule_count", &self.rules.len())
71            .field("matcher", &self.matcher)
72            .finish()
73    }
74}
75
76/// Default constructor -- production configuration.
77impl RegexEngine {
78    /// Create an engine with `ProductionNormalizer` + `PrefixPrefilter`.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if any regex pattern is invalid or the compiled
83    /// DFA exceeds the 100MB size limit.
84    pub fn new(rules: Vec<Rule>) -> anyhow::Result<Self> {
85        Self::with_plugins(
86            rules,
87            ChainedNormalizer {
88                first: PathNormalizer,
89                second: SqlCommentStripper,
90            },
91            PrefixPrefilter,
92        )
93    }
94}
95
96impl<N: Normalizer, P: Prefilter> RegexEngine<N, P> {
97    /// Create an engine with custom normalizer and prefilter.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if any regex pattern is invalid.
102    pub fn with_plugins(rules: Vec<Rule>, normalizer: N, prefilter: P) -> anyhow::Result<Self> {
103        let patterns: Vec<String> = rules.iter().map(|r| r.pattern.clone()).collect();
104        let matcher = RegexMatcher::with_plugins(patterns, normalizer, prefilter)?;
105        Ok(Self { matcher, rules })
106    }
107}
108
109impl<N: Normalizer, P: Prefilter> RuleEngine for RegexEngine<N, P> {
110    fn check(&self, command: &str) -> Decision {
111        let matches = self.matcher.check(command);
112        if matches.is_empty() {
113            return Decision::Allow;
114        }
115
116        let mut best_warn: Option<&Rule> = None;
117
118        for idx in matches {
119            let rule = &self.rules[idx];
120            match rule.severity {
121                Severity::Block => return Decision::from_rule(rule),
122                Severity::Warn if best_warn.is_none() => best_warn = Some(rule),
123                Severity::Warn => {}
124            }
125        }
126
127        best_warn.map_or(Decision::Allow, Decision::from_rule)
128    }
129
130    fn rules(&self) -> &[Rule] {
131        &self.rules
132    }
133}
134
135// ═══════════════════════════════════════════════════════════════════
136// Tests
137// ═══════════════════════════════════════════════════════════════════
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::config;
143    use crate::model::Category;
144
145    fn engine() -> RegexEngine {
146        RegexEngine::new(config::default_rules()).unwrap()
147    }
148
149    fn assert_blocks(cmd: &str) {
150        let e = engine();
151        match e.check(cmd) {
152            Decision::Block { .. } => {}
153            other => panic!("expected Block for '{cmd}', got {other}"),
154        }
155    }
156
157    fn assert_warns(cmd: &str) {
158        let e = engine();
159        match e.check(cmd) {
160            Decision::Warn { .. } => {}
161            other => panic!("expected Warn for '{cmd}', got {other}"),
162        }
163    }
164
165    fn assert_allows(cmd: &str) {
166        let e = engine();
167        match e.check(cmd) {
168            Decision::Allow => {}
169            other => panic!("expected Allow for '{cmd}', got {other}"),
170        }
171    }
172
173    // -- Normalizer trait -----------------------------------------
174
175    #[test]
176    fn path_normalizer_strips_nix_store_path() {
177        let n = PathNormalizer;
178        let result = n.normalize("/nix/store/abc123-pkg-1.0/bin/guardrail check");
179        assert_eq!(&*result, "guardrail check");
180        assert!(matches!(result, Cow::Owned(_)));
181    }
182
183    #[test]
184    fn path_normalizer_borrows_when_no_path() {
185        let n = PathNormalizer;
186        let result = n.normalize("cargo test");
187        assert_eq!(&*result, "cargo test");
188        assert!(matches!(result, Cow::Borrowed(_)));
189    }
190
191    #[test]
192    fn path_normalizer_strips_multiple_nix_paths() {
193        let n = PathNormalizer;
194        let result =
195            n.normalize("/nix/store/abc-foo-1.0/bin/cmd1 && /nix/store/def-bar-2.0/bin/cmd2");
196        assert_eq!(&*result, "cmd1 && cmd2");
197    }
198
199    #[test]
200    fn path_normalizer_strips_usr_bin() {
201        let n = PathNormalizer;
202        let result = n.normalize("/usr/bin/rm -rf /");
203        assert_eq!(&*result, "rm -rf /");
204        assert!(matches!(result, Cow::Owned(_)));
205    }
206
207    #[test]
208    fn path_normalizer_strips_usr_local_bin() {
209        let n = PathNormalizer;
210        let result = n.normalize("/usr/local/bin/terraform destroy");
211        assert_eq!(&*result, "terraform destroy");
212    }
213
214    #[test]
215    fn path_normalizer_strips_bin() {
216        let n = PathNormalizer;
217        let result = n.normalize("/bin/rm -rf /");
218        assert_eq!(&*result, "rm -rf /");
219    }
220
221    #[test]
222    fn path_normalizer_strips_sbin() {
223        let n = PathNormalizer;
224        let result = n.normalize("/sbin/mkfs.ext4 /dev/sda1");
225        assert_eq!(&*result, "mkfs.ext4 /dev/sda1");
226    }
227
228    #[test]
229    fn identity_normalizer_is_noop() {
230        let n = IdentityNormalizer;
231        let result = n.normalize("anything");
232        assert!(matches!(result, Cow::Borrowed("anything")));
233    }
234
235    // -- SQL comment stripping ------------------------------------
236
237    #[test]
238    fn sql_comment_stripper_block_comment() {
239        let n = SqlCommentStripper;
240        let result = n.normalize("DELETE/**/FROM users");
241        assert_eq!(result.trim(), "DELETE FROM users");
242    }
243
244    #[test]
245    fn sql_comment_stripper_sneaky_block_comment() {
246        let n = SqlCommentStripper;
247        let result = n.normalize("DROP/* sneaky */TABLE users");
248        assert_eq!(result.trim(), "DROP TABLE users");
249    }
250
251    #[test]
252    fn sql_comment_stripper_line_comment() {
253        let n = SqlCommentStripper;
254        let result = n.normalize("DROP TABLE -- this is a comment\nusers");
255        assert!(result.contains("DROP TABLE"));
256    }
257
258    #[test]
259    fn sql_comment_stripper_preserves_cli_flags() {
260        let n = SqlCommentStripper;
261        let result = n.normalize("cargo build -- --release");
262        assert_eq!(&*result, "cargo build -- --release");
263        assert!(matches!(result, Cow::Borrowed(_)));
264    }
265
266    #[test]
267    fn sql_comment_stripper_no_comments() {
268        let n = SqlCommentStripper;
269        let result = n.normalize("SELECT * FROM users");
270        assert!(matches!(result, Cow::Borrowed(_)));
271    }
272
273    // -- ChainedNormalizer / ProductionNormalizer ------------------
274
275    #[test]
276    fn chained_normalizer_chains_path_and_sql() {
277        let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
278        let result = n.normalize("/usr/bin/psql -c 'DROP/**/TABLE users'");
279        assert!(result.contains("DROP"));
280        assert!(result.contains("TABLE"));
281        assert!(!result.contains("/usr/bin/"));
282    }
283
284    #[test]
285    fn chained_normalizer_borrows_when_clean() {
286        let n: ProductionNormalizer = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
287        let result = n.normalize("cargo test");
288        assert!(matches!(result, Cow::Borrowed(_)));
289    }
290
291    #[test]
292    fn chained_normalizer_only_first_transforms() {
293        let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
294        let result = n.normalize("/usr/bin/ls -la");
295        assert_eq!(&*result, "ls -la");
296    }
297
298    #[test]
299    fn chained_normalizer_only_second_transforms() {
300        let n = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
301        let result = n.normalize("DROP/**/TABLE users");
302        assert!(result.contains("DROP"));
303        assert!(result.contains("TABLE"));
304    }
305
306    #[test]
307    fn chained_normalizer_identity_is_noop() {
308        let n = ChainedNormalizer { first: IdentityNormalizer, second: IdentityNormalizer };
309        let result = n.normalize("anything");
310        assert!(matches!(result, Cow::Borrowed("anything")));
311    }
312
313    // -- Prefilter trait ------------------------------------------
314
315    #[test]
316    fn prefix_prefilter_safe_commands() {
317        let p = PrefixPrefilter;
318        assert!(p.is_safe("ls -la"));
319        assert!(p.is_safe("cat file.txt"));
320        assert!(p.is_safe("rg pattern ."));
321        assert!(p.is_safe("wc -l file"));
322        assert!(p.is_safe("head -5 file"));
323    }
324
325    #[test]
326    fn prefix_prefilter_dangerous_commands() {
327        let p = PrefixPrefilter;
328        assert!(!p.is_safe("rm -rf /"));
329        assert!(!p.is_safe("git push --force"));
330        assert!(!p.is_safe("kubectl delete namespace prod"));
331        assert!(!p.is_safe("terraform destroy"));
332    }
333
334    #[test]
335    fn prefix_prefilter_sql_keywords() {
336        let p = PrefixPrefilter;
337        assert!(!p.is_safe("echo 'DROP TABLE users' | psql"));
338    }
339
340    #[test]
341    fn null_prefilter_never_safe() {
342        let p = NullPrefilter;
343        assert!(!p.is_safe("ls -la"));
344        assert!(!p.is_safe("cargo test"));
345    }
346
347    // -- Engine with plugins --------------------------------------
348
349    #[test]
350    fn engine_with_null_prefilter_checks_everything() {
351        let rules = config::default_rules();
352        let normalizer = ChainedNormalizer { first: PathNormalizer, second: SqlCommentStripper };
353        let engine =
354            RegexEngine::with_plugins(rules, normalizer, NullPrefilter).unwrap();
355        assert!(matches!(engine.check("ls -la"), Decision::Allow));
356        assert!(matches!(engine.check("rm -rf /"), Decision::Block { .. }));
357    }
358
359    #[test]
360    fn engine_with_identity_normalizer_no_nix_strip() {
361        let rules = config::default_rules();
362        let engine =
363            RegexEngine::with_plugins(rules, IdentityNormalizer, PrefixPrefilter).unwrap();
364        assert!(matches!(
365            engine.check("/nix/store/abc123-coreutils-9.0/bin/rm -rf /"),
366            Decision::Allow
367        ));
368    }
369
370    #[test]
371    fn engine_debug_impl() {
372        let engine = engine();
373        let debug = format!("{engine:?}");
374        assert!(debug.contains("RegexEngine"));
375        assert!(debug.contains("rule_count"));
376    }
377
378    // -- Nix store path normalization -----------------------------
379
380    #[test]
381    fn nix_shell_wrapped_mkfs_not_blocked() {
382        let cmd = "/nix/store/abc123-e2fsprogs-1.47/bin/crate2nix generate";
383        assert_allows(cmd);
384    }
385
386    #[test]
387    fn actual_mkfs_still_blocked() {
388        assert_blocks("mkfs.ext4 /dev/sda1");
389        assert_blocks("sudo mkfs.ext4 /dev/sda1");
390    }
391
392    #[test]
393    fn nix_store_path_with_real_danger() {
394        assert_blocks("/nix/store/abc123-coreutils-9.0/bin/rm -rf /");
395    }
396
397    #[test]
398    fn usr_bin_rm_blocked() {
399        assert_blocks("/usr/bin/rm -rf /");
400    }
401
402    #[test]
403    fn sbin_mkfs_blocked() {
404        assert_blocks("/sbin/mkfs.ext4 /dev/sda1");
405    }
406
407    #[test]
408    fn usr_local_bin_terraform_blocked() {
409        assert_blocks("/usr/local/bin/terraform destroy");
410    }
411
412    #[test]
413    fn bin_rm_blocked() {
414        assert_blocks("/bin/rm -rf /");
415    }
416
417    // -- Filesystem -----------------------------------------------
418
419    #[test] fn rm_rf_root_blocked()        { assert_blocks("rm -rf /"); }
420    #[test] fn rm_rf_root_var_blocked()    { assert_blocks("rm -rf /"); }
421    #[test] fn rm_rf_home_blocked()        { assert_blocks("rm -rf ~"); }
422    #[test] fn rm_rf_home_var_blocked()    { assert_blocks("rm -rf $HOME"); }
423    #[test] fn rm_rf_cwd_blocked()         { assert_blocks("rm -rf ."); }
424    #[test] fn rm_rf_target_allowed()      { assert_allows("rm -rf ./target"); }
425    #[test] fn rm_rf_subdir_allowed()      { assert_allows("rm -rf ~/code/old-project"); }
426    #[test] fn rm_single_file_allowed()    { assert_allows("rm file.txt"); }
427    #[test] fn dd_disk_blocked()           { assert_blocks("dd if=/dev/zero of=/dev/sda bs=1M"); }
428    #[test] fn dd_file_allowed()           { assert_allows("dd if=input.img of=output.img"); }
429    #[test] fn mkfs_blocked()              { assert_blocks("mkfs.ext4 /dev/sda1"); }
430
431    // -- Git ------------------------------------------------------
432
433    #[test] fn force_push_main_blocked()     { assert_blocks("git push --force origin main"); }
434    #[test] fn force_push_master_blocked()   { assert_blocks("git push --force origin master"); }
435    #[test] fn force_push_bare_blocked()     { assert_blocks("git push --force"); }
436    #[test] fn force_push_feature_allowed()  { assert_allows("git push --force origin feature-xyz"); }
437    #[test] fn normal_push_allowed()         { assert_allows("git push origin main"); }
438    #[test] fn reset_hard_warned()           { assert_warns("git reset --hard HEAD~1"); }
439    #[test] fn reset_soft_allowed()          { assert_allows("git reset --soft HEAD~1"); }
440    #[test] fn clean_force_warned()          { assert_warns("git clean -fd"); }
441    #[test] fn branch_force_delete_warned()  { assert_warns("git branch -D old-branch"); }
442    #[test] fn branch_delete_allowed()       { assert_allows("git branch -d merged-branch"); }
443
444    // -- Database -------------------------------------------------
445
446    #[test] fn drop_table_blocked()            { assert_blocks("psql -c 'DROP TABLE users'"); }
447    #[test] fn drop_table_lower_blocked()      { assert_blocks("psql -c 'drop table users'"); }
448    #[test] fn drop_database_blocked()         { assert_blocks("psql -c 'DROP DATABASE mydb'"); }
449    #[test] fn drop_schema_blocked()           { assert_blocks("mysql -e 'DROP SCHEMA test'"); }
450    #[test] fn truncate_blocked()              { assert_blocks("psql -c 'TRUNCATE TABLE logs'"); }
451    #[test] fn delete_no_where_blocked()       { assert_blocks("psql -c 'DELETE FROM users'"); }
452    #[test] fn delete_with_where_allowed()     { assert_allows("psql -c 'DELETE FROM users WHERE id = 5'"); }
453    #[test] fn select_allowed()                { assert_allows("psql -c 'SELECT * FROM users'"); }
454    #[test] fn create_table_allowed()          { assert_allows("psql -c 'CREATE TABLE new_table (id int)'"); }
455    #[test] fn insert_allowed()                { assert_allows("psql -c 'INSERT INTO users VALUES (1)'"); }
456
457    // -- SQL escaping ---------------------------------------------
458
459    #[test] fn drop_table_single_quotes()  { assert_blocks("psql -c 'DROP TABLE users'"); }
460    #[test] fn drop_table_double_quotes()  { assert_blocks(r#"psql -c "DROP TABLE users""#); }
461    #[test] fn drop_table_heredoc()        { assert_blocks("psql <<EOF\nDROP TABLE users;\nEOF"); }
462    #[test] fn drop_table_pipe()           { assert_blocks("echo 'DROP TABLE users' | psql"); }
463    #[test] fn drop_table_e_flag()         { assert_blocks("mysql -e 'DROP TABLE users'"); }
464    #[test] fn drop_table_multiline()      { assert_blocks("psql -c '\nDROP TABLE\nusers\n'"); }
465    #[test] fn truncate_semicolon()        { assert_blocks("psql -c 'TRUNCATE TABLE logs;'"); }
466    #[test] fn delete_from_semicolon()     { assert_blocks("psql -c 'DELETE FROM users;'"); }
467
468    // -- SQL comment bypass blocked -------------------------------
469
470    #[test] fn drop_table_block_comment()  { assert_blocks("psql -c 'DROP/**/TABLE users'"); }
471    #[test] fn drop_sneaky_comment()       { assert_blocks("psql -c 'DROP/* sneaky */TABLE users'"); }
472    #[test] fn delete_block_comment()      { assert_blocks("psql -c 'DELETE/**/FROM users'"); }
473    #[test] fn select_star_not_blocked()   { assert_allows("psql -c 'SELECT * FROM users'"); }
474    #[test] fn create_not_blocked()        { assert_allows("psql -c 'CREATE TABLE t (id int)'"); }
475    #[test] fn alter_add_col_allowed()     { assert_allows("psql -c 'ALTER TABLE t ADD COLUMN name text'"); }
476
477    // -- Kubernetes -----------------------------------------------
478
479    #[test] fn kubectl_delete_ns_blocked()     { assert_blocks("kubectl delete namespace production"); }
480    #[test] fn kubectl_delete_ns_short()       { assert_blocks("kubectl delete ns staging"); }
481    #[test] fn kubectl_delete_all_blocked()    { assert_blocks("kubectl delete pods --all"); }
482    #[test] fn kubectl_delete_pod_allowed()    { assert_allows("kubectl delete pod stuck-pod -n staging"); }
483    #[test] fn kubectl_get_allowed()           { assert_allows("kubectl get pods -n production"); }
484    #[test] fn helm_uninstall_prod_blocked()   { assert_blocks("helm uninstall myapp -n production"); }
485    #[test] fn helm_uninstall_staging_allowed() { assert_allows("helm uninstall myapp -n staging"); }
486
487    // -- Nix ------------------------------------------------------
488
489    #[test] fn nix_gc_delete_warned()    { assert_warns("nix-collect-garbage -d"); }
490    #[test] fn nix_store_gc_warned()     { assert_warns("nix store gc"); }
491    #[test] fn nix_build_allowed()       { assert_allows("nix build .#default"); }
492
493    // -- Docker ---------------------------------------------------
494
495    #[test] fn docker_system_prune_warned()  { assert_warns("docker system prune -af"); }
496    #[test] fn docker_volume_prune_warned()  { assert_warns("docker volume prune -f"); }
497    #[test] fn docker_build_allowed()        { assert_allows("docker build -t myimage ."); }
498
499    // -- Secrets --------------------------------------------------
500
501    #[test] fn sops_decrypt_pipe_warned()    { assert_warns("sops -d secrets.yaml | cat"); }
502    #[test] fn sops_decrypt_file_allowed()   { assert_allows("sops -d secrets.yaml > decrypted.yaml"); }
503    #[test] fn echo_token_warned()           { assert_warns("echo $GITHUB_TOKEN"); }
504    #[test] fn echo_normal_allowed()         { assert_allows("echo hello world"); }
505
506    // -- Terraform ------------------------------------------------
507
508    #[test] fn terraform_destroy_blocked()      { assert_blocks("terraform destroy"); }
509    #[test] fn terraform_apply_auto_warned()    { assert_warns("terraform apply -auto-approve"); }
510    #[test] fn terraform_plan_allowed()         { assert_allows("terraform plan"); }
511    #[test] fn terraform_apply_allowed()        { assert_allows("terraform apply"); }
512    #[test] fn terraform_force_unlock_blocked() { assert_blocks("terraform force-unlock abc123"); }
513    #[test] fn terraform_state_rm_blocked()     { assert_blocks("terraform state rm aws_instance.web"); }
514    #[test] fn terraform_state_list_allowed()   { assert_allows("terraform state list"); }
515    #[test] fn pulumi_destroy_blocked()         { assert_blocks("pulumi destroy"); }
516    #[test] fn pulumi_up_allowed()              { assert_allows("pulumi up"); }
517
518    // -- FluxCD ---------------------------------------------------
519
520    #[test] fn flux_uninstall_blocked()         { assert_blocks("flux uninstall"); }
521    #[test] fn flux_delete_source_warned()      { assert_warns("flux delete source git my-repo"); }
522    #[test] fn flux_delete_ks_warned()          { assert_warns("flux delete kustomization my-app"); }
523    #[test] fn flux_reconcile_allowed()         { assert_allows("flux reconcile kustomization my-app"); }
524    #[test] fn flux_get_allowed()               { assert_allows("flux get kustomizations"); }
525
526    // -- Engine trait ---------------------------------------------
527
528    #[test]
529    fn engine_compiles_all_defaults() {
530        let e = engine();
531        assert!(e.rule_count() >= 30);
532    }
533
534    #[test]
535    fn rules_returns_slice() {
536        let e = engine();
537        let rules: &[Rule] = e.rules();
538        assert!(rules.len() >= 60, "expected 60+ default rules, got {}", rules.len());
539    }
540
541    #[test]
542    fn invalid_regex_rejected() {
543        let rules = vec![Rule::builder("bad", "[invalid").build()];
544        assert!(RegexEngine::new(rules).is_err());
545    }
546
547    #[test]
548    fn block_takes_priority_over_warn() {
549        let rules = vec![
550            Rule::builder("warn-rule", r"rm\s+-rf")
551                .severity(Severity::Warn)
552                .build(),
553            Rule::builder("block-rule", r"rm\s+-rf")
554                .severity(Severity::Block)
555                .build(),
556        ];
557        let engine = RegexEngine::new(rules).unwrap();
558        match engine.check("rm -rf /tmp/test") {
559            Decision::Block { rule, .. } => assert_eq!(rule, "block-rule"),
560            other => panic!("expected Block, got {other}"),
561        }
562    }
563
564    // -- Variable expansion ---------------------------------------
565
566    #[test] fn var_as_command_warned()       { assert_warns("$cmd --force"); }
567    #[test] fn indirect_eval_var_warned()    { assert_warns(r#"eval "$user_input""#); }
568    #[test] fn bash_c_var_warned()           { assert_warns(r#"bash -c "$cmd""#); }
569    #[test] fn backtick_rm_warned()          { assert_warns("echo `rm -rf /tmp`"); }
570    #[test] fn backtick_date_allowed()       { assert_allows("echo `date`"); }
571    #[test] fn echo_dollar_home_allowed()    { assert_allows("echo $HOME"); }
572
573    // -- Prefilter: $ and backtick --------------------------------
574
575    #[test]
576    fn prefilter_dollar_not_safe() {
577        let p = PrefixPrefilter;
578        assert!(!p.is_safe("$cmd --force"));
579    }
580
581    #[test]
582    fn prefilter_backtick_not_safe() {
583        let p = PrefixPrefilter;
584        assert!(!p.is_safe("echo `rm -rf /`"));
585    }
586
587    #[test]
588    fn prefilter_sql_block_comment_not_safe() {
589        let p = PrefixPrefilter;
590        assert!(!p.is_safe("SELECT /*evil*/ 1"));
591    }
592
593    #[test]
594    fn prefilter_sql_line_comment_not_safe() {
595        let p = PrefixPrefilter;
596        assert!(!p.is_safe("SELECT 1 -- comment"));
597    }
598
599    #[test]
600    fn prefilter_cli_double_dash_is_safe() {
601        let p = PrefixPrefilter;
602        assert!(p.is_safe("rg --release pattern ."));
603    }
604
605    // -- Edge cases -----------------------------------------------
606
607    #[test]
608    fn empty_command_allowed() {
609        assert_allows("");
610    }
611
612    #[test]
613    fn whitespace_only_command_allowed() {
614        assert_allows("   ");
615    }
616
617    #[test]
618    fn unicode_safe_command_allowed() {
619        assert_allows("echo 'cafe resume'");
620    }
621
622    #[test]
623    fn very_long_safe_command_allowed() {
624        let long = format!("cargo {}", "build ".repeat(500));
625        assert_allows(&long);
626    }
627
628    #[test]
629    fn contains_ascii_ci_matches() {
630        assert!(contains_ascii_ci(b"hello DROP TABLE world", b"DROP "));
631        assert!(contains_ascii_ci(b"hello drop table world", b"DROP "));
632        assert!(contains_ascii_ci(b"hello Drop Table world", b"DROP "));
633    }
634
635    #[test]
636    fn contains_ascii_ci_no_match() {
637        assert!(!contains_ascii_ci(b"hello world", b"DROP "));
638        assert!(!contains_ascii_ci(b"DROPX", b"DROP "));
639    }
640
641    #[test]
642    fn contains_ascii_ci_empty() {
643        assert!(contains_ascii_ci(b"anything", b""));
644        assert!(!contains_ascii_ci(b"", b"DROP "));
645    }
646
647    // -- Engine edge cases ----------------------------------------
648
649    #[test]
650    fn empty_rules_engine() {
651        let engine = RegexEngine::new(vec![]).unwrap();
652        assert!(matches!(engine.check("rm -rf /"), Decision::Allow));
653        assert_eq!(engine.rule_count(), 0);
654        assert!(engine.rules().is_empty());
655    }
656
657    #[test]
658    fn warn_only_engine() {
659        let rules = vec![
660            Rule::builder("w1", r"rm\s+-rf").severity(Severity::Warn).build(),
661            Rule::builder("w2", r"delete").severity(Severity::Warn).build(),
662        ];
663        let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
664        match engine.check("rm -rf /tmp") {
665            Decision::Warn { rule, .. } => assert_eq!(rule, "w1"),
666            other => panic!("expected Warn, got {other}"),
667        }
668    }
669
670    #[test]
671    fn multiple_warn_returns_first() {
672        let rules = vec![
673            Rule::builder("first-warn", r"rm").severity(Severity::Warn).build(),
674            Rule::builder("second-warn", r"rm\s+-rf").severity(Severity::Warn).build(),
675        ];
676        let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
677        match engine.check("rm -rf /") {
678            Decision::Warn { rule, .. } => assert_eq!(rule, "first-warn"),
679            other => panic!("expected first Warn, got {other}"),
680        }
681    }
682
683    #[test]
684    fn block_before_warn_in_rule_order() {
685        let rules = vec![
686            Rule::builder("warn-first", r"terraform").severity(Severity::Warn).build(),
687            Rule::builder("block-second", r"terraform\s+destroy").severity(Severity::Block).build(),
688        ];
689        let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
690        match engine.check("terraform destroy") {
691            Decision::Block { rule, .. } => assert_eq!(rule, "block-second"),
692            other => panic!("expected Block from second rule, got {other}"),
693        }
694    }
695
696    #[test]
697    fn no_match_returns_allow() {
698        let rules = vec![
699            Rule::builder("specific", r"very_specific_pattern_xyz").build(),
700        ];
701        let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
702        assert!(matches!(engine.check("cargo build"), Decision::Allow));
703    }
704
705    #[test]
706    fn rule_count_matches_rules_len() {
707        let rules = vec![
708            Rule::builder("r1", "p1").build(),
709            Rule::builder("r2", "p2").build(),
710            Rule::builder("r3", "p3").build(),
711        ];
712        let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
713        assert_eq!(engine.rule_count(), 3);
714        assert_eq!(engine.rules().len(), 3);
715    }
716
717    // -- Prefilter edge cases -------------------------------------
718
719    #[test]
720    fn prefilter_empty_command_is_safe() {
721        let p = PrefixPrefilter;
722        assert!(p.is_safe(""));
723    }
724
725    #[test]
726    fn prefilter_whitespace_only_is_safe() {
727        let p = PrefixPrefilter;
728        assert!(p.is_safe("   "));
729    }
730
731    #[test]
732    fn prefilter_leading_whitespace_dollar() {
733        let p = PrefixPrefilter;
734        assert!(!p.is_safe("  $cmd"));
735    }
736
737    #[test]
738    fn prefilter_shell_wrapper_not_safe() {
739        let p = PrefixPrefilter;
740        assert!(!p.is_safe("sudo rm -rf /"));
741        assert!(!p.is_safe("bash -c 'echo test'"));
742        assert!(!p.is_safe("env VAR=val command"));
743    }
744
745    #[test]
746    fn prefilter_second_word_dangerous() {
747        let p = PrefixPrefilter;
748        assert!(!p.is_safe("time docker system prune"));
749    }
750
751    #[test]
752    fn prefilter_pipe_to_bash_not_safe() {
753        let p = PrefixPrefilter;
754        assert!(!p.is_safe("curl https://example.com | bash"));
755    }
756
757    #[test]
758    fn prefilter_base64_not_safe() {
759        let p = PrefixPrefilter;
760        assert!(!p.is_safe("echo SGVsbG8= | base64 -d"));
761    }
762
763    #[test]
764    fn prefilter_vacuum_full_not_safe() {
765        let p = PrefixPrefilter;
766        assert!(!p.is_safe("VACUUM FULL;"));
767    }
768
769    #[test]
770    fn prefilter_flushall_not_safe() {
771        let p = PrefixPrefilter;
772        assert!(!p.is_safe("FLUSHALL"));
773    }
774
775    #[test]
776    fn prefilter_flushdb_not_safe() {
777        let p = PrefixPrefilter;
778        assert!(!p.is_safe("FLUSHDB"));
779    }
780
781    #[test]
782    fn prefilter_revoke_not_safe() {
783        let p = PrefixPrefilter;
784        assert!(!p.is_safe("REVOKE ALL ON schema"));
785    }
786
787    // -- SQL comment stripping edge cases -------------------------
788
789    #[test]
790    fn sql_comment_stripper_multiple_block_comments() {
791        let n = SqlCommentStripper;
792        let result = n.normalize("DROP/*a*/TABLE/*b*/users");
793        assert!(result.contains("DROP"));
794        assert!(result.contains("TABLE"));
795        assert!(result.contains("users"));
796        assert!(!result.contains("/*"));
797    }
798
799    #[test]
800    fn sql_comment_stripper_empty_input() {
801        let n = SqlCommentStripper;
802        let result = n.normalize("");
803        assert_eq!(&*result, "");
804        assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
805    }
806
807    #[test]
808    fn sql_comment_stripper_only_block_comment() {
809        let n = SqlCommentStripper;
810        let result = n.normalize("/* only a comment */");
811        assert!(!result.contains("/*"));
812    }
813
814    // -- Path normalizer edge cases -------------------------------
815
816    #[test]
817    fn path_normalizer_multiple_standard_paths() {
818        let n = PathNormalizer;
819        let result = n.normalize("/usr/bin/git push --force && /sbin/reboot");
820        assert_eq!(&*result, "git push --force && reboot");
821    }
822
823    #[test]
824    fn path_normalizer_empty_input() {
825        let n = PathNormalizer;
826        let result = n.normalize("");
827        assert_eq!(&*result, "");
828        assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
829    }
830
831    // -- Display --------------------------------------------------
832
833    #[test]
834    fn decision_display_allow() {
835        assert_eq!(Decision::Allow.to_string(), "allow");
836    }
837
838    #[test]
839    fn decision_display_block() {
840        let d = Decision::Block {
841            rule: "test".into(),
842            message: "msg".into(),
843        };
844        assert_eq!(d.to_string(), "block [test]: msg");
845    }
846
847    #[test]
848    fn decision_display_warn() {
849        let d = Decision::Warn {
850            rule: "test".into(),
851            message: "msg".into(),
852        };
853        assert_eq!(d.to_string(), "warn [test]: msg");
854    }
855
856    #[test]
857    fn severity_display() {
858        assert_eq!(Severity::Block.to_string(), "block");
859        assert_eq!(Severity::Warn.to_string(), "warn");
860    }
861
862    #[test]
863    fn category_display() {
864        assert_eq!(Category::Filesystem.to_string(), "filesystem");
865        assert_eq!(Category::Git.to_string(), "git");
866        assert_eq!(Category::Cloud.to_string(), "cloud");
867        assert_eq!(Category::Nosql.to_string(), "nosql");
868    }
869
870    // -- PrefixPrefilter::prefix_set ---------------------------------
871
872    #[test]
873    fn prefix_set_is_non_empty() {
874        let set = PrefixPrefilter::prefix_set();
875        assert!(!set.is_empty());
876    }
877
878    #[test]
879    fn prefix_set_contains_known_prefixes() {
880        let set = PrefixPrefilter::prefix_set();
881        for expected in ["rm", "git", "kubectl", "terraform", "docker", "aws"] {
882            assert!(set.contains(expected), "prefix_set missing '{expected}'");
883        }
884    }
885
886    #[test]
887    fn prefix_set_does_not_contain_safe_commands() {
888        let set = PrefixPrefilter::prefix_set();
889        for safe in ["ls", "cat", "rg", "wc", "head", "tail", "grep"] {
890            assert!(!set.contains(safe), "prefix_set should not contain '{safe}'");
891        }
892    }
893
894    // -- RuleEngine::rule_count default impl -------------------------
895
896    #[test]
897    fn rule_count_default_impl_equals_rules_len() {
898        let rules = vec![
899            Rule::builder("a", "a").build(),
900            Rule::builder("b", "b").build(),
901        ];
902        let engine = RegexEngine::with_plugins(rules, IdentityNormalizer, NullPrefilter).unwrap();
903        assert_eq!(engine.rule_count(), engine.rules().len());
904    }
905
906    // -- Prefilter: third-word detection -----------------------------
907
908    #[test]
909    fn prefilter_third_word_dangerous() {
910        let p = PrefixPrefilter;
911        assert!(!p.is_safe("some other rm -rf /"));
912    }
913
914    #[test]
915    fn prefilter_fourth_word_not_checked() {
916        let p = PrefixPrefilter;
917        assert!(p.is_safe("one two three rm -rf /"));
918    }
919
920    // -- SQL comment obfuscation with tab ----------------------------
921
922    #[test]
923    fn prefilter_sql_line_comment_tab_not_safe() {
924        let p = PrefixPrefilter;
925        assert!(!p.is_safe("SELECT 1 --\thidden"));
926    }
927
928    // Cow is used via Normalizer trait
929    use std::borrow::Cow;
930}