//! GRASP Audit CLI Tool use clap::{CommandFactory, Parser, Subcommand}; use grasp_audit::*; use std::path::PathBuf; use std::time::Duration; #[derive(Parser)] #[command(name = "grasp-audit")] #[command(about = "GRASP audit and compliance testing tool", long_about = None)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Run a probe/smoke test against a server Probe { /// Relay URL (e.g., wss://relay.ngit.dev) #[arg(short, long)] relay: Option, /// Output machine-readable JSON #[arg(long, default_value_t = false)] json: bool, /// Per-step timeout in seconds #[arg(long, default_value_t = 30)] timeout: u64, /// Re-run every N seconds (watch mode) #[arg(long)] watch: Option, /// Secret key in nsec bech32 format (for whitelisted relays) #[arg(long)] nsec: Option, /// Create a test repo on the relay to verify the full write path /// (publish events, git push, verify refs match state). /// Requires write access; use --nsec for whitelisted relays. #[arg(long, default_value_t = false)] create_repo: bool, }, /// Run audit tests against a server Audit { /// Relay URL (e.g., ws://localhost:7000) #[arg(short, long)] relay: String, /// Fixture mode: shared (default) or isolated /// /// - shared: Fixtures are cached and reused across tests (efficient for sequential test runs) /// - isolated: Each test creates fresh fixtures (for parallel tests like cargo test) #[arg(short, long, default_value = "shared")] mode: String, /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, git-filter, push-auth, repo-creation, purgatory, all) #[arg(short, long, default_value = "all")] spec: String, /// Git data directory (required for cors, git-clone, push-auth, repo-creation specs) #[arg(short, long)] git_data_dir: Option, }, } #[tokio::main] async fn main() -> Result<()> { // Probe output is self-contained — library chatter (nostr_relay_pool etc.) // adds no value and clutters both human and JSON output. Skip the tracing // subscriber entirely for the probe subcommand; initialise it normally for // audit subcommands where verbose output is expected. let is_probe = std::env::args().nth(1).as_deref() == Some("probe"); if !is_probe { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive(tracing::Level::INFO.into()), ) .init(); } let cli = Cli::parse(); match cli.command { Commands::Probe { relay, json, timeout, watch, nsec, create_repo, } => { let relay = match relay { Some(r) => r, None => { // Print probe-specific help and exit cleanly let mut cmd = Cli::command(); let _ = cmd.find_subcommand_mut("probe").unwrap().print_help(); println!(); return Ok(()); } }; // Parse nsec if provided let keys = if let Some(nsec_str) = nsec { use nostr_sdk::prelude::SecretKey; let sk = SecretKey::from_bech32(&nsec_str) .map_err(|e| anyhow!("Invalid nsec: {}", e))?; Some(Keys::new(sk)) } else { None }; // read_only is the default; --create-repo opts into the write path let read_only = !create_repo; // Overall probe timeout: min(20s, watch_interval) to prevent // overlapping runs under --watch or cron scheduling. let overall_secs = match watch { Some(interval) => interval.min(20), None => 20, }; if let Some(interval) = watch { let mut run = 1u64; loop { if !json { println!("\n[Run {}]", run); } let report = grasp_audit::probe::run_probe( &relay, keys.clone(), read_only, timeout, overall_secs, ) .await; if json { report.print_json(); } else { report.print_human(); } run += 1; tokio::time::sleep(Duration::from_secs(interval)).await; } } else { let report = grasp_audit::probe::run_probe(&relay, keys, read_only, timeout, overall_secs) .await; if json { report.print_json(); } else { report.print_human(); } if !report.all_passed { std::process::exit(1); } } } Commands::Audit { relay, mode, spec, git_data_dir, } => { let mut config = match mode.as_str() { "shared" => AuditConfig::shared(), "isolated" => AuditConfig::isolated(), // Backwards compatibility aliases "ci" => AuditConfig::isolated(), "production" => AuditConfig::shared(), _ => { return Err(anyhow!( "Invalid mode: {}. Use 'shared' or 'isolated'", mode )) } }; // Audit needs to create events to test the relay, so disable read-only mode config.read_only = false; // Derive relay_domain from relay URL (e.g., "ws://localhost:8081" -> "localhost:8081") let relay_domain = relay .replace("ws://", "") .replace("wss://", "") .trim_end_matches('/') .to_string(); println!("🔍 GRASP Audit Tool"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("Relay: {}", relay); println!("Mode: {}", mode); println!("Spec: {}", spec); println!("Run ID: {}", config.run_id); if let Some(ref dir) = git_data_dir { println!("Git Dir: {}", dir.display()); } println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); println!("Connecting to relay..."); let client = AuditClient::new(&relay, config) .await .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?; if !client.is_connected().await { return Err(anyhow!("Could not establish connection to relay")); } println!("✓ Connected\n"); let results = match spec.as_str() { "nip01-smoke" => { println!("Running NIP-01 smoke tests...\n"); specs::Nip01SmokeTests::run_all(&client).await } "nip11" => { println!("Running NIP-11 document tests...\n"); specs::Nip11DocumentTests::run_all(&client).await } "event-acceptance" => { println!("Running event acceptance policy tests...\n"); specs::EventAcceptancePolicyTests::run_all(&client).await } "cors" => { println!("Running CORS tests...\n"); specs::CorsTests::run_all(&client, &relay_domain).await } "git-clone" => { println!("Running Git clone tests...\n"); specs::GitCloneTests::run_all(&client, &relay_domain).await } "git-filter" => { println!("Running Git filter capability tests...\n"); specs::GitFilterTests::run_all(&client, &relay_domain).await } "push-auth" => { println!("Running push authorization tests...\n"); specs::PushAuthorizationTests::run_all(&client, &relay_domain).await } "repo-creation" => { println!("Running repository creation tests...\n"); specs::RepositoryCreationTests::run_all(&client, &relay_domain).await } "purgatory" => { println!("Running purgatory tests...\n"); specs::PurgatoryTests::run_all(&client).await } "all" => { println!("Running all tests...\n"); let mut all_results = AuditResult::new("All GRASP-01 Tests"); // NIP-01 smoke tests (stateless - no shared fixture dependencies) println!(" → NIP-01 smoke tests..."); let nip01_results = specs::Nip01SmokeTests::run_all(&client).await; all_results.merge(nip01_results); // NIP-11 document tests (stateless) println!(" → NIP-11 document tests..."); let nip11_results = specs::Nip11DocumentTests::run_all(&client).await; all_results.merge(nip11_results); // CORS tests (stateless HTTP checks) println!(" → CORS tests..."); let cors_results = specs::CorsTests::run_all(&client, &relay_domain).await; all_results.merge(cors_results); // Repository creation tests (uses ValidRepoSent only - no state events) println!(" → Repository creation tests..."); let repo_results = specs::RepositoryCreationTests::run_all(&client, &relay_domain).await; all_results.merge(repo_results); // Git clone tests (uses ValidRepoSent only - no state events) println!(" → Git clone tests..."); let clone_results = specs::GitCloneTests::run_all(&client, &relay_domain).await; all_results.merge(clone_results); // Git filter capability tests (uses ValidRepoSent only - no state events) println!(" → Git filter capability tests..."); let filter_results = specs::GitFilterTests::run_all(&client, &relay_domain).await; all_results.merge(filter_results); // Event acceptance policy tests (uses ValidRepoServed - no extra state events) println!(" → Event acceptance policy tests..."); let event_results = specs::EventAcceptancePolicyTests::run_all(&client).await; all_results.merge(event_results); // Purgatory tests MUST run before push-auth. // Push-auth sends new replaceable state events (kind 30618) for the same // repo_id as OwnerStateDataPushed (e.g. test_head_set_after_git_push_with_required_oids // sends a develop1 state event that displaces the original). If purgatory ran // after push-auth, is_event_on_relay(original_id) would return false because // the original state event has been replaced on the relay. println!(" → Purgatory tests..."); let purgatory_results = specs::PurgatoryTests::run_all(&client).await; all_results.merge(purgatory_results); // Push authorization tests (mutates shared state - must run last among git specs) println!(" → Push authorization tests..."); let push_results = specs::PushAuthorizationTests::run_all(&client, &relay_domain).await; all_results.merge(push_results); println!(); all_results } _ => { return Err(anyhow!( "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'git-filter', 'push-auth', 'repo-creation', 'purgatory', or 'all'", spec )) } }; results.print_report(); if !results.all_passed() { println!("❌ Some tests failed"); std::process::exit(1); } else { println!("✅ All tests passed!"); } } } Ok(()) }