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" || tokens[1] == "-h") {
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" || sub == "-h") {
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" || tokens[1] == "-h") {
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" || tokens[1] == "-h") {
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 && matches!(arg, "--help" | "-h" | "--version" | "-V") {
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                && matches!(tokens[1].as_str(), "--help" | "-h" | "--version" | "-V")
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", "-v"]),
360        valued: WordSet::new(&["--output", "-o"]),
361        bare: true,
362        max_positional: None,
363        flag_style: FlagStyle::Strict,
364    };
365
366    static SIMPLE_CMD: CommandDef = CommandDef {
367        name: "mycmd",
368        subs: &[SubDef::Policy {
369            name: "build",
370            policy: &TEST_POLICY,
371        }],
372        bare_flags: &["--info"],
373        help_eligible: true,
374        url: "",
375    };
376
377    #[test]
378    fn bare_rejected() {
379        assert!(!SIMPLE_CMD.check(&toks(&["mycmd"]), &no_safe));
380    }
381
382    #[test]
383    fn bare_flag_accepted() {
384        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "--info"]), &no_safe));
385    }
386
387    #[test]
388    fn bare_flag_with_extra_rejected() {
389        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"]), &no_safe));
390    }
391
392    #[test]
393    fn policy_sub_bare() {
394        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build"]), &no_safe));
395    }
396
397    #[test]
398    fn policy_sub_with_flag() {
399        assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"]), &no_safe));
400    }
401
402    #[test]
403    fn policy_sub_unknown_flag() {
404        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"]), &no_safe));
405    }
406
407    #[test]
408    fn unknown_sub_rejected() {
409        assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "deploy"]), &no_safe));
410    }
411
412    #[test]
413    fn dispatch_matches() {
414        assert_eq!(
415            SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"]), &no_safe),
416            Some(true)
417        );
418    }
419
420    #[test]
421    fn dispatch_no_match() {
422        assert_eq!(
423            SIMPLE_CMD.dispatch("other", &toks(&["other", "build"]), &no_safe),
424            None
425        );
426    }
427
428    static NESTED_CMD: CommandDef = CommandDef {
429        name: "nested",
430        subs: &[SubDef::Nested {
431            name: "package",
432            subs: &[SubDef::Policy {
433                name: "describe",
434                policy: &TEST_POLICY,
435            }],
436        }],
437        bare_flags: &[],
438        help_eligible: false,
439        url: "",
440    };
441
442    #[test]
443    fn nested_sub() {
444        assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"]), &no_safe));
445    }
446
447    #[test]
448    fn nested_sub_with_flag() {
449        assert!(NESTED_CMD.check(
450            &toks(&["nested", "package", "describe", "--verbose"]),
451            &no_safe,
452        ));
453    }
454
455    #[test]
456    fn nested_bare_rejected() {
457        assert!(!NESTED_CMD.check(&toks(&["nested", "package"]), &no_safe));
458    }
459
460    #[test]
461    fn nested_unknown_sub_rejected() {
462        assert!(!NESTED_CMD.check(&toks(&["nested", "package", "deploy"]), &no_safe));
463    }
464
465    static GUARDED_POLICY: FlagPolicy = FlagPolicy {
466        standalone: WordSet::new(&["--all", "--check"]),
467        valued: WordSet::new(&[]),
468        bare: false,
469        max_positional: None,
470        flag_style: FlagStyle::Strict,
471    };
472
473    static GUARDED_CMD: CommandDef = CommandDef {
474        name: "guarded",
475        subs: &[SubDef::Guarded {
476            name: "fmt",
477            guard_short: None,
478            guard_long: "--check",
479            policy: &GUARDED_POLICY,
480        }],
481        bare_flags: &[],
482        help_eligible: false,
483        url: "",
484    };
485
486    #[test]
487    fn guarded_with_guard() {
488        assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"]), &no_safe));
489    }
490
491    #[test]
492    fn guarded_without_guard() {
493        assert!(!GUARDED_CMD.check(&toks(&["guarded", "fmt"]), &no_safe));
494    }
495
496    #[test]
497    fn guarded_with_guard_and_flag() {
498        assert!(GUARDED_CMD.check(
499            &toks(&["guarded", "fmt", "--check", "--all"]),
500            &no_safe,
501        ));
502    }
503
504    fn safe_echo(seg: &Segment) -> bool {
505        seg.as_str() == "echo hello"
506    }
507
508    static DELEGATION_CMD: CommandDef = CommandDef {
509        name: "runner",
510        subs: &[SubDef::Delegation {
511            name: "run",
512            skip: 2,
513            doc: "run delegates to inner command.",
514        }],
515        bare_flags: &[],
516        help_eligible: false,
517        url: "",
518    };
519
520    #[test]
521    fn delegation_safe_inner() {
522        assert!(DELEGATION_CMD.check(
523            &toks(&["runner", "run", "stable", "echo", "hello"]),
524            &safe_echo,
525        ));
526    }
527
528    #[test]
529    fn delegation_unsafe_inner() {
530        assert!(!DELEGATION_CMD.check(
531            &toks(&["runner", "run", "stable", "rm", "-rf"]),
532            &no_safe,
533        ));
534    }
535
536    #[test]
537    fn delegation_no_inner() {
538        assert!(!DELEGATION_CMD.check(
539            &toks(&["runner", "run", "stable"]),
540            &no_safe,
541        ));
542    }
543
544    fn custom_check(tokens: &[Token], _is_safe: &dyn Fn(&Segment) -> bool) -> bool {
545        tokens.len() >= 2 && tokens[1] == "safe"
546    }
547
548    static CUSTOM_CMD: CommandDef = CommandDef {
549        name: "custom",
550        subs: &[SubDef::Custom {
551            name: "special",
552            check: custom_check,
553            doc: "special (safe only).",
554            test_suffix: Some("safe"),
555        }],
556        bare_flags: &[],
557        help_eligible: false,
558        url: "",
559    };
560
561    #[test]
562    fn custom_passes() {
563        assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"]), &no_safe));
564    }
565
566    #[test]
567    fn custom_fails() {
568        assert!(!CUSTOM_CMD.check(&toks(&["custom", "special", "bad"]), &no_safe));
569    }
570
571    #[test]
572    fn doc_simple() {
573        let doc = SIMPLE_CMD.to_doc();
574        assert_eq!(doc.name, "mycmd");
575        assert_eq!(
576            doc.description,
577            "- Info flags: --info\n- **build**: Flags: --verbose, -v. Valued: --output, -o"
578        );
579    }
580
581    #[test]
582    fn doc_nested() {
583        let doc = NESTED_CMD.to_doc();
584        assert_eq!(
585            doc.description,
586            "- **package describe**: Flags: --verbose, -v. Valued: --output, -o"
587        );
588    }
589
590    #[test]
591    fn doc_guarded() {
592        let doc = GUARDED_CMD.to_doc();
593        assert_eq!(
594            doc.description,
595            "- **fmt** (requires --check): Flags: --all, --check"
596        );
597    }
598
599    #[test]
600    fn doc_delegation() {
601        let doc = DELEGATION_CMD.to_doc();
602        assert_eq!(doc.description, "- **run**: run delegates to inner command.");
603    }
604
605    #[test]
606    fn doc_custom() {
607        let doc = CUSTOM_CMD.to_doc();
608        assert_eq!(doc.description, "- **special**: special (safe only).");
609    }
610}