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 parts = Vec::new();
116
117        let mut policy_names: Vec<&str> = Vec::new();
118        let mut nested_names: Vec<String> = Vec::new();
119        let mut guarded_descs: Vec<String> = Vec::new();
120        let mut extra_docs: Vec<&str> = Vec::new();
121
122        for sub in self.subs {
123            match sub {
124                SubDef::Policy { name, .. } => {
125                    policy_names.push(name);
126                }
127                SubDef::Nested { name, subs } => {
128                    let visible: Vec<_> = subs.iter()
129                        .filter(|s| !s.name().starts_with('-'))
130                        .collect();
131                    if visible.len() <= 5 {
132                        for s in &visible {
133                            nested_names.push(format!("{name} {}", s.name()));
134                        }
135                    } else {
136                        nested_names.push((*name).to_string());
137                    }
138                }
139                SubDef::Guarded { name, guard_long, .. } => {
140                    guarded_descs.push(format!("{name} (requires {guard_long})"));
141                }
142                SubDef::Custom { name, doc, .. } => {
143                    if doc.is_empty() {
144                        policy_names.push(name);
145                    } else if !doc.trim().is_empty() {
146                        extra_docs.push(doc);
147                    }
148                }
149                SubDef::Delegation { doc, .. } => {
150                    if !doc.is_empty() {
151                        extra_docs.push(doc);
152                    }
153                }
154            }
155        }
156
157        if !policy_names.is_empty() {
158            policy_names.sort();
159            parts.push(format!("Subcommands: {}.", policy_names.join(", ")));
160        }
161
162        if !nested_names.is_empty() {
163            nested_names.sort();
164            parts.push(format!("Multi-level: {}.", nested_names.join(", ")));
165        }
166
167        if !self.bare_flags.is_empty() {
168            parts.push(format!("Info flags: {}.", self.bare_flags.join(", ")));
169        }
170
171        if !guarded_descs.is_empty() {
172            parts.push(format!("{}.", guarded_descs.join(", ")));
173        }
174
175        for doc in extra_docs {
176            parts.push(doc.to_string());
177        }
178
179        crate::docs::CommandDoc::handler(self.name, parts.join(" "))
180    }
181}
182
183#[cfg(test)]
184impl CommandDef {
185    pub fn auto_test_reject_unknown(&self) {
186        let mut failures = Vec::new();
187
188        assert!(
189            !crate::is_safe_command(self.name),
190            "{}: accepted bare invocation",
191            self.name,
192        );
193
194        let test = format!("{} xyzzy-unknown-42", self.name);
195        assert!(
196            !crate::is_safe_command(&test),
197            "{}: accepted unknown subcommand: {test}",
198            self.name,
199        );
200
201        for sub in self.subs {
202            auto_test_sub(self.name, sub, &mut failures);
203        }
204        assert!(
205            failures.is_empty(),
206            "{}: unknown flags/subcommands accepted:\n{}",
207            self.name,
208            failures.join("\n"),
209        );
210    }
211}
212
213#[cfg(test)]
214fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
215    const UNKNOWN: &str = "--xyzzy-unknown-42";
216
217    match sub {
218        SubDef::Policy { name, policy } => {
219            if policy.flag_style == FlagStyle::Positional {
220                return;
221            }
222            let test = format!("{prefix} {name} {UNKNOWN}");
223            if crate::is_safe_command(&test) {
224                failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
225            }
226        }
227        SubDef::Nested { name, subs } => {
228            let path = format!("{prefix} {name}");
229            let test = format!("{path} xyzzy-unknown-42");
230            if crate::is_safe_command(&test) {
231                failures.push(format!("{path}: accepted unknown subcommand: {test}"));
232            }
233            for s in *subs {
234                auto_test_sub(&path, s, failures);
235            }
236        }
237        SubDef::Guarded {
238            name, guard_long, ..
239        } => {
240            let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
241            if crate::is_safe_command(&test) {
242                failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
243            }
244        }
245        SubDef::Custom {
246            name, test_suffix, ..
247        } => {
248            if let Some(suffix) = test_suffix {
249                let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
250                if crate::is_safe_command(&test) {
251                    failures.push(format!(
252                        "{prefix} {name}: accepted unknown flag: {test}"
253                    ));
254                }
255            }
256        }
257        SubDef::Delegation { .. } => {}
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::parse::WordSet;
265    use crate::policy::FlagStyle;
266
267    fn toks(words: &[&str]) -> Vec<Token> {
268        words.iter().map(|s| Token::from_test(s)).collect()
269    }
270
271    fn no_safe(_: &Segment) -> bool {
272        false
273    }
274
275    static TEST_POLICY: FlagPolicy = FlagPolicy {
276        standalone: WordSet::new(&["--verbose"]),
277        standalone_short: b"v",
278        valued: WordSet::new(&["--output"]),
279        valued_short: b"o",
280        bare: true,
281        max_positional: None,
282        flag_style: FlagStyle::Strict,
283    };
284
285    static SIMPLE_CMD: CommandDef = CommandDef {
286        name: "mycmd",
287        subs: &[SubDef::Policy {
288            name: "build",
289            policy: &TEST_POLICY,
290        }],
291        bare_flags: &["--info"],
292        help_eligible: true,
293    };
294
295    #[test]
296    fn bare_rejected() {
297        assert!(!SIMPLE_CMD.check(&toks(&["mycmd"]), &no_safe));
298    }
299
300    #[test]
301    fn bare_flag_accepted() {
302        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "--info"]), &no_safe));
303    }
304
305    #[test]
306    fn bare_flag_with_extra_rejected() {
307        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"]), &no_safe));
308    }
309
310    #[test]
311    fn policy_sub_bare() {
312        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build"]), &no_safe));
313    }
314
315    #[test]
316    fn policy_sub_with_flag() {
317        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"]), &no_safe));
318    }
319
320    #[test]
321    fn policy_sub_unknown_flag() {
322        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"]), &no_safe));
323    }
324
325    #[test]
326    fn unknown_sub_rejected() {
327        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "deploy"]), &no_safe));
328    }
329
330    #[test]
331    fn dispatch_matches() {
332        assert_eq!(
333            SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"]), &no_safe),
334            Some(true)
335        );
336    }
337
338    #[test]
339    fn dispatch_no_match() {
340        assert_eq!(
341            SIMPLE_CMD.dispatch("other", &toks(&["other", "build"]), &no_safe),
342            None
343        );
344    }
345
346    static NESTED_CMD: CommandDef = CommandDef {
347        name: "nested",
348        subs: &[SubDef::Nested {
349            name: "package",
350            subs: &[SubDef::Policy {
351                name: "describe",
352                policy: &TEST_POLICY,
353            }],
354        }],
355        bare_flags: &[],
356        help_eligible: false,
357    };
358
359    #[test]
360    fn nested_sub() {
361        assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"]), &no_safe));
362    }
363
364    #[test]
365    fn nested_sub_with_flag() {
366        assert!(NESTED_CMD.check(
367            &toks(&["nested", "package", "describe", "--verbose"]),
368            &no_safe,
369        ));
370    }
371
372    #[test]
373    fn nested_bare_rejected() {
374        assert!(!NESTED_CMD.check(&toks(&["nested", "package"]), &no_safe));
375    }
376
377    #[test]
378    fn nested_unknown_sub_rejected() {
379        assert!(!NESTED_CMD.check(&toks(&["nested", "package", "deploy"]), &no_safe));
380    }
381
382    static GUARDED_POLICY: FlagPolicy = FlagPolicy {
383        standalone: WordSet::new(&["--all", "--check"]),
384        standalone_short: b"",
385        valued: WordSet::new(&[]),
386        valued_short: b"",
387        bare: false,
388        max_positional: None,
389        flag_style: FlagStyle::Strict,
390    };
391
392    static GUARDED_CMD: CommandDef = CommandDef {
393        name: "guarded",
394        subs: &[SubDef::Guarded {
395            name: "fmt",
396            guard_short: None,
397            guard_long: "--check",
398            policy: &GUARDED_POLICY,
399        }],
400        bare_flags: &[],
401        help_eligible: false,
402    };
403
404    #[test]
405    fn guarded_with_guard() {
406        assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"]), &no_safe));
407    }
408
409    #[test]
410    fn guarded_without_guard() {
411        assert!(!GUARDED_CMD.check(&toks(&["guarded", "fmt"]), &no_safe));
412    }
413
414    #[test]
415    fn guarded_with_guard_and_flag() {
416        assert!(GUARDED_CMD.check(
417            &toks(&["guarded", "fmt", "--check", "--all"]),
418            &no_safe,
419        ));
420    }
421
422    fn safe_echo(seg: &Segment) -> bool {
423        seg.as_str() == "echo hello"
424    }
425
426    static DELEGATION_CMD: CommandDef = CommandDef {
427        name: "runner",
428        subs: &[SubDef::Delegation {
429            name: "run",
430            skip: 2,
431            doc: "run delegates to inner command.",
432        }],
433        bare_flags: &[],
434        help_eligible: false,
435    };
436
437    #[test]
438    fn delegation_safe_inner() {
439        assert!(DELEGATION_CMD.check(
440            &toks(&["runner", "run", "stable", "echo", "hello"]),
441            &safe_echo,
442        ));
443    }
444
445    #[test]
446    fn delegation_unsafe_inner() {
447        assert!(!DELEGATION_CMD.check(
448            &toks(&["runner", "run", "stable", "rm", "-rf"]),
449            &no_safe,
450        ));
451    }
452
453    #[test]
454    fn delegation_no_inner() {
455        assert!(!DELEGATION_CMD.check(
456            &toks(&["runner", "run", "stable"]),
457            &no_safe,
458        ));
459    }
460
461    fn custom_check(tokens: &[Token], _is_safe: &dyn Fn(&Segment) -> bool) -> bool {
462        tokens.len() >= 2 && tokens[1] == "safe"
463    }
464
465    static CUSTOM_CMD: CommandDef = CommandDef {
466        name: "custom",
467        subs: &[SubDef::Custom {
468            name: "special",
469            check: custom_check,
470            doc: "special (safe only).",
471            test_suffix: Some("safe"),
472        }],
473        bare_flags: &[],
474        help_eligible: false,
475    };
476
477    #[test]
478    fn custom_passes() {
479        assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"]), &no_safe));
480    }
481
482    #[test]
483    fn custom_fails() {
484        assert!(!CUSTOM_CMD.check(&toks(&["custom", "special", "bad"]), &no_safe));
485    }
486
487    #[test]
488    fn doc_simple() {
489        let doc = SIMPLE_CMD.to_doc();
490        assert_eq!(doc.name, "mycmd");
491        assert_eq!(doc.description, "Subcommands: build. Info flags: --info.");
492    }
493
494    #[test]
495    fn doc_nested() {
496        let doc = NESTED_CMD.to_doc();
497        assert_eq!(doc.description, "Multi-level: package describe.");
498    }
499
500    #[test]
501    fn doc_guarded() {
502        let doc = GUARDED_CMD.to_doc();
503        assert_eq!(doc.description, "fmt (requires --check).");
504    }
505
506    #[test]
507    fn doc_delegation() {
508        let doc = DELEGATION_CMD.to_doc();
509        assert_eq!(doc.description, "run delegates to inner command.");
510    }
511
512    #[test]
513    fn doc_custom() {
514        let doc = CUSTOM_CMD.to_doc();
515        assert_eq!(doc.description, "special (safe only).");
516    }
517}