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