Skip to main content

safe_chains/
command.rs

1use crate::parse::{has_flag, Segment, Token};
2use crate::policy::{self, FlagPolicy};
3#[cfg(test)]
4use crate::policy::FlagStyle;
5
6pub type CheckFn = fn(&[Token], &dyn Fn(&Segment) -> bool) -> bool;
7
8pub enum SubDef {
9    Policy {
10        name: &'static str,
11        policy: &'static FlagPolicy,
12    },
13    Nested {
14        name: &'static str,
15        subs: &'static [SubDef],
16    },
17    Guarded {
18        name: &'static str,
19        guard_short: Option<&'static str>,
20        guard_long: &'static str,
21        policy: &'static FlagPolicy,
22    },
23    Custom {
24        name: &'static str,
25        check: CheckFn,
26        doc: &'static str,
27        test_suffix: Option<&'static str>,
28    },
29    Delegation {
30        name: &'static str,
31        skip: usize,
32        doc: &'static str,
33    },
34}
35
36pub struct CommandDef {
37    pub name: &'static str,
38    pub subs: &'static [SubDef],
39    pub bare_flags: &'static [&'static str],
40    pub help_eligible: bool,
41}
42
43impl SubDef {
44    pub fn name(&self) -> &'static str {
45        match self {
46            Self::Policy { name, .. }
47            | Self::Nested { name, .. }
48            | Self::Guarded { name, .. }
49            | Self::Custom { name, .. }
50            | Self::Delegation { name, .. } => name,
51        }
52    }
53
54    pub fn check(&self, tokens: &[Token], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
55        match self {
56            Self::Policy { policy, .. } => policy::check(tokens, policy),
57            Self::Nested { subs, .. } => {
58                if tokens.len() < 2 {
59                    return false;
60                }
61                let sub = tokens[1].as_str();
62                subs.iter()
63                    .any(|s| s.name() == sub && s.check(&tokens[1..], is_safe))
64            }
65            Self::Guarded {
66                guard_short,
67                guard_long,
68                policy,
69                ..
70            } => {
71                has_flag(tokens, *guard_short, Some(guard_long))
72                    && policy::check(tokens, policy)
73            }
74            Self::Custom { check: f, .. } => f(tokens, is_safe),
75            Self::Delegation { skip, .. } => {
76                if tokens.len() <= *skip {
77                    return false;
78                }
79                let inner = Token::join(&tokens[*skip..]);
80                is_safe(&inner)
81            }
82        }
83    }
84}
85
86impl CommandDef {
87    pub fn check(&self, tokens: &[Token], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
88        if tokens.len() < 2 {
89            return false;
90        }
91        let arg = tokens[1].as_str();
92        if tokens.len() == 2 && self.bare_flags.contains(&arg) {
93            return true;
94        }
95        self.subs
96            .iter()
97            .find(|s| s.name() == arg)
98            .is_some_and(|s| s.check(&tokens[1..], is_safe))
99    }
100
101    pub fn dispatch(
102        &self,
103        cmd: &str,
104        tokens: &[Token],
105        is_safe: &dyn Fn(&Segment) -> bool,
106    ) -> Option<bool> {
107        if cmd == self.name {
108            Some(self.check(tokens, is_safe))
109        } else {
110            None
111        }
112    }
113
114    pub fn to_doc(&self) -> crate::docs::CommandDoc {
115        let mut lines = Vec::new();
116
117        if !self.bare_flags.is_empty() {
118            lines.push(format!("Info flags: {}.", self.bare_flags.join(", ")));
119        }
120
121        let mut sub_lines: Vec<String> = Vec::new();
122        for sub in self.subs {
123            sub_doc_line(sub, "", &mut sub_lines);
124        }
125        sub_lines.sort();
126        lines.extend(sub_lines);
127
128        crate::docs::CommandDoc::handler(self.name, lines.join("\n"))
129    }
130}
131
132fn sub_doc_line(sub: &SubDef, prefix: &str, out: &mut Vec<String>) {
133    match sub {
134        SubDef::Policy { name, policy } => {
135            let summary = policy.flag_summary();
136            let label = if prefix.is_empty() {
137                (*name).to_string()
138            } else {
139                format!("{prefix} {name}")
140            };
141            if summary.is_empty() {
142                out.push(format!("- **{label}**"));
143            } else {
144                out.push(format!("- **{label}**: {summary}"));
145            }
146        }
147        SubDef::Nested { name, subs } => {
148            let path = if prefix.is_empty() {
149                (*name).to_string()
150            } else {
151                format!("{prefix} {name}")
152            };
153            for s in *subs {
154                sub_doc_line(s, &path, out);
155            }
156        }
157        SubDef::Guarded {
158            name,
159            guard_long,
160            policy,
161            ..
162        } => {
163            let summary = policy.flag_summary();
164            let label = if prefix.is_empty() {
165                (*name).to_string()
166            } else {
167                format!("{prefix} {name}")
168            };
169            if summary.is_empty() {
170                out.push(format!("- **{label}** (requires {guard_long})"));
171            } else {
172                out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
173            }
174        }
175        SubDef::Custom { name, doc, .. } => {
176            if !doc.is_empty() && doc.trim().is_empty() {
177                return;
178            }
179            let label = if prefix.is_empty() {
180                (*name).to_string()
181            } else {
182                format!("{prefix} {name}")
183            };
184            if doc.is_empty() {
185                out.push(format!("- **{label}**"));
186            } else {
187                out.push(format!("- **{label}**: {doc}"));
188            }
189        }
190        SubDef::Delegation { name, doc, .. } => {
191            if doc.is_empty() {
192                return;
193            }
194            let label = if prefix.is_empty() {
195                (*name).to_string()
196            } else {
197                format!("{prefix} {name}")
198            };
199            out.push(format!("- **{label}**: {doc}"));
200        }
201    }
202}
203
204#[cfg(test)]
205impl CommandDef {
206    pub fn auto_test_reject_unknown(&self) {
207        let mut failures = Vec::new();
208
209        assert!(
210            !crate::is_safe_command(self.name),
211            "{}: accepted bare invocation",
212            self.name,
213        );
214
215        let test = format!("{} xyzzy-unknown-42", self.name);
216        assert!(
217            !crate::is_safe_command(&test),
218            "{}: accepted unknown subcommand: {test}",
219            self.name,
220        );
221
222        for sub in self.subs {
223            auto_test_sub(self.name, sub, &mut failures);
224        }
225        assert!(
226            failures.is_empty(),
227            "{}: unknown flags/subcommands accepted:\n{}",
228            self.name,
229            failures.join("\n"),
230        );
231    }
232}
233
234#[cfg(test)]
235fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
236    const UNKNOWN: &str = "--xyzzy-unknown-42";
237
238    match sub {
239        SubDef::Policy { name, policy } => {
240            if policy.flag_style == FlagStyle::Positional {
241                return;
242            }
243            let test = format!("{prefix} {name} {UNKNOWN}");
244            if crate::is_safe_command(&test) {
245                failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
246            }
247        }
248        SubDef::Nested { name, subs } => {
249            let path = format!("{prefix} {name}");
250            let test = format!("{path} xyzzy-unknown-42");
251            if crate::is_safe_command(&test) {
252                failures.push(format!("{path}: accepted unknown subcommand: {test}"));
253            }
254            for s in *subs {
255                auto_test_sub(&path, s, failures);
256            }
257        }
258        SubDef::Guarded {
259            name, guard_long, ..
260        } => {
261            let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
262            if crate::is_safe_command(&test) {
263                failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
264            }
265        }
266        SubDef::Custom {
267            name, test_suffix, ..
268        } => {
269            if let Some(suffix) = test_suffix {
270                let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
271                if crate::is_safe_command(&test) {
272                    failures.push(format!(
273                        "{prefix} {name}: accepted unknown flag: {test}"
274                    ));
275                }
276            }
277        }
278        SubDef::Delegation { .. } => {}
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::parse::WordSet;
286    use crate::policy::FlagStyle;
287
288    fn toks(words: &[&str]) -> Vec<Token> {
289        words.iter().map(|s| Token::from_test(s)).collect()
290    }
291
292    fn no_safe(_: &Segment) -> bool {
293        false
294    }
295
296    static TEST_POLICY: FlagPolicy = FlagPolicy {
297        standalone: WordSet::new(&["--verbose"]),
298        standalone_short: b"v",
299        valued: WordSet::new(&["--output"]),
300        valued_short: b"o",
301        bare: true,
302        max_positional: None,
303        flag_style: FlagStyle::Strict,
304    };
305
306    static SIMPLE_CMD: CommandDef = CommandDef {
307        name: "mycmd",
308        subs: &[SubDef::Policy {
309            name: "build",
310            policy: &TEST_POLICY,
311        }],
312        bare_flags: &["--info"],
313        help_eligible: true,
314    };
315
316    #[test]
317    fn bare_rejected() {
318        assert!(!SIMPLE_CMD.check(&toks(&["mycmd"]), &no_safe));
319    }
320
321    #[test]
322    fn bare_flag_accepted() {
323        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "--info"]), &no_safe));
324    }
325
326    #[test]
327    fn bare_flag_with_extra_rejected() {
328        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"]), &no_safe));
329    }
330
331    #[test]
332    fn policy_sub_bare() {
333        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build"]), &no_safe));
334    }
335
336    #[test]
337    fn policy_sub_with_flag() {
338        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"]), &no_safe));
339    }
340
341    #[test]
342    fn policy_sub_unknown_flag() {
343        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"]), &no_safe));
344    }
345
346    #[test]
347    fn unknown_sub_rejected() {
348        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "deploy"]), &no_safe));
349    }
350
351    #[test]
352    fn dispatch_matches() {
353        assert_eq!(
354            SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"]), &no_safe),
355            Some(true)
356        );
357    }
358
359    #[test]
360    fn dispatch_no_match() {
361        assert_eq!(
362            SIMPLE_CMD.dispatch("other", &toks(&["other", "build"]), &no_safe),
363            None
364        );
365    }
366
367    static NESTED_CMD: CommandDef = CommandDef {
368        name: "nested",
369        subs: &[SubDef::Nested {
370            name: "package",
371            subs: &[SubDef::Policy {
372                name: "describe",
373                policy: &TEST_POLICY,
374            }],
375        }],
376        bare_flags: &[],
377        help_eligible: false,
378    };
379
380    #[test]
381    fn nested_sub() {
382        assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"]), &no_safe));
383    }
384
385    #[test]
386    fn nested_sub_with_flag() {
387        assert!(NESTED_CMD.check(
388            &toks(&["nested", "package", "describe", "--verbose"]),
389            &no_safe,
390        ));
391    }
392
393    #[test]
394    fn nested_bare_rejected() {
395        assert!(!NESTED_CMD.check(&toks(&["nested", "package"]), &no_safe));
396    }
397
398    #[test]
399    fn nested_unknown_sub_rejected() {
400        assert!(!NESTED_CMD.check(&toks(&["nested", "package", "deploy"]), &no_safe));
401    }
402
403    static GUARDED_POLICY: FlagPolicy = FlagPolicy {
404        standalone: WordSet::new(&["--all", "--check"]),
405        standalone_short: b"",
406        valued: WordSet::new(&[]),
407        valued_short: b"",
408        bare: false,
409        max_positional: None,
410        flag_style: FlagStyle::Strict,
411    };
412
413    static GUARDED_CMD: CommandDef = CommandDef {
414        name: "guarded",
415        subs: &[SubDef::Guarded {
416            name: "fmt",
417            guard_short: None,
418            guard_long: "--check",
419            policy: &GUARDED_POLICY,
420        }],
421        bare_flags: &[],
422        help_eligible: false,
423    };
424
425    #[test]
426    fn guarded_with_guard() {
427        assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"]), &no_safe));
428    }
429
430    #[test]
431    fn guarded_without_guard() {
432        assert!(!GUARDED_CMD.check(&toks(&["guarded", "fmt"]), &no_safe));
433    }
434
435    #[test]
436    fn guarded_with_guard_and_flag() {
437        assert!(GUARDED_CMD.check(
438            &toks(&["guarded", "fmt", "--check", "--all"]),
439            &no_safe,
440        ));
441    }
442
443    fn safe_echo(seg: &Segment) -> bool {
444        seg.as_str() == "echo hello"
445    }
446
447    static DELEGATION_CMD: CommandDef = CommandDef {
448        name: "runner",
449        subs: &[SubDef::Delegation {
450            name: "run",
451            skip: 2,
452            doc: "run delegates to inner command.",
453        }],
454        bare_flags: &[],
455        help_eligible: false,
456    };
457
458    #[test]
459    fn delegation_safe_inner() {
460        assert!(DELEGATION_CMD.check(
461            &toks(&["runner", "run", "stable", "echo", "hello"]),
462            &safe_echo,
463        ));
464    }
465
466    #[test]
467    fn delegation_unsafe_inner() {
468        assert!(!DELEGATION_CMD.check(
469            &toks(&["runner", "run", "stable", "rm", "-rf"]),
470            &no_safe,
471        ));
472    }
473
474    #[test]
475    fn delegation_no_inner() {
476        assert!(!DELEGATION_CMD.check(
477            &toks(&["runner", "run", "stable"]),
478            &no_safe,
479        ));
480    }
481
482    fn custom_check(tokens: &[Token], _is_safe: &dyn Fn(&Segment) -> bool) -> bool {
483        tokens.len() >= 2 && tokens[1] == "safe"
484    }
485
486    static CUSTOM_CMD: CommandDef = CommandDef {
487        name: "custom",
488        subs: &[SubDef::Custom {
489            name: "special",
490            check: custom_check,
491            doc: "special (safe only).",
492            test_suffix: Some("safe"),
493        }],
494        bare_flags: &[],
495        help_eligible: false,
496    };
497
498    #[test]
499    fn custom_passes() {
500        assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"]), &no_safe));
501    }
502
503    #[test]
504    fn custom_fails() {
505        assert!(!CUSTOM_CMD.check(&toks(&["custom", "special", "bad"]), &no_safe));
506    }
507
508    #[test]
509    fn doc_simple() {
510        let doc = SIMPLE_CMD.to_doc();
511        assert_eq!(doc.name, "mycmd");
512        assert_eq!(
513            doc.description,
514            "Info flags: --info.\n- **build**: Flags: --verbose. Valued: --output"
515        );
516    }
517
518    #[test]
519    fn doc_nested() {
520        let doc = NESTED_CMD.to_doc();
521        assert_eq!(
522            doc.description,
523            "- **package describe**: Flags: --verbose. Valued: --output"
524        );
525    }
526
527    #[test]
528    fn doc_guarded() {
529        let doc = GUARDED_CMD.to_doc();
530        assert_eq!(
531            doc.description,
532            "- **fmt** (requires --check): Flags: --all, --check"
533        );
534    }
535
536    #[test]
537    fn doc_delegation() {
538        let doc = DELEGATION_CMD.to_doc();
539        assert_eq!(doc.description, "- **run**: run delegates to inner command.");
540    }
541
542    #[test]
543    fn doc_custom() {
544        let doc = CUSTOM_CMD.to_doc();
545        assert_eq!(doc.description, "- **special**: special (safe only).");
546    }
547}