From cd01c7379f23d9189beef840ddc523a3c90a9a10 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 25 Feb 2026 10:50:59 +0000 Subject: add probe subcommand for end-to-end relay health checks Implements grasp-audit probe with full write path (publish events, poll for repo init, push, verify refs match state) and read-only fallback (find existing announcement, fetch refs). Supports --nsec for whitelisted relays, --json output, and --watch for continuous monitoring. --- grasp-audit/Cargo.toml | 1 + grasp-audit/src/audit.rs | 14 + grasp-audit/src/bin/grasp-audit.rs | 75 ++++ grasp-audit/src/client.rs | 67 +++ grasp-audit/src/fixtures.rs | 72 +++ grasp-audit/src/lib.rs | 3 + grasp-audit/src/probe.rs | 882 +++++++++++++++++++++++++++++++++++++ 7 files changed, 1114 insertions(+) create mode 100644 grasp-audit/src/probe.rs (limited to 'grasp-audit') diff --git a/grasp-audit/Cargo.toml b/grasp-audit/Cargo.toml index 789a9ca..9e97b16 100644 --- a/grasp-audit/Cargo.toml +++ b/grasp-audit/Cargo.toml @@ -18,6 +18,7 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "4767ad13" } tokio = { version = "1", features = ["full"] } # Serialization +serde = { version = "1", features = ["derive"] } serde_json = "1" # Error handling diff --git a/grasp-audit/src/audit.rs b/grasp-audit/src/audit.rs index 5fb6904..a0ff53c 100644 --- a/grasp-audit/src/audit.rs +++ b/grasp-audit/src/audit.rs @@ -68,6 +68,20 @@ impl AuditConfig { } } + /// Create config for probe/smoke test mode + /// + /// Identical to `isolated()` but uses a `probe-` prefix for the run ID, + /// making probe events easy to distinguish from regular audit events. + pub fn probe() -> Self { + let run_id = format!("probe-{}", &uuid::Uuid::new_v4().to_string()[..8]); + Self { + run_id, + mode: AuditMode::Isolated, + cleanup_after: Timestamp::now() + 3600, // 1 hour from now + read_only: false, + } + } + /// Create config with custom run ID pub fn with_run_id(run_id: String, mode: AuditMode) -> Self { Self { diff --git a/grasp-audit/src/bin/grasp-audit.rs b/grasp-audit/src/bin/grasp-audit.rs index d192f04..e77a698 100644 --- a/grasp-audit/src/bin/grasp-audit.rs +++ b/grasp-audit/src/bin/grasp-audit.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand}; use grasp_audit::*; use std::path::PathBuf; +use std::time::Duration; #[derive(Parser)] #[command(name = "grasp-audit")] @@ -14,6 +15,33 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Run a probe/smoke test against a server + Probe { + /// Relay URL (e.g., ws://localhost:7000) + #[arg(short, long)] + relay: String, + + /// 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, + + /// Read-only mode: skip write steps, only check existing repos + #[arg(long, default_value_t = false)] + read_only: bool, + }, + /// Run audit tests against a server Audit { /// Relay URL (e.g., ws://localhost:7000) @@ -50,6 +78,53 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Commands::Probe { + relay, + json, + timeout, + watch, + nsec, + read_only, + } => { + // 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 + }; + + if let Some(interval) = watch { + let mut run = 1u64; + loop { + println!("\n[Run {}]", run); + let report = + grasp_audit::probe::run_probe(&relay, keys.clone(), read_only, timeout) + .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).await; + if json { + report.print_json(); + } else { + report.print_human(); + } + if !report.all_passed { + std::process::exit(1); + } + } + } + Commands::Audit { relay, mode, diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index 5c263ad..e5f2021 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -112,6 +112,73 @@ impl AuditClient { }) } + /// Create a new audit client with explicit keys + /// + /// Identical to [`new()`] but accepts an explicit `Keys` parameter instead of + /// generating fresh ones. The maintainer, recursive maintainer, and PR author + /// keys are still generated fresh internally. + /// + /// This is useful for probe mode where the caller wants to use a specific + /// identity (e.g., from an `nsec` argument) for all events. + pub async fn new_with_keys(relay_url: &str, config: AuditConfig, keys: Keys) -> Result { + let maintainer_keys = Keys::generate(); + let recursive_maintainer_keys = Keys::generate(); + let pr_author_keys = Keys::generate(); + let client = Client::new(keys.clone()); + + // Add relay and connect + client.add_relay(relay_url).await?; + client.connect().await; + + // Wait for connection to establish (with retries) + let mut attempts = 0; + let mut connected = false; + while attempts < 20 { + tokio::time::sleep(Duration::from_millis(100)).await; + + let relays = client.relays().await; + connected = relays.values().any(|r| r.is_connected()); + + if connected { + break; + } + + attempts += 1; + } + + // Verify we actually connected + if !connected { + return Err(anyhow!( + "Failed to connect to relay at '{}'\n\ + \n\ + Possible causes:\n\ + • Relay is not running at this address\n\ + • Network connectivity issues\n\ + • Incorrect URL or port\n\ + \n\ + To start ngit-relay for testing:\n\ + docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest\n\ + \n\ + Or use the test script:\n\ + cd grasp-audit && ./test-ngit-relay.sh", + relay_url + )); + } + + // Give it a bit more time to stabilize + tokio::time::sleep(Duration::from_millis(200)).await; + + Ok(Self { + client, + config, + keys, + maintainer_keys, + recursive_maintainer_keys, + pr_author_keys, + fixture_cache: Arc::new(Mutex::new(HashMap::new())), + }) + } + /// Get the fixture cache for TestContext usage /// /// This cache is shared across all TestContext instances created from this client. diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 0a9bf65..4678790 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -2516,6 +2516,78 @@ pub fn try_push_to_ref(clone_path: &Path, ref_name: &str) -> Result Result<(), String> { + // Step 1: git init + let output = Command::new("git") + .args(["init", path.to_str().unwrap_or(".")]) + .output() + .map_err(|e| format!("Failed to execute git init: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git init failed: {}", stderr)); + } + + // Step 2: git config user.email + let output = Command::new("git") + .args(["config", "user.email", "probe@grasp-audit.local"]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to set git user.email: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git config user.email failed: {}", stderr)); + } + + // Step 3: git config user.name + let output = Command::new("git") + .args(["config", "user.name", "GRASP Probe"]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to set git user.name: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git config user.name failed: {}", stderr)); + } + + // Step 4: git symbolic-ref HEAD refs/heads/main (sets default branch to main) + let output = Command::new("git") + .args(["symbolic-ref", "HEAD", "refs/heads/main"]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to set default branch: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git symbolic-ref HEAD failed: {}", stderr)); + } + + // Step 5: git remote add origin + let output = Command::new("git") + .args(["remote", "add", "origin", remote_url]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to add remote: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git remote add origin failed: {}", stderr)); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index 655ee83..33d0990 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs @@ -32,6 +32,7 @@ pub mod audit; pub mod client; pub mod fixtures; pub mod isolation; +pub mod probe; pub mod result; pub mod specs; @@ -43,6 +44,7 @@ pub use fixtures::{ create_commit, create_deterministic_commit, create_deterministic_commit_with_variant, + init_local_repo, // Verification helpers send_and_verify_accepted, send_and_verify_rejected, @@ -58,6 +60,7 @@ pub use fixtures::{ PR_TEST_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, }; +pub use probe::{run_probe, ProbeCheck, ProbeReport}; pub use result::{AuditResult, TestResult}; // Re-export commonly used types diff --git a/grasp-audit/src/probe.rs b/grasp-audit/src/probe.rs new file mode 100644 index 0000000..c626b94 --- /dev/null +++ b/grasp-audit/src/probe.rs @@ -0,0 +1,882 @@ +//! Probe/smoke-test logic for GRASP relay health checks +//! +//! The probe runs a series of checks against a relay and reports results in +//! human-readable or JSON format. It is designed to be fast and non-destructive +//! when run in read-only mode. + +use crate::audit::AuditConfig; +use crate::client::AuditClient; +use crate::fixtures::{create_commit, init_local_repo, try_push}; +use nostr_sdk::prelude::*; +use std::time::{Duration, Instant}; + +// ============================================================ +// Result types +// ============================================================ + +/// Result of a single probe check +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProbeCheck { + pub name: &'static str, + pub passed: bool, + pub skipped: bool, + pub duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Full probe report containing all check results +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProbeReport { + pub relay_url: String, + pub timestamp: String, + pub all_passed: bool, + pub total_duration_ms: u64, + pub checks: Vec, +} + +impl ProbeReport { + /// Print a human-readable report with ANSI colours + pub fn print_human(&self) { + let green = "\x1b[1;92m"; + let red = "\x1b[1;91m"; + let yellow = "\x1b[33m"; + let bold = "\x1b[1m"; + let reset = "\x1b[0m"; + let sep = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; + + println!( + "{}GRASP Probe{} — {} [{}]", + bold, reset, self.relay_url, self.timestamp + ); + println!("{}", sep); + + for check in &self.checks { + if check.skipped { + let reason = check.error.as_deref().unwrap_or("skipped"); + println!( + "{}→{} {:<28} skipped {}({}){} ", + yellow, reset, check.name, yellow, reason, reset + ); + } else if check.passed { + let detail_str = check + .detail + .as_deref() + .map(|d| format!(" {}", d)) + .unwrap_or_default(); + println!( + "{}✓{} {:<28} {}ms{}", + green, reset, check.name, check.duration_ms, detail_str + ); + } else { + let detail_str = check + .detail + .as_deref() + .map(|d| format!(" {}", d)) + .unwrap_or_default(); + println!( + "{}✗{} {:<28} {}ms{}", + red, reset, check.name, check.duration_ms, detail_str + ); + if let Some(ref err) = check.error { + println!(" {}↳ {}{}", red, err, reset); + } + } + } + + println!("{}", sep); + + if self.all_passed { + println!( + "{}All checks passed{} total: {}ms", + green, reset, self.total_duration_ms + ); + } else { + println!( + "{}Some checks failed{} total: {}ms", + red, reset, self.total_duration_ms + ); + } + } + + /// Print machine-readable JSON + pub fn print_json(&self) { + println!("{}", serde_json::to_string_pretty(self).unwrap()); + } +} + +// ============================================================ +// Helpers +// ============================================================ + +/// Build a skipped ProbeCheck +fn skipped(name: &'static str, reason: &str) -> ProbeCheck { + ProbeCheck { + name, + passed: false, + skipped: true, + duration_ms: 0, + detail: None, + error: Some(reason.to_string()), + } +} + +/// Format current time as ISO 8601 UTC (YYYY-MM-DDTHH:MM:SSZ) +fn now_iso8601() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Integer arithmetic to decompose Unix timestamp into date/time components + let s = secs % 60; + let m = (secs / 60) % 60; + let h = (secs / 3600) % 24; + let days = secs / 86400; // days since 1970-01-01 + + // Compute year, month, day from days since epoch + // Using the algorithm from https://howardhinnant.github.io/date_algorithms.html + let z = days as i64 + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; // day of era [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365] + let mp = (5 * doy + 2) / 153; // month of year [0, 11] (March=0) + let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31] + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12] + let yr = if mo <= 2 { y + 1 } else { y }; + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + yr, mo, d, h, m, s + ) +} + +// ============================================================ +// Main probe function +// ============================================================ + +/// Run a probe against a GRASP relay and return a full report. +/// +/// # Arguments +/// * `relay_url` - WebSocket URL of the relay (e.g. `ws://localhost:7000`) +/// * `keys` - Optional keypair to use; `None` generates fresh keys +/// * `read_only` - When `true`, skip write steps and only check existing repos +/// * `timeout_secs` - Per-step timeout in seconds +pub async fn run_probe( + relay_url: &str, + keys: Option, + read_only: bool, + timeout_secs: u64, +) -> ProbeReport { + let total_start = Instant::now(); + let timestamp = now_iso8601(); + let mut checks: Vec = Vec::new(); + + // ============================================================ + // PREPARE (offline) + // ============================================================ + let keys = keys.unwrap_or_else(Keys::generate); + let npub = match keys.public_key().to_bech32() { + Ok(n) => n, + Err(e) => { + // Can't proceed without npub + return ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed: false, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks: vec![ProbeCheck { + name: "prepare", + passed: false, + skipped: false, + duration_ms: 0, + detail: None, + error: Some(format!("Failed to derive npub: {}", e)), + }], + }; + } + }; + + let repo_id = format!("probe-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let _relay_domain = relay_url + .trim_start_matches("ws://") + .trim_start_matches("wss://") + .trim_end_matches('/') + .to_string(); + let http_base = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://") + .trim_end_matches('/') + .to_string(); + let clone_url = format!("{}/{}/{}.git", http_base, npub, repo_id); + + // Create temp dir for local repo + let local_repo_path = std::env::temp_dir() + .join(format!("grasp-probe-{}", uuid::Uuid::new_v4())); + + // Initialise local repo (offline) + let init_result = init_local_repo(&local_repo_path, &clone_url); + let commit_hash = if init_result.is_ok() { + create_commit(&local_repo_path, "GRASP probe commit").ok() + } else { + None + }; + + // Build announcement and state events (not sent yet) + let config = AuditConfig::probe(); + let announcement_event_opt: Option; + let state_event_opt: Option; + + { + use nostr_sdk::prelude::*; + + let ann_result = crate::audit::AuditEventBuilder::new( + Kind::GitRepoAnnouncement, + "GRASP probe repository", + config.clone(), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec!["GRASP Probe Repository"], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![clone_url.clone()], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.to_string()], + )) + .build(&keys); + + announcement_event_opt = ann_result.ok(); + + let state_result = if let Some(ref ch) = commit_hash { + crate::audit::AuditEventBuilder::new(Kind::RepoState, "", config.clone()) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![ch.clone()], + )) + .tag(Tag::custom( + TagKind::custom("HEAD"), + vec!["ref: refs/heads/main".to_string()], + )) + .build(&keys) + .ok() + } else { + None + }; + + state_event_opt = state_result; + } + + // ============================================================ + // Step 1: connect_websocket + // ============================================================ + let step1_start = Instant::now(); + let client_result = AuditClient::new_with_keys(relay_url, config.clone(), keys.clone()).await; + let step1_ms = step1_start.elapsed().as_millis() as u64; + + let client = match client_result { + Ok(c) => { + checks.push(ProbeCheck { + name: "connect_websocket", + passed: true, + skipped: false, + duration_ms: step1_ms, + detail: None, + error: None, + }); + c + } + Err(e) => { + checks.push(ProbeCheck { + name: "connect_websocket", + passed: false, + skipped: false, + duration_ms: step1_ms, + detail: None, + error: Some(e.to_string()), + }); + // Skip all remaining steps + for name in &[ + "nip11_fetch", + "publish_events", + "git_repo_initialised", + "git_push", + "git_fetch_refs", + ] { + checks.push(skipped(name, "connect_websocket failed")); + } + let _ = std::fs::remove_dir_all(&local_repo_path); + return ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed: false, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks, + }; + } + }; + + // ============================================================ + // Step 2: nip11_fetch (independent — always runs if step 1 passed) + // ============================================================ + { + let step2_start = Instant::now(); + let http_client = reqwest::Client::new(); + let nip11_result = tokio::time::timeout( + Duration::from_secs(timeout_secs), + http_client + .get(&http_base) + .header("Accept", "application/nostr+json") + .send(), + ) + .await; + + let step2_ms = step2_start.elapsed().as_millis() as u64; + + match nip11_result { + Ok(Ok(resp)) if resp.status().is_success() => { + let detail = resp + .json::() + .await + .ok() + .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())); + checks.push(ProbeCheck { + name: "nip11_fetch", + passed: true, + skipped: false, + duration_ms: step2_ms, + detail, + error: None, + }); + } + Ok(Ok(resp)) => { + checks.push(ProbeCheck { + name: "nip11_fetch", + passed: false, + skipped: false, + duration_ms: step2_ms, + detail: None, + error: Some(format!("HTTP {}", resp.status())), + }); + } + Ok(Err(e)) => { + checks.push(ProbeCheck { + name: "nip11_fetch", + passed: false, + skipped: false, + duration_ms: step2_ms, + detail: None, + error: Some(e.to_string()), + }); + } + Err(_) => { + checks.push(ProbeCheck { + name: "nip11_fetch", + passed: false, + skipped: false, + duration_ms: step2_ms, + detail: None, + error: Some("timeout".to_string()), + }); + } + } + } + + // ============================================================ + // Step 3: publish_events (requires step 1; skipped in read_only) + // ============================================================ + let mut write_succeeded = false; + + if read_only { + checks.push(skipped("publish_events", "read-only mode")); + checks.push(skipped("git_repo_initialised", "read-only mode")); + checks.push(skipped("git_push", "read-only mode")); + } else { + let step3_start = Instant::now(); + + let send_result = match (&announcement_event_opt, &state_event_opt) { + (Some(ann), Some(state)) => { + let r1 = client.send_event(ann.clone()).await; + let r2 = if r1.is_ok() { + client.send_event(state.clone()).await + } else { + r1.map(|_| EventId::all_zeros()) + }; + r2 + } + _ => Err(anyhow::anyhow!( + "Events could not be built (local repo init failed)" + )), + }; + + let step3_ms = step3_start.elapsed().as_millis() as u64; + + match send_result { + Ok(_) => { + checks.push(ProbeCheck { + name: "publish_events", + passed: true, + skipped: false, + duration_ms: step3_ms, + detail: None, + error: None, + }); + write_succeeded = true; + } + Err(e) => { + checks.push(ProbeCheck { + name: "publish_events", + passed: false, + skipped: false, + duration_ms: step3_ms, + detail: None, + error: Some(e.to_string()), + }); + // Skip steps 4 and 5; step 6 will use fallback + checks.push(skipped( + "git_repo_initialised", + "publish_events failed", + )); + checks.push(skipped("git_push", "publish_events failed")); + } + } + + // ============================================================ + // Step 4: git_repo_initialised (requires step 3) + // ============================================================ + if write_succeeded { + let step4_start = Instant::now(); + let poll_url = format!("{}/info/refs?service=git-upload-pack", clone_url); + let http_client = reqwest::Client::new(); + let deadline = Instant::now() + Duration::from_secs(15); + let mut repo_ready = false; + + loop { + if Instant::now() >= deadline { + break; + } + match http_client.get(&poll_url).send().await { + Ok(resp) if resp.status().as_u16() != 404 => { + repo_ready = true; + break; + } + _ => {} + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + let step4_ms = step4_start.elapsed().as_millis() as u64; + + if repo_ready { + checks.push(ProbeCheck { + name: "git_repo_initialised", + passed: true, + skipped: false, + duration_ms: step4_ms, + detail: None, + error: None, + }); + } else { + checks.push(ProbeCheck { + name: "git_repo_initialised", + passed: false, + skipped: false, + duration_ms: step4_ms, + detail: None, + error: Some("timeout waiting for repo to be initialised (15s)".to_string()), + }); + write_succeeded = false; + checks.push(skipped("git_push", "git_repo_initialised timed out")); + } + } + + // ============================================================ + // Step 5: git_push (requires step 4) + // ============================================================ + if write_succeeded { + let step5_start = Instant::now(); + let push_result = try_push(&local_repo_path); + let step5_ms = step5_start.elapsed().as_millis() as u64; + + match push_result { + Ok(true) => { + checks.push(ProbeCheck { + name: "git_push", + passed: true, + skipped: false, + duration_ms: step5_ms, + detail: None, + error: None, + }); + } + Ok(false) => { + checks.push(ProbeCheck { + name: "git_push", + passed: false, + skipped: false, + duration_ms: step5_ms, + detail: None, + error: Some("push rejected by relay".to_string()), + }); + write_succeeded = false; + } + Err(e) => { + checks.push(ProbeCheck { + name: "git_push", + passed: false, + skipped: false, + duration_ms: step5_ms, + detail: None, + error: Some(e), + }); + write_succeeded = false; + } + } + } + } + + // ============================================================ + // Step 6: git_fetch_refs + // ============================================================ + // Two paths: + // write_succeeded=true → check our own repo; compare refs against our state event + // write_succeeded=false → find any existing kind 30617; just verify refs are readable + // + // In read-only mode we also run a find_announcement check first. + + // Helper: parse pkt-line body into a map of refname -> commit hash, + // excluding refs/nostr/* entries (only branches and tags). + fn parse_refs(body: &str) -> Vec<(String, String)> { + let mut refs = Vec::new(); + for line in body.lines() { + // pkt-line: 4-hex-char length prefix, then " \0" or " " + let content = if line.len() > 4 { &line[4..] } else { continue }; + // Strip NUL and everything after (capabilities on first line) + let content = content.split('\0').next().unwrap_or(content).trim(); + // Skip flush packets and service lines + if content.starts_with('#') || content.is_empty() || content == "0000" { + continue; + } + let mut parts = content.splitn(2, ' '); + let hash = match parts.next() { Some(h) if h.len() == 40 => h, _ => continue }; + let refname = match parts.next() { Some(r) => r.trim(), None => continue }; + // Skip refs/nostr/* — only branches (refs/heads/*) and tags (refs/tags/*) + if refname.starts_with("refs/nostr/") { + continue; + } + refs.push((refname.to_string(), hash.to_string())); + } + refs + } + + if write_succeeded { + // ---- Write path ---- + // Step 6a: git_fetch_refs — just verify the endpoint returns 200 + let refs_url = format!("{}/info/refs?service=git-upload-pack", clone_url); + let http_client = reqwest::Client::new(); + + let step6_start = Instant::now(); + let refs_result = tokio::time::timeout( + Duration::from_secs(timeout_secs), + http_client.get(&refs_url).send(), + ) + .await; + let step6_ms = step6_start.elapsed().as_millis() as u64; + + // Capture body for the next check; only proceed to match check if fetch succeeded + let refs_body: Option = match refs_result { + Ok(Ok(resp)) if resp.status().is_success() => { + let body = resp.text().await.unwrap_or_default(); + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: true, + skipped: false, + duration_ms: step6_ms, + detail: None, + error: None, + }); + Some(body) + } + Ok(Ok(resp)) => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: None, + error: Some(format!("HTTP {}", resp.status())), + }); + None + } + Ok(Err(e)) => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: None, + error: Some(e.to_string()), + }); + None + } + Err(_) => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: None, + error: Some("timeout".to_string()), + }); + None + } + }; + + // Step 6b: git_refs_match_state — compare fetched refs against our state event + match refs_body { + None => { + checks.push(skipped("git_refs_match_state", "git_fetch_refs failed")); + } + Some(body) => { + let fetched_refs = parse_refs(&body); + let mut mismatches: Vec = Vec::new(); + + if let Some(ref state_ev) = state_event_opt { + for tag in state_ev.tags.iter() { + let kind_str = match tag.kind() { + TagKind::Custom(ref s) => s.clone(), + _ => continue, + }; + // Only check refs/heads/* and refs/tags/*, skip HEAD and refs/nostr/* + if !kind_str.starts_with("refs/heads/") + && !kind_str.starts_with("refs/tags/") + { + continue; + } + let expected_hash = match tag.content() { + Some(h) => h.to_string(), + None => continue, + }; + let found = fetched_refs.iter().find(|(r, _)| r == &kind_str); + match found { + Some((_, actual_hash)) if actual_hash == &expected_hash => {} + Some((_, actual_hash)) => { + mismatches.push(format!( + "{}: expected {} got {}", + kind_str, + &expected_hash[..8.min(expected_hash.len())], + &actual_hash[..8.min(actual_hash.len())] + )); + } + None => { + mismatches.push(format!( + "{}: expected {} not found in refs", + kind_str, + &expected_hash[..8.min(expected_hash.len())] + )); + } + } + } + } + + checks.push(ProbeCheck { + name: "git_refs_match_state", + passed: mismatches.is_empty(), + skipped: false, + duration_ms: 0, // no extra network call; cost already in git_fetch_refs + detail: None, + error: if mismatches.is_empty() { + None + } else { + Some(mismatches.join("; ")) + }, + }); + } + } + } else { + // ---- Fallback path: find any existing kind 30617, check refs readable ---- + + // In read-only mode: first check that at least one announcement exists + let filter = Filter::new().kind(Kind::GitRepoAnnouncement).limit(1); + let existing = client + .client() + .fetch_events(filter, Duration::from_secs(5)) + .await + .unwrap_or_default(); + + let found_event = existing.into_iter().next(); + + if read_only { + // Explicit check: was an announcement found? + match &found_event { + Some(ev) => { + let ann_npub = ev.pubkey.to_bech32().unwrap_or_else(|_| ev.pubkey.to_hex()); + let ann_id = ev + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .unwrap_or("unknown") + .to_string(); + checks.push(ProbeCheck { + name: "find_announcement", + passed: true, + skipped: false, + duration_ms: 0, + detail: Some(format!("{}/{}", ann_npub, ann_id)), + error: None, + }); + } + None => { + checks.push(ProbeCheck { + name: "find_announcement", + passed: false, + skipped: false, + duration_ms: 0, + detail: None, + error: Some("no kind:30617 announcements found on relay".to_string()), + }); + let _ = std::fs::remove_dir_all(&local_repo_path); + let all_passed = checks.iter().all(|c| c.passed || c.skipped); + return ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks, + }; + } + } + } + + // Now fetch refs from the found repo + match found_event { + Some(ev) => { + let ann_npub = ev.pubkey.to_bech32().unwrap_or_else(|_| ev.pubkey.to_hex()); + let ann_id = ev + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .unwrap_or("unknown") + .to_string(); + + // detail (npub/identifier) only shown in read-only mode + let detail_id = if read_only { + Some(format!("{}/{}", ann_npub, ann_id)) + } else { + None + }; + + // Prefer the clone tag URL; fall back to constructing from relay + let fetch_url = ev + .tags + .iter() + .find(|t| t.kind() == TagKind::custom("clone")) + .and_then(|t| t.content()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + format!("{}/{}/{}.git", http_base, ann_npub, ann_id) + }); + + let step6_start = Instant::now(); + let refs_url = format!("{}/info/refs?service=git-upload-pack", fetch_url); + let http_client = reqwest::Client::new(); + let refs_result = tokio::time::timeout( + Duration::from_secs(timeout_secs), + http_client.get(&refs_url).send(), + ) + .await; + let step6_ms = step6_start.elapsed().as_millis() as u64; + + match refs_result { + Ok(Ok(resp)) if resp.status().is_success() => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: true, + skipped: false, + duration_ms: step6_ms, + detail: detail_id, + error: None, + }); + } + Ok(Ok(resp)) => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: detail_id, + error: Some(format!("HTTP {}", resp.status())), + }); + } + Ok(Err(e)) => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: detail_id, + error: Some(e.to_string()), + }); + } + Err(_) => { + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: detail_id, + error: Some("timeout".to_string()), + }); + } + } + + // git_refs_match_state is skipped in fallback — no state event to compare + checks.push(skipped( + "git_refs_match_state", + "no state event (fallback path)", + )); + } + None => { + // Not read-only (already handled above) but no repo found + checks.push(ProbeCheck { + name: "git_fetch_refs", + passed: false, + skipped: false, + duration_ms: 0, + detail: None, + error: Some("no repositories found on relay".to_string()), + }); + checks.push(skipped( + "git_refs_match_state", + "no state event (fallback path)", + )); + } + } + } + + // ============================================================ + // CLEANUP + // ============================================================ + let _ = std::fs::remove_dir_all(&local_repo_path); + + let all_passed = checks.iter().all(|c| c.passed || c.skipped); + ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks, + } +} -- cgit v1.2.3