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