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