Skip to main content

safe_chains/
registry.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use crate::parse::Token;
6use crate::policy::FlagStyle;
7use crate::verdict::{SafetyLevel, Verdict};
8
9#[derive(Debug, Deserialize)]
10struct TomlFile {
11    command: Vec<TomlCommand>,
12}
13
14#[derive(Debug, Deserialize)]
15struct TomlCommand {
16    name: String,
17    #[serde(default)]
18    aliases: Vec<String>,
19    #[serde(default)]
20    url: String,
21    #[serde(default)]
22    level: Option<TomlLevel>,
23    #[serde(default)]
24    bare: Option<bool>,
25    #[serde(default)]
26    max_positional: Option<usize>,
27    #[serde(default)]
28    positional_style: Option<bool>,
29    #[serde(default)]
30    standalone: Vec<String>,
31    #[serde(default)]
32    valued: Vec<String>,
33    #[serde(default)]
34    bare_flags: Vec<String>,
35    #[serde(default)]
36    sub: Vec<TomlSub>,
37    #[serde(default)]
38    handler: Option<String>,
39    #[allow(dead_code)]
40    #[serde(default)]
41    doc: Option<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct TomlSub {
46    name: String,
47    #[serde(default)]
48    level: Option<TomlLevel>,
49    #[serde(default)]
50    bare: Option<bool>,
51    #[serde(default)]
52    max_positional: Option<usize>,
53    #[serde(default)]
54    positional_style: Option<bool>,
55    #[serde(default)]
56    standalone: Vec<String>,
57    #[serde(default)]
58    valued: Vec<String>,
59    #[serde(default)]
60    guard: Option<String>,
61    #[serde(default)]
62    guard_short: Option<String>,
63    #[serde(default)]
64    allow_all: Option<bool>,
65    #[serde(default)]
66    sub: Vec<TomlSub>,
67    #[serde(default)]
68    write_flags: Vec<String>,
69    #[serde(default)]
70    delegate_after: Option<String>,
71    #[serde(default)]
72    delegate_skip: Option<usize>,
73    #[serde(default)]
74    handler: Option<String>,
75    #[serde(default)]
76    doc: Option<String>,
77}
78
79#[derive(Debug, Clone, Copy, Deserialize)]
80enum TomlLevel {
81    Inert,
82    SafeRead,
83    SafeWrite,
84}
85
86impl From<TomlLevel> for SafetyLevel {
87    fn from(l: TomlLevel) -> Self {
88        match l {
89            TomlLevel::Inert => SafetyLevel::Inert,
90            TomlLevel::SafeRead => SafetyLevel::SafeRead,
91            TomlLevel::SafeWrite => SafetyLevel::SafeWrite,
92        }
93    }
94}
95
96#[derive(Debug)]
97pub struct CommandSpec {
98    pub name: String,
99    pub aliases: Vec<String>,
100    pub url: String,
101    kind: CommandKind,
102}
103
104#[derive(Debug)]
105enum CommandKind {
106    Flat {
107        policy: OwnedPolicy,
108        level: SafetyLevel,
109    },
110    Structured {
111        bare_flags: Vec<String>,
112        subs: Vec<SubSpec>,
113    },
114    Custom {
115        #[allow(dead_code)]
116        handler_name: String,
117    },
118}
119
120#[derive(Debug)]
121struct SubSpec {
122    name: String,
123    kind: SubKind,
124}
125
126#[derive(Debug)]
127enum SubKind {
128    Policy {
129        policy: OwnedPolicy,
130        level: SafetyLevel,
131    },
132    Guarded {
133        guard_long: String,
134        guard_short: Option<String>,
135        policy: OwnedPolicy,
136        level: SafetyLevel,
137    },
138    Nested {
139        subs: Vec<SubSpec>,
140    },
141    AllowAll {
142        level: SafetyLevel,
143    },
144    WriteFlagged {
145        policy: OwnedPolicy,
146        base_level: SafetyLevel,
147        write_flags: Vec<String>,
148    },
149    DelegateAfterSeparator {
150        separator: String,
151    },
152    DelegateSkip {
153        skip: usize,
154        #[allow(dead_code)]
155        doc: String,
156    },
157    Custom {
158        #[allow(dead_code)]
159        handler_name: String,
160    },
161}
162
163#[derive(Debug)]
164pub struct OwnedPolicy {
165    pub standalone: Vec<String>,
166    pub valued: Vec<String>,
167    pub bare: bool,
168    pub max_positional: Option<usize>,
169    pub flag_style: FlagStyle,
170}
171
172fn check_owned(tokens: &[Token], policy: &OwnedPolicy) -> bool {
173    if tokens.len() == 1 {
174        return policy.bare;
175    }
176
177    let mut i = 1;
178    let mut positionals: usize = 0;
179    while i < tokens.len() {
180        let t = &tokens[i];
181
182        if *t == "--" {
183            positionals += tokens.len() - i - 1;
184            break;
185        }
186
187        if !t.starts_with('-') {
188            positionals += 1;
189            i += 1;
190            continue;
191        }
192
193        if policy.standalone.iter().any(|f| t == f.as_str()) {
194            i += 1;
195            continue;
196        }
197
198        if policy.valued.iter().any(|f| t == f.as_str()) {
199            i += 2;
200            continue;
201        }
202
203        if let Some(flag) = t.as_str().split_once('=').map(|(f, _)| f) {
204            if policy.valued.iter().any(|f| f.as_str() == flag) {
205                i += 1;
206                continue;
207            }
208            if policy.flag_style == FlagStyle::Positional {
209                positionals += 1;
210                i += 1;
211                continue;
212            }
213            return false;
214        }
215
216        if t.starts_with("--") {
217            if policy.flag_style == FlagStyle::Positional {
218                positionals += 1;
219                i += 1;
220                continue;
221            }
222            return false;
223        }
224
225        let bytes = t.as_bytes();
226        let mut j = 1;
227        while j < bytes.len() {
228            let b = bytes[j];
229            let is_last = j == bytes.len() - 1;
230            if policy.standalone.iter().any(|f| f.len() == 2 && f.as_bytes()[1] == b) {
231                j += 1;
232                continue;
233            }
234            if policy.valued.iter().any(|f| f.len() == 2 && f.as_bytes()[1] == b) {
235                if is_last {
236                    i += 1;
237                }
238                break;
239            }
240            if policy.flag_style == FlagStyle::Positional {
241                positionals += 1;
242                break;
243            }
244            return false;
245        }
246        i += 1;
247    }
248    policy.max_positional.is_none_or(|max| positionals <= max)
249}
250
251fn build_policy(
252    standalone: Vec<String>,
253    valued: Vec<String>,
254    bare: Option<bool>,
255    max_positional: Option<usize>,
256    positional_style: Option<bool>,
257) -> OwnedPolicy {
258    OwnedPolicy {
259        standalone,
260        valued,
261        bare: bare.unwrap_or(true),
262        max_positional,
263        flag_style: if positional_style.unwrap_or(false) {
264            FlagStyle::Positional
265        } else {
266            FlagStyle::Strict
267        },
268    }
269}
270
271fn build_sub(toml: TomlSub) -> SubSpec {
272    if let Some(handler_name) = toml.handler {
273        return SubSpec {
274            name: toml.name,
275            kind: SubKind::Custom { handler_name },
276        };
277    }
278
279    if toml.allow_all.unwrap_or(false) {
280        return SubSpec {
281            name: toml.name,
282            kind: SubKind::AllowAll {
283                level: toml.level.unwrap_or(TomlLevel::Inert).into(),
284            },
285        };
286    }
287
288    if let Some(sep) = toml.delegate_after {
289        return SubSpec {
290            name: toml.name,
291            kind: SubKind::DelegateAfterSeparator { separator: sep },
292        };
293    }
294
295    if let Some(skip) = toml.delegate_skip {
296        return SubSpec {
297            name: toml.name,
298            kind: SubKind::DelegateSkip {
299                skip,
300                doc: toml.doc.unwrap_or_default(),
301            },
302        };
303    }
304
305    if !toml.sub.is_empty() {
306        return SubSpec {
307            name: toml.name,
308            kind: SubKind::Nested {
309                subs: toml.sub.into_iter().map(build_sub).collect(),
310            },
311        };
312    }
313
314    let policy = build_policy(
315        toml.standalone,
316        toml.valued,
317        toml.bare,
318        toml.max_positional,
319        toml.positional_style,
320    );
321    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
322
323    if !toml.write_flags.is_empty() {
324        return SubSpec {
325            name: toml.name,
326            kind: SubKind::WriteFlagged {
327                policy,
328                base_level: level,
329                write_flags: toml.write_flags,
330            },
331        };
332    }
333
334    if let Some(guard) = toml.guard {
335        return SubSpec {
336            name: toml.name,
337            kind: SubKind::Guarded {
338                guard_long: guard,
339                guard_short: toml.guard_short,
340                policy,
341                level,
342            },
343        };
344    }
345
346    SubSpec {
347        name: toml.name,
348        kind: SubKind::Policy { policy, level },
349    }
350}
351
352fn build_command(toml: TomlCommand) -> CommandSpec {
353    if let Some(handler_name) = toml.handler {
354        return CommandSpec {
355            name: toml.name,
356            aliases: toml.aliases,
357            url: toml.url,
358            kind: CommandKind::Custom { handler_name },
359        };
360    }
361
362    if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
363        return CommandSpec {
364            name: toml.name,
365            aliases: toml.aliases,
366            url: toml.url,
367            kind: CommandKind::Structured {
368                bare_flags: toml.bare_flags,
369                subs: toml.sub.into_iter().map(build_sub).collect(),
370            },
371        };
372    }
373
374    let policy = build_policy(
375        toml.standalone,
376        toml.valued,
377        toml.bare,
378        toml.max_positional,
379        toml.positional_style,
380    );
381
382    CommandSpec {
383        name: toml.name,
384        aliases: toml.aliases,
385        url: toml.url,
386        kind: CommandKind::Flat {
387            policy,
388            level: toml.level.unwrap_or(TomlLevel::Inert).into(),
389        },
390    }
391}
392
393pub fn load_toml(source: &str) -> Vec<CommandSpec> {
394    let file: TomlFile = toml::from_str(source).expect("invalid TOML command definition");
395    file.command.into_iter().map(build_command).collect()
396}
397
398pub fn build_registry(specs: Vec<CommandSpec>) -> HashMap<String, CommandSpec> {
399    let mut map = HashMap::new();
400    for spec in specs {
401        for alias in &spec.aliases {
402            map.insert(alias.clone(), CommandSpec {
403                name: spec.name.clone(),
404                aliases: vec![],
405                url: spec.url.clone(),
406                kind: match &spec.kind {
407                    CommandKind::Flat { policy, level } => CommandKind::Flat {
408                        policy: OwnedPolicy {
409                            standalone: policy.standalone.clone(),
410                            valued: policy.valued.clone(),
411                            bare: policy.bare,
412                            max_positional: policy.max_positional,
413                            flag_style: policy.flag_style,
414                        },
415                        level: *level,
416                    },
417                    _ => continue,
418                },
419            });
420        }
421        map.insert(spec.name.clone(), spec);
422    }
423    map
424}
425
426fn has_flag_owned(tokens: &[Token], short: Option<&str>, long: &str) -> bool {
427    tokens[1..].iter().any(|t| {
428        t == long
429            || short.is_some_and(|s| t == s)
430            || t.as_str().starts_with(&format!("{long}="))
431    })
432}
433
434fn dispatch_sub(tokens: &[Token], sub: &SubSpec) -> Verdict {
435    match &sub.kind {
436        SubKind::Policy { policy, level } => {
437            if check_owned(tokens, policy) {
438                Verdict::Allowed(*level)
439            } else {
440                Verdict::Denied
441            }
442        }
443        SubKind::Guarded {
444            guard_long,
445            guard_short,
446            policy,
447            level,
448        } => {
449            if has_flag_owned(tokens, guard_short.as_deref(), guard_long)
450                && check_owned(tokens, policy)
451            {
452                Verdict::Allowed(*level)
453            } else {
454                Verdict::Denied
455            }
456        }
457        SubKind::Nested { subs } => {
458            if tokens.len() < 2 {
459                return Verdict::Denied;
460            }
461            let name = tokens[1].as_str();
462            subs.iter()
463                .find(|s| s.name == name)
464                .map(|s| dispatch_sub(&tokens[1..], s))
465                .unwrap_or(Verdict::Denied)
466        }
467        SubKind::AllowAll { level } => Verdict::Allowed(*level),
468        SubKind::WriteFlagged {
469            policy,
470            base_level,
471            write_flags,
472        } => {
473            if !check_owned(tokens, policy) {
474                return Verdict::Denied;
475            }
476            let has_write = tokens[1..].iter().any(|t| {
477                write_flags.iter().any(|f| t == f.as_str() || t.as_str().starts_with(&format!("{f}=")))
478            });
479            if has_write {
480                Verdict::Allowed(SafetyLevel::SafeWrite)
481            } else {
482                Verdict::Allowed(*base_level)
483            }
484        }
485        SubKind::DelegateAfterSeparator { separator } => {
486            let sep_pos = tokens[1..].iter().position(|t| t == separator.as_str());
487            let Some(pos) = sep_pos else {
488                return Verdict::Denied;
489            };
490            let inner_start = pos + 2;
491            if inner_start >= tokens.len() {
492                return Verdict::Denied;
493            }
494            let inner = shell_words::join(tokens[inner_start..].iter().map(|t| t.as_str()));
495            crate::command_verdict(&inner)
496        }
497        SubKind::DelegateSkip { skip, .. } => {
498            if tokens.len() <= *skip {
499                return Verdict::Denied;
500            }
501            let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
502            crate::command_verdict(&inner)
503        }
504        SubKind::Custom { .. } => Verdict::Denied,
505    }
506}
507
508pub fn dispatch_spec(tokens: &[Token], spec: &CommandSpec) -> Verdict {
509    match &spec.kind {
510        CommandKind::Flat { policy, level } => {
511            if check_owned(tokens, policy) {
512                Verdict::Allowed(*level)
513            } else {
514                Verdict::Denied
515            }
516        }
517        CommandKind::Structured { bare_flags, subs } => {
518            if tokens.len() < 2 {
519                return Verdict::Denied;
520            }
521            let arg = tokens[1].as_str();
522            if tokens.len() == 2 && bare_flags.iter().any(|f| f == arg) {
523                return Verdict::Allowed(SafetyLevel::Inert);
524            }
525            subs.iter()
526                .find(|s| s.name == arg)
527                .map(|s| dispatch_sub(&tokens[1..], s))
528                .unwrap_or(Verdict::Denied)
529        }
530        CommandKind::Custom { .. } => Verdict::Denied,
531    }
532}
533
534use std::sync::LazyLock;
535
536static TOML_REGISTRY: LazyLock<HashMap<String, CommandSpec>> = LazyLock::new(|| {
537    let mut all = Vec::new();
538    all.extend(load_toml(include_str!("../commands/hash.toml")));
539    build_registry(all)
540});
541
542pub fn toml_dispatch(tokens: &[Token]) -> Option<Verdict> {
543    let cmd = tokens[0].command_name();
544    TOML_REGISTRY.get(cmd).map(|spec| dispatch_spec(tokens, spec))
545}
546
547pub fn toml_command_names() -> Vec<&'static str> {
548    TOML_REGISTRY
549        .keys()
550        .map(|k| k.as_str())
551        .collect()
552}
553
554pub fn toml_command_docs() -> Vec<crate::docs::CommandDoc> {
555    TOML_REGISTRY
556        .values()
557        .map(|spec| spec.to_command_doc())
558        .collect()
559}
560
561impl CommandSpec {
562    fn to_command_doc(&self) -> crate::docs::CommandDoc {
563        let description = match &self.kind {
564            CommandKind::Flat { policy, .. } => policy.describe(),
565            CommandKind::Structured { bare_flags, subs } => {
566                let mut lines = Vec::new();
567                if !bare_flags.is_empty() {
568                    lines.push(format!("- Allowed standalone flags: {}", bare_flags.join(", ")));
569                }
570                for sub in subs {
571                    sub.doc_line("", &mut lines);
572                }
573                lines.sort();
574                lines.join("\n")
575            }
576            CommandKind::Custom { .. } => String::new(),
577        };
578        let mut doc = crate::docs::CommandDoc::handler(
579            Box::leak(self.name.clone().into_boxed_str()),
580            Box::leak(self.url.clone().into_boxed_str()),
581            description,
582        );
583        doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
584        doc
585    }
586}
587
588impl OwnedPolicy {
589    fn describe(&self) -> String {
590        let mut lines = Vec::new();
591        if !self.standalone.is_empty() {
592            lines.push(format!("- Allowed standalone flags: {}", self.standalone.join(", ")));
593        }
594        if !self.valued.is_empty() {
595            lines.push(format!("- Allowed valued flags: {}", self.valued.join(", ")));
596        }
597        if self.bare {
598            lines.push("- Bare invocation allowed".to_string());
599        }
600        if self.flag_style == FlagStyle::Positional {
601            lines.push("- Hyphen-prefixed positional arguments accepted".to_string());
602        }
603        if lines.is_empty() && !self.bare {
604            return "- Positional arguments only".to_string();
605        }
606        lines.join("\n")
607    }
608
609    fn flag_summary(&self) -> String {
610        let mut parts = Vec::new();
611        if !self.standalone.is_empty() {
612            parts.push(format!("Flags: {}", self.standalone.join(", ")));
613        }
614        if !self.valued.is_empty() {
615            parts.push(format!("Valued: {}", self.valued.join(", ")));
616        }
617        if self.flag_style == FlagStyle::Positional {
618            parts.push("Positional args accepted".to_string());
619        }
620        parts.join(". ")
621    }
622}
623
624impl SubSpec {
625    fn doc_line(&self, prefix: &str, out: &mut Vec<String>) {
626        let label = if prefix.is_empty() {
627            self.name.clone()
628        } else {
629            format!("{prefix} {}", self.name)
630        };
631        match &self.kind {
632            SubKind::Policy { policy, .. } => {
633                let summary = policy.flag_summary();
634                if summary.is_empty() {
635                    out.push(format!("- **{label}**"));
636                } else {
637                    out.push(format!("- **{label}**: {summary}"));
638                }
639            }
640            SubKind::Guarded { guard_long, policy, .. } => {
641                let summary = policy.flag_summary();
642                if summary.is_empty() {
643                    out.push(format!("- **{label}** (requires {guard_long})"));
644                } else {
645                    out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
646                }
647            }
648            SubKind::Nested { subs } => {
649                for sub in subs {
650                    sub.doc_line(&label, out);
651                }
652            }
653            SubKind::AllowAll { .. } => {
654                out.push(format!("- **{label}**"));
655            }
656            SubKind::WriteFlagged { policy, .. } => {
657                let summary = policy.flag_summary();
658                if summary.is_empty() {
659                    out.push(format!("- **{label}**"));
660                } else {
661                    out.push(format!("- **{label}**: {summary}"));
662                }
663            }
664            SubKind::DelegateAfterSeparator { .. } | SubKind::DelegateSkip { .. } => {}
665            SubKind::Custom { .. } => {}
666        }
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use crate::parse::Token;
674
675    fn toks(words: &[&str]) -> Vec<Token> {
676        words.iter().map(|s| Token::from_test(s)).collect()
677    }
678
679    fn load_one(toml_str: &str) -> CommandSpec {
680        let mut specs = load_toml(toml_str);
681        assert_eq!(specs.len(), 1);
682        specs.remove(0)
683    }
684
685    // ---------------------------------------------------------------
686    // Flat commands
687    // ---------------------------------------------------------------
688
689    #[test]
690    fn flat_bare_allowed() {
691        let spec = load_one(r#"
692            [[command]]
693            name = "wc"
694            bare = true
695        "#);
696        assert_eq!(dispatch_spec(&toks(&["wc"]), &spec), Verdict::Allowed(SafetyLevel::Inert));
697    }
698
699    #[test]
700    fn flat_bare_denied_when_false() {
701        let spec = load_one(r#"
702            [[command]]
703            name = "grep"
704            bare = false
705        "#);
706        assert_eq!(dispatch_spec(&toks(&["grep"]), &spec), Verdict::Denied);
707    }
708
709    #[test]
710    fn flat_standalone_flag() {
711        let spec = load_one(r#"
712            [[command]]
713            name = "wc"
714            bare = true
715            standalone = ["-l", "--lines"]
716        "#);
717        assert_eq!(
718            dispatch_spec(&toks(&["wc", "-l", "file.txt"]), &spec),
719            Verdict::Allowed(SafetyLevel::Inert),
720        );
721    }
722
723    #[test]
724    fn flat_unknown_flag_rejected() {
725        let spec = load_one(r#"
726            [[command]]
727            name = "wc"
728            standalone = ["-l"]
729        "#);
730        assert_eq!(dispatch_spec(&toks(&["wc", "--evil"]), &spec), Verdict::Denied);
731    }
732
733    #[test]
734    fn flat_valued_flag_space() {
735        let spec = load_one(r#"
736            [[command]]
737            name = "grep"
738            bare = false
739            valued = ["--max-count", "-m"]
740        "#);
741        assert_eq!(
742            dispatch_spec(&toks(&["grep", "--max-count", "5", "pattern"]), &spec),
743            Verdict::Allowed(SafetyLevel::Inert),
744        );
745    }
746
747    #[test]
748    fn flat_valued_flag_eq() {
749        let spec = load_one(r#"
750            [[command]]
751            name = "grep"
752            bare = false
753            valued = ["--max-count"]
754        "#);
755        assert_eq!(
756            dispatch_spec(&toks(&["grep", "--max-count=5", "pattern"]), &spec),
757            Verdict::Allowed(SafetyLevel::Inert),
758        );
759    }
760
761    #[test]
762    fn flat_combined_short_flags() {
763        let spec = load_one(r#"
764            [[command]]
765            name = "grep"
766            bare = false
767            standalone = ["-r", "-n", "-i"]
768        "#);
769        assert_eq!(
770            dispatch_spec(&toks(&["grep", "-rni", "pattern", "."]), &spec),
771            Verdict::Allowed(SafetyLevel::Inert),
772        );
773    }
774
775    #[test]
776    fn flat_combined_short_unknown_rejected() {
777        let spec = load_one(r#"
778            [[command]]
779            name = "grep"
780            bare = false
781            standalone = ["-r", "-n"]
782        "#);
783        assert_eq!(
784            dispatch_spec(&toks(&["grep", "-rnz", "pattern"]), &spec),
785            Verdict::Denied,
786        );
787    }
788
789    #[test]
790    fn flat_combined_short_with_valued_last() {
791        let spec = load_one(r#"
792            [[command]]
793            name = "grep"
794            bare = false
795            standalone = ["-r", "-n"]
796            valued = ["-m"]
797        "#);
798        assert_eq!(
799            dispatch_spec(&toks(&["grep", "-rnm", "5", "pattern"]), &spec),
800            Verdict::Allowed(SafetyLevel::Inert),
801        );
802    }
803
804    #[test]
805    fn flat_double_dash_stops_flag_checking() {
806        let spec = load_one(r#"
807            [[command]]
808            name = "grep"
809            bare = false
810            standalone = ["-r"]
811        "#);
812        assert_eq!(
813            dispatch_spec(&toks(&["grep", "-r", "--", "--not-a-flag", "file"]), &spec),
814            Verdict::Allowed(SafetyLevel::Inert),
815        );
816    }
817
818    #[test]
819    fn flat_max_positional_enforced() {
820        let spec = load_one(r#"
821            [[command]]
822            name = "uniq"
823            bare = true
824            max_positional = 1
825        "#);
826        assert_eq!(
827            dispatch_spec(&toks(&["uniq", "a"]), &spec),
828            Verdict::Allowed(SafetyLevel::Inert),
829        );
830        assert_eq!(
831            dispatch_spec(&toks(&["uniq", "a", "b"]), &spec),
832            Verdict::Denied,
833        );
834    }
835
836    #[test]
837    fn flat_max_positional_after_double_dash() {
838        let spec = load_one(r#"
839            [[command]]
840            name = "uniq"
841            bare = true
842            max_positional = 1
843        "#);
844        assert_eq!(
845            dispatch_spec(&toks(&["uniq", "--", "a", "b"]), &spec),
846            Verdict::Denied,
847        );
848    }
849
850    #[test]
851    fn flat_positional_style() {
852        let spec = load_one(r#"
853            [[command]]
854            name = "echo"
855            bare = true
856            positional_style = true
857            standalone = ["-n", "-e"]
858        "#);
859        assert_eq!(
860            dispatch_spec(&toks(&["echo", "--unknown", "hello"]), &spec),
861            Verdict::Allowed(SafetyLevel::Inert),
862        );
863    }
864
865    #[test]
866    fn flat_level_safe_read() {
867        let spec = load_one(r#"
868            [[command]]
869            name = "cargo"
870            level = "SafeRead"
871            bare = true
872        "#);
873        assert_eq!(
874            dispatch_spec(&toks(&["cargo"]), &spec),
875            Verdict::Allowed(SafetyLevel::SafeRead),
876        );
877    }
878
879    #[test]
880    fn flat_level_safe_write() {
881        let spec = load_one(r#"
882            [[command]]
883            name = "cargo"
884            level = "SafeWrite"
885            bare = true
886        "#);
887        assert_eq!(
888            dispatch_spec(&toks(&["cargo"]), &spec),
889            Verdict::Allowed(SafetyLevel::SafeWrite),
890        );
891    }
892
893    // ---------------------------------------------------------------
894    // Structured commands with subcommands
895    // ---------------------------------------------------------------
896
897    #[test]
898    fn structured_bare_rejected() {
899        let spec = load_one(r#"
900            [[command]]
901            name = "cargo"
902            bare_flags = ["--help"]
903
904            [[command.sub]]
905            name = "build"
906            level = "SafeWrite"
907        "#);
908        assert_eq!(dispatch_spec(&toks(&["cargo"]), &spec), Verdict::Denied);
909    }
910
911    #[test]
912    fn structured_bare_flag() {
913        let spec = load_one(r#"
914            [[command]]
915            name = "cargo"
916            bare_flags = ["--help", "-h"]
917
918            [[command.sub]]
919            name = "build"
920        "#);
921        assert_eq!(
922            dispatch_spec(&toks(&["cargo", "--help"]), &spec),
923            Verdict::Allowed(SafetyLevel::Inert),
924        );
925    }
926
927    #[test]
928    fn structured_bare_flag_with_extra_rejected() {
929        let spec = load_one(r#"
930            [[command]]
931            name = "cargo"
932            bare_flags = ["--help"]
933
934            [[command.sub]]
935            name = "build"
936        "#);
937        assert_eq!(
938            dispatch_spec(&toks(&["cargo", "--help", "extra"]), &spec),
939            Verdict::Denied,
940        );
941    }
942
943    #[test]
944    fn structured_unknown_sub_rejected() {
945        let spec = load_one(r#"
946            [[command]]
947            name = "cargo"
948
949            [[command.sub]]
950            name = "build"
951        "#);
952        assert_eq!(
953            dispatch_spec(&toks(&["cargo", "deploy"]), &spec),
954            Verdict::Denied,
955        );
956    }
957
958    #[test]
959    fn structured_sub_policy() {
960        let spec = load_one(r#"
961            [[command]]
962            name = "cargo"
963
964            [[command.sub]]
965            name = "test"
966            level = "SafeRead"
967            standalone = ["--release", "-h"]
968            valued = ["--jobs", "-j"]
969        "#);
970        assert_eq!(
971            dispatch_spec(&toks(&["cargo", "test", "--release", "-j", "4"]), &spec),
972            Verdict::Allowed(SafetyLevel::SafeRead),
973        );
974    }
975
976    #[test]
977    fn structured_sub_unknown_flag_rejected() {
978        let spec = load_one(r#"
979            [[command]]
980            name = "cargo"
981
982            [[command.sub]]
983            name = "test"
984            standalone = ["--release"]
985        "#);
986        assert_eq!(
987            dispatch_spec(&toks(&["cargo", "test", "--evil"]), &spec),
988            Verdict::Denied,
989        );
990    }
991
992    // ---------------------------------------------------------------
993    // Guarded subcommands
994    // ---------------------------------------------------------------
995
996    #[test]
997    fn guarded_with_guard() {
998        let spec = load_one(r#"
999            [[command]]
1000            name = "cargo"
1001
1002            [[command.sub]]
1003            name = "fmt"
1004            guard = "--check"
1005            standalone = ["--all", "--check", "-h"]
1006        "#);
1007        assert_eq!(
1008            dispatch_spec(&toks(&["cargo", "fmt", "--check"]), &spec),
1009            Verdict::Allowed(SafetyLevel::Inert),
1010        );
1011    }
1012
1013    #[test]
1014    fn guarded_without_guard_rejected() {
1015        let spec = load_one(r#"
1016            [[command]]
1017            name = "cargo"
1018
1019            [[command.sub]]
1020            name = "fmt"
1021            guard = "--check"
1022            standalone = ["--all", "--check"]
1023        "#);
1024        assert_eq!(
1025            dispatch_spec(&toks(&["cargo", "fmt"]), &spec),
1026            Verdict::Denied,
1027        );
1028    }
1029
1030    #[test]
1031    fn guarded_with_short_form() {
1032        let spec = load_one(r#"
1033            [[command]]
1034            name = "cargo"
1035
1036            [[command.sub]]
1037            name = "package"
1038            guard = "--list"
1039            guard_short = "-l"
1040            standalone = ["--list", "-l"]
1041        "#);
1042        assert_eq!(
1043            dispatch_spec(&toks(&["cargo", "package", "-l"]), &spec),
1044            Verdict::Allowed(SafetyLevel::Inert),
1045        );
1046    }
1047
1048    #[test]
1049    fn guarded_with_eq_syntax() {
1050        let spec = load_one(r#"
1051            [[command]]
1052            name = "tool"
1053
1054            [[command.sub]]
1055            name = "sub"
1056            guard = "--mode"
1057            valued = ["--mode"]
1058        "#);
1059        assert_eq!(
1060            dispatch_spec(&toks(&["tool", "sub", "--mode=check"]), &spec),
1061            Verdict::Allowed(SafetyLevel::Inert),
1062        );
1063    }
1064
1065    // ---------------------------------------------------------------
1066    // Nested subcommands
1067    // ---------------------------------------------------------------
1068
1069    #[test]
1070    fn nested_sub() {
1071        let spec = load_one(r#"
1072            [[command]]
1073            name = "mise"
1074
1075            [[command.sub]]
1076            name = "config"
1077
1078            [[command.sub.sub]]
1079            name = "get"
1080            standalone = ["--help", "-h"]
1081
1082            [[command.sub.sub]]
1083            name = "list"
1084            standalone = ["--help", "-h"]
1085        "#);
1086        assert_eq!(
1087            dispatch_spec(&toks(&["mise", "config", "get"]), &spec),
1088            Verdict::Allowed(SafetyLevel::Inert),
1089        );
1090        assert_eq!(
1091            dispatch_spec(&toks(&["mise", "config", "delete"]), &spec),
1092            Verdict::Denied,
1093        );
1094    }
1095
1096    #[test]
1097    fn nested_bare_rejected() {
1098        let spec = load_one(r#"
1099            [[command]]
1100            name = "mise"
1101
1102            [[command.sub]]
1103            name = "config"
1104
1105            [[command.sub.sub]]
1106            name = "get"
1107        "#);
1108        assert_eq!(
1109            dispatch_spec(&toks(&["mise", "config"]), &spec),
1110            Verdict::Denied,
1111        );
1112    }
1113
1114    // ---------------------------------------------------------------
1115    // AllowAll
1116    // ---------------------------------------------------------------
1117
1118    #[test]
1119    fn allow_all_accepts_anything() {
1120        let spec = load_one(r#"
1121            [[command]]
1122            name = "git"
1123
1124            [[command.sub]]
1125            name = "help"
1126            allow_all = true
1127        "#);
1128        assert_eq!(
1129            dispatch_spec(&toks(&["git", "help"]), &spec),
1130            Verdict::Allowed(SafetyLevel::Inert),
1131        );
1132        assert_eq!(
1133            dispatch_spec(&toks(&["git", "help", "commit", "--verbose"]), &spec),
1134            Verdict::Allowed(SafetyLevel::Inert),
1135        );
1136    }
1137
1138    // ---------------------------------------------------------------
1139    // WriteFlagged
1140    // ---------------------------------------------------------------
1141
1142    #[test]
1143    fn write_flagged_base_level() {
1144        let spec = load_one(r#"
1145            [[command]]
1146            name = "sk"
1147
1148            [[command.sub]]
1149            name = "run"
1150            write_flags = ["--history"]
1151            standalone = ["--help", "-h"]
1152            valued = ["--history", "--query", "-q"]
1153        "#);
1154        assert_eq!(
1155            dispatch_spec(&toks(&["sk", "run", "-q", "test"]), &spec),
1156            Verdict::Allowed(SafetyLevel::Inert),
1157        );
1158    }
1159
1160    #[test]
1161    fn write_flagged_with_write_flag() {
1162        let spec = load_one(r#"
1163            [[command]]
1164            name = "sk"
1165
1166            [[command.sub]]
1167            name = "run"
1168            write_flags = ["--history"]
1169            standalone = ["--help"]
1170            valued = ["--history", "--query"]
1171        "#);
1172        assert_eq!(
1173            dispatch_spec(&toks(&["sk", "run", "--history", "/tmp/h"]), &spec),
1174            Verdict::Allowed(SafetyLevel::SafeWrite),
1175        );
1176    }
1177
1178    #[test]
1179    fn write_flagged_with_eq_syntax() {
1180        let spec = load_one(r#"
1181            [[command]]
1182            name = "sk"
1183
1184            [[command.sub]]
1185            name = "run"
1186            write_flags = ["--history"]
1187            valued = ["--history"]
1188        "#);
1189        assert_eq!(
1190            dispatch_spec(&toks(&["sk", "run", "--history=/tmp/h"]), &spec),
1191            Verdict::Allowed(SafetyLevel::SafeWrite),
1192        );
1193    }
1194
1195    // ---------------------------------------------------------------
1196    // DelegateAfterSeparator
1197    // ---------------------------------------------------------------
1198
1199    #[test]
1200    fn delegate_after_separator_safe() {
1201        let spec = load_one(r#"
1202            [[command]]
1203            name = "mise"
1204
1205            [[command.sub]]
1206            name = "exec"
1207            delegate_after = "--"
1208        "#);
1209        assert_eq!(
1210            dispatch_spec(&toks(&["mise", "exec", "--", "echo", "hello"]), &spec),
1211            Verdict::Allowed(SafetyLevel::Inert),
1212        );
1213    }
1214
1215    #[test]
1216    fn delegate_after_separator_unsafe() {
1217        let spec = load_one(r#"
1218            [[command]]
1219            name = "mise"
1220
1221            [[command.sub]]
1222            name = "exec"
1223            delegate_after = "--"
1224        "#);
1225        assert_eq!(
1226            dispatch_spec(&toks(&["mise", "exec", "--", "rm", "-rf", "/"]), &spec),
1227            Verdict::Denied,
1228        );
1229    }
1230
1231    #[test]
1232    fn delegate_after_separator_no_separator() {
1233        let spec = load_one(r#"
1234            [[command]]
1235            name = "mise"
1236
1237            [[command.sub]]
1238            name = "exec"
1239            delegate_after = "--"
1240        "#);
1241        assert_eq!(
1242            dispatch_spec(&toks(&["mise", "exec", "echo"]), &spec),
1243            Verdict::Denied,
1244        );
1245    }
1246
1247    // ---------------------------------------------------------------
1248    // DelegateSkip
1249    // ---------------------------------------------------------------
1250
1251    #[test]
1252    fn delegate_skip_safe() {
1253        let spec = load_one(r#"
1254            [[command]]
1255            name = "rustup"
1256
1257            [[command.sub]]
1258            name = "run"
1259            delegate_skip = 2
1260        "#);
1261        assert_eq!(
1262            dispatch_spec(&toks(&["rustup", "run", "stable", "echo", "hello"]), &spec),
1263            Verdict::Allowed(SafetyLevel::Inert),
1264        );
1265    }
1266
1267    #[test]
1268    fn delegate_skip_unsafe() {
1269        let spec = load_one(r#"
1270            [[command]]
1271            name = "rustup"
1272
1273            [[command.sub]]
1274            name = "run"
1275            delegate_skip = 2
1276        "#);
1277        assert_eq!(
1278            dispatch_spec(&toks(&["rustup", "run", "stable", "rm", "-rf"]), &spec),
1279            Verdict::Denied,
1280        );
1281    }
1282
1283    #[test]
1284    fn delegate_skip_no_inner() {
1285        let spec = load_one(r#"
1286            [[command]]
1287            name = "rustup"
1288
1289            [[command.sub]]
1290            name = "run"
1291            delegate_skip = 2
1292        "#);
1293        assert_eq!(
1294            dispatch_spec(&toks(&["rustup", "run", "stable"]), &spec),
1295            Verdict::Denied,
1296        );
1297    }
1298
1299    // ---------------------------------------------------------------
1300    // Aliases
1301    // ---------------------------------------------------------------
1302
1303    #[test]
1304    fn alias_dispatch() {
1305        let specs = load_toml(r#"
1306            [[command]]
1307            name = "grep"
1308            aliases = ["egrep"]
1309            bare = false
1310            standalone = ["-r"]
1311        "#);
1312        let registry = build_registry(specs);
1313        let spec = registry.get("egrep").expect("alias registered");
1314        assert_eq!(
1315            dispatch_spec(&toks(&["egrep", "-r", "pattern"]), spec),
1316            Verdict::Allowed(SafetyLevel::Inert),
1317        );
1318    }
1319
1320    // ---------------------------------------------------------------
1321    // Custom handler reference
1322    // ---------------------------------------------------------------
1323
1324    #[test]
1325    fn custom_handler_returns_denied_by_default() {
1326        let spec = load_one(r#"
1327            [[command]]
1328            name = "curl"
1329            handler = "curl"
1330        "#);
1331        assert_eq!(
1332            dispatch_spec(&toks(&["curl", "http://example.com"]), &spec),
1333            Verdict::Denied,
1334        );
1335    }
1336
1337    // ---------------------------------------------------------------
1338    // Multiple commands in one file
1339    // ---------------------------------------------------------------
1340
1341    #[test]
1342    fn multiple_commands() {
1343        let specs = load_toml(r#"
1344            [[command]]
1345            name = "cat"
1346            bare = true
1347            standalone = ["-n"]
1348
1349            [[command]]
1350            name = "head"
1351            bare = false
1352            valued = ["-n"]
1353        "#);
1354        assert_eq!(specs.len(), 2);
1355        assert_eq!(specs[0].name, "cat");
1356        assert_eq!(specs[1].name, "head");
1357    }
1358
1359    // ---------------------------------------------------------------
1360    // Edge cases
1361    // ---------------------------------------------------------------
1362
1363    #[test]
1364    fn valued_flag_at_end_without_value() {
1365        let spec = load_one(r#"
1366            [[command]]
1367            name = "grep"
1368            bare = false
1369            valued = ["--max-count"]
1370        "#);
1371        assert_eq!(
1372            dispatch_spec(&toks(&["grep", "--max-count"]), &spec),
1373            Verdict::Allowed(SafetyLevel::Inert),
1374        );
1375    }
1376
1377    #[test]
1378    fn bare_dash_as_stdin() {
1379        let spec = load_one(r#"
1380            [[command]]
1381            name = "grep"
1382            bare = false
1383            standalone = ["-r"]
1384        "#);
1385        assert_eq!(
1386            dispatch_spec(&toks(&["grep", "pattern", "-"]), &spec),
1387            Verdict::Allowed(SafetyLevel::Inert),
1388        );
1389    }
1390
1391    #[test]
1392    fn positional_style_unknown_eq() {
1393        let spec = load_one(r#"
1394            [[command]]
1395            name = "echo"
1396            bare = true
1397            positional_style = true
1398        "#);
1399        assert_eq!(
1400            dispatch_spec(&toks(&["echo", "--foo=bar"]), &spec),
1401            Verdict::Allowed(SafetyLevel::Inert),
1402        );
1403    }
1404
1405    #[test]
1406    fn positional_style_with_max() {
1407        let spec = load_one(r#"
1408            [[command]]
1409            name = "echo"
1410            bare = true
1411            positional_style = true
1412            max_positional = 2
1413        "#);
1414        assert_eq!(
1415            dispatch_spec(&toks(&["echo", "--a", "--b"]), &spec),
1416            Verdict::Allowed(SafetyLevel::Inert),
1417        );
1418        assert_eq!(
1419            dispatch_spec(&toks(&["echo", "--a", "--b", "--c"]), &spec),
1420            Verdict::Denied,
1421        );
1422    }
1423
1424    // ---------------------------------------------------------------
1425    // Integration: TOML registry rejects unknown flags
1426    // ---------------------------------------------------------------
1427
1428    #[test]
1429    fn toml_registry_rejects_unknown_flags() {
1430        let mut failures = Vec::new();
1431        for (name, _spec) in TOML_REGISTRY.iter() {
1432            let test = format!("{name} --xyzzy-unknown-42");
1433            if crate::is_safe_command(&test) {
1434                failures.push(format!("{name}: accepted unknown flag"));
1435            }
1436        }
1437        assert!(failures.is_empty(), "TOML commands accepted unknown flags:\n{}", failures.join("\n"));
1438    }
1439
1440    #[test]
1441    fn toml_hash_commands_work() {
1442        assert!(crate::is_safe_command("md5sum file.txt"));
1443        assert!(crate::is_safe_command("sha256sum file.txt"));
1444        assert!(crate::is_safe_command("b2sum file.txt"));
1445        assert!(crate::is_safe_command("shasum -a 256 file.txt"));
1446        assert!(crate::is_safe_command("cksum file.txt"));
1447        assert!(crate::is_safe_command("md5 file.txt"));
1448        assert!(crate::is_safe_command("sum file.txt"));
1449        assert!(crate::is_safe_command("md5sum --check checksums.md5"));
1450    }
1451
1452    #[test]
1453    fn toml_hash_commands_reject_unknown() {
1454        assert!(!crate::is_safe_command("md5sum --evil"));
1455        assert!(!crate::is_safe_command("sha256sum --evil"));
1456        assert!(!crate::is_safe_command("b2sum --evil"));
1457    }
1458}