Skip to main content

safe_chains/registry/
mod.rs

1mod build;
2mod custom;
3mod dispatch;
4mod docs;
5mod policy;
6pub(crate) mod types;
7
8use std::collections::HashMap;
9use std::sync::LazyLock;
10
11use crate::parse::Token;
12use crate::verdict::Verdict;
13
14pub use build::{build_registry, load_toml};
15pub(crate) use custom::user_config_level;
16pub use dispatch::dispatch_spec;
17pub use types::{CommandSpec, OwnedPolicy};
18
19use types::DispatchKind;
20
21type HandlerFn = fn(&[Token]) -> Verdict;
22
23static CMD_HANDLERS: LazyLock<HashMap<&'static str, HandlerFn>> =
24    LazyLock::new(crate::handlers::custom_cmd_handlers);
25
26static SUB_HANDLERS: LazyLock<HashMap<&'static str, HandlerFn>> =
27    LazyLock::new(crate::handlers::custom_sub_handlers);
28
29static TOML_REGISTRY: LazyLock<HashMap<String, CommandSpec>> = LazyLock::new(||
30    include!(concat!(env!("OUT_DIR"), "/toml_includes.rs"))
31);
32
33static CUSTOM_REGISTRY: LazyLock<HashMap<String, CommandSpec>> = LazyLock::new(|| {
34    let mut map = HashMap::new();
35    custom::apply_custom(&mut map);
36    map
37});
38
39pub fn toml_dispatch(tokens: &[Token]) -> Option<Verdict> {
40    let cmd = tokens[0].command_name();
41    TOML_REGISTRY.get(cmd).map(|spec| dispatch_spec(tokens, spec))
42}
43
44/// Looks up the command in the runtime custom registry (project-local
45/// `.safe-chains.toml`, then user-level `~/.config/safe-chains.toml`).
46/// A match here wins over the built-in hardcoded handlers, which is how
47/// an override of `gh` takes effect.
48pub fn custom_dispatch(tokens: &[Token]) -> Option<Verdict> {
49    let cmd = tokens[0].command_name();
50    CUSTOM_REGISTRY.get(cmd).map(|spec| dispatch_spec(tokens, spec))
51}
52
53/// The canonical command name `cmd` resolves to via the registry's alias map (`gcat` → `cat`,
54/// `glink` → `ln`). Returns `cmd` unchanged when it is already canonical or unknown, so callers
55/// can canonicalize unconditionally. This is what lets the engine's path-gate dispatch reach an
56/// aliased invocation: without it, `gcat /etc/shadow` misses every resolver and falls through to
57/// the (ungated) legacy classifier. Custom registry first (an override may rename), then TOML.
58pub fn canonical_name(cmd: &str) -> &str {
59    CUSTOM_REGISTRY
60        .get(cmd)
61        .or_else(|| TOML_REGISTRY.get(cmd))
62        .map_or(cmd, |spec| spec.name.as_str())
63}
64
65/// The command's own declared path-argument gate (`[command.path_gate]`), if any — consulted by
66/// `pathgate::should_deny` when a command isn't in `pathgates.toml`, so a path-bearing flag gates
67/// from the command's own definition. `cmd` is already canonicalized by the caller.
68pub(crate) fn command_path_gate(cmd: &str) -> Option<&'static crate::pathgate::RoleSpec> {
69    CUSTOM_REGISTRY
70        .get(cmd)
71        .or_else(|| TOML_REGISTRY.get(cmd))
72        .and_then(|spec| spec.path_gate.as_ref())
73}
74
75/// The command's declarative facet behavior (`[command.behavior]`), if any — the engine's
76/// non-legacy classification path and the ONLY thing `engine::resolve::resolve` consults (the
77/// hardcoded `RESOLVERS` table is gone; every facet-classified command declares behavior).
78/// `cmd` is already canonicalized by the caller.
79pub(crate) fn command_behavior(cmd: &str) -> Option<&'static crate::registry::types::BehaviorSpec> {
80    CUSTOM_REGISTRY
81        .get(cmd)
82        .or_else(|| TOML_REGISTRY.get(cmd))
83        .and_then(|spec| spec.behavior.as_ref())
84}
85
86/// The facet archetypes (`archetypes.toml`) the subcommand `tokens` resolve to — the Phase-1
87/// `profile = …` classification plus a capability for every present escalating `[[command.sub.flag]]`
88/// (`git push` → `[vcs-sync]`; `git push --force` → `[vcs-sync, remote-destroy-irreversible]`). The
89/// engine emits a Capability per name and the level algebra takes the max. Descends `Branching`/
90/// `Custom` subs to the DEEPEST profile-bearing sub (nested `<resource> <action>`). `None` when no
91/// matched sub declares a profile.
92pub(crate) fn sub_archetypes(tokens: &[Token]) -> Option<Vec<&'static str>> {
93    let cmd = canonical_name(tokens.first()?.command_name());
94    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
95    let sub = walk_to_profiled_sub(&tokens[1..], &spec.kind)?;
96    let mut out = vec![sub.profile.as_deref()?];
97    for flag in &sub.flags {
98        if flag_escalates(tokens, flag) {
99            out.push(flag.classifies.as_str());
100        }
101    }
102    Some(out)
103}
104
105/// The facet archetypes a flat command's PRESENT top-level classifying flags (`[[command.flag]]`)
106/// resolve to — the command-level analog of `sub_archetypes` for a flag-triggered mode (`age -d` →
107/// `[decrypt-read]`, `sops --decrypt` → `[decrypt-read]`). `None` when the command declares none or
108/// none are present, so `engine::resolve` falls through to the command's ordinary resolution (the
109/// bare/encrypt form of a bimodal tool). Uses the same `flag_escalates` predicate as the sub flags.
110pub(crate) fn command_flag_archetypes(tokens: &[Token]) -> Option<Vec<&'static str>> {
111    let cmd = canonical_name(tokens.first()?.command_name());
112    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
113    let present: Vec<&'static str> = spec
114        .archetype_flags
115        .iter()
116        .filter(|f| flag_escalates(tokens, f))
117        .map(|f| f.classifies.as_str())
118        .collect();
119    (!present.is_empty()).then_some(present)
120}
121
122/// Whether `flag` escalates given `tokens`. A bare flag (no `value_prefix`) escalates on presence; a
123/// value-matched flag escalates only when its VALUE starts with the prefix — the space form
124/// (`-c core.sshCommand=…`) or the glued form (`--flag=core.sshCommand=…`). Scans the whole line;
125/// an escalator counts wherever it sits.
126fn flag_escalates(tokens: &[Token], flag: &types::FlagProvenance) -> bool {
127    if flag.when_absent {
128        // A SAFETY flag whose ABSENCE is the escalation (`npm ci` without `--ignore-scripts`).
129        // It must be AFFIRMATIVELY set — `--ignore-scripts=false` / `--no-ignore-scripts` re-ENABLE
130        // scripts, so they escalate exactly like the flag being missing (a fail-open otherwise).
131        return !flag_is_affirmatively_set(tokens, &flag.name);
132    }
133    let Some(prefix) = flag.value_prefix.as_deref() else {
134        return flag_present(tokens, &flag.name);
135    };
136    // space form: `NAME VALUE`, VALUE starting with the prefix
137    tokens.windows(2).any(|w| w[0].as_str() == flag.name && w[1].as_str().starts_with(prefix))
138        // glued form: `NAME=VALUE`, VALUE starting with the prefix
139        || tokens.iter().any(|t| {
140            t.as_str()
141                .strip_prefix(flag.name.as_str())
142                .and_then(|r| r.strip_prefix('='))
143                .is_some_and(|v| v.starts_with(prefix))
144        })
145}
146
147/// Whether a boolean flag is AFFIRMATIVELY enabled: bare `--flag`, or `--flag=<truthy>`. A
148/// `--flag=false/0/no/off` or a `--no-flag` DISABLES it (returns false), as does absence. Last
149/// occurrence wins, the CLI convention. Used by the `when_absent` escalator so a re-enabling spelling
150/// can't masquerade as the safety flag being set.
151fn flag_is_affirmatively_set(tokens: &[Token], flag: &str) -> bool {
152    let neg = format!("--no-{}", flag.trim_start_matches('-'));
153    let mut set = false;
154    for t in tokens {
155        let s = t.as_str();
156        if s == flag {
157            set = true;
158        } else if let Some(v) = s.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
159            set = !matches!(v.to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off" | "");
160        } else if s == neg {
161            set = false;
162        }
163    }
164    set
165}
166
167fn walk_to_profiled_sub(
168    remaining: &[Token],
169    kind: &'static DispatchKind,
170) -> Option<&'static types::SubSpec> {
171    let subs = match kind {
172        DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => subs,
173        _ => return None,
174    };
175    let arg = remaining.first()?;
176    let sub = subs.iter().find(|s| s.name == arg.as_str())?;
177    // Deepest profiled match wins: a nested action's profile overrides its resource sub's.
178    walk_to_profiled_sub(&remaining[1..], &sub.kind).or_else(|| sub.profile.is_some().then_some(sub))
179}
180
181/// Like `walk_to_profiled_sub`, but also returns the tokens AFTER the matched sub's name — the
182/// operands the engine still needs to inspect (the destination positional, for `network_destination`).
183fn walk_to_profiled_sub_rest<'a>(
184    remaining: &'a [Token],
185    kind: &'static DispatchKind,
186) -> Option<(&'static types::SubSpec, &'a [Token])> {
187    let subs = match kind {
188        DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => subs,
189        _ => return None,
190    };
191    let arg = remaining.first()?;
192    let sub = subs.iter().find(|s| s.name == arg.as_str())?;
193    let rest = &remaining[1..];
194    walk_to_profiled_sub_rest(rest, &sub.kind).or_else(|| sub.profile.is_some().then_some((sub, rest)))
195}
196
197/// For a profiled sub declaring `network_destination`, the first positional after it — the send
198/// TARGET (`git push origin` → `origin`; bare `git push` → `None`, the configured default). `None`
199/// (outer) when the resolved sub does not classify a destination. The engine maps the token's
200/// PROVENANCE onto `locus.provenance` (`resolve::destination_provenance`).
201///
202/// "First non-`-` token" is a heuristic: a VALUED flag's value sitting before the target
203/// (`git push -o $VAR origin`) is read as the destination. That only ever misreads CONSERVATIVELY —
204/// a stray value classifies to `literal`/`opaque` (equal-or-stricter than the real `established`
205/// target), never looser — so it can over-deny a rare form but never under-approve.
206pub(crate) fn sub_destination_token(tokens: &[Token]) -> Option<Option<&str>> {
207    let cmd = canonical_name(tokens.first()?.command_name());
208    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
209    let (sub, rest) = walk_to_profiled_sub_rest(&tokens[1..], &spec.kind)?;
210    if !sub.network_destination {
211        return None;
212    }
213    // A destination-carrying flag (`git push --repo=<dest>`) OVERRIDES the positional — else a
214    // `--repo=ext::sh` RCE would slip past a benign positional (`origin`). Scanned across the whole
215    // line since the flag may sit anywhere.
216    if let Some(flag) = sub.destination_flag.as_deref()
217        && let Some(v) = flag_value(tokens, flag)
218    {
219        return Some(Some(v));
220    }
221    Some(rest.iter().map(Token::as_str).find(|t| !t.starts_with('-')))
222}
223
224/// A flag's value, glued (`--repo=VALUE`) or space-separated (`--repo VALUE`); `None` if absent.
225fn flag_value<'a>(tokens: &'a [Token], flag: &str) -> Option<&'a str> {
226    if let Some(v) = tokens.iter().find_map(|t| t.as_str().strip_prefix(flag).and_then(|r| r.strip_prefix('='))) {
227        return Some(v);
228    }
229    tokens.windows(2).find(|w| w[0].as_str() == flag).map(|w| w[1].as_str())
230}
231
232/// For a profiled `data-export` sub declaring `output_path_flags`, the output-file PATH one of them
233/// carries — or `None` when the export streams to stdout (no output flag present). The engine adds a
234/// path-gated write capability at this path's locus, so a dump to `/etc/cron.d/job` gates on locus
235/// exactly as a redirect there would. `None` (outer) when the resolved sub declares none.
236///
237/// Values are matched in every spelling the flag admits: `--file=X`, `--file X`, `-f X`, `-f=X`, and
238/// the glued short form `-fX` — the last mustn't be a bypass (`-f/etc/cron.d/job` reaching a system
239/// path would otherwise drop the write cap and auto-approve). A bare `-f` with no value is a
240/// malformed invocation the tool itself rejects, so a `None` there is harmless.
241pub(crate) fn sub_output_path_token(tokens: &[Token]) -> Option<&str> {
242    let cmd = canonical_name(tokens.first()?.command_name());
243    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
244    let (sub, _rest) = walk_to_profiled_sub_rest(&tokens[1..], &spec.kind)?;
245    sub.output_path_flags.iter().find_map(|f| output_flag_value(tokens, f))
246}
247
248/// `flag_value`, plus the glued short form `-fVALUE` (a two-char `-x` flag with the value fused on).
249/// Kept separate from `flag_value` because a glued short is only unambiguous for the single-letter
250/// output flags this classifies (`-f`, `-r`); the destination-flag path (`--repo`) never needs it.
251fn output_flag_value<'a>(tokens: &'a [Token], flag: &'a str) -> Option<&'a str> {
252    if let Some(v) = flag_value(tokens, flag) {
253        return Some(v);
254    }
255    if flag.len() == 2 && flag.starts_with('-') && !flag.starts_with("--") {
256        return tokens
257            .iter()
258            .find_map(|t| t.as_str().strip_prefix(flag).filter(|r| !r.is_empty()));
259    }
260    None
261}
262
263/// Whether `flag` appears anywhere in `tokens` — bare (`--force`), glued (`--flag=v`), or, for a SHORT
264/// single-char flag (`-d`), hidden inside a short CLUSTER (`-da`, `-vd`) for tools that combine short
265/// options. A flag is an escalator wherever it sits, so this scans the whole line. The flag ALLOWLIST
266/// cluster-expands, so this must too — otherwise a cluster admits the command while the classifier
267/// misses the dangerous flag inside it (a classifier/allowlist disagreement; a live bypass for any
268/// clustering tool with a classifying short flag).
269fn flag_present(tokens: &[Token], flag: &str) -> bool {
270    // A `-X` short flag (exactly two chars, leading `-`) can appear in a cluster; `--long` cannot.
271    let short_char = (flag.len() == 2 && flag.as_bytes()[0] == b'-').then(|| flag.as_bytes()[1]);
272    tokens.iter().any(|t| {
273        let s = t.as_str();
274        if s == flag || s.strip_prefix(flag).is_some_and(|rest| rest.starts_with('=')) {
275            return true;
276        }
277        // Short cluster `-<letters>` (single dash, not `--`): scan only the boolean-letter run BEFORE
278        // any `=value`, so a glued value (`-o=decrypted`) can't spuriously match the flag char.
279        matches!(short_char, Some(c)
280            if s.len() > 1 && s.as_bytes()[0] == b'-' && s.as_bytes()[1] != b'-'
281            && s[1..].split('=').next().is_some_and(|run| run.as_bytes().contains(&c)))
282    })
283}
284
285pub fn toml_command_names() -> Vec<&'static str> {
286    TOML_REGISTRY
287        .keys()
288        .map(|k| k.as_str())
289        .collect()
290}
291
292/// Every command's canonical name with its declared `examples_safe` / `examples_denied`
293/// — the corpus the engine's never-looser corpus gate runs against.
294#[cfg(test)]
295pub(crate) fn corpus_examples()
296-> Vec<(&'static str, &'static [String], &'static [String])> {
297    TOML_REGISTRY
298        .iter()
299        .map(|(name, spec)| {
300            (name.as_str(), spec.examples_safe.as_slice(), spec.examples_denied.as_slice())
301        })
302        .collect()
303}
304
305/// Look up `cmd_name`'s TOML-declared subs (set via `[[command.sub]]`
306/// blocks alongside `handler = "..."`) and dispatch the one whose name
307/// matches `tokens[1]`. Returns `None` if no sub matched, so the
308/// handler can fall through to its fallback grammar (or deny).
309pub fn try_sub_dispatch(cmd_name: &str, tokens: &[Token]) -> Option<Verdict> {
310    let spec = handler_spec(cmd_name)?;
311    let DispatchKind::Custom { subs, .. } = &spec.kind else {
312        return None;
313    };
314    let arg = tokens.get(1)?.as_str();
315    let sub = subs.iter().find(|s| s.name == arg)?;
316    Some(dispatch::dispatch_sub_kind(&tokens[1..], &sub.kind))
317}
318
319/// Apply `cmd_name`'s TOML-declared `[command.fallback]` grammar.
320/// Returns `None` if no fallback is declared.
321pub fn try_fallback_grammar(cmd_name: &str, tokens: &[Token]) -> Option<Verdict> {
322    let spec = handler_spec(cmd_name)?;
323    let DispatchKind::Custom { fallback, .. } = &spec.kind else {
324        return None;
325    };
326    let f = fallback.as_ref()?;
327    Some(dispatch::dispatch_fallback(tokens, f))
328}
329
330/// Dispatch `tokens` against `cmd_name`'s `[[command.matrix]]`
331/// blocks. Looks at `tokens[1]` (parent) and `tokens[2]` (action),
332/// finds the first matrix whose `parents` contains the parent and
333/// whose `actions` map contains the action, then validates
334/// `tokens[2..]` against the named policy (and a guard flag if the
335/// matrix entry declared one). Returns `None` if no matrix matched —
336/// the handler can then fall through to its remaining special cases
337/// or deny.
338pub fn try_matrix_dispatch(cmd_name: &str, tokens: &[Token]) -> Option<Verdict> {
339    let spec = handler_spec(cmd_name)?;
340    let DispatchKind::Custom { matrices, handler_policies, .. } = &spec.kind else {
341        return None;
342    };
343    let parent = tokens.get(1)?.as_str();
344    let action = tokens.get(2)?.as_str();
345    for matrix in matrices {
346        if !matrix.parents.iter().any(|p| p == parent) {
347            continue;
348        }
349        let Some(action_spec) = matrix.actions.get(action) else { continue; };
350        if let Some(long) = action_spec.guard.as_deref()
351            && !crate::parse::has_flag(&tokens[2..], action_spec.guard_short.as_deref(), Some(long))
352        {
353            return Some(Verdict::Denied);
354        }
355        let Some(policy) = handler_policies.get(&action_spec.policy_key) else {
356            return Some(Verdict::Denied);
357        };
358        return Some(dispatch::dispatch_matrix_action(&tokens[2..], policy, matrix.level));
359    }
360    None
361}
362
363/// Validate `tokens` against `cmd_name`'s named flag policy declared
364/// in a `[command.handler_policy.KEY]` block. Returns `false` if no
365/// such policy is declared or the tokens fail it. Used by handlers
366/// whose dispatch logic genuinely can't move to TOML (e.g. gh's
367/// sub × action matrix) but whose per-policy WordSets should live
368/// in TOML rather than as Rust `WordSet` constants.
369pub fn check_handler_policy(cmd_name: &str, key: &str, tokens: &[Token]) -> bool {
370    let Some(spec) = handler_spec(cmd_name) else { return false; };
371    let DispatchKind::Custom { handler_policies, .. } = &spec.kind else {
372        return false;
373    };
374    let Some(policy) = handler_policies.get(key) else { return false; };
375    dispatch::check_handler_policy_owned(tokens, policy)
376}
377
378fn handler_spec(cmd_name: &str) -> Option<&'static CommandSpec> {
379    CUSTOM_REGISTRY
380        .get(cmd_name)
381        .or_else(|| TOML_REGISTRY.get(cmd_name))
382}
383
384/// Returns true iff this invocation is tagged eval-safe — meaning its
385/// stdout is documented shell-init code that can safely be substituted
386/// inside `eval "$(...)"`.
387///
388/// The walker descends through `DispatchKind::Branching` AND
389/// `DispatchKind::Custom` matching subs token-by-token (handler-based
390/// commands such as `gh` can have tagged TOML-declared subs even though
391/// the handler does the actual dispatch). The leaf is the deepest matched
392/// node (where no further sub matches). `eval_safe` is checked only at
393/// the leaf — ancestor tags do NOT propagate. After confirming the leaf
394/// is tagged, every `-`-prefixed token in the remaining tail must appear
395/// in `eval_safe_flags`; positionals are unrestricted.
396///
397/// Tagged nodes are vetted manually per-command (see SAMPLE.toml). This
398/// function does not validate that `tokens` is syntactically allowed —
399/// callers must have already passed it through the regular dispatcher.
400pub fn is_eval_safe_invocation(tokens: &[Token]) -> bool {
401    if tokens.is_empty() {
402        return false;
403    }
404    let cmd = tokens[0].command_name();
405    let Some(spec) = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)) else {
406        return false;
407    };
408    is_eval_safe_for_spec(spec, tokens)
409}
410
411/// Spec-local variant used by tests so they can build a `CommandSpec`
412/// via `load_toml` and exercise the walker without touching the global
413/// `TOML_REGISTRY`.
414pub(crate) fn is_eval_safe_for_spec(spec: &CommandSpec, tokens: &[Token]) -> bool {
415    if tokens.is_empty() {
416        return false;
417    }
418    walk_to_eval_safe_leaf(
419        &tokens[1..],
420        &spec.kind,
421        spec.eval_safe,
422        &spec.eval_safe_flags,
423        &spec.eval_safe_flag_values,
424        &spec.eval_safe_required_flags,
425    )
426}
427
428fn walk_to_eval_safe_leaf(
429    remaining: &[Token],
430    kind: &DispatchKind,
431    eval_safe: bool,
432    eval_safe_flags: &[String],
433    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
434    eval_safe_required_flags: &[String],
435) -> bool {
436    let subs_opt = match kind {
437        DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => Some(subs),
438        _ => None,
439    };
440    if let Some(subs) = subs_opt
441        && let Some(arg) = remaining.first()
442        && let Some(sub) = subs.iter().find(|s| s.name == arg.as_str())
443    {
444        return walk_to_eval_safe_leaf(
445            &remaining[1..],
446            &sub.kind,
447            sub.eval_safe,
448            &sub.eval_safe_flags,
449            &sub.eval_safe_flag_values,
450            &sub.eval_safe_required_flags,
451        );
452    }
453    if !eval_safe {
454        return false;
455    }
456    let mut i = 0;
457    let mut seen_required = false;
458    while i < remaining.len() {
459        let s = remaining[i].as_str();
460        if !s.starts_with('-') {
461            i += 1;
462            continue;
463        }
464        let (bare, eq_value) = match s.split_once('=') {
465            Some((k, v)) => (k, Some(v)),
466            None => (s, None),
467        };
468        if !eval_safe_flags.iter().any(|f| f == bare) {
469            return false;
470        }
471        if eval_safe_required_flags.iter().any(|f| f == bare) {
472            seen_required = true;
473        }
474        if let Some(allowed) = eval_safe_flag_values.get(bare) {
475            // Valued flag declared in eval_safe_flag_values. The value
476            // arrives either as `--flag=VALUE` (eq_value is Some) or as
477            // the next token (`--flag VALUE`); either way the walker
478            // consumes it because a flag in eval_safe_flag_values is
479            // structurally valued.
480            //
481            // `allowed` empty = explicit-unrestricted: contributor
482            // vetted that any bare-literal value preserves shell-init
483            // output. Non-empty = value must appear in the allowlist.
484            let value: &str = if let Some(v) = eq_value {
485                v
486            } else if let Some(next) = remaining.get(i + 1) {
487                let v = next.as_str();
488                i += 1;
489                v
490            } else {
491                return false;
492            };
493            // Empty value is denied even under the explicit-
494            // unrestricted (`= []`) posture: `--flag=` and an empty
495            // following token never represent a meaningful tool
496            // argument. The bare-literal alphabet check is per-char
497            // and vacuously passes empty strings, so the walker
498            // has to reject explicitly.
499            if value.is_empty() {
500                return false;
501            }
502            if !allowed.is_empty() && !allowed.iter().any(|av| av == value) {
503                return false;
504            }
505        }
506        i += 1;
507    }
508    if !eval_safe_required_flags.is_empty() && !seen_required {
509        return false;
510    }
511    true
512}
513
514pub fn toml_command_docs() -> Vec<crate::docs::CommandDoc> {
515    TOML_REGISTRY
516        .iter()
517        .filter(|(key, spec)| *key == &spec.name)
518        .map(|(_, spec)| spec.to_command_doc())
519        .collect()
520}
521
522#[cfg(test)]
523mod tests;