Skip to main content

safe_chains/
command.rs

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