upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-25 10:50:59 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-25 10:50:59 +0000
commitcd01c7379f23d9189beef840ddc523a3c90a9a10 (patch)
treed1a83d2b3cd8813f3ec586694158be70a026e091
parent7f71a2e75a66bcacad9057f5e339e511e689b828 (diff)
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.
-rw-r--r--grasp-audit/Cargo.toml1
-rw-r--r--grasp-audit/src/audit.rs14
-rw-r--r--grasp-audit/src/bin/grasp-audit.rs75
-rw-r--r--grasp-audit/src/client.rs67
-rw-r--r--grasp-audit/src/fixtures.rs72
-rw-r--r--grasp-audit/src/lib.rs3
-rw-r--r--grasp-audit/src/probe.rs882
7 files changed, 1114 insertions, 0 deletions
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" }
18tokio = { version = "1", features = ["full"] } 18tokio = { version = "1", features = ["full"] }
19 19
20# Serialization 20# Serialization
21serde = { version = "1", features = ["derive"] }
21serde_json = "1" 22serde_json = "1"
22 23
23# Error handling 24# 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 {
68 } 68 }
69 } 69 }
70 70
71 /// Create config for probe/smoke test mode
72 ///
73 /// Identical to `isolated()` but uses a `probe-` prefix for the run ID,
74 /// making probe events easy to distinguish from regular audit events.
75 pub fn probe() -> Self {
76 let run_id = format!("probe-{}", &uuid::Uuid::new_v4().to_string()[..8]);
77 Self {
78 run_id,
79 mode: AuditMode::Isolated,
80 cleanup_after: Timestamp::now() + 3600, // 1 hour from now
81 read_only: false,
82 }
83 }
84
71 /// Create config with custom run ID 85 /// Create config with custom run ID
72 pub fn with_run_id(run_id: String, mode: AuditMode) -> Self { 86 pub fn with_run_id(run_id: String, mode: AuditMode) -> Self {
73 Self { 87 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 @@
3use clap::{Parser, Subcommand}; 3use clap::{Parser, Subcommand};
4use grasp_audit::*; 4use grasp_audit::*;
5use std::path::PathBuf; 5use std::path::PathBuf;
6use std::time::Duration;
6 7
7#[derive(Parser)] 8#[derive(Parser)]
8#[command(name = "grasp-audit")] 9#[command(name = "grasp-audit")]
@@ -14,6 +15,33 @@ struct Cli {
14 15
15#[derive(Subcommand)] 16#[derive(Subcommand)]
16enum Commands { 17enum Commands {
18 /// Run a probe/smoke test against a server
19 Probe {
20 /// Relay URL (e.g., ws://localhost:7000)
21 #[arg(short, long)]
22 relay: String,
23
24 /// Output machine-readable JSON
25 #[arg(long, default_value_t = false)]
26 json: bool,
27
28 /// Per-step timeout in seconds
29 #[arg(long, default_value_t = 30)]
30 timeout: u64,
31
32 /// Re-run every N seconds (watch mode)
33 #[arg(long)]
34 watch: Option<u64>,
35
36 /// Secret key in nsec bech32 format (for whitelisted relays)
37 #[arg(long)]
38 nsec: Option<String>,
39
40 /// Read-only mode: skip write steps, only check existing repos
41 #[arg(long, default_value_t = false)]
42 read_only: bool,
43 },
44
17 /// Run audit tests against a server 45 /// Run audit tests against a server
18 Audit { 46 Audit {
19 /// Relay URL (e.g., ws://localhost:7000) 47 /// Relay URL (e.g., ws://localhost:7000)
@@ -50,6 +78,53 @@ async fn main() -> Result<()> {
50 let cli = Cli::parse(); 78 let cli = Cli::parse();
51 79
52 match cli.command { 80 match cli.command {
81 Commands::Probe {
82 relay,
83 json,
84 timeout,
85 watch,
86 nsec,
87 read_only,
88 } => {
89 // Parse nsec if provided
90 let keys = if let Some(nsec_str) = nsec {
91 use nostr_sdk::prelude::SecretKey;
92 let sk = SecretKey::from_bech32(&nsec_str)
93 .map_err(|e| anyhow!("Invalid nsec: {}", e))?;
94 Some(Keys::new(sk))
95 } else {
96 None
97 };
98
99 if let Some(interval) = watch {
100 let mut run = 1u64;
101 loop {
102 println!("\n[Run {}]", run);
103 let report =
104 grasp_audit::probe::run_probe(&relay, keys.clone(), read_only, timeout)
105 .await;
106 if json {
107 report.print_json();
108 } else {
109 report.print_human();
110 }
111 run += 1;
112 tokio::time::sleep(Duration::from_secs(interval)).await;
113 }
114 } else {
115 let report =
116 grasp_audit::probe::run_probe(&relay, keys, read_only, timeout).await;
117 if json {
118 report.print_json();
119 } else {
120 report.print_human();
121 }
122 if !report.all_passed {
123 std::process::exit(1);
124 }
125 }
126 }
127
53 Commands::Audit { 128 Commands::Audit {
54 relay, 129 relay,
55 mode, 130 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 {
112 }) 112 })
113 } 113 }
114 114
115 /// Create a new audit client with explicit keys
116 ///
117 /// Identical to [`new()`] but accepts an explicit `Keys` parameter instead of
118 /// generating fresh ones. The maintainer, recursive maintainer, and PR author
119 /// keys are still generated fresh internally.
120 ///
121 /// This is useful for probe mode where the caller wants to use a specific
122 /// identity (e.g., from an `nsec` argument) for all events.
123 pub async fn new_with_keys(relay_url: &str, config: AuditConfig, keys: Keys) -> Result<Self> {
124 let maintainer_keys = Keys::generate();
125 let recursive_maintainer_keys = Keys::generate();
126 let pr_author_keys = Keys::generate();
127 let client = Client::new(keys.clone());
128
129 // Add relay and connect
130 client.add_relay(relay_url).await?;
131 client.connect().await;
132
133 // Wait for connection to establish (with retries)
134 let mut attempts = 0;
135 let mut connected = false;
136 while attempts < 20 {
137 tokio::time::sleep(Duration::from_millis(100)).await;
138
139 let relays = client.relays().await;
140 connected = relays.values().any(|r| r.is_connected());
141
142 if connected {
143 break;
144 }
145
146 attempts += 1;
147 }
148
149 // Verify we actually connected
150 if !connected {
151 return Err(anyhow!(
152 "Failed to connect to relay at '{}'\n\
153 \n\
154 Possible causes:\n\
155 • Relay is not running at this address\n\
156 • Network connectivity issues\n\
157 • Incorrect URL or port\n\
158 \n\
159 To start ngit-relay for testing:\n\
160 docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest\n\
161 \n\
162 Or use the test script:\n\
163 cd grasp-audit && ./test-ngit-relay.sh",
164 relay_url
165 ));
166 }
167
168 // Give it a bit more time to stabilize
169 tokio::time::sleep(Duration::from_millis(200)).await;
170
171 Ok(Self {
172 client,
173 config,
174 keys,
175 maintainer_keys,
176 recursive_maintainer_keys,
177 pr_author_keys,
178 fixture_cache: Arc::new(Mutex::new(HashMap::new())),
179 })
180 }
181
115 /// Get the fixture cache for TestContext usage 182 /// Get the fixture cache for TestContext usage
116 /// 183 ///
117 /// This cache is shared across all TestContext instances created from this client. 184 /// 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<bool, String
2516 Ok(output.status.success()) 2516 Ok(output.status.success())
2517} 2517}
2518 2518
2519/// Initialise a fresh local git repo with a remote URL configured, ready for push.
2520///
2521/// Creates a new git repository at `path`, configures user identity,
2522/// sets the default branch to `main`, and adds `remote_url` as the `origin` remote.
2523/// After calling this, use `create_commit()` then `try_push()`.
2524///
2525/// # Arguments
2526/// * `path` - Directory to initialise (must not already exist as a git repo)
2527/// * `remote_url` - The remote URL to add as `origin`
2528pub fn init_local_repo(path: &Path, remote_url: &str) -> Result<(), String> {
2529 // Step 1: git init <path>
2530 let output = Command::new("git")
2531 .args(["init", path.to_str().unwrap_or(".")])
2532 .output()
2533 .map_err(|e| format!("Failed to execute git init: {}", e))?;
2534
2535 if !output.status.success() {
2536 let stderr = String::from_utf8_lossy(&output.stderr);
2537 return Err(format!("git init failed: {}", stderr));
2538 }
2539
2540 // Step 2: git config user.email
2541 let output = Command::new("git")
2542 .args(["config", "user.email", "probe@grasp-audit.local"])
2543 .current_dir(path)
2544 .output()
2545 .map_err(|e| format!("Failed to set git user.email: {}", e))?;
2546
2547 if !output.status.success() {
2548 let stderr = String::from_utf8_lossy(&output.stderr);
2549 return Err(format!("git config user.email failed: {}", stderr));
2550 }
2551
2552 // Step 3: git config user.name
2553 let output = Command::new("git")
2554 .args(["config", "user.name", "GRASP Probe"])
2555 .current_dir(path)
2556 .output()
2557 .map_err(|e| format!("Failed to set git user.name: {}", e))?;
2558
2559 if !output.status.success() {
2560 let stderr = String::from_utf8_lossy(&output.stderr);
2561 return Err(format!("git config user.name failed: {}", stderr));
2562 }
2563
2564 // Step 4: git symbolic-ref HEAD refs/heads/main (sets default branch to main)
2565 let output = Command::new("git")
2566 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
2567 .current_dir(path)
2568 .output()
2569 .map_err(|e| format!("Failed to set default branch: {}", e))?;
2570
2571 if !output.status.success() {
2572 let stderr = String::from_utf8_lossy(&output.stderr);
2573 return Err(format!("git symbolic-ref HEAD failed: {}", stderr));
2574 }
2575
2576 // Step 5: git remote add origin <remote_url>
2577 let output = Command::new("git")
2578 .args(["remote", "add", "origin", remote_url])
2579 .current_dir(path)
2580 .output()
2581 .map_err(|e| format!("Failed to add remote: {}", e))?;
2582
2583 if !output.status.success() {
2584 let stderr = String::from_utf8_lossy(&output.stderr);
2585 return Err(format!("git remote add origin failed: {}", stderr));
2586 }
2587
2588 Ok(())
2589}
2590
2519#[cfg(test)] 2591#[cfg(test)]
2520mod tests { 2592mod tests {
2521 use super::*; 2593 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;
32pub mod client; 32pub mod client;
33pub mod fixtures; 33pub mod fixtures;
34pub mod isolation; 34pub mod isolation;
35pub mod probe;
35pub mod result; 36pub mod result;
36pub mod specs; 37pub mod specs;
37 38
@@ -43,6 +44,7 @@ pub use fixtures::{
43 create_commit, 44 create_commit,
44 create_deterministic_commit, 45 create_deterministic_commit,
45 create_deterministic_commit_with_variant, 46 create_deterministic_commit_with_variant,
47 init_local_repo,
46 // Verification helpers 48 // Verification helpers
47 send_and_verify_accepted, 49 send_and_verify_accepted,
48 send_and_verify_rejected, 50 send_and_verify_rejected,
@@ -58,6 +60,7 @@ pub use fixtures::{
58 PR_TEST_COMMIT_HASH, 60 PR_TEST_COMMIT_HASH,
59 RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, 61 RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH,
60}; 62};
63pub use probe::{run_probe, ProbeCheck, ProbeReport};
61pub use result::{AuditResult, TestResult}; 64pub use result::{AuditResult, TestResult};
62 65
63// Re-export commonly used types 66// 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 @@
1//! Probe/smoke-test logic for GRASP relay health checks
2//!
3//! The probe runs a series of checks against a relay and reports results in
4//! human-readable or JSON format. It is designed to be fast and non-destructive
5//! when run in read-only mode.
6
7use crate::audit::AuditConfig;
8use crate::client::AuditClient;
9use crate::fixtures::{create_commit, init_local_repo, try_push};
10use nostr_sdk::prelude::*;
11use std::time::{Duration, Instant};
12
13// ============================================================
14// Result types
15// ============================================================
16
17/// Result of a single probe check
18#[derive(Debug, Clone, serde::Serialize)]
19pub struct ProbeCheck {
20 pub name: &'static str,
21 pub passed: bool,
22 pub skipped: bool,
23 pub duration_ms: u64,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub detail: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub error: Option<String>,
28}
29
30/// Full probe report containing all check results
31#[derive(Debug, Clone, serde::Serialize)]
32pub struct ProbeReport {
33 pub relay_url: String,
34 pub timestamp: String,
35 pub all_passed: bool,
36 pub total_duration_ms: u64,
37 pub checks: Vec<ProbeCheck>,
38}
39
40impl ProbeReport {
41 /// Print a human-readable report with ANSI colours
42 pub fn print_human(&self) {
43 let green = "\x1b[1;92m";
44 let red = "\x1b[1;91m";
45 let yellow = "\x1b[33m";
46 let bold = "\x1b[1m";
47 let reset = "\x1b[0m";
48 let sep = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
49
50 println!(
51 "{}GRASP Probe{} — {} [{}]",
52 bold, reset, self.relay_url, self.timestamp
53 );
54 println!("{}", sep);
55
56 for check in &self.checks {
57 if check.skipped {
58 let reason = check.error.as_deref().unwrap_or("skipped");
59 println!(
60 "{}→{} {:<28} skipped {}({}){} ",
61 yellow, reset, check.name, yellow, reason, reset
62 );
63 } else if check.passed {
64 let detail_str = check
65 .detail
66 .as_deref()
67 .map(|d| format!(" {}", d))
68 .unwrap_or_default();
69 println!(
70 "{}✓{} {:<28} {}ms{}",
71 green, reset, check.name, check.duration_ms, detail_str
72 );
73 } else {
74 let detail_str = check
75 .detail
76 .as_deref()
77 .map(|d| format!(" {}", d))
78 .unwrap_or_default();
79 println!(
80 "{}✗{} {:<28} {}ms{}",
81 red, reset, check.name, check.duration_ms, detail_str
82 );
83 if let Some(ref err) = check.error {
84 println!(" {}↳ {}{}", red, err, reset);
85 }
86 }
87 }
88
89 println!("{}", sep);
90
91 if self.all_passed {
92 println!(
93 "{}All checks passed{} total: {}ms",
94 green, reset, self.total_duration_ms
95 );
96 } else {
97 println!(
98 "{}Some checks failed{} total: {}ms",
99 red, reset, self.total_duration_ms
100 );
101 }
102 }
103
104 /// Print machine-readable JSON
105 pub fn print_json(&self) {
106 println!("{}", serde_json::to_string_pretty(self).unwrap());
107 }
108}
109
110// ============================================================
111// Helpers
112// ============================================================
113
114/// Build a skipped ProbeCheck
115fn skipped(name: &'static str, reason: &str) -> ProbeCheck {
116 ProbeCheck {
117 name,
118 passed: false,
119 skipped: true,
120 duration_ms: 0,
121 detail: None,
122 error: Some(reason.to_string()),
123 }
124}
125
126/// Format current time as ISO 8601 UTC (YYYY-MM-DDTHH:MM:SSZ)
127fn now_iso8601() -> String {
128 use std::time::{SystemTime, UNIX_EPOCH};
129 let secs = SystemTime::now()
130 .duration_since(UNIX_EPOCH)
131 .unwrap_or_default()
132 .as_secs();
133
134 // Integer arithmetic to decompose Unix timestamp into date/time components
135 let s = secs % 60;
136 let m = (secs / 60) % 60;
137 let h = (secs / 3600) % 24;
138 let days = secs / 86400; // days since 1970-01-01
139
140 // Compute year, month, day from days since epoch
141 // Using the algorithm from https://howardhinnant.github.io/date_algorithms.html
142 let z = days as i64 + 719468;
143 let era = if z >= 0 { z } else { z - 146096 } / 146097;
144 let doe = z - era * 146097; // day of era [0, 146096]
145 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era [0, 399]
146 let y = yoe + era * 400;
147 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
148 let mp = (5 * doy + 2) / 153; // month of year [0, 11] (March=0)
149 let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
150 let mo = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
151 let yr = if mo <= 2 { y + 1 } else { y };
152
153 format!(
154 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
155 yr, mo, d, h, m, s
156 )
157}
158
159// ============================================================
160// Main probe function
161// ============================================================
162
163/// Run a probe against a GRASP relay and return a full report.
164///
165/// # Arguments
166/// * `relay_url` - WebSocket URL of the relay (e.g. `ws://localhost:7000`)
167/// * `keys` - Optional keypair to use; `None` generates fresh keys
168/// * `read_only` - When `true`, skip write steps and only check existing repos
169/// * `timeout_secs` - Per-step timeout in seconds
170pub async fn run_probe(
171 relay_url: &str,
172 keys: Option<Keys>,
173 read_only: bool,
174 timeout_secs: u64,
175) -> ProbeReport {
176 let total_start = Instant::now();
177 let timestamp = now_iso8601();
178 let mut checks: Vec<ProbeCheck> = Vec::new();
179
180 // ============================================================
181 // PREPARE (offline)
182 // ============================================================
183 let keys = keys.unwrap_or_else(Keys::generate);
184 let npub = match keys.public_key().to_bech32() {
185 Ok(n) => n,
186 Err(e) => {
187 // Can't proceed without npub
188 return ProbeReport {
189 relay_url: relay_url.to_string(),
190 timestamp,
191 all_passed: false,
192 total_duration_ms: total_start.elapsed().as_millis() as u64,
193 checks: vec![ProbeCheck {
194 name: "prepare",
195 passed: false,
196 skipped: false,
197 duration_ms: 0,
198 detail: None,
199 error: Some(format!("Failed to derive npub: {}", e)),
200 }],
201 };
202 }
203 };
204
205 let repo_id = format!("probe-{}", &uuid::Uuid::new_v4().to_string()[..8]);
206 let _relay_domain = relay_url
207 .trim_start_matches("ws://")
208 .trim_start_matches("wss://")
209 .trim_end_matches('/')
210 .to_string();
211 let http_base = relay_url
212 .replace("ws://", "http://")
213 .replace("wss://", "https://")
214 .trim_end_matches('/')
215 .to_string();
216 let clone_url = format!("{}/{}/{}.git", http_base, npub, repo_id);
217
218 // Create temp dir for local repo
219 let local_repo_path = std::env::temp_dir()
220 .join(format!("grasp-probe-{}", uuid::Uuid::new_v4()));
221
222 // Initialise local repo (offline)
223 let init_result = init_local_repo(&local_repo_path, &clone_url);
224 let commit_hash = if init_result.is_ok() {
225 create_commit(&local_repo_path, "GRASP probe commit").ok()
226 } else {
227 None
228 };
229
230 // Build announcement and state events (not sent yet)
231 let config = AuditConfig::probe();
232 let announcement_event_opt: Option<Event>;
233 let state_event_opt: Option<Event>;
234
235 {
236 use nostr_sdk::prelude::*;
237
238 let ann_result = crate::audit::AuditEventBuilder::new(
239 Kind::GitRepoAnnouncement,
240 "GRASP probe repository",
241 config.clone(),
242 )
243 .tag(Tag::identifier(&repo_id))
244 .tag(Tag::custom(
245 TagKind::custom("name"),
246 vec!["GRASP Probe Repository"],
247 ))
248 .tag(Tag::custom(
249 TagKind::custom("clone"),
250 vec![clone_url.clone()],
251 ))
252 .tag(Tag::custom(
253 TagKind::custom("relays"),
254 vec![relay_url.to_string()],
255 ))
256 .build(&keys);
257
258 announcement_event_opt = ann_result.ok();
259
260 let state_result = if let Some(ref ch) = commit_hash {
261 crate::audit::AuditEventBuilder::new(Kind::RepoState, "", config.clone())
262 .tag(Tag::identifier(&repo_id))
263 .tag(Tag::custom(
264 TagKind::custom("refs/heads/main"),
265 vec![ch.clone()],
266 ))
267 .tag(Tag::custom(
268 TagKind::custom("HEAD"),
269 vec!["ref: refs/heads/main".to_string()],
270 ))
271 .build(&keys)
272 .ok()
273 } else {
274 None
275 };
276
277 state_event_opt = state_result;
278 }
279
280 // ============================================================
281 // Step 1: connect_websocket
282 // ============================================================
283 let step1_start = Instant::now();
284 let client_result = AuditClient::new_with_keys(relay_url, config.clone(), keys.clone()).await;
285 let step1_ms = step1_start.elapsed().as_millis() as u64;
286
287 let client = match client_result {
288 Ok(c) => {
289 checks.push(ProbeCheck {
290 name: "connect_websocket",
291 passed: true,
292 skipped: false,
293 duration_ms: step1_ms,
294 detail: None,
295 error: None,
296 });
297 c
298 }
299 Err(e) => {
300 checks.push(ProbeCheck {
301 name: "connect_websocket",
302 passed: false,
303 skipped: false,
304 duration_ms: step1_ms,
305 detail: None,
306 error: Some(e.to_string()),
307 });
308 // Skip all remaining steps
309 for name in &[
310 "nip11_fetch",
311 "publish_events",
312 "git_repo_initialised",
313 "git_push",
314 "git_fetch_refs",
315 ] {
316 checks.push(skipped(name, "connect_websocket failed"));
317 }
318 let _ = std::fs::remove_dir_all(&local_repo_path);
319 return ProbeReport {
320 relay_url: relay_url.to_string(),
321 timestamp,
322 all_passed: false,
323 total_duration_ms: total_start.elapsed().as_millis() as u64,
324 checks,
325 };
326 }
327 };
328
329 // ============================================================
330 // Step 2: nip11_fetch (independent — always runs if step 1 passed)
331 // ============================================================
332 {
333 let step2_start = Instant::now();
334 let http_client = reqwest::Client::new();
335 let nip11_result = tokio::time::timeout(
336 Duration::from_secs(timeout_secs),
337 http_client
338 .get(&http_base)
339 .header("Accept", "application/nostr+json")
340 .send(),
341 )
342 .await;
343
344 let step2_ms = step2_start.elapsed().as_millis() as u64;
345
346 match nip11_result {
347 Ok(Ok(resp)) if resp.status().is_success() => {
348 let detail = resp
349 .json::<serde_json::Value>()
350 .await
351 .ok()
352 .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()));
353 checks.push(ProbeCheck {
354 name: "nip11_fetch",
355 passed: true,
356 skipped: false,
357 duration_ms: step2_ms,
358 detail,
359 error: None,
360 });
361 }
362 Ok(Ok(resp)) => {
363 checks.push(ProbeCheck {
364 name: "nip11_fetch",
365 passed: false,
366 skipped: false,
367 duration_ms: step2_ms,
368 detail: None,
369 error: Some(format!("HTTP {}", resp.status())),
370 });
371 }
372 Ok(Err(e)) => {
373 checks.push(ProbeCheck {
374 name: "nip11_fetch",
375 passed: false,
376 skipped: false,
377 duration_ms: step2_ms,
378 detail: None,
379 error: Some(e.to_string()),
380 });
381 }
382 Err(_) => {
383 checks.push(ProbeCheck {
384 name: "nip11_fetch",
385 passed: false,
386 skipped: false,
387 duration_ms: step2_ms,
388 detail: None,
389 error: Some("timeout".to_string()),
390 });
391 }
392 }
393 }
394
395 // ============================================================
396 // Step 3: publish_events (requires step 1; skipped in read_only)
397 // ============================================================
398 let mut write_succeeded = false;
399
400 if read_only {
401 checks.push(skipped("publish_events", "read-only mode"));
402 checks.push(skipped("git_repo_initialised", "read-only mode"));
403 checks.push(skipped("git_push", "read-only mode"));
404 } else {
405 let step3_start = Instant::now();
406
407 let send_result = match (&announcement_event_opt, &state_event_opt) {
408 (Some(ann), Some(state)) => {
409 let r1 = client.send_event(ann.clone()).await;
410 let r2 = if r1.is_ok() {
411 client.send_event(state.clone()).await
412 } else {
413 r1.map(|_| EventId::all_zeros())
414 };
415 r2
416 }
417 _ => Err(anyhow::anyhow!(
418 "Events could not be built (local repo init failed)"
419 )),
420 };
421
422 let step3_ms = step3_start.elapsed().as_millis() as u64;
423
424 match send_result {
425 Ok(_) => {
426 checks.push(ProbeCheck {
427 name: "publish_events",
428 passed: true,
429 skipped: false,
430 duration_ms: step3_ms,
431 detail: None,
432 error: None,
433 });
434 write_succeeded = true;
435 }
436 Err(e) => {
437 checks.push(ProbeCheck {
438 name: "publish_events",
439 passed: false,
440 skipped: false,
441 duration_ms: step3_ms,
442 detail: None,
443 error: Some(e.to_string()),
444 });
445 // Skip steps 4 and 5; step 6 will use fallback
446 checks.push(skipped(
447 "git_repo_initialised",
448 "publish_events failed",
449 ));
450 checks.push(skipped("git_push", "publish_events failed"));
451 }
452 }
453
454 // ============================================================
455 // Step 4: git_repo_initialised (requires step 3)
456 // ============================================================
457 if write_succeeded {
458 let step4_start = Instant::now();
459 let poll_url = format!("{}/info/refs?service=git-upload-pack", clone_url);
460 let http_client = reqwest::Client::new();
461 let deadline = Instant::now() + Duration::from_secs(15);
462 let mut repo_ready = false;
463
464 loop {
465 if Instant::now() >= deadline {
466 break;
467 }
468 match http_client.get(&poll_url).send().await {
469 Ok(resp) if resp.status().as_u16() != 404 => {
470 repo_ready = true;
471 break;
472 }
473 _ => {}
474 }
475 tokio::time::sleep(Duration::from_millis(500)).await;
476 }
477
478 let step4_ms = step4_start.elapsed().as_millis() as u64;
479
480 if repo_ready {
481 checks.push(ProbeCheck {
482 name: "git_repo_initialised",
483 passed: true,
484 skipped: false,
485 duration_ms: step4_ms,
486 detail: None,
487 error: None,
488 });
489 } else {
490 checks.push(ProbeCheck {
491 name: "git_repo_initialised",
492 passed: false,
493 skipped: false,
494 duration_ms: step4_ms,
495 detail: None,
496 error: Some("timeout waiting for repo to be initialised (15s)".to_string()),
497 });
498 write_succeeded = false;
499 checks.push(skipped("git_push", "git_repo_initialised timed out"));
500 }
501 }
502
503 // ============================================================
504 // Step 5: git_push (requires step 4)
505 // ============================================================
506 if write_succeeded {
507 let step5_start = Instant::now();
508 let push_result = try_push(&local_repo_path);
509 let step5_ms = step5_start.elapsed().as_millis() as u64;
510
511 match push_result {
512 Ok(true) => {
513 checks.push(ProbeCheck {
514 name: "git_push",
515 passed: true,
516 skipped: false,
517 duration_ms: step5_ms,
518 detail: None,
519 error: None,
520 });
521 }
522 Ok(false) => {
523 checks.push(ProbeCheck {
524 name: "git_push",
525 passed: false,
526 skipped: false,
527 duration_ms: step5_ms,
528 detail: None,
529 error: Some("push rejected by relay".to_string()),
530 });
531 write_succeeded = false;
532 }
533 Err(e) => {
534 checks.push(ProbeCheck {
535 name: "git_push",
536 passed: false,
537 skipped: false,
538 duration_ms: step5_ms,
539 detail: None,
540 error: Some(e),
541 });
542 write_succeeded = false;
543 }
544 }
545 }
546 }
547
548 // ============================================================
549 // Step 6: git_fetch_refs
550 // ============================================================
551 // Two paths:
552 // write_succeeded=true → check our own repo; compare refs against our state event
553 // write_succeeded=false → find any existing kind 30617; just verify refs are readable
554 //
555 // In read-only mode we also run a find_announcement check first.
556
557 // Helper: parse pkt-line body into a map of refname -> commit hash,
558 // excluding refs/nostr/* entries (only branches and tags).
559 fn parse_refs(body: &str) -> Vec<(String, String)> {
560 let mut refs = Vec::new();
561 for line in body.lines() {
562 // pkt-line: 4-hex-char length prefix, then "<hash> <refname>\0<caps>" or "<hash> <refname>"
563 let content = if line.len() > 4 { &line[4..] } else { continue };
564 // Strip NUL and everything after (capabilities on first line)
565 let content = content.split('\0').next().unwrap_or(content).trim();
566 // Skip flush packets and service lines
567 if content.starts_with('#') || content.is_empty() || content == "0000" {
568 continue;
569 }
570 let mut parts = content.splitn(2, ' ');
571 let hash = match parts.next() { Some(h) if h.len() == 40 => h, _ => continue };
572 let refname = match parts.next() { Some(r) => r.trim(), None => continue };
573 // Skip refs/nostr/* — only branches (refs/heads/*) and tags (refs/tags/*)
574 if refname.starts_with("refs/nostr/") {
575 continue;
576 }
577 refs.push((refname.to_string(), hash.to_string()));
578 }
579 refs
580 }
581
582 if write_succeeded {
583 // ---- Write path ----
584 // Step 6a: git_fetch_refs — just verify the endpoint returns 200
585 let refs_url = format!("{}/info/refs?service=git-upload-pack", clone_url);
586 let http_client = reqwest::Client::new();
587
588 let step6_start = Instant::now();
589 let refs_result = tokio::time::timeout(
590 Duration::from_secs(timeout_secs),
591 http_client.get(&refs_url).send(),
592 )
593 .await;
594 let step6_ms = step6_start.elapsed().as_millis() as u64;
595
596 // Capture body for the next check; only proceed to match check if fetch succeeded
597 let refs_body: Option<String> = match refs_result {
598 Ok(Ok(resp)) if resp.status().is_success() => {
599 let body = resp.text().await.unwrap_or_default();
600 checks.push(ProbeCheck {
601 name: "git_fetch_refs",
602 passed: true,
603 skipped: false,
604 duration_ms: step6_ms,
605 detail: None,
606 error: None,
607 });
608 Some(body)
609 }
610 Ok(Ok(resp)) => {
611 checks.push(ProbeCheck {
612 name: "git_fetch_refs",
613 passed: false,
614 skipped: false,
615 duration_ms: step6_ms,
616 detail: None,
617 error: Some(format!("HTTP {}", resp.status())),
618 });
619 None
620 }
621 Ok(Err(e)) => {
622 checks.push(ProbeCheck {
623 name: "git_fetch_refs",
624 passed: false,
625 skipped: false,
626 duration_ms: step6_ms,
627 detail: None,
628 error: Some(e.to_string()),
629 });
630 None
631 }
632 Err(_) => {
633 checks.push(ProbeCheck {
634 name: "git_fetch_refs",
635 passed: false,
636 skipped: false,
637 duration_ms: step6_ms,
638 detail: None,
639 error: Some("timeout".to_string()),
640 });
641 None
642 }
643 };
644
645 // Step 6b: git_refs_match_state — compare fetched refs against our state event
646 match refs_body {
647 None => {
648 checks.push(skipped("git_refs_match_state", "git_fetch_refs failed"));
649 }
650 Some(body) => {
651 let fetched_refs = parse_refs(&body);
652 let mut mismatches: Vec<String> = Vec::new();
653
654 if let Some(ref state_ev) = state_event_opt {
655 for tag in state_ev.tags.iter() {
656 let kind_str = match tag.kind() {
657 TagKind::Custom(ref s) => s.clone(),
658 _ => continue,
659 };
660 // Only check refs/heads/* and refs/tags/*, skip HEAD and refs/nostr/*
661 if !kind_str.starts_with("refs/heads/")
662 && !kind_str.starts_with("refs/tags/")
663 {
664 continue;
665 }
666 let expected_hash = match tag.content() {
667 Some(h) => h.to_string(),
668 None => continue,
669 };
670 let found = fetched_refs.iter().find(|(r, _)| r == &kind_str);
671 match found {
672 Some((_, actual_hash)) if actual_hash == &expected_hash => {}
673 Some((_, actual_hash)) => {
674 mismatches.push(format!(
675 "{}: expected {} got {}",
676 kind_str,
677 &expected_hash[..8.min(expected_hash.len())],
678 &actual_hash[..8.min(actual_hash.len())]
679 ));
680 }
681 None => {
682 mismatches.push(format!(
683 "{}: expected {} not found in refs",
684 kind_str,
685 &expected_hash[..8.min(expected_hash.len())]
686 ));
687 }
688 }
689 }
690 }
691
692 checks.push(ProbeCheck {
693 name: "git_refs_match_state",
694 passed: mismatches.is_empty(),
695 skipped: false,
696 duration_ms: 0, // no extra network call; cost already in git_fetch_refs
697 detail: None,
698 error: if mismatches.is_empty() {
699 None
700 } else {
701 Some(mismatches.join("; "))
702 },
703 });
704 }
705 }
706 } else {
707 // ---- Fallback path: find any existing kind 30617, check refs readable ----
708
709 // In read-only mode: first check that at least one announcement exists
710 let filter = Filter::new().kind(Kind::GitRepoAnnouncement).limit(1);
711 let existing = client
712 .client()
713 .fetch_events(filter, Duration::from_secs(5))
714 .await
715 .unwrap_or_default();
716
717 let found_event = existing.into_iter().next();
718
719 if read_only {
720 // Explicit check: was an announcement found?
721 match &found_event {
722 Some(ev) => {
723 let ann_npub = ev.pubkey.to_bech32().unwrap_or_else(|_| ev.pubkey.to_hex());
724 let ann_id = ev
725 .tags
726 .iter()
727 .find(|t| t.kind() == TagKind::d())
728 .and_then(|t| t.content())
729 .unwrap_or("unknown")
730 .to_string();
731 checks.push(ProbeCheck {
732 name: "find_announcement",
733 passed: true,
734 skipped: false,
735 duration_ms: 0,
736 detail: Some(format!("{}/{}", ann_npub, ann_id)),
737 error: None,
738 });
739 }
740 None => {
741 checks.push(ProbeCheck {
742 name: "find_announcement",
743 passed: false,
744 skipped: false,
745 duration_ms: 0,
746 detail: None,
747 error: Some("no kind:30617 announcements found on relay".to_string()),
748 });
749 let _ = std::fs::remove_dir_all(&local_repo_path);
750 let all_passed = checks.iter().all(|c| c.passed || c.skipped);
751 return ProbeReport {
752 relay_url: relay_url.to_string(),
753 timestamp,
754 all_passed,
755 total_duration_ms: total_start.elapsed().as_millis() as u64,
756 checks,
757 };
758 }
759 }
760 }
761
762 // Now fetch refs from the found repo
763 match found_event {
764 Some(ev) => {
765 let ann_npub = ev.pubkey.to_bech32().unwrap_or_else(|_| ev.pubkey.to_hex());
766 let ann_id = ev
767 .tags
768 .iter()
769 .find(|t| t.kind() == TagKind::d())
770 .and_then(|t| t.content())
771 .unwrap_or("unknown")
772 .to_string();
773
774 // detail (npub/identifier) only shown in read-only mode
775 let detail_id = if read_only {
776 Some(format!("{}/{}", ann_npub, ann_id))
777 } else {
778 None
779 };
780
781 // Prefer the clone tag URL; fall back to constructing from relay
782 let fetch_url = ev
783 .tags
784 .iter()
785 .find(|t| t.kind() == TagKind::custom("clone"))
786 .and_then(|t| t.content())
787 .map(|s| s.to_string())
788 .unwrap_or_else(|| {
789 format!("{}/{}/{}.git", http_base, ann_npub, ann_id)
790 });
791
792 let step6_start = Instant::now();
793 let refs_url = format!("{}/info/refs?service=git-upload-pack", fetch_url);
794 let http_client = reqwest::Client::new();
795 let refs_result = tokio::time::timeout(
796 Duration::from_secs(timeout_secs),
797 http_client.get(&refs_url).send(),
798 )
799 .await;
800 let step6_ms = step6_start.elapsed().as_millis() as u64;
801
802 match refs_result {
803 Ok(Ok(resp)) if resp.status().is_success() => {
804 checks.push(ProbeCheck {
805 name: "git_fetch_refs",
806 passed: true,
807 skipped: false,
808 duration_ms: step6_ms,
809 detail: detail_id,
810 error: None,
811 });
812 }
813 Ok(Ok(resp)) => {
814 checks.push(ProbeCheck {
815 name: "git_fetch_refs",
816 passed: false,
817 skipped: false,
818 duration_ms: step6_ms,
819 detail: detail_id,
820 error: Some(format!("HTTP {}", resp.status())),
821 });
822 }
823 Ok(Err(e)) => {
824 checks.push(ProbeCheck {
825 name: "git_fetch_refs",
826 passed: false,
827 skipped: false,
828 duration_ms: step6_ms,
829 detail: detail_id,
830 error: Some(e.to_string()),
831 });
832 }
833 Err(_) => {
834 checks.push(ProbeCheck {
835 name: "git_fetch_refs",
836 passed: false,
837 skipped: false,
838 duration_ms: step6_ms,
839 detail: detail_id,
840 error: Some("timeout".to_string()),
841 });
842 }
843 }
844
845 // git_refs_match_state is skipped in fallback — no state event to compare
846 checks.push(skipped(
847 "git_refs_match_state",
848 "no state event (fallback path)",
849 ));
850 }
851 None => {
852 // Not read-only (already handled above) but no repo found
853 checks.push(ProbeCheck {
854 name: "git_fetch_refs",
855 passed: false,
856 skipped: false,
857 duration_ms: 0,
858 detail: None,
859 error: Some("no repositories found on relay".to_string()),
860 });
861 checks.push(skipped(
862 "git_refs_match_state",
863 "no state event (fallback path)",
864 ));
865 }
866 }
867 }
868
869 // ============================================================
870 // CLEANUP
871 // ============================================================
872 let _ = std::fs::remove_dir_all(&local_repo_path);
873
874 let all_passed = checks.iter().all(|c| c.passed || c.skipped);
875 ProbeReport {
876 relay_url: relay_url.to_string(),
877 timestamp,
878 all_passed,
879 total_duration_ms: total_start.elapsed().as_millis() as u64,
880 checks,
881 }
882}