Skip to main content

safe_chains/
command.rs

1use crate::parse::{has_flag, Token};
2use crate::policy::{self, FlagPolicy};
3use crate::verdict::{SafetyLevel, Verdict};
4#[cfg(test)]
5use crate::policy::FlagStyle;
6
7pub type CheckFn = fn(&[Token]) -> Verdict;
8
9pub enum SubDef {
10    Policy {
11        name: &'static str,
12        policy: &'static FlagPolicy,
13        level: SafetyLevel,
14    },
15    Nested {
16        name: &'static str,
17        subs: &'static [SubDef],
18    },
19    Guarded {
20        name: &'static str,
21        guard_short: Option<&'static str>,
22        guard_long: &'static str,
23        policy: &'static FlagPolicy,
24        level: SafetyLevel,
25    },
26    Custom {
27        name: &'static str,
28        check: CheckFn,
29        doc: &'static str,
30        test_suffix: Option<&'static str>,
31    },
32    Delegation {
33        name: &'static str,
34        skip: usize,
35        doc: &'static str,
36    },
37}
38
39pub struct CommandDef {
40    pub name: &'static str,
41    pub subs: &'static [SubDef],
42    pub bare_flags: &'static [&'static str],
43    pub url: &'static str,
44    pub aliases: &'static [&'static str],
45}
46
47impl SubDef {
48    pub fn name(&self) -> &'static str {
49        match self {
50            Self::Policy { name, .. }
51            | Self::Nested { name, .. }
52            | Self::Guarded { name, .. }
53            | Self::Custom { name, .. }
54            | Self::Delegation { name, .. } => name,
55        }
56    }
57
58    pub fn check(&self, tokens: &[Token]) -> Verdict {
59        match self {
60            Self::Policy { policy, level, .. } => {
61                if policy::check(tokens, policy) {
62                    Verdict::Allowed(*level)
63                } else {
64                    Verdict::Denied
65                }
66            }
67            Self::Nested { subs, .. } => {
68                if tokens.len() < 2 {
69                    return Verdict::Denied;
70                }
71                let sub = tokens[1].as_str();
72                subs.iter()
73                    .find(|s| s.name() == sub)
74                    .map(|s| s.check(&tokens[1..]))
75                    .unwrap_or(Verdict::Denied)
76            }
77            Self::Guarded {
78                guard_short,
79                guard_long,
80                policy,
81                level,
82                ..
83            } => {
84                if has_flag(tokens, *guard_short, Some(guard_long))
85                    && policy::check(tokens, policy)
86                {
87                    Verdict::Allowed(*level)
88                } else {
89                    Verdict::Denied
90                }
91            }
92            Self::Custom { check: f, .. } => f(tokens),
93            Self::Delegation { skip, .. } => {
94                if tokens.len() <= *skip {
95                    return Verdict::Denied;
96                }
97                let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
98                crate::command_verdict(&inner)
99            }
100        }
101    }
102}
103
104impl CommandDef {
105    pub fn opencode_patterns(&self) -> Vec<String> {
106        let mut patterns = Vec::new();
107        let names: Vec<&str> = std::iter::once(self.name)
108            .chain(self.aliases.iter().copied())
109            .collect();
110        for name in &names {
111            for sub in self.subs {
112                sub_opencode_patterns(name, sub, &mut patterns);
113            }
114        }
115        patterns
116    }
117
118    pub fn check(&self, tokens: &[Token]) -> Verdict {
119        if tokens.len() < 2 {
120            return Verdict::Denied;
121        }
122        let arg = tokens[1].as_str();
123        if tokens.len() == 2 && self.bare_flags.contains(&arg) {
124            return Verdict::Allowed(SafetyLevel::Inert);
125        }
126        self.subs
127            .iter()
128            .find(|s| s.name() == arg)
129            .map(|s| s.check(&tokens[1..]))
130            .unwrap_or(Verdict::Denied)
131    }
132
133    pub fn dispatch(
134        &self,
135        cmd: &str,
136        tokens: &[Token],
137    ) -> Option<Verdict> {
138        if cmd == self.name || self.aliases.contains(&cmd) {
139            Some(self.check(tokens))
140        } else {
141            None
142        }
143    }
144
145    pub fn to_doc(&self) -> crate::docs::CommandDoc {
146        let mut lines = Vec::new();
147
148        if !self.bare_flags.is_empty() {
149            lines.push(format!("- Allowed standalone flags: {}", self.bare_flags.join(", ")));
150        }
151
152        let mut sub_lines: Vec<String> = Vec::new();
153        for sub in self.subs {
154            sub_doc_line(sub, "", &mut sub_lines);
155        }
156        sub_lines.sort();
157        lines.extend(sub_lines);
158
159        let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, lines.join("\n"));
160        doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
161        doc
162    }
163}
164
165pub struct FlatDef {
166    pub name: &'static str,
167    pub policy: &'static FlagPolicy,
168    pub level: SafetyLevel,
169    pub url: &'static str,
170    pub aliases: &'static [&'static str],
171}
172
173impl FlatDef {
174    pub fn opencode_patterns(&self) -> Vec<String> {
175        let mut patterns = Vec::new();
176        let names: Vec<&str> = std::iter::once(self.name)
177            .chain(self.aliases.iter().copied())
178            .collect();
179        for name in names {
180            patterns.push(name.to_string());
181            patterns.push(format!("{name} *"));
182        }
183        patterns
184    }
185
186    pub fn dispatch(&self, cmd: &str, tokens: &[Token]) -> Option<Verdict> {
187        if cmd == self.name || self.aliases.contains(&cmd) {
188            if policy::check(tokens, self.policy) {
189                Some(Verdict::Allowed(self.level))
190            } else {
191                Some(Verdict::Denied)
192            }
193        } else {
194            None
195        }
196    }
197
198    pub fn to_doc(&self) -> crate::docs::CommandDoc {
199        let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, self.policy.describe());
200        doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
201        doc
202    }
203}
204
205#[cfg(test)]
206impl FlatDef {
207    pub fn auto_test_reject_unknown(&self) {
208        if self.policy.flag_style == FlagStyle::Positional {
209            return;
210        }
211        let test = format!("{} --xyzzy-unknown-42", self.name);
212        assert!(
213            !crate::is_safe_command(&test),
214            "{}: accepted unknown flag: {test}",
215            self.name,
216        );
217        for alias in self.aliases {
218            let test = format!("{alias} --xyzzy-unknown-42");
219            assert!(
220                !crate::is_safe_command(&test),
221                "{alias}: alias accepted unknown flag: {test}",
222            );
223        }
224    }
225}
226
227fn sub_opencode_patterns(prefix: &str, sub: &SubDef, out: &mut Vec<String>) {
228    match sub {
229        SubDef::Policy { name, .. } => {
230            out.push(format!("{prefix} {name}"));
231            out.push(format!("{prefix} {name} *"));
232        }
233        SubDef::Nested { name, subs } => {
234            let path = format!("{prefix} {name}");
235            for s in *subs {
236                sub_opencode_patterns(&path, s, out);
237            }
238        }
239        SubDef::Guarded {
240            name, guard_long, ..
241        } => {
242            out.push(format!("{prefix} {name} {guard_long}"));
243            out.push(format!("{prefix} {name} {guard_long} *"));
244        }
245        SubDef::Custom { name, .. } => {
246            out.push(format!("{prefix} {name}"));
247            out.push(format!("{prefix} {name} *"));
248        }
249        SubDef::Delegation { .. } => {}
250    }
251}
252
253fn sub_doc_line(sub: &SubDef, prefix: &str, out: &mut Vec<String>) {
254    match sub {
255        SubDef::Policy { name, policy, .. } => {
256            let summary = policy.flag_summary();
257            let label = if prefix.is_empty() {
258                (*name).to_string()
259            } else {
260                format!("{prefix} {name}")
261            };
262            if summary.is_empty() {
263                out.push(format!("- **{label}**"));
264            } else {
265                out.push(format!("- **{label}**: {summary}"));
266            }
267        }
268        SubDef::Nested { name, subs } => {
269            let path = if prefix.is_empty() {
270                (*name).to_string()
271            } else {
272                format!("{prefix} {name}")
273            };
274            for s in *subs {
275                sub_doc_line(s, &path, out);
276            }
277        }
278        SubDef::Guarded {
279            name,
280            guard_long,
281            policy,
282            ..
283        } => {
284            let summary = policy.flag_summary();
285            let label = if prefix.is_empty() {
286                (*name).to_string()
287            } else {
288                format!("{prefix} {name}")
289            };
290            if summary.is_empty() {
291                out.push(format!("- **{label}** (requires {guard_long})"));
292            } else {
293                out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
294            }
295        }
296        SubDef::Custom { name, doc, .. } => {
297            if !doc.is_empty() && doc.trim().is_empty() {
298                return;
299            }
300            let label = if prefix.is_empty() {
301                (*name).to_string()
302            } else {
303                format!("{prefix} {name}")
304            };
305            if doc.is_empty() {
306                out.push(format!("- **{label}**"));
307            } else {
308                out.push(format!("- **{label}**: {doc}"));
309            }
310        }
311        SubDef::Delegation { name, doc, .. } => {
312            if doc.is_empty() {
313                return;
314            }
315            let label = if prefix.is_empty() {
316                (*name).to_string()
317            } else {
318                format!("{prefix} {name}")
319            };
320            out.push(format!("- **{label}**: {doc}"));
321        }
322    }
323}
324
325#[cfg(test)]
326impl CommandDef {
327    pub fn auto_test_reject_unknown(&self) {
328        let mut failures = Vec::new();
329
330        assert!(
331            !crate::is_safe_command(self.name),
332            "{}: accepted bare invocation",
333            self.name,
334        );
335
336        let test = format!("{} xyzzy-unknown-42", self.name);
337        assert!(
338            !crate::is_safe_command(&test),
339            "{}: accepted unknown subcommand: {test}",
340            self.name,
341        );
342
343        for sub in self.subs {
344            auto_test_sub(self.name, sub, &mut failures);
345        }
346        assert!(
347            failures.is_empty(),
348            "{}: unknown flags/subcommands accepted:\n{}",
349            self.name,
350            failures.join("\n"),
351        );
352    }
353}
354
355#[cfg(test)]
356fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
357    const UNKNOWN: &str = "--xyzzy-unknown-42";
358
359    match sub {
360        SubDef::Policy { name, policy, .. } => {
361            if policy.flag_style == FlagStyle::Positional {
362                return;
363            }
364            let test = format!("{prefix} {name} {UNKNOWN}");
365            if crate::is_safe_command(&test) {
366                failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
367            }
368        }
369        SubDef::Nested { name, subs } => {
370            let path = format!("{prefix} {name}");
371            let test = format!("{path} xyzzy-unknown-42");
372            if crate::is_safe_command(&test) {
373                failures.push(format!("{path}: accepted unknown subcommand: {test}"));
374            }
375            for s in *subs {
376                auto_test_sub(&path, s, failures);
377            }
378        }
379        SubDef::Guarded {
380            name, guard_long, ..
381        } => {
382            let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
383            if crate::is_safe_command(&test) {
384                failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
385            }
386        }
387        SubDef::Custom {
388            name, test_suffix, ..
389        } => {
390            if let Some(suffix) = test_suffix {
391                let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
392                if crate::is_safe_command(&test) {
393                    failures.push(format!(
394                        "{prefix} {name}: accepted unknown flag: {test}"
395                    ));
396                }
397            }
398        }
399        SubDef::Delegation { .. } => {}
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::parse::WordSet;
407    use crate::policy::FlagStyle;
408
409    fn toks(words: &[&str]) -> Vec<Token> {
410        words.iter().map(|s| Token::from_test(s)).collect()
411    }
412
413
414    static TEST_POLICY: FlagPolicy = FlagPolicy {
415        standalone: WordSet::new(&["--help", "--verbose", "-h", "-v"]),
416        valued: WordSet::new(&["--output", "-o"]),
417        bare: true,
418        max_positional: None,
419        flag_style: FlagStyle::Strict,
420    };
421
422    static SIMPLE_CMD: CommandDef = CommandDef {
423        name: "mycmd",
424        subs: &[SubDef::Policy {
425            name: "build",
426            policy: &TEST_POLICY,
427            level: SafetyLevel::SafeWrite,
428        }],
429        bare_flags: &["--help", "--info", "--version", "-V", "-h"],
430        url: "",
431        aliases: &[],
432    };
433
434    #[test]
435    fn bare_rejected() {
436        assert_eq!(SIMPLE_CMD.check(&toks(&["mycmd"])), Verdict::Denied);
437    }
438
439    #[test]
440    fn bare_flag_accepted() {
441        assert_eq!(
442            SIMPLE_CMD.check(&toks(&["mycmd", "--info"])),
443            Verdict::Allowed(SafetyLevel::Inert),
444        );
445    }
446
447    #[test]
448    fn bare_flag_with_extra_rejected() {
449        assert_eq!(
450            SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"])),
451            Verdict::Denied,
452        );
453    }
454
455    #[test]
456    fn policy_sub_bare() {
457        assert_eq!(
458            SIMPLE_CMD.check(&toks(&["mycmd", "build"])),
459            Verdict::Allowed(SafetyLevel::SafeWrite),
460        );
461    }
462
463    #[test]
464    fn policy_sub_with_flag() {
465        assert_eq!(
466            SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"])),
467            Verdict::Allowed(SafetyLevel::SafeWrite),
468        );
469    }
470
471    #[test]
472    fn policy_sub_unknown_flag() {
473        assert_eq!(
474            SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"])),
475            Verdict::Denied,
476        );
477    }
478
479    #[test]
480    fn unknown_sub_rejected() {
481        assert_eq!(
482            SIMPLE_CMD.check(&toks(&["mycmd", "deploy"])),
483            Verdict::Denied,
484        );
485    }
486
487    #[test]
488    fn dispatch_matches() {
489        assert_eq!(
490            SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"])),
491            Some(Verdict::Allowed(SafetyLevel::SafeWrite)),
492        );
493    }
494
495    #[test]
496    fn dispatch_no_match() {
497        assert_eq!(
498            SIMPLE_CMD.dispatch("other", &toks(&["other", "build"])),
499            None
500        );
501    }
502
503    static NESTED_CMD: CommandDef = CommandDef {
504        name: "nested",
505        subs: &[SubDef::Nested {
506            name: "package",
507            subs: &[SubDef::Policy {
508                name: "describe",
509                policy: &TEST_POLICY,
510                level: SafetyLevel::Inert,
511            }],
512        }],
513        bare_flags: &[],
514        url: "",
515        aliases: &[],
516    };
517
518    #[test]
519    fn nested_sub() {
520        assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"])).is_allowed());
521    }
522
523    #[test]
524    fn nested_sub_with_flag() {
525        assert!(NESTED_CMD.check(
526            &toks(&["nested", "package", "describe", "--verbose"]),
527        ).is_allowed());
528    }
529
530    #[test]
531    fn nested_bare_rejected() {
532        assert_eq!(
533            NESTED_CMD.check(&toks(&["nested", "package"])),
534            Verdict::Denied,
535        );
536    }
537
538    #[test]
539    fn nested_unknown_sub_rejected() {
540        assert_eq!(
541            NESTED_CMD.check(&toks(&["nested", "package", "deploy"])),
542            Verdict::Denied,
543        );
544    }
545
546    static GUARDED_POLICY: FlagPolicy = FlagPolicy {
547        standalone: WordSet::new(&["--all", "--check", "--help", "-h"]),
548        valued: WordSet::new(&[]),
549        bare: false,
550        max_positional: None,
551        flag_style: FlagStyle::Strict,
552    };
553
554    static GUARDED_CMD: CommandDef = CommandDef {
555        name: "guarded",
556        subs: &[SubDef::Guarded {
557            name: "fmt",
558            guard_short: None,
559            guard_long: "--check",
560            policy: &GUARDED_POLICY,
561            level: SafetyLevel::Inert,
562        }],
563        bare_flags: &[],
564        url: "",
565        aliases: &[],
566    };
567
568    #[test]
569    fn guarded_with_guard() {
570        assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"])).is_allowed());
571    }
572
573    #[test]
574    fn guarded_without_guard() {
575        assert_eq!(
576            GUARDED_CMD.check(&toks(&["guarded", "fmt"])),
577            Verdict::Denied,
578        );
579    }
580
581    #[test]
582    fn guarded_with_guard_and_flag() {
583        assert!(GUARDED_CMD.check(
584            &toks(&["guarded", "fmt", "--check", "--all"]),
585        ).is_allowed());
586    }
587
588    static DELEGATION_CMD: CommandDef = CommandDef {
589        name: "runner",
590        subs: &[SubDef::Delegation {
591            name: "run",
592            skip: 2,
593            doc: "run delegates to inner command.",
594        }],
595        bare_flags: &[],
596        url: "",
597        aliases: &[],
598    };
599
600    #[test]
601    fn delegation_safe_inner() {
602        assert!(DELEGATION_CMD.check(
603            &toks(&["runner", "run", "stable", "echo", "hello"]),
604        ).is_allowed());
605    }
606
607    #[test]
608    fn delegation_unsafe_inner() {
609        assert_eq!(
610            DELEGATION_CMD.check(&toks(&["runner", "run", "stable", "rm", "-rf"])),
611            Verdict::Denied,
612        );
613    }
614
615    #[test]
616    fn delegation_no_inner() {
617        assert_eq!(
618            DELEGATION_CMD.check(&toks(&["runner", "run", "stable"])),
619            Verdict::Denied,
620        );
621    }
622
623    fn custom_check(tokens: &[Token]) -> Verdict {
624        if tokens.len() >= 2 && tokens[1] == "safe" {
625            Verdict::Allowed(SafetyLevel::Inert)
626        } else {
627            Verdict::Denied
628        }
629    }
630
631    static CUSTOM_CMD: CommandDef = CommandDef {
632        name: "custom",
633        subs: &[SubDef::Custom {
634            name: "special",
635            check: custom_check,
636            doc: "special (safe only).",
637            test_suffix: Some("safe"),
638        }],
639        bare_flags: &[],
640        url: "",
641        aliases: &[],
642    };
643
644    #[test]
645    fn custom_passes() {
646        assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"])).is_allowed());
647    }
648
649    #[test]
650    fn custom_fails() {
651        assert_eq!(
652            CUSTOM_CMD.check(&toks(&["custom", "special", "bad"])),
653            Verdict::Denied,
654        );
655    }
656
657    #[test]
658    fn help_on_sub_uses_sub_level() {
659        assert_eq!(
660            SIMPLE_CMD.check(&toks(&["mycmd", "build", "--help"])),
661            Verdict::Allowed(SafetyLevel::SafeWrite),
662        );
663    }
664
665    #[test]
666    fn help_on_command_uses_bare_flags() {
667        assert_eq!(
668            SIMPLE_CMD.check(&toks(&["mycmd", "--help"])),
669            Verdict::Allowed(SafetyLevel::Inert),
670        );
671    }
672
673    #[test]
674    fn doc_simple() {
675        let doc = SIMPLE_CMD.to_doc();
676        assert_eq!(doc.name, "mycmd");
677        assert_eq!(
678            doc.description,
679            "- Allowed standalone flags: --help, --info, --version, -V, -h\n- **build**: Flags: --help, --verbose, -h, -v. Valued: --output, -o"
680        );
681    }
682
683    #[test]
684    fn doc_nested() {
685        let doc = NESTED_CMD.to_doc();
686        assert_eq!(
687            doc.description,
688            "- **package describe**: Flags: --help, --verbose, -h, -v. Valued: --output, -o"
689        );
690    }
691
692    #[test]
693    fn doc_guarded() {
694        let doc = GUARDED_CMD.to_doc();
695        assert_eq!(
696            doc.description,
697            "- **fmt** (requires --check): Flags: --all, --check, --help, -h"
698        );
699    }
700
701    #[test]
702    fn doc_delegation() {
703        let doc = DELEGATION_CMD.to_doc();
704        assert_eq!(doc.description, "- **run**: run delegates to inner command.");
705    }
706
707    #[test]
708    fn doc_custom() {
709        let doc = CUSTOM_CMD.to_doc();
710        assert_eq!(doc.description, "- **special**: special (safe only).");
711    }
712
713    #[test]
714    fn opencode_patterns_simple() {
715        let patterns = SIMPLE_CMD.opencode_patterns();
716        assert!(patterns.contains(&"mycmd build".to_string()));
717        assert!(patterns.contains(&"mycmd build *".to_string()));
718    }
719
720    #[test]
721    fn opencode_patterns_nested() {
722        let patterns = NESTED_CMD.opencode_patterns();
723        assert!(patterns.contains(&"nested package describe".to_string()));
724        assert!(patterns.contains(&"nested package describe *".to_string()));
725        assert!(!patterns.iter().any(|p| p == "nested package"));
726    }
727
728    #[test]
729    fn opencode_patterns_guarded() {
730        let patterns = GUARDED_CMD.opencode_patterns();
731        assert!(patterns.contains(&"guarded fmt --check".to_string()));
732        assert!(patterns.contains(&"guarded fmt --check *".to_string()));
733        assert!(!patterns.iter().any(|p| p == "guarded fmt"));
734    }
735
736    #[test]
737    fn opencode_patterns_delegation_skipped() {
738        let patterns = DELEGATION_CMD.opencode_patterns();
739        assert!(patterns.is_empty());
740    }
741
742    #[test]
743    fn opencode_patterns_custom() {
744        let patterns = CUSTOM_CMD.opencode_patterns();
745        assert!(patterns.contains(&"custom special".to_string()));
746        assert!(patterns.contains(&"custom special *".to_string()));
747    }
748
749    #[test]
750    fn opencode_patterns_aliases() {
751        static ALIASED: CommandDef = CommandDef {
752            name: "primary",
753            subs: &[SubDef::Policy {
754                name: "list",
755                policy: &TEST_POLICY,
756                level: SafetyLevel::Inert,
757            }],
758            bare_flags: &[],
759            url: "",
760            aliases: &["alt"],
761        };
762        let patterns = ALIASED.opencode_patterns();
763        assert!(patterns.contains(&"primary list".to_string()));
764        assert!(patterns.contains(&"alt list".to_string()));
765        assert!(patterns.contains(&"alt list *".to_string()));
766    }
767
768    #[test]
769    fn flat_def_opencode_patterns() {
770        static FLAT: FlatDef = FlatDef {
771            name: "grep",
772            policy: &TEST_POLICY,
773            level: SafetyLevel::Inert,
774            url: "",
775            aliases: &["rg"],
776        };
777        let patterns = FLAT.opencode_patterns();
778        assert_eq!(patterns, vec!["grep", "grep *", "rg", "rg *"]);
779    }
780}