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