use clap::ArgMatches;
pub const MUTATING_VERBS: &[&str] = &[
"add", "apply", "delete", "migrate", "power", "run", "restore",
];
#[cfg(test)]
const READ_ONLY_VERBS: &[&str] = &[
"get",
"console",
"log",
"backup",
"config",
"upgrade",
"gen-autocomplete",
"gen-man",
];
pub fn dry_run_set(verb_matches: &ArgMatches) -> bool {
let mut current = verb_matches;
while let Some((_, child)) = current.subcommand() {
current = child;
}
current
.try_get_one::<bool>("dry-run")
.ok()
.flatten()
.copied()
.unwrap_or(false)
}
pub fn ensure_can_mutate(read_only: bool, verb: &str) -> anyhow::Result<()> {
if !read_only {
return Ok(());
}
Err(anyhow::anyhow!(
"manta is in read-only mode (`read_only = true` in cli.toml).\n\
This `manta {verb} \u{2026}` invocation would change backend state and has been refused.\n\
Re-run with `--dry-run` to preview, or disable the policy with `manta config unset read-only`."
))
}
pub fn read_only_gate(
cli_root: &ArgMatches,
read_only: bool,
) -> anyhow::Result<()> {
let Some((verb, verb_matches)) = cli_root.subcommand() else {
return Ok(());
};
if !read_only {
return Ok(());
}
if !MUTATING_VERBS.contains(&verb) {
return Ok(());
}
if dry_run_set(verb_matches) {
return Ok(());
}
ensure_can_mutate(read_only, verb)
}
#[cfg(test)]
mod tests {
use super::*;
fn matches(args: &[&str]) -> clap::ArgMatches {
crate::build::build_cli().get_matches_from(args)
}
#[test]
fn mutating_verbs_match_spec() {
let expected = [
"add", "apply", "delete", "migrate", "power", "run", "restore",
];
for v in expected {
assert!(MUTATING_VERBS.contains(&v), "missing mutating verb: {v}");
}
assert_eq!(MUTATING_VERBS.len(), expected.len());
}
#[test]
fn verb_lists_have_no_overlap() {
for verb in MUTATING_VERBS {
assert!(
!READ_ONLY_VERBS.contains(verb),
"verb {verb} is in both MUTATING_VERBS and READ_ONLY_VERBS"
);
}
}
#[test]
fn every_top_level_verb_is_classified() {
use std::collections::BTreeSet;
let cli = crate::build::build_cli();
let actual: BTreeSet<String> = cli
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
let classified: BTreeSet<String> = MUTATING_VERBS
.iter()
.chain(READ_ONLY_VERBS.iter())
.map(|s| s.to_string())
.collect();
let unclassified: Vec<_> = actual.difference(&classified).collect();
let phantom: Vec<_> = classified.difference(&actual).collect();
assert!(
unclassified.is_empty(),
"build_cli() declares verbs that are NOT in MUTATING_VERBS or READ_ONLY_VERBS: {unclassified:?}"
);
assert!(
phantom.is_empty(),
"MUTATING_VERBS / READ_ONLY_VERBS reference verbs build_cli() does not declare: {phantom:?}"
);
}
#[test]
fn dry_run_set_apply_boot_nodes_with_flag() {
let m = matches(&[
"manta",
"apply",
"boot",
"nodes",
"--boot-image",
"abc",
"--dry-run",
"x1000c0s0b0n0",
]);
let (_, verb_matches) = m.subcommand().unwrap();
assert!(dry_run_set(verb_matches));
}
#[test]
fn dry_run_set_apply_boot_nodes_without_flag() {
let m = matches(&[
"manta",
"apply",
"boot",
"nodes",
"--boot-image",
"abc",
"x1000c0s0b0n0",
]);
let (_, verb_matches) = m.subcommand().unwrap();
assert!(!dry_run_set(verb_matches));
}
#[test]
fn dry_run_set_delete_session_with_flag() {
let m = matches(&["manta", "delete", "session", "my-session", "--dry-run"]);
let (_, verb_matches) = m.subcommand().unwrap();
assert!(dry_run_set(verb_matches));
}
#[test]
fn dry_run_set_get_sessions_returns_false() {
let m = matches(&["manta", "get", "sessions"]);
let (_, verb_matches) = m.subcommand().unwrap();
assert!(!dry_run_set(verb_matches));
}
#[test]
fn ensure_can_mutate_allows_when_not_read_only() {
assert!(ensure_can_mutate(false, "apply").is_ok());
}
#[test]
fn ensure_can_mutate_blocks_when_read_only() {
let err = ensure_can_mutate(true, "apply").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("apply"), "msg should name the verb: {msg}");
assert!(
msg.contains("--dry-run"),
"msg should mention --dry-run: {msg}"
);
assert!(
msg.contains("manta config unset read-only"),
"msg should point at the unset command: {msg}"
);
}
#[test]
fn gate_allows_when_read_only_off() {
let m = matches(&[
"manta",
"apply",
"boot",
"nodes",
"--boot-image",
"abc",
"x1000c0s0b0n0",
]);
assert!(read_only_gate(&m, false).is_ok());
}
#[test]
fn gate_blocks_mutating_verb_when_read_only_on() {
let m = matches(&[
"manta",
"apply",
"boot",
"nodes",
"--boot-image",
"abc",
"x1000c0s0b0n0",
]);
let err = read_only_gate(&m, true).unwrap_err();
assert!(format!("{err}").contains("apply"));
}
#[test]
fn gate_allows_mutating_verb_with_dry_run_when_read_only_on() {
let m = matches(&[
"manta",
"apply",
"boot",
"nodes",
"--boot-image",
"abc",
"--dry-run",
"x1000c0s0b0n0",
]);
assert!(read_only_gate(&m, true).is_ok());
}
#[test]
fn gate_allows_read_only_verb_when_read_only_on() {
let m = matches(&["manta", "get", "sessions"]);
assert!(read_only_gate(&m, true).is_ok());
}
#[test]
fn ensure_can_mutate_suggests_dry_run_for_every_mutating_verb() {
for verb in MUTATING_VERBS {
let err = ensure_can_mutate(true, verb).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains(verb), "msg should name {verb}: {msg}");
assert!(
msg.contains("--dry-run"),
"msg should suggest --dry-run for {verb}: {msg}"
);
assert!(
msg.contains("manta config unset read-only"),
"msg should suggest the unset command for {verb}: {msg}"
);
}
}
}