use clap::CommandFactory;
use serde_json::json;
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, ResponseTemplate};
const DENY: &[&str] = &[
"runner start",
"runner restart",
"auth login",
"runner configure",
"runner unconfigure",
"runner logs",
"runner service install",
"runner service uninstall",
"runner service start",
"runner service stop",
"runner service restart",
"runner service status",
"runner service list",
"pr checkout",
"pr diff",
"repo clone",
"requirement export",
"requirement seed",
"release download",
"update",
];
fn adversarial_body() -> serde_json::Value {
json!({
"id": "11111111-1111-1111-1111-111111111111",
"token": "anvil_secret_plaintext",
"name": "x",
"status": "running",
"data": [],
"items": [],
"requirements": [],
"standards": [],
"columns": [],
"deployments": [],
"environments": [],
"releases": [],
"runner": { "id": "x", "name": "x", "status": "online" },
"label": { "id": "x", "name": "x" },
"milestone": { "id": "x", "title": "x" },
"column": { "id": "x", "name": "x" },
"release": { "id": "x", "tag_name": "x" }
})
}
fn leaves_with_args() -> Vec<(String, Vec<String>)> {
let root = anvil::commands::Cli::command();
let mut out = Vec::new();
walk(&root, Vec::new(), &mut out);
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn walk(cmd: &clap::Command, path: Vec<String>, out: &mut Vec<(String, Vec<String>)>) {
let subs: Vec<&clap::Command> = cmd.get_subcommands().collect();
if subs.is_empty() {
if !path.is_empty() {
out.push((path.join(" "), synth_args(cmd)));
}
return;
}
for sub in subs {
let mut p = path.clone();
p.push(sub.get_name().to_string());
walk(sub, p, out);
}
}
fn synth_args(leaf: &clap::Command) -> Vec<String> {
let mut opts: Vec<String> = Vec::new();
let mut positionals: Vec<String> = Vec::new();
for arg in leaf.get_arguments() {
if !arg.is_required_set() {
continue;
}
let value = arg
.get_possible_values()
.first()
.map(|p| p.get_name().to_string())
.unwrap_or_else(|| "1".to_string());
if arg.is_positional() {
positionals.push(value);
} else if let Some(long) = arg.get_long() {
opts.push(format!("--{long}"));
if takes_value(arg) {
opts.push(value);
}
}
}
opts.extend(positionals);
opts
}
fn takes_value(arg: &clap::Arg) -> bool {
matches!(
arg.get_action(),
clap::ArgAction::Set | clap::ArgAction::Append
)
}
fn run_leaf(server_uri: &str, config_path: &str, path: &str, args: &[String]) -> LeafOutcome {
let mut full: Vec<String> = path.split(' ').map(String::from).collect();
full.push("--json".into());
full.extend(args.iter().cloned());
let mut child = Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(&full)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_CONFIG", config_path)
.env_remove("ANVIL_RUNNER_TOKEN")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn anvil");
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(_status) = child.try_wait().expect("try_wait") {
let out = child.wait_with_output().expect("wait_with_output");
return LeafOutcome::Exited {
stdout: out.stdout,
stderr: out.stderr,
};
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return LeafOutcome::TimedOut;
}
std::thread::sleep(Duration::from_millis(25));
}
}
enum LeafOutcome {
Exited { stdout: Vec<u8>, stderr: Vec<u8> },
TimedOut,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_leaf_emits_valid_json_under_json() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_body_json(adversarial_body()))
.mount(&server)
.await;
let uri = server.uri();
let dir = std::env::temp_dir().join(format!("anvil-fuzz-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.json");
let mut f = std::fs::File::create(&config_path).unwrap();
f.write_all(
serde_json::to_string_pretty(&json!({
"server_url": uri,
"token": "test-token",
"default_repo": "test-org/test-repo"
}))
.unwrap()
.as_bytes(),
)
.unwrap();
let config_path = config_path.to_str().unwrap().to_string();
let mut failures: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut checked = 0usize;
for (path, args) in leaves_with_args() {
if DENY.contains(&path.as_str()) {
skipped.push(path);
continue;
}
checked += 1;
match run_leaf(&uri, &config_path, &path, &args) {
LeafOutcome::TimedOut => {
failures.push(format!(
"`{path}` [{}]: TIMED OUT (hang — fix or denylist)",
args.join(" ")
));
}
LeafOutcome::Exited { stdout, stderr } => {
if serde_json::from_slice::<serde_json::Value>(&stdout).is_err() {
failures.push(format!(
"`{path}` [{}]: stdout is not valid JSON\n stdout: {}\n stderr: {}",
args.join(" "),
String::from_utf8_lossy(&stdout).trim(),
String::from_utf8_lossy(&stderr).trim(),
));
}
}
}
}
let _ = std::fs::remove_dir_all(&dir);
eprintln!(
"fuzzer: checked {checked} leaves, skipped {} (denylist): {skipped:?}",
skipped.len()
);
assert!(
failures.is_empty(),
"{} leaf command(s) broke the --json contract:\n\n{}",
failures.len(),
failures.join("\n\n"),
);
}