diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-03 17:02:31 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-03 17:02:31 +0000 |
| commit | d428baf30feec295870fadda2d335d1e7f89507b (patch) | |
| tree | 4d23e3a3fabb2512f903b778fb77fed97b805832 /docs | |
docs: one-prompt architecture plan
ok 2 prompts, the second one was about the test strategy so we could
reuse it. I was thinking of a tool like blossom audit. but i didnt
mention it specifically.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/ARCHITECTURE.md | 808 | ||||
| -rw-r--r-- | docs/COMPARISON.md | 256 | ||||
| -rw-r--r-- | docs/DECISION_SUMMARY.md | 174 | ||||
| -rw-r--r-- | docs/GETTING_STARTED.md | 437 | ||||
| -rw-r--r-- | docs/GIT_PROTOCOL.md | 435 | ||||
| -rw-r--r-- | docs/README.md | 84 | ||||
| -rw-r--r-- | docs/TEST_STRATEGY.md | 1238 |
7 files changed, 3432 insertions, 0 deletions
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ebf7a74 --- /dev/null +++ b/docs/ARCHITECTURE.md | |||
| @@ -0,0 +1,808 @@ | |||
| 1 | # ngit-grasp Architecture | ||
| 2 | |||
| 3 | ## Executive Summary | ||
| 4 | |||
| 5 | `ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that the `git-http-backend` Rust crate provides sufficient flexibility to intercept and validate Git push operations before they reach the Git repository, eliminating the need for pre-receive hooks. | ||
| 6 | |||
| 7 | ## Architectural Decision: Inline vs. Hook-Based Authorization | ||
| 8 | |||
| 9 | ### Investigation Summary | ||
| 10 | |||
| 11 | After examining both the reference implementation and the `git-http-backend` Rust crate, we have two options: | ||
| 12 | |||
| 13 | #### Option 1: Hook-Based (Reference Implementation Approach) | ||
| 14 | - Use `git-http-backend` crate as-is | ||
| 15 | - Create pre-receive and post-receive hooks | ||
| 16 | - Hooks query the Nostr relay and validate pushes | ||
| 17 | - **Pros**: Follows reference implementation closely | ||
| 18 | - **Cons**: Requires hook management, harder to test, less Rust-native | ||
| 19 | |||
| 20 | #### Option 2: Inline Authorization (Recommended) | ||
| 21 | - Intercept Git receive-pack requests in the HTTP handler | ||
| 22 | - Validate against Nostr state before spawning Git process | ||
| 23 | - Only forward valid pushes to Git | ||
| 24 | - **Pros**: Better error handling, easier testing, pure Rust, simpler deployment | ||
| 25 | - **Cons**: Requires custom Git protocol handling | ||
| 26 | |||
| 27 | ### Decision: Inline Authorization (Option 2) | ||
| 28 | |||
| 29 | **Rationale:** | ||
| 30 | |||
| 31 | 1. **The `git-http-backend` crate is sufficiently flexible**: Examining `src/actix/git_receive_pack.rs` shows it spawns `git receive-pack` as a subprocess and streams data. We can intercept this. | ||
| 32 | |||
| 33 | 2. **Better Developer Experience**: | ||
| 34 | - Validation errors can be returned as proper HTTP responses | ||
| 35 | - No need to parse hook stderr output | ||
| 36 | - Shared state between Git and Nostr components | ||
| 37 | - Pure Rust testing without shell scripts | ||
| 38 | |||
| 39 | 3. **Simpler Deployment**: | ||
| 40 | - Single binary | ||
| 41 | - No hook symlinks or permissions to manage | ||
| 42 | - No multi-process coordination | ||
| 43 | |||
| 44 | 4. **Performance**: | ||
| 45 | - Can parse incoming pack data once | ||
| 46 | - Avoid process spawn overhead for invalid pushes | ||
| 47 | - Better async integration | ||
| 48 | |||
| 49 | ## System Architecture | ||
| 50 | |||
| 51 | ``` | ||
| 52 | ┌─────────────────────────────────────────────────────────────┐ | ||
| 53 | │ ngit-grasp │ | ||
| 54 | │ (Single Rust Binary) │ | ||
| 55 | ├─────────────────────────────────────────────────────────────┤ | ||
| 56 | │ │ | ||
| 57 | │ ┌──────────────────┐ ┌──────────────────┐ │ | ||
| 58 | │ │ HTTP Router │ │ Nostr Relay │ │ | ||
| 59 | │ │ (actix-web) │ │ (nostr-relay- │ │ | ||
| 60 | │ │ │ │ builder) │ │ | ||
| 61 | │ └────────┬─────────┘ └────────┬─────────┘ │ | ||
| 62 | │ │ │ │ | ||
| 63 | │ │ │ │ | ||
| 64 | │ ┌────────▼──────────────────────────────────▼─────────┐ │ | ||
| 65 | │ │ Shared State & Storage │ │ | ||
| 66 | │ │ ┌──────────────┐ ┌──────────────┐ │ │ | ||
| 67 | │ │ │ Repository │ │ Event Store │ │ │ | ||
| 68 | │ │ │ Manager │ │ (LMDB/NDB) │ │ │ | ||
| 69 | │ │ └──────────────┘ └──────────────┘ │ │ | ||
| 70 | │ └─────────────────────────────────────────────────────┘ │ | ||
| 71 | │ │ | ||
| 72 | │ ┌──────────────────────────────────────────────────────┐ │ | ||
| 73 | │ │ Git Protocol Handler │ │ | ||
| 74 | │ │ │ │ | ||
| 75 | │ │ 1. Receive git-receive-pack request │ │ | ||
| 76 | │ │ 2. Parse ref updates from request │ │ | ||
| 77 | │ │ 3. Query Nostr relay for state event │ │ | ||
| 78 | │ │ 4. Validate refs against state │ │ | ||
| 79 | │ │ 5. If valid: spawn git-receive-pack │ │ | ||
| 80 | │ │ 6. If invalid: return HTTP error │ │ | ||
| 81 | │ │ │ │ | ||
| 82 | │ └──────────────────────────────────────────────────────┘ │ | ||
| 83 | │ │ | ||
| 84 | └─────────────────────────────────────────────────────────────┘ | ||
| 85 | │ │ | ||
| 86 | │ HTTP/Git │ WebSocket/Nostr | ||
| 87 | ▼ ▼ | ||
| 88 | Git Clients Nostr Clients | ||
| 89 | ``` | ||
| 90 | |||
| 91 | ## Component Design | ||
| 92 | |||
| 93 | ### 1. Main Server (`src/main.rs`) | ||
| 94 | |||
| 95 | **Responsibilities:** | ||
| 96 | - Initialize configuration from environment | ||
| 97 | - Set up actix-web HTTP server | ||
| 98 | - Initialize Nostr relay builder | ||
| 99 | - Set up shared storage | ||
| 100 | - Configure routes for both Git and Nostr endpoints | ||
| 101 | - Handle graceful shutdown | ||
| 102 | |||
| 103 | **Key Dependencies:** | ||
| 104 | ```rust | ||
| 105 | actix-web = "4" | ||
| 106 | tokio = { version = "1", features = ["full"] } | ||
| 107 | nostr-relay-builder = "0.43" | ||
| 108 | nostr-sdk = "0.43" | ||
| 109 | ``` | ||
| 110 | |||
| 111 | ### 2. Git Module (`src/git/`) | ||
| 112 | |||
| 113 | #### `handler.rs` - Git HTTP Handlers | ||
| 114 | |||
| 115 | Implements actix-web handlers for Git Smart HTTP protocol: | ||
| 116 | |||
| 117 | ```rust | ||
| 118 | // GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack | ||
| 119 | async fn info_refs_upload_pack( | ||
| 120 | req: HttpRequest, | ||
| 121 | state: web::Data<AppState>, | ||
| 122 | ) -> Result<HttpResponse> | ||
| 123 | |||
| 124 | // POST /<npub>/<identifier>.git/git-upload-pack | ||
| 125 | async fn git_upload_pack( | ||
| 126 | req: HttpRequest, | ||
| 127 | body: web::Payload, | ||
| 128 | state: web::Data<AppState>, | ||
| 129 | ) -> Result<HttpResponse> | ||
| 130 | |||
| 131 | // GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack | ||
| 132 | async fn info_refs_receive_pack( | ||
| 133 | req: HttpRequest, | ||
| 134 | state: web::Data<AppState>, | ||
| 135 | ) -> Result<HttpResponse> | ||
| 136 | |||
| 137 | // POST /<npub>/<identifier>.git/git-receive-pack | ||
| 138 | // THIS IS WHERE THE MAGIC HAPPENS | ||
| 139 | async fn git_receive_pack( | ||
| 140 | req: HttpRequest, | ||
| 141 | body: web::Payload, | ||
| 142 | state: web::Data<AppState>, | ||
| 143 | ) -> Result<HttpResponse> | ||
| 144 | ``` | ||
| 145 | |||
| 146 | #### `authorization.rs` - Push Validation | ||
| 147 | |||
| 148 | **Core Logic:** | ||
| 149 | |||
| 150 | ```rust | ||
| 151 | pub struct PushValidator { | ||
| 152 | nostr_client: Arc<Client>, | ||
| 153 | relay_url: String, | ||
| 154 | } | ||
| 155 | |||
| 156 | impl PushValidator { | ||
| 157 | /// Validate a push operation against Nostr state | ||
| 158 | pub async fn validate_push( | ||
| 159 | &self, | ||
| 160 | npub: &str, | ||
| 161 | identifier: &str, | ||
| 162 | ref_updates: Vec<RefUpdate>, | ||
| 163 | ) -> Result<ValidationResult> { | ||
| 164 | // 1. Fetch announcement and state events from local relay | ||
| 165 | let events = self.fetch_events(identifier).await?; | ||
| 166 | |||
| 167 | // 2. Extract pubkey from npub | ||
| 168 | let pubkey = decode_npub(npub)?; | ||
| 169 | |||
| 170 | // 3. Get recursive maintainer set | ||
| 171 | let maintainers = get_maintainers(&events, &pubkey, identifier); | ||
| 172 | |||
| 173 | // 4. Get latest state from maintainers | ||
| 174 | let state = get_state_from_maintainers(&events, &maintainers)?; | ||
| 175 | |||
| 176 | // 5. Validate each ref update | ||
| 177 | for ref_update in ref_updates { | ||
| 178 | if ref_update.ref_name.starts_with("refs/nostr/") { | ||
| 179 | // Allow refs/nostr/<event-id> for PRs | ||
| 180 | validate_pr_ref(&ref_update)?; | ||
| 181 | } else if ref_update.ref_name.starts_with("refs/heads/pr/") { | ||
| 182 | // Reject pr/* branches - should use refs/nostr/ | ||
| 183 | return Err(Error::InvalidRef("pr/* branches must use refs/nostr/")); | ||
| 184 | } else { | ||
| 185 | // Validate against state event | ||
| 186 | validate_state_ref(&state, &ref_update)?; | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | Ok(ValidationResult::Accept) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | ``` | ||
| 194 | |||
| 195 | **Key Functions:** | ||
| 196 | |||
| 197 | ```rust | ||
| 198 | /// Parse ref updates from git-receive-pack request body | ||
| 199 | fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> | ||
| 200 | |||
| 201 | /// Recursively find all maintainers | ||
| 202 | fn get_maintainers( | ||
| 203 | events: &[Event], | ||
| 204 | pubkey: &str, | ||
| 205 | identifier: &str, | ||
| 206 | ) -> Vec<String> | ||
| 207 | |||
| 208 | /// Get latest state from maintainer set | ||
| 209 | fn get_state_from_maintainers( | ||
| 210 | events: &[Event], | ||
| 211 | maintainers: &[String], | ||
| 212 | ) -> Result<RepositoryState> | ||
| 213 | |||
| 214 | /// Validate a ref matches the state event | ||
| 215 | fn validate_state_ref( | ||
| 216 | state: &RepositoryState, | ||
| 217 | ref_update: &RefUpdate, | ||
| 218 | ) -> Result<()> | ||
| 219 | ``` | ||
| 220 | |||
| 221 | ### 3. Nostr Module (`src/nostr/`) | ||
| 222 | |||
| 223 | #### `relay.rs` - Relay Configuration | ||
| 224 | |||
| 225 | ```rust | ||
| 226 | pub async fn build_relay(config: &Config) -> Result<LocalRelay> { | ||
| 227 | let builder = RelayBuilder::default() | ||
| 228 | .write_policy(RepositoryAnnouncementPolicy::new(config.domain.clone())) | ||
| 229 | .write_policy(RelatedEventsPolicy::new()) | ||
| 230 | .query_policy(StandardQueryPolicy::new()) | ||
| 231 | .on_event_saved(create_repository_hook(config.git_data_path.clone())); | ||
| 232 | |||
| 233 | // Configure storage backend (LMDB or NDB) | ||
| 234 | let relay = LocalRelay::run(builder).await?; | ||
| 235 | |||
| 236 | Ok(relay) | ||
| 237 | } | ||
| 238 | ``` | ||
| 239 | |||
| 240 | #### `events.rs` - Event Handlers | ||
| 241 | |||
| 242 | ```rust | ||
| 243 | /// Hook called when events are saved | ||
| 244 | pub fn create_repository_hook( | ||
| 245 | git_data_path: PathBuf, | ||
| 246 | ) -> impl Fn(&Event) -> BoxFuture<'static, ()> { | ||
| 247 | move |event: &Event| { | ||
| 248 | let git_path = git_data_path.clone(); | ||
| 249 | Box::pin(async move { | ||
| 250 | if event.kind == Kind::RepositoryAnnouncement { | ||
| 251 | handle_repository_announcement(event, &git_path).await; | ||
| 252 | } else if event.kind == Kind::RepositoryState { | ||
| 253 | handle_repository_state(event, &git_path).await; | ||
| 254 | } | ||
| 255 | }) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | async fn handle_repository_announcement(event: &Event, git_path: &Path) { | ||
| 260 | // 1. Parse repository from event | ||
| 261 | // 2. Check if listed in clone and relays tags | ||
| 262 | // 3. Create empty bare Git repository | ||
| 263 | // 4. Configure uploadpack.allowTipSHA1InWant | ||
| 264 | // 5. Configure uploadpack.allowUnreachable | ||
| 265 | // 6. Configure http.receivepack | ||
| 266 | } | ||
| 267 | |||
| 268 | async fn handle_repository_state(event: &Event, git_path: &Path) { | ||
| 269 | // 1. Parse state from event | ||
| 270 | // 2. Update repository HEAD if needed | ||
| 271 | // 3. Trigger proactive sync (GRASP-02) | ||
| 272 | } | ||
| 273 | ``` | ||
| 274 | |||
| 275 | **Write Policies:** | ||
| 276 | |||
| 277 | ```rust | ||
| 278 | /// Accept repository announcements that list this instance | ||
| 279 | pub struct RepositoryAnnouncementPolicy { | ||
| 280 | domain: String, | ||
| 281 | } | ||
| 282 | |||
| 283 | impl WritePolicy for RepositoryAnnouncementPolicy { | ||
| 284 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 285 | -> BoxFuture<PolicyResult> | ||
| 286 | { | ||
| 287 | Box::pin(async move { | ||
| 288 | if event.kind != Kind::RepositoryAnnouncement { | ||
| 289 | return PolicyResult::Accept; // Not our concern | ||
| 290 | } | ||
| 291 | |||
| 292 | // Check if this instance is in clone and relays tags | ||
| 293 | let has_clone = event.tags.iter() | ||
| 294 | .any(|t| t.kind() == "clone" && t.content() == Some(&self.domain)); | ||
| 295 | let has_relay = event.tags.iter() | ||
| 296 | .any(|t| t.kind() == "relays" && t.content() == Some(&self.domain)); | ||
| 297 | |||
| 298 | if has_clone && has_relay { | ||
| 299 | PolicyResult::Accept | ||
| 300 | } else { | ||
| 301 | PolicyResult::Reject("instance not listed in clone and relays".into()) | ||
| 302 | } | ||
| 303 | }) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | /// Accept events related to stored announcements/issues/patches | ||
| 308 | pub struct RelatedEventsPolicy; | ||
| 309 | |||
| 310 | impl WritePolicy for RelatedEventsPolicy { | ||
| 311 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 312 | -> BoxFuture<PolicyResult> | ||
| 313 | { | ||
| 314 | // Accept if event tags or is tagged by stored events | ||
| 315 | // Implementation requires querying the event store | ||
| 316 | } | ||
| 317 | } | ||
| 318 | ``` | ||
| 319 | |||
| 320 | ### 4. Storage Module (`src/storage/`) | ||
| 321 | |||
| 322 | #### `repository.rs` - Repository Management | ||
| 323 | |||
| 324 | ```rust | ||
| 325 | pub struct RepositoryManager { | ||
| 326 | git_data_path: PathBuf, | ||
| 327 | } | ||
| 328 | |||
| 329 | impl RepositoryManager { | ||
| 330 | /// Create a new bare Git repository | ||
| 331 | pub async fn create_repository( | ||
| 332 | &self, | ||
| 333 | npub: &str, | ||
| 334 | identifier: &str, | ||
| 335 | ) -> Result<PathBuf> { | ||
| 336 | let repo_path = self.git_data_path | ||
| 337 | .join(npub) | ||
| 338 | .join(format!("{}.git", identifier)); | ||
| 339 | |||
| 340 | // Create directory | ||
| 341 | tokio::fs::create_dir_all(&repo_path).await?; | ||
| 342 | |||
| 343 | // Initialize bare repo | ||
| 344 | Command::new("git") | ||
| 345 | .args(&["init", "--bare"]) | ||
| 346 | .arg(&repo_path) | ||
| 347 | .output() | ||
| 348 | .await?; | ||
| 349 | |||
| 350 | // Configure | ||
| 351 | self.configure_repository(&repo_path).await?; | ||
| 352 | |||
| 353 | Ok(repo_path) | ||
| 354 | } | ||
| 355 | |||
| 356 | async fn configure_repository(&self, repo_path: &Path) -> Result<()> { | ||
| 357 | // Enable unauthenticated push (we handle auth ourselves) | ||
| 358 | git_config(repo_path, "http.receivepack", "true").await?; | ||
| 359 | |||
| 360 | // Enable tip SHA1 fetching (required for ngit) | ||
| 361 | git_config(repo_path, "uploadpack.allowTipSHA1InWant", "true").await?; | ||
| 362 | |||
| 363 | // Enable unreachable object fetching | ||
| 364 | git_config(repo_path, "uploadpack.allowUnreachable", "true").await?; | ||
| 365 | |||
| 366 | Ok(()) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Check if repository exists | ||
| 370 | pub async fn repository_exists( | ||
| 371 | &self, | ||
| 372 | npub: &str, | ||
| 373 | identifier: &str, | ||
| 374 | ) -> bool { | ||
| 375 | let repo_path = self.git_data_path | ||
| 376 | .join(npub) | ||
| 377 | .join(format!("{}.git", identifier)); | ||
| 378 | |||
| 379 | repo_path.join("HEAD").exists() && | ||
| 380 | repo_path.join("config").exists() | ||
| 381 | } | ||
| 382 | } | ||
| 383 | ``` | ||
| 384 | |||
| 385 | ### 5. Configuration (`src/config.rs`) | ||
| 386 | |||
| 387 | ```rust | ||
| 388 | pub struct Config { | ||
| 389 | pub domain: String, | ||
| 390 | pub owner_npub: String, | ||
| 391 | pub relay_name: String, | ||
| 392 | pub relay_description: String, | ||
| 393 | pub git_data_path: PathBuf, | ||
| 394 | pub relay_data_path: PathBuf, | ||
| 395 | pub bind_address: SocketAddr, | ||
| 396 | pub log_level: String, | ||
| 397 | } | ||
| 398 | |||
| 399 | impl Config { | ||
| 400 | pub fn from_env() -> Result<Self> { | ||
| 401 | Ok(Config { | ||
| 402 | domain: env::var("NGIT_DOMAIN")?, | ||
| 403 | owner_npub: env::var("NGIT_OWNER_NPUB")?, | ||
| 404 | relay_name: env::var("NGIT_RELAY_NAME")?, | ||
| 405 | relay_description: env::var("NGIT_RELAY_DESCRIPTION")?, | ||
| 406 | git_data_path: PathBuf::from( | ||
| 407 | env::var("NGIT_GIT_DATA_PATH") | ||
| 408 | .unwrap_or_else(|_| "./data/git".to_string()) | ||
| 409 | ), | ||
| 410 | relay_data_path: PathBuf::from( | ||
| 411 | env::var("NGIT_RELAY_DATA_PATH") | ||
| 412 | .unwrap_or_else(|_| "./data/relay".to_string()) | ||
| 413 | ), | ||
| 414 | bind_address: env::var("NGIT_BIND_ADDRESS") | ||
| 415 | .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) | ||
| 416 | .parse()?, | ||
| 417 | log_level: env::var("RUST_LOG") | ||
| 418 | .unwrap_or_else(|_| "info".to_string()), | ||
| 419 | }) | ||
| 420 | } | ||
| 421 | } | ||
| 422 | ``` | ||
| 423 | |||
| 424 | ## Data Flow | ||
| 425 | |||
| 426 | ### Push Operation Flow | ||
| 427 | |||
| 428 | ``` | ||
| 429 | 1. Git Client → POST /<npub>/<id>.git/git-receive-pack | ||
| 430 | ↓ | ||
| 431 | 2. git_receive_pack handler receives request | ||
| 432 | ↓ | ||
| 433 | 3. Parse ref updates from request body | ||
| 434 | ↓ | ||
| 435 | 4. Extract npub and identifier from URL | ||
| 436 | ↓ | ||
| 437 | 5. PushValidator::validate_push() | ||
| 438 | ├─ Fetch events from local Nostr relay | ||
| 439 | ├─ Get maintainers recursively | ||
| 440 | ├─ Get latest state from maintainers | ||
| 441 | └─ Validate each ref update | ||
| 442 | ↓ | ||
| 443 | 6. If VALID: | ||
| 444 | ├─ Spawn git-receive-pack subprocess | ||
| 445 | ├─ Stream request body to git stdin | ||
| 446 | └─ Stream git stdout back to client | ||
| 447 | ↓ | ||
| 448 | 7. If INVALID: | ||
| 449 | └─ Return HTTP 403 with error message | ||
| 450 | ``` | ||
| 451 | |||
| 452 | ### Repository Announcement Flow | ||
| 453 | |||
| 454 | ``` | ||
| 455 | 1. Nostr Client → EVENT (Kind 30317) | ||
| 456 | ↓ | ||
| 457 | 2. Nostr relay receives event | ||
| 458 | ↓ | ||
| 459 | 3. RepositoryAnnouncementPolicy::admit_event() | ||
| 460 | ├─ Check if instance in clone tags | ||
| 461 | ├─ Check if instance in relays tags | ||
| 462 | └─ Accept or reject | ||
| 463 | ↓ | ||
| 464 | 4. If ACCEPTED: | ||
| 465 | ├─ Event saved to store | ||
| 466 | └─ on_event_saved hook triggered | ||
| 467 | ↓ | ||
| 468 | 5. handle_repository_announcement() | ||
| 469 | ├─ Parse repository details | ||
| 470 | ├─ Create Git repository directory | ||
| 471 | ├─ Initialize bare Git repo | ||
| 472 | └─ Configure Git settings | ||
| 473 | ``` | ||
| 474 | |||
| 475 | ## Key Implementation Details | ||
| 476 | |||
| 477 | ### 1. Parsing Git Receive-Pack Protocol | ||
| 478 | |||
| 479 | The Git receive-pack protocol uses a pkt-line format. We need to parse: | ||
| 480 | |||
| 481 | ``` | ||
| 482 | 0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/main\0 report-status | ||
| 483 | 0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/dev | ||
| 484 | ``` | ||
| 485 | |||
| 486 | Each line has: | ||
| 487 | - Old SHA (40 hex chars) | ||
| 488 | - Space | ||
| 489 | - New SHA (40 hex chars) | ||
| 490 | - Space | ||
| 491 | - Ref name | ||
| 492 | - Optional capabilities (first line only, after \0) | ||
| 493 | |||
| 494 | ```rust | ||
| 495 | pub struct RefUpdate { | ||
| 496 | pub old_sha: String, | ||
| 497 | pub new_sha: String, | ||
| 498 | pub ref_name: String, | ||
| 499 | } | ||
| 500 | |||
| 501 | pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> { | ||
| 502 | // Parse pkt-line format | ||
| 503 | // Extract ref updates | ||
| 504 | // Return structured data | ||
| 505 | } | ||
| 506 | ``` | ||
| 507 | |||
| 508 | ### 2. Maintainer Recursion | ||
| 509 | |||
| 510 | The maintainer resolution must handle cycles and correctly build the set: | ||
| 511 | |||
| 512 | ```rust | ||
| 513 | fn get_maintainers_recursive( | ||
| 514 | events: &[Event], | ||
| 515 | pubkey: &str, | ||
| 516 | identifier: &str, | ||
| 517 | visited: &mut HashSet<String>, | ||
| 518 | ) -> HashSet<String> { | ||
| 519 | if visited.contains(pubkey) { | ||
| 520 | return HashSet::new(); | ||
| 521 | } | ||
| 522 | |||
| 523 | visited.insert(pubkey.to_string()); | ||
| 524 | |||
| 525 | let announcement = find_announcement(events, pubkey, identifier); | ||
| 526 | if announcement.is_none() { | ||
| 527 | return HashSet::new(); | ||
| 528 | } | ||
| 529 | |||
| 530 | let repo = parse_repository(announcement.unwrap()); | ||
| 531 | |||
| 532 | for maintainer in repo.maintainers { | ||
| 533 | get_maintainers_recursive(events, &maintainer, identifier, visited); | ||
| 534 | } | ||
| 535 | |||
| 536 | visited.clone() | ||
| 537 | } | ||
| 538 | ``` | ||
| 539 | |||
| 540 | ### 3. State Event Validation | ||
| 541 | |||
| 542 | ```rust | ||
| 543 | fn validate_state_ref( | ||
| 544 | state: &RepositoryState, | ||
| 545 | ref_update: &RefUpdate, | ||
| 546 | ) -> Result<()> { | ||
| 547 | if ref_update.ref_name.starts_with("refs/heads/") { | ||
| 548 | let branch_name = &ref_update.ref_name[11..]; | ||
| 549 | if let Some(commit) = state.branches.get(branch_name) { | ||
| 550 | if commit == &ref_update.new_sha { | ||
| 551 | return Ok(()); | ||
| 552 | } | ||
| 553 | return Err(Error::StateMismatch { | ||
| 554 | ref_name: ref_update.ref_name.clone(), | ||
| 555 | expected: commit.clone(), | ||
| 556 | got: ref_update.new_sha.clone(), | ||
| 557 | }); | ||
| 558 | } | ||
| 559 | return Err(Error::RefNotInState(ref_update.ref_name.clone())); | ||
| 560 | } | ||
| 561 | |||
| 562 | if ref_update.ref_name.starts_with("refs/tags/") { | ||
| 563 | let tag_name = &ref_update.ref_name[10..]; | ||
| 564 | if let Some(commit) = state.tags.get(tag_name) { | ||
| 565 | if commit == &ref_update.new_sha { | ||
| 566 | return Ok(()); | ||
| 567 | } | ||
| 568 | return Err(Error::StateMismatch { | ||
| 569 | ref_name: ref_update.ref_name.clone(), | ||
| 570 | expected: commit.clone(), | ||
| 571 | got: ref_update.new_sha.clone(), | ||
| 572 | }); | ||
| 573 | } | ||
| 574 | return Err(Error::RefNotInState(ref_update.ref_name.clone())); | ||
| 575 | } | ||
| 576 | |||
| 577 | Err(Error::InvalidRef(ref_update.ref_name.clone())) | ||
| 578 | } | ||
| 579 | ``` | ||
| 580 | |||
| 581 | ### 4. CORS Support | ||
| 582 | |||
| 583 | As per GRASP-01, we must support CORS: | ||
| 584 | |||
| 585 | ```rust | ||
| 586 | use actix_cors::Cors; | ||
| 587 | |||
| 588 | fn configure_cors() -> Cors { | ||
| 589 | Cors::default() | ||
| 590 | .allow_any_origin() | ||
| 591 | .allowed_methods(vec!["GET", "POST", "OPTIONS"]) | ||
| 592 | .allowed_headers(vec!["Content-Type"]) | ||
| 593 | .max_age(3600) | ||
| 594 | } | ||
| 595 | |||
| 596 | // In main.rs | ||
| 597 | App::new() | ||
| 598 | .wrap(configure_cors()) | ||
| 599 | .configure(git_routes) | ||
| 600 | .configure(nostr_routes) | ||
| 601 | ``` | ||
| 602 | |||
| 603 | ## Testing Strategy | ||
| 604 | |||
| 605 | See [TEST_STRATEGY.md](TEST_STRATEGY.md) for comprehensive testing documentation, including: | ||
| 606 | |||
| 607 | - **GRASP Compliance Testing Tool**: Reusable test suite that validates any GRASP implementation against the spec | ||
| 608 | - **Spec-Mirrored Tests**: Test structure matches GRASP protocol documents exactly | ||
| 609 | - **Clear Failure Messages**: Test failures cite exact spec lines (e.g., "GRASP-01:12-13") | ||
| 610 | - **Multiple Test Levels**: Unit, integration, compliance, and end-to-end tests | ||
| 611 | |||
| 612 | ### Quick Overview | ||
| 613 | |||
| 614 | ```rust | ||
| 615 | // Unit Tests - Individual functions | ||
| 616 | #[test] | ||
| 617 | fn test_parse_ref_updates() { | ||
| 618 | let body = b"0000... 0000... refs/heads/main\0report-status\n"; | ||
| 619 | let updates = parse_ref_updates(body).unwrap(); | ||
| 620 | assert_eq!(updates.len(), 1); | ||
| 621 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 622 | } | ||
| 623 | |||
| 624 | // Integration Tests - Component interaction | ||
| 625 | #[tokio::test] | ||
| 626 | async fn test_full_push_flow() { | ||
| 627 | let app = test_app().await; | ||
| 628 | let (announcement, state) = app.create_repo_with_state() | ||
| 629 | .branch("main", "commit-123") | ||
| 630 | .build() | ||
| 631 | .await; | ||
| 632 | |||
| 633 | let result = app.git_push("main", "commit-123").await; | ||
| 634 | assert!(result.success); | ||
| 635 | } | ||
| 636 | |||
| 637 | // Compliance Tests - GRASP spec validation | ||
| 638 | #[tokio::test] | ||
| 639 | async fn test_grasp_01_compliance() { | ||
| 640 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | ||
| 641 | |||
| 642 | let ctx = TestContext::builder() | ||
| 643 | .base_url(&server.url()) | ||
| 644 | .build(); | ||
| 645 | |||
| 646 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 647 | assert!(results.all_passed(), "{}", results.report()); | ||
| 648 | } | ||
| 649 | ``` | ||
| 650 | |||
| 651 | The compliance testing tool is designed as a **standalone crate** that can be: | ||
| 652 | - Used by ngit-grasp for self-validation | ||
| 653 | - Published for other GRASP implementations to use | ||
| 654 | - Updated as new GRASP specs are released | ||
| 655 | - Run in CI/CD for continuous compliance verification | ||
| 656 | |||
| 657 | ## Performance Considerations | ||
| 658 | |||
| 659 | ### 1. Async All The Way | ||
| 660 | |||
| 661 | - Use `tokio` for all I/O | ||
| 662 | - Non-blocking Git subprocess spawning | ||
| 663 | - Stream large pack files without buffering | ||
| 664 | |||
| 665 | ### 2. Connection Pooling | ||
| 666 | |||
| 667 | - Reuse Nostr relay connections | ||
| 668 | - Connection pool for internal relay queries | ||
| 669 | |||
| 670 | ### 3. Caching | ||
| 671 | |||
| 672 | - Cache parsed state events (with TTL) | ||
| 673 | - Cache maintainer sets | ||
| 674 | - Invalidate on new state events | ||
| 675 | |||
| 676 | ```rust | ||
| 677 | pub struct StateCache { | ||
| 678 | cache: Arc<RwLock<HashMap<String, CachedState>>>, | ||
| 679 | } | ||
| 680 | |||
| 681 | struct CachedState { | ||
| 682 | state: RepositoryState, | ||
| 683 | maintainers: Vec<String>, | ||
| 684 | timestamp: Instant, | ||
| 685 | } | ||
| 686 | |||
| 687 | impl StateCache { | ||
| 688 | pub async fn get_or_fetch( | ||
| 689 | &self, | ||
| 690 | identifier: &str, | ||
| 691 | fetcher: impl Future<Output = Result<(RepositoryState, Vec<String>)>>, | ||
| 692 | ) -> Result<(RepositoryState, Vec<String>)> { | ||
| 693 | // Check cache | ||
| 694 | // Return if fresh | ||
| 695 | // Otherwise fetch and cache | ||
| 696 | } | ||
| 697 | } | ||
| 698 | ``` | ||
| 699 | |||
| 700 | ## Future Extensions | ||
| 701 | |||
| 702 | ### GRASP-02: Proactive Sync | ||
| 703 | |||
| 704 | Add background tasks: | ||
| 705 | |||
| 706 | ```rust | ||
| 707 | pub struct ProactiveSyncTask { | ||
| 708 | relay_client: Client, | ||
| 709 | git_manager: RepositoryManager, | ||
| 710 | } | ||
| 711 | |||
| 712 | impl ProactiveSyncTask { | ||
| 713 | pub async fn run(&self) { | ||
| 714 | loop { | ||
| 715 | tokio::time::sleep(Duration::from_secs(3600)).await; | ||
| 716 | |||
| 717 | // Fetch all announcements from our relay | ||
| 718 | let announcements = self.fetch_announcements().await; | ||
| 719 | |||
| 720 | for ann in announcements { | ||
| 721 | // Sync events from listed relays | ||
| 722 | self.sync_events(&ann).await; | ||
| 723 | |||
| 724 | // Sync git data from listed clones | ||
| 725 | self.sync_git_data(&ann).await; | ||
| 726 | |||
| 727 | // Fetch PR data | ||
| 728 | self.sync_pr_data(&ann).await; | ||
| 729 | } | ||
| 730 | } | ||
| 731 | } | ||
| 732 | } | ||
| 733 | ``` | ||
| 734 | |||
| 735 | ### GRASP-05: Archive | ||
| 736 | |||
| 737 | Relax the policy: | ||
| 738 | |||
| 739 | ```rust | ||
| 740 | pub struct ArchiveAnnouncementPolicy; | ||
| 741 | |||
| 742 | impl WritePolicy for ArchiveAnnouncementPolicy { | ||
| 743 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 744 | -> BoxFuture<PolicyResult> | ||
| 745 | { | ||
| 746 | // Accept all repository announcements | ||
| 747 | // Don't check clone/relays tags | ||
| 748 | PolicyResult::Accept | ||
| 749 | } | ||
| 750 | } | ||
| 751 | ``` | ||
| 752 | |||
| 753 | ## Deployment | ||
| 754 | |||
| 755 | ### Single Binary | ||
| 756 | |||
| 757 | ```bash | ||
| 758 | cargo build --release | ||
| 759 | ./target/release/ngit-grasp | ||
| 760 | ``` | ||
| 761 | |||
| 762 | ### Docker | ||
| 763 | |||
| 764 | ```dockerfile | ||
| 765 | FROM rust:1.75 as builder | ||
| 766 | WORKDIR /app | ||
| 767 | COPY . . | ||
| 768 | RUN cargo build --release | ||
| 769 | |||
| 770 | FROM debian:bookworm-slim | ||
| 771 | RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* | ||
| 772 | COPY --from=builder /app/target/release/ngit-grasp /usr/local/bin/ | ||
| 773 | EXPOSE 8080 | ||
| 774 | CMD ["ngit-grasp"] | ||
| 775 | ``` | ||
| 776 | |||
| 777 | ### Systemd | ||
| 778 | |||
| 779 | ```ini | ||
| 780 | [Unit] | ||
| 781 | Description=ngit-grasp GRASP server | ||
| 782 | After=network.target | ||
| 783 | |||
| 784 | [Service] | ||
| 785 | Type=simple | ||
| 786 | User=git | ||
| 787 | WorkingDirectory=/opt/ngit-grasp | ||
| 788 | EnvironmentFile=/opt/ngit-grasp/.env | ||
| 789 | ExecStart=/usr/local/bin/ngit-grasp | ||
| 790 | Restart=on-failure | ||
| 791 | |||
| 792 | [Install] | ||
| 793 | WantedBy=multi-user.target | ||
| 794 | ``` | ||
| 795 | |||
| 796 | ## Security Considerations | ||
| 797 | |||
| 798 | 1. **Input Validation**: All npub/identifier inputs must be validated | ||
| 799 | 2. **Path Traversal**: Prevent directory traversal in repository paths | ||
| 800 | 3. **DoS Protection**: Rate limiting on both HTTP and WebSocket | ||
| 801 | 4. **Resource Limits**: Limit pack file sizes, event sizes | ||
| 802 | 5. **Nostr Event Validation**: Strict signature verification | ||
| 803 | |||
| 804 | ## Conclusion | ||
| 805 | |||
| 806 | The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. The Rust ecosystem provides excellent libraries for both Git and Nostr protocols, enabling a high-performance, type-safe implementation. | ||
| 807 | |||
| 808 | The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components. | ||
diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md new file mode 100644 index 0000000..be16f9e --- /dev/null +++ b/docs/COMPARISON.md | |||
| @@ -0,0 +1,256 @@ | |||
| 1 | # ngit-grasp vs ngit-relay Comparison | ||
| 2 | |||
| 3 | ## High-Level Comparison | ||
| 4 | |||
| 5 | | Aspect | ngit-relay (Reference) | ngit-grasp (This Project) | | ||
| 6 | |--------|------------------------|---------------------------| | ||
| 7 | | **Language** | Go | Rust | | ||
| 8 | | **Architecture** | Multi-process (nginx, git-http-backend, hooks, relay) | Single integrated process | | ||
| 9 | | **Authorization** | Git pre-receive hook | Inline HTTP handler | | ||
| 10 | | **Packaging** | Docker + supervisord | Single binary or Docker | | ||
| 11 | | **Configuration** | Multiple config files | Environment variables | | ||
| 12 | | **Deployment** | Docker Compose | Binary or Docker | | ||
| 13 | | **Testing** | Go tests + shell scripts | Rust unit + integration tests | | ||
| 14 | |||
| 15 | ## Component Breakdown | ||
| 16 | |||
| 17 | ### ngit-relay (Go) | ||
| 18 | |||
| 19 | ``` | ||
| 20 | ┌─────────────────────────────────────────────────┐ | ||
| 21 | │ Docker Container │ | ||
| 22 | ├─────────────────────────────────────────────────┤ | ||
| 23 | │ │ | ||
| 24 | │ ┌──────────┐ ┌─────────────────────┐ │ | ||
| 25 | │ │ nginx │────────▶│ git-http-backend │ │ | ||
| 26 | │ │ :80 │ │ (C binary) │ │ | ||
| 27 | │ └──────────┘ └──────────┬──────────┘ │ | ||
| 28 | │ │ │ │ | ||
| 29 | │ │ ▼ │ | ||
| 30 | │ │ ┌─────────────────┐ │ | ||
| 31 | │ │ │ Git Repo │ │ | ||
| 32 | │ │ │ + Hooks │ │ | ||
| 33 | │ │ └────────┬────────┘ │ | ||
| 34 | │ │ │ │ | ||
| 35 | │ │ ▼ │ | ||
| 36 | │ │ ┌─────────────────┐ │ | ||
| 37 | │ │ │ pre-receive │ │ | ||
| 38 | │ │ │ (Go binary) │ │ | ||
| 39 | │ │ └────────┬────────┘ │ | ||
| 40 | │ │ │ │ | ||
| 41 | │ │ │ WebSocket │ | ||
| 42 | │ │ ▼ │ | ||
| 43 | │ │ ┌─────────────────┐ │ | ||
| 44 | │ └─────────────────▶│ Khatru Relay │ │ | ||
| 45 | │ │ (Go) │ │ | ||
| 46 | │ └─────────────────┘ │ | ||
| 47 | │ │ | ||
| 48 | │ ┌──────────────────────────────────────────┐ │ | ||
| 49 | │ │ supervisord │ │ | ||
| 50 | │ │ - nginx │ │ | ||
| 51 | │ │ - khatru │ │ | ||
| 52 | │ │ - proactive-sync │ │ | ||
| 53 | │ └──────────────────────────────────────────┘ │ | ||
| 54 | │ │ | ||
| 55 | └─────────────────────────────────────────────────┘ | ||
| 56 | ``` | ||
| 57 | |||
| 58 | ### ngit-grasp (Rust) | ||
| 59 | |||
| 60 | ``` | ||
| 61 | ┌─────────────────────────────────────────────────┐ | ||
| 62 | │ ngit-grasp (Single Binary) │ | ||
| 63 | ├─────────────────────────────────────────────────┤ | ||
| 64 | │ │ | ||
| 65 | │ ┌──────────────────────────────────────────┐ │ | ||
| 66 | │ │ actix-web HTTP Server │ │ | ||
| 67 | │ │ :8080 │ │ | ||
| 68 | │ └───────┬──────────────────────┬────────────┘ │ | ||
| 69 | │ │ │ │ | ||
| 70 | │ ▼ ▼ │ | ||
| 71 | │ ┌──────────────┐ ┌──────────────────┐ │ | ||
| 72 | │ │ Git Handlers │ │ Nostr Relay │ │ | ||
| 73 | │ │ │ │ (relay-builder) │ │ | ||
| 74 | │ │ - upload-pk │ │ │ │ | ||
| 75 | │ │ - receive-pk │◀─────│ - Policies │ │ | ||
| 76 | │ │ + inline │ query│ - Event store │ │ | ||
| 77 | │ │ validation │ │ - WebSocket │ │ | ||
| 78 | │ └──────┬───────┘ └──────────────────┘ │ | ||
| 79 | │ │ │ | ||
| 80 | │ ▼ │ | ||
| 81 | │ ┌──────────────┐ │ | ||
| 82 | │ │ Git Repos │ │ | ||
| 83 | │ │ (spawned │ │ | ||
| 84 | │ │ git cmds) │ │ | ||
| 85 | │ └──────────────┘ │ | ||
| 86 | │ │ | ||
| 87 | │ ┌──────────────────────────────────────────┐ │ | ||
| 88 | │ │ Shared State (Arc<AppState>) │ │ | ||
| 89 | │ │ - RepositoryManager │ │ | ||
| 90 | │ │ - NostrClient │ │ | ||
| 91 | │ │ - StateCache │ │ | ||
| 92 | │ └──────────────────────────────────────────┘ │ | ||
| 93 | │ │ | ||
| 94 | └─────────────────────────────────────────────────┘ | ||
| 95 | ``` | ||
| 96 | |||
| 97 | ## Detailed Feature Comparison | ||
| 98 | |||
| 99 | ### Git Protocol Handling | ||
| 100 | |||
| 101 | | Feature | ngit-relay | ngit-grasp | | ||
| 102 | |---------|-----------|-----------| | ||
| 103 | | Implementation | git-http-backend (C) | git-http-backend (Rust crate) | | ||
| 104 | | Process model | nginx → C binary | actix-web → Rust handler | | ||
| 105 | | Upload pack | Passthrough | Passthrough with validation | | ||
| 106 | | Receive pack | Hook-based auth | Inline validation | | ||
| 107 | | Error handling | Hook stderr | HTTP response | | ||
| 108 | | CORS | nginx config | actix-cors middleware | | ||
| 109 | |||
| 110 | ### Nostr Relay | ||
| 111 | |||
| 112 | | Feature | ngit-relay | ngit-grasp | | ||
| 113 | |---------|-----------|-----------| | ||
| 114 | | Implementation | Khatru (Go) | nostr-relay-builder (Rust) | | ||
| 115 | | Event store | Badger (Go) | LMDB or NDB (Rust) | | ||
| 116 | | Policies | Go functions | Rust traits | | ||
| 117 | | WebSocket | Khatru built-in | nostr-relay-builder | | ||
| 118 | | NIP-11 | Manual JSON | Built-in support | | ||
| 119 | |||
| 120 | ### Authorization Logic | ||
| 121 | |||
| 122 | | Feature | ngit-relay | ngit-grasp | | ||
| 123 | |---------|-----------|-----------| | ||
| 124 | | Location | pre-receive hook | HTTP handler | | ||
| 125 | | Language | Go | Rust | | ||
| 126 | | State query | WebSocket to localhost:3334 | In-process function call | | ||
| 127 | | Error reporting | stderr → git client | HTTP response body | | ||
| 128 | | Ref validation | Line-by-line stdin | Parsed from request body | | ||
| 129 | | Maintainer resolution | Recursive Go function | Recursive Rust function | | ||
| 130 | | State caching | Per-request | Shared cache with TTL | | ||
| 131 | |||
| 132 | ### Repository Management | ||
| 133 | |||
| 134 | | Feature | ngit-relay | ngit-grasp | | ||
| 135 | |---------|-----------|-----------| | ||
| 136 | | Creation | Event hook + shell commands | Event hook + tokio::process | | ||
| 137 | | Configuration | git config via shell | git config via tokio::process | | ||
| 138 | | Hook installation | Symlinks | Not needed (inline auth) | | ||
| 139 | | Permissions | chown nginx:nginx | tokio::fs permissions | | ||
| 140 | | Path structure | `<npub>/<id>.git` | `<npub>/<id>.git` (same) | | ||
| 141 | |||
| 142 | ### Deployment | ||
| 143 | |||
| 144 | | Feature | ngit-relay | ngit-grasp | | ||
| 145 | |---------|-----------|-----------| | ||
| 146 | | Dependencies | nginx, git, Go runtime | git, Rust binary (no runtime) | | ||
| 147 | | Process management | supervisord | Single process (tokio) | | ||
| 148 | | Configuration | Multiple files + .env | .env only | | ||
| 149 | | Docker image size | ~500MB (Alpine + tools) | ~50MB (scratch + binary + git) | | ||
| 150 | | Startup time | ~2-5 seconds | ~0.5 seconds | | ||
| 151 | | Memory usage | ~100-200MB (multiple processes) | ~50-100MB (single process) | | ||
| 152 | |||
| 153 | ### Development Experience | ||
| 154 | |||
| 155 | | Feature | ngit-relay | ngit-grasp | | ||
| 156 | |---------|-----------|-----------| | ||
| 157 | | Build time | Fast (Go) | Medium (Rust first build, then fast) | | ||
| 158 | | Type safety | Go (good) | Rust (excellent) | | ||
| 159 | | Testing | Go test + shell | Rust test (unit + integration) | | ||
| 160 | | Debugging | Multiple processes | Single process | | ||
| 161 | | Hot reload | Manual | cargo-watch | | ||
| 162 | | IDE support | Good (Go) | Excellent (rust-analyzer) | | ||
| 163 | |||
| 164 | ## Performance Comparison (Estimated) | ||
| 165 | |||
| 166 | | Metric | ngit-relay | ngit-grasp | Notes | | ||
| 167 | |--------|-----------|-----------|-------| | ||
| 168 | | Startup | ~2-5s | ~0.5s | Fewer processes | | ||
| 169 | | Memory | ~150MB | ~75MB | Single process, no GC | | ||
| 170 | | CPU (idle) | ~1-2% | ~0.5% | Fewer processes | | ||
| 171 | | Push latency | +50-100ms | +10-20ms | No hook spawn overhead | | ||
| 172 | | Clone latency | ~same | ~same | Both passthrough to Git | | ||
| 173 | | Concurrent pushes | Good | Excellent | Tokio async vs goroutines | | ||
| 174 | | Event ingestion | Good | Excellent | Rust async + zero-copy | | ||
| 175 | |||
| 176 | *Note: These are estimates. Actual performance depends on workload and hardware.* | ||
| 177 | |||
| 178 | ## Code Complexity | ||
| 179 | |||
| 180 | ### Lines of Code (Estimated) | ||
| 181 | |||
| 182 | | Component | ngit-relay | ngit-grasp | | ||
| 183 | |-----------|-----------|-----------| | ||
| 184 | | Main server | ~150 | ~200 | | ||
| 185 | | Git handlers | ~0 (C binary) | ~500 | | ||
| 186 | | Auth logic | ~200 | ~300 | | ||
| 187 | | Nostr relay | ~500 | ~100 (using library) | | ||
| 188 | | Shared utils | ~300 | ~200 | | ||
| 189 | | Config/setup | ~200 | ~100 | | ||
| 190 | | **Total** | **~1,350** | **~1,400** | | ||
| 191 | |||
| 192 | Similar complexity, but ngit-grasp has: | ||
| 193 | - More Git protocol code (we implement it) | ||
| 194 | - Less Nostr relay code (using library) | ||
| 195 | - Less deployment code (no hooks/supervisord) | ||
| 196 | |||
| 197 | ## Migration Path | ||
| 198 | |||
| 199 | For users of ngit-relay, migration to ngit-grasp would involve: | ||
| 200 | |||
| 201 | 1. **Export data** from Badger to LMDB/NDB | ||
| 202 | 2. **Copy Git repositories** (same structure) | ||
| 203 | 3. **Update environment variables** (mostly compatible) | ||
| 204 | 4. **Change deployment** from Docker Compose to binary/Docker | ||
| 205 | 5. **Update URLs** if domain changes | ||
| 206 | |||
| 207 | The **Nostr events** and **Git data** are compatible - only the server changes. | ||
| 208 | |||
| 209 | ## When to Choose Each | ||
| 210 | |||
| 211 | ### Choose ngit-relay (Reference) if: | ||
| 212 | |||
| 213 | - ✅ You need a proven, production-tested implementation | ||
| 214 | - ✅ You're already familiar with Go | ||
| 215 | - ✅ You want to stay close to the reference | ||
| 216 | - ✅ You need to deploy immediately | ||
| 217 | - ✅ You prefer Docker Compose workflows | ||
| 218 | |||
| 219 | ### Choose ngit-grasp (This Project) if: | ||
| 220 | |||
| 221 | - ✅ You want better performance and lower resource usage | ||
| 222 | - ✅ You prefer Rust's type safety and ecosystem | ||
| 223 | - ✅ You want simpler deployment (single binary) | ||
| 224 | - ✅ You want to contribute to a modern codebase | ||
| 225 | - ✅ You're building on top of the GRASP protocol | ||
| 226 | - ✅ You want inline authorization over hooks | ||
| 227 | - ✅ You need better integration testing | ||
| 228 | |||
| 229 | ## Future Roadmap Comparison | ||
| 230 | |||
| 231 | ### ngit-relay (Reference) | ||
| 232 | - ✅ GRASP-01 complete | ||
| 233 | - 🔄 GRASP-02 in progress | ||
| 234 | - ⏭️ GRASP-05 planned | ||
| 235 | - ⏭️ NIP-42 auth-to-read | ||
| 236 | - ⏭️ NIP-70 protected events | ||
| 237 | - ⏭️ Spam prevention | ||
| 238 | |||
| 239 | ### ngit-grasp (This Project) | ||
| 240 | - 🔄 GRASP-01 in development | ||
| 241 | - ⏭️ GRASP-02 planned (easier with Rust async) | ||
| 242 | - ⏭️ GRASP-05 planned | ||
| 243 | - ⏭️ Advanced caching strategies | ||
| 244 | - ⏭️ Metrics and observability | ||
| 245 | - ⏭️ Plugin system for custom policies | ||
| 246 | |||
| 247 | ## Conclusion | ||
| 248 | |||
| 249 | Both implementations are valid approaches to GRASP: | ||
| 250 | |||
| 251 | - **ngit-relay** is the mature, proven reference implementation | ||
| 252 | - **ngit-grasp** is a modern, performant alternative with better DX | ||
| 253 | |||
| 254 | The choice depends on your priorities: stability vs. performance, familiarity vs. innovation, proven vs. cutting-edge. | ||
| 255 | |||
| 256 | For new deployments where performance and simplicity matter, **ngit-grasp** is the recommended choice. For production systems requiring maximum stability, **ngit-relay** is the safer bet until ngit-grasp reaches maturity. | ||
diff --git a/docs/DECISION_SUMMARY.md b/docs/DECISION_SUMMARY.md new file mode 100644 index 0000000..e9b7422 --- /dev/null +++ b/docs/DECISION_SUMMARY.md | |||
| @@ -0,0 +1,174 @@ | |||
| 1 | # Architecture Decision Summary | ||
| 2 | |||
| 3 | ## Question: Pre-receive Hook vs. Inline Authorization? | ||
| 4 | |||
| 5 | After investigating the `git-http-backend` Rust crate and the reference implementation, we have determined that **inline authorization is both pragmatic and superior**. | ||
| 6 | |||
| 7 | ## Investigation Findings | ||
| 8 | |||
| 9 | ### git-http-backend Crate Analysis | ||
| 10 | |||
| 11 | The `git-http-backend` crate (v0.1.3) provides: | ||
| 12 | |||
| 13 | 1. **Low-level Git protocol handling** via actix-web handlers | ||
| 14 | 2. **Process spawning** of `git-receive-pack` and `git-upload-pack` | ||
| 15 | 3. **Stream-based I/O** between HTTP and Git processes | ||
| 16 | 4. **Flexible path rewriting** through the `GitConfig` trait | ||
| 17 | |||
| 18 | **Key Finding**: The crate spawns Git as a subprocess in `git_receive_pack.rs`. We can intercept **before** this spawn happens. | ||
| 19 | |||
| 20 | ### Reference Implementation (ngit-relay) Analysis | ||
| 21 | |||
| 22 | The Go-based reference uses: | ||
| 23 | |||
| 24 | 1. **nginx** as HTTP frontend | ||
| 25 | 2. **git-http-backend** (C binary) for Git protocol | ||
| 26 | 3. **Pre-receive hook** (Go binary) for authorization | ||
| 27 | 4. **Khatru** (Go) for Nostr relay | ||
| 28 | 5. **supervisord** for process management | ||
| 29 | 6. **Docker** for packaging | ||
| 30 | |||
| 31 | The pre-receive hook: | ||
| 32 | - Reads ref updates from stdin | ||
| 33 | - Queries local Nostr relay via WebSocket | ||
| 34 | - Validates each ref against state events | ||
| 35 | - Exits with 0 (accept) or 1 (reject) | ||
| 36 | - Errors printed to stderr appear as `remote:` messages in git client | ||
| 37 | |||
| 38 | ## Decision: Inline Authorization ✅ | ||
| 39 | |||
| 40 | ### Why This Is Pragmatic | ||
| 41 | |||
| 42 | 1. **The crate supports it**: We can implement a custom `git_receive_pack` handler that validates before spawning Git | ||
| 43 | 2. **Better error handling**: Direct HTTP responses vs. parsing hook stderr | ||
| 44 | 3. **Simpler deployment**: Single binary, no hook management | ||
| 45 | 4. **Easier testing**: Pure Rust unit tests, no shell scripts | ||
| 46 | 5. **Performance**: Avoid spawning Git for invalid pushes | ||
| 47 | 6. **Type safety**: Share types between Git and Nostr modules | ||
| 48 | |||
| 49 | ### Implementation Approach | ||
| 50 | |||
| 51 | ```rust | ||
| 52 | // Instead of using git-http-backend's handler as-is: | ||
| 53 | pub async fn git_receive_pack( | ||
| 54 | req: HttpRequest, | ||
| 55 | body: web::Payload, | ||
| 56 | state: web::Data<AppState>, | ||
| 57 | ) -> Result<HttpResponse> { | ||
| 58 | // 1. Parse repository path from URL | ||
| 59 | let (npub, identifier) = parse_repo_path(&req)?; | ||
| 60 | |||
| 61 | // 2. Buffer enough of the request to parse ref updates | ||
| 62 | let ref_updates = parse_ref_updates(&body).await?; | ||
| 63 | |||
| 64 | // 3. VALIDATE AGAINST NOSTR STATE | ||
| 65 | let validator = PushValidator::new(&state.nostr_client); | ||
| 66 | match validator.validate_push(&npub, &identifier, &ref_updates).await { | ||
| 67 | Ok(_) => { | ||
| 68 | // 4. Valid! Spawn git-receive-pack and stream | ||
| 69 | spawn_git_receive_pack(req, body, state).await | ||
| 70 | } | ||
| 71 | Err(e) => { | ||
| 72 | // 5. Invalid! Return HTTP error | ||
| 73 | Ok(HttpResponse::Forbidden() | ||
| 74 | .body(format!("Push rejected: {}", e))) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | ``` | ||
| 79 | |||
| 80 | ### Advantages Over Hooks | ||
| 81 | |||
| 82 | | Aspect | Pre-receive Hook | Inline Authorization | | ||
| 83 | |--------|------------------|---------------------| | ||
| 84 | | Error messages | Via stderr, prefixed with `remote:` | Direct HTTP response body | | ||
| 85 | | Testing | Requires Git repo setup | Pure Rust unit tests | | ||
| 86 | | Debugging | Hook logs separate from server | Unified logging | | ||
| 87 | | Deployment | Symlinks, permissions, hook scripts | Single binary | | ||
| 88 | | Performance | Always spawn Git | Skip Git for invalid pushes | | ||
| 89 | | State sharing | IPC or network | Direct memory access | | ||
| 90 | | Type safety | Separate binaries | Shared Rust types | | ||
| 91 | |||
| 92 | ### Potential Concerns & Mitigations | ||
| 93 | |||
| 94 | **Concern**: "What if we need to validate the actual pack data, not just refs?" | ||
| 95 | |||
| 96 | **Mitigation**: We can still do this inline! Parse the pack stream before forwarding to Git. The `git-http-backend` crate already buffers the request body. | ||
| 97 | |||
| 98 | **Concern**: "Doesn't Git expect hooks for certain operations?" | ||
| 99 | |||
| 100 | **Mitigation**: We're not eliminating hooks entirely. Post-receive hooks might still be useful for notifications. We're just moving *authorization* out of hooks. | ||
| 101 | |||
| 102 | **Concern**: "What about compatibility with standard Git setups?" | ||
| 103 | |||
| 104 | **Mitigation**: The Git Smart HTTP protocol is standardized. Our inline validation is transparent to clients. We're still using real Git repositories and spawning real `git-receive-pack`. | ||
| 105 | |||
| 106 | ## Comparison with Reference Implementation | ||
| 107 | |||
| 108 | ### Reference (ngit-relay) | ||
| 109 | ``` | ||
| 110 | Client → nginx → git-http-backend → Git → pre-receive hook → validate → accept/reject | ||
| 111 | ↓ | ||
| 112 | Query Nostr relay (WebSocket) | ||
| 113 | ``` | ||
| 114 | |||
| 115 | ### Our Approach (ngit-grasp) | ||
| 116 | ``` | ||
| 117 | Client → actix-web → validate → Git → accept | ||
| 118 | ↓ | ||
| 119 | Query Nostr relay (in-process) | ||
| 120 | ↓ | ||
| 121 | reject ← return HTTP error | ||
| 122 | ``` | ||
| 123 | |||
| 124 | ## Implementation Complexity | ||
| 125 | |||
| 126 | ### Hook-based (if we went that route) | ||
| 127 | - ✅ Simpler: Follow reference implementation | ||
| 128 | - ❌ More components: Hook binaries, symlinks | ||
| 129 | - ❌ More complex testing: Need Git repos, shell scripts | ||
| 130 | - ❌ More complex deployment: Hook installation, permissions | ||
| 131 | |||
| 132 | ### Inline (our choice) | ||
| 133 | - ❌ More complex: Custom Git protocol handling | ||
| 134 | - ✅ Fewer components: Single binary | ||
| 135 | - ✅ Simpler testing: Pure Rust | ||
| 136 | - ✅ Simpler deployment: Just run the binary | ||
| 137 | |||
| 138 | **Verdict**: Slightly more complex initially, but much simpler long-term. | ||
| 139 | |||
| 140 | ## Code Reuse from Reference | ||
| 141 | |||
| 142 | We can still reuse the **logic** from the reference implementation: | ||
| 143 | |||
| 144 | - Maintainer recursion algorithm | ||
| 145 | - State validation logic | ||
| 146 | - Event filtering policies | ||
| 147 | - Repository provisioning workflow | ||
| 148 | |||
| 149 | We're just implementing it in Rust within our HTTP handlers rather than in Git hooks. | ||
| 150 | |||
| 151 | ## Conclusion | ||
| 152 | |||
| 153 | **Inline authorization is both pragmatic and superior for a Rust implementation.** | ||
| 154 | |||
| 155 | The `git-http-backend` crate provides sufficient flexibility through its handler architecture. By intercepting at the HTTP layer, we gain: | ||
| 156 | |||
| 157 | 1. Better error handling and user experience | ||
| 158 | 2. Simpler deployment and operations | ||
| 159 | 3. Easier testing and debugging | ||
| 160 | 4. Better performance characteristics | ||
| 161 | 5. Tighter integration between components | ||
| 162 | |||
| 163 | The additional complexity of parsing the Git protocol is minimal compared to the benefits, and we're still using the standard Git binaries for the actual repository operations. | ||
| 164 | |||
| 165 | ## Next Steps | ||
| 166 | |||
| 167 | 1. ✅ Document architecture (this file + ARCHITECTURE.md) | ||
| 168 | 2. ⏭️ Set up project structure with Cargo workspace | ||
| 169 | 3. ⏭️ Implement core types (RefUpdate, RepositoryState, etc.) | ||
| 170 | 4. ⏭️ Implement Git protocol parsing | ||
| 171 | 5. ⏭️ Implement Nostr relay with policies | ||
| 172 | 6. ⏭️ Implement push validation logic | ||
| 173 | 7. ⏭️ Integration tests | ||
| 174 | 8. ⏭️ GRASP-01 compliance testing | ||
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..7fea590 --- /dev/null +++ b/docs/GETTING_STARTED.md | |||
| @@ -0,0 +1,437 @@ | |||
| 1 | # Getting Started with Implementation | ||
| 2 | |||
| 3 | This guide helps you start implementing ngit-grasp based on the architecture design. | ||
| 4 | |||
| 5 | ## Prerequisites | ||
| 6 | |||
| 7 | - Rust 1.75 or later | ||
| 8 | - Git 2.x | ||
| 9 | - Basic understanding of async Rust (tokio) | ||
| 10 | - Familiarity with actix-web (helpful) | ||
| 11 | - Understanding of Nostr basics (helpful) | ||
| 12 | |||
| 13 | ## Step 1: Initialize Cargo Project | ||
| 14 | |||
| 15 | ```bash | ||
| 16 | # Create new binary project | ||
| 17 | cargo init --name ngit-grasp | ||
| 18 | |||
| 19 | # Or if already created: | ||
| 20 | cargo build | ||
| 21 | ``` | ||
| 22 | |||
| 23 | ## Step 2: Add Dependencies | ||
| 24 | |||
| 25 | Edit `Cargo.toml`: | ||
| 26 | |||
| 27 | ```toml | ||
| 28 | [package] | ||
| 29 | name = "ngit-grasp" | ||
| 30 | version = "0.1.0" | ||
| 31 | edition = "2021" | ||
| 32 | rust-version = "1.75" | ||
| 33 | |||
| 34 | [dependencies] | ||
| 35 | # HTTP Server | ||
| 36 | actix-web = "4" | ||
| 37 | actix-cors = "0.7" | ||
| 38 | |||
| 39 | # Async Runtime | ||
| 40 | tokio = { version = "1", features = ["full"] } | ||
| 41 | |||
| 42 | # Git Protocol | ||
| 43 | git-http-backend = "0.1.3" | ||
| 44 | |||
| 45 | # Nostr | ||
| 46 | nostr-sdk = { version = "0.43", features = ["all-nips"] } | ||
| 47 | nostr-relay-builder = "0.43" | ||
| 48 | |||
| 49 | # Serialization | ||
| 50 | serde = { version = "1", features = ["derive"] } | ||
| 51 | serde_json = "1" | ||
| 52 | |||
| 53 | # Error Handling | ||
| 54 | anyhow = "1" | ||
| 55 | thiserror = "1" | ||
| 56 | |||
| 57 | # Logging | ||
| 58 | tracing = "0.1" | ||
| 59 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } | ||
| 60 | |||
| 61 | # Environment | ||
| 62 | dotenv = "0.15" | ||
| 63 | |||
| 64 | # Utilities | ||
| 65 | async-trait = "0.1" | ||
| 66 | futures = "0.3" | ||
| 67 | bytes = "1" | ||
| 68 | |||
| 69 | [dev-dependencies] | ||
| 70 | tokio-test = "0.4" | ||
| 71 | ``` | ||
| 72 | |||
| 73 | ## Step 3: Project Structure | ||
| 74 | |||
| 75 | Create the directory structure: | ||
| 76 | |||
| 77 | ```bash | ||
| 78 | mkdir -p src/{git,nostr,storage} | ||
| 79 | mkdir -p tests/{integration,fixtures} | ||
| 80 | mkdir -p data/{git,relay} | ||
| 81 | ``` | ||
| 82 | |||
| 83 | ## Step 4: Configuration Module | ||
| 84 | |||
| 85 | Create `src/config.rs`: | ||
| 86 | |||
| 87 | ```rust | ||
| 88 | use anyhow::Result; | ||
| 89 | use std::env; | ||
| 90 | use std::net::SocketAddr; | ||
| 91 | use std::path::PathBuf; | ||
| 92 | |||
| 93 | #[derive(Debug, Clone)] | ||
| 94 | pub struct Config { | ||
| 95 | pub domain: String, | ||
| 96 | pub owner_npub: String, | ||
| 97 | pub relay_name: String, | ||
| 98 | pub relay_description: String, | ||
| 99 | pub git_data_path: PathBuf, | ||
| 100 | pub relay_data_path: PathBuf, | ||
| 101 | pub bind_address: SocketAddr, | ||
| 102 | } | ||
| 103 | |||
| 104 | impl Config { | ||
| 105 | pub fn from_env() -> Result<Self> { | ||
| 106 | dotenv::dotenv().ok(); | ||
| 107 | |||
| 108 | Ok(Config { | ||
| 109 | domain: env::var("NGIT_DOMAIN")?, | ||
| 110 | owner_npub: env::var("NGIT_OWNER_NPUB")?, | ||
| 111 | relay_name: env::var("NGIT_RELAY_NAME")?, | ||
| 112 | relay_description: env::var("NGIT_RELAY_DESCRIPTION")?, | ||
| 113 | git_data_path: PathBuf::from( | ||
| 114 | env::var("NGIT_GIT_DATA_PATH") | ||
| 115 | .unwrap_or_else(|_| "./data/git".to_string()) | ||
| 116 | ), | ||
| 117 | relay_data_path: PathBuf::from( | ||
| 118 | env::var("NGIT_RELAY_DATA_PATH") | ||
| 119 | .unwrap_or_else(|_| "./data/relay".to_string()) | ||
| 120 | ), | ||
| 121 | bind_address: env::var("NGIT_BIND_ADDRESS") | ||
| 122 | .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) | ||
| 123 | .parse()?, | ||
| 124 | }) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | ``` | ||
| 128 | |||
| 129 | ## Step 5: Core Types | ||
| 130 | |||
| 131 | Create `src/git/types.rs`: | ||
| 132 | |||
| 133 | ```rust | ||
| 134 | use serde::{Deserialize, Serialize}; | ||
| 135 | |||
| 136 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 137 | pub struct RefUpdate { | ||
| 138 | pub old_oid: String, | ||
| 139 | pub new_oid: String, | ||
| 140 | pub ref_name: String, | ||
| 141 | } | ||
| 142 | |||
| 143 | impl RefUpdate { | ||
| 144 | pub fn is_create(&self) -> bool { | ||
| 145 | self.old_oid == "0000000000000000000000000000000000000000" | ||
| 146 | } | ||
| 147 | |||
| 148 | pub fn is_delete(&self) -> bool { | ||
| 149 | self.new_oid == "0000000000000000000000000000000000000000" | ||
| 150 | } | ||
| 151 | |||
| 152 | pub fn is_update(&self) -> bool { | ||
| 153 | !self.is_create() && !self.is_delete() | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | #[derive(Debug, thiserror::Error)] | ||
| 158 | pub enum GitError { | ||
| 159 | #[error("Invalid pkt-line format")] | ||
| 160 | InvalidPktLine, | ||
| 161 | |||
| 162 | #[error("Invalid ref update format")] | ||
| 163 | InvalidRefUpdate, | ||
| 164 | |||
| 165 | #[error("Repository not found: {0}")] | ||
| 166 | RepositoryNotFound(String), | ||
| 167 | |||
| 168 | #[error("Invalid repository path")] | ||
| 169 | InvalidPath, | ||
| 170 | } | ||
| 171 | ``` | ||
| 172 | |||
| 173 | ## Step 6: Main Application State | ||
| 174 | |||
| 175 | Create `src/main.rs`: | ||
| 176 | |||
| 177 | ```rust | ||
| 178 | use actix_web::{web, App, HttpServer}; | ||
| 179 | use anyhow::Result; | ||
| 180 | use std::sync::Arc; | ||
| 181 | use tracing::info; | ||
| 182 | |||
| 183 | mod config; | ||
| 184 | mod git; | ||
| 185 | mod nostr; | ||
| 186 | mod storage; | ||
| 187 | |||
| 188 | use config::Config; | ||
| 189 | |||
| 190 | #[derive(Clone)] | ||
| 191 | pub struct AppState { | ||
| 192 | pub config: Arc<Config>, | ||
| 193 | // TODO: Add NostrClient, RepositoryManager, etc. | ||
| 194 | } | ||
| 195 | |||
| 196 | #[actix_web::main] | ||
| 197 | async fn main() -> Result<()> { | ||
| 198 | // Initialize logging | ||
| 199 | tracing_subscriber::fmt() | ||
| 200 | .with_env_filter( | ||
| 201 | tracing_subscriber::EnvFilter::from_default_env() | ||
| 202 | ) | ||
| 203 | .init(); | ||
| 204 | |||
| 205 | // Load configuration | ||
| 206 | let config = Config::from_env()?; | ||
| 207 | info!("Starting ngit-grasp on {}", config.bind_address); | ||
| 208 | |||
| 209 | // Create application state | ||
| 210 | let state = AppState { | ||
| 211 | config: Arc::new(config.clone()), | ||
| 212 | }; | ||
| 213 | |||
| 214 | // Start HTTP server | ||
| 215 | HttpServer::new(move || { | ||
| 216 | App::new() | ||
| 217 | .app_data(web::Data::new(state.clone())) | ||
| 218 | .configure(git::routes::configure) | ||
| 219 | .configure(nostr::routes::configure) | ||
| 220 | }) | ||
| 221 | .bind(config.bind_address)? | ||
| 222 | .run() | ||
| 223 | .await?; | ||
| 224 | |||
| 225 | Ok(()) | ||
| 226 | } | ||
| 227 | ``` | ||
| 228 | |||
| 229 | ## Step 7: Git Module Skeleton | ||
| 230 | |||
| 231 | Create `src/git/mod.rs`: | ||
| 232 | |||
| 233 | ```rust | ||
| 234 | pub mod routes; | ||
| 235 | pub mod handler; | ||
| 236 | pub mod parser; | ||
| 237 | pub mod authorization; | ||
| 238 | pub mod types; | ||
| 239 | |||
| 240 | pub use types::{RefUpdate, GitError}; | ||
| 241 | ``` | ||
| 242 | |||
| 243 | Create `src/git/routes.rs`: | ||
| 244 | |||
| 245 | ```rust | ||
| 246 | use actix_web::web; | ||
| 247 | |||
| 248 | pub fn configure(cfg: &mut web::ServiceConfig) { | ||
| 249 | cfg.service( | ||
| 250 | web::scope("/{npub}/{identifier}.git") | ||
| 251 | .route("/info/refs", web::get().to(super::handler::info_refs)) | ||
| 252 | .route("/git-upload-pack", web::post().to(super::handler::git_upload_pack)) | ||
| 253 | .route("/git-receive-pack", web::post().to(super::handler::git_receive_pack)) | ||
| 254 | ); | ||
| 255 | } | ||
| 256 | ``` | ||
| 257 | |||
| 258 | ## Step 8: First Test | ||
| 259 | |||
| 260 | Create `tests/integration/basic_test.rs`: | ||
| 261 | |||
| 262 | ```rust | ||
| 263 | use actix_web::{test, App}; | ||
| 264 | |||
| 265 | #[actix_web::test] | ||
| 266 | async fn test_server_starts() { | ||
| 267 | // TODO: Initialize test app | ||
| 268 | // TODO: Make test request | ||
| 269 | assert!(true); | ||
| 270 | } | ||
| 271 | ``` | ||
| 272 | |||
| 273 | Run tests: | ||
| 274 | |||
| 275 | ```bash | ||
| 276 | cargo test | ||
| 277 | ``` | ||
| 278 | |||
| 279 | ## Step 9: Implementation Order | ||
| 280 | |||
| 281 | Follow this order for implementation: | ||
| 282 | |||
| 283 | ### Phase 1: Basic Infrastructure (Week 1) | ||
| 284 | 1. ✅ Config module | ||
| 285 | 2. ✅ Main server setup | ||
| 286 | 3. ✅ Core types | ||
| 287 | 4. ⏭️ Git pkt-line parser | ||
| 288 | 5. ⏭️ Ref update parser | ||
| 289 | 6. ⏭️ Parser tests | ||
| 290 | |||
| 291 | ### Phase 2: Git Protocol (Week 2) | ||
| 292 | 1. ⏭️ Git upload-pack handler (read-only) | ||
| 293 | 2. ⏭️ Repository manager | ||
| 294 | 3. ⏭️ Path validation and security | ||
| 295 | 4. ⏭️ Integration tests for cloning | ||
| 296 | |||
| 297 | ### Phase 3: Nostr Relay (Week 2-3) | ||
| 298 | 1. ⏭️ Nostr relay setup with nostr-relay-builder | ||
| 299 | 2. ⏭️ Repository announcement policy | ||
| 300 | 3. ⏭️ Event hooks for repo creation | ||
| 301 | 4. ⏭️ NIP-11 configuration | ||
| 302 | |||
| 303 | ### Phase 4: Authorization (Week 3-4) | ||
| 304 | 1. ⏭️ Maintainer resolution logic | ||
| 305 | 2. ⏭️ State validation logic | ||
| 306 | 3. ⏭️ Git receive-pack with inline validation | ||
| 307 | 4. ⏭️ Integration tests for pushing | ||
| 308 | |||
| 309 | ### Phase 5: Polish (Week 4-6) | ||
| 310 | 1. ⏭️ Error handling improvements | ||
| 311 | 2. ⏭️ Logging and observability | ||
| 312 | 3. ⏭️ Performance optimization | ||
| 313 | 4. ⏭️ GRASP-01 compliance testing | ||
| 314 | 5. ⏭️ Documentation updates | ||
| 315 | |||
| 316 | ## Development Workflow | ||
| 317 | |||
| 318 | ### Running Locally | ||
| 319 | |||
| 320 | ```bash | ||
| 321 | # Copy environment template | ||
| 322 | cp .env.example .env | ||
| 323 | |||
| 324 | # Edit configuration | ||
| 325 | vim .env | ||
| 326 | |||
| 327 | # Run in development mode | ||
| 328 | cargo run | ||
| 329 | |||
| 330 | # With debug logging | ||
| 331 | RUST_LOG=debug cargo run | ||
| 332 | ``` | ||
| 333 | |||
| 334 | ### Testing | ||
| 335 | |||
| 336 | ```bash | ||
| 337 | # Run all tests | ||
| 338 | cargo test | ||
| 339 | |||
| 340 | # Run with output | ||
| 341 | cargo test -- --nocapture | ||
| 342 | |||
| 343 | # Run specific test | ||
| 344 | cargo test test_parse_ref_updates | ||
| 345 | |||
| 346 | # Run integration tests only | ||
| 347 | cargo test --test '*' | ||
| 348 | ``` | ||
| 349 | |||
| 350 | ### Code Quality | ||
| 351 | |||
| 352 | ```bash | ||
| 353 | # Format code | ||
| 354 | cargo fmt | ||
| 355 | |||
| 356 | # Check formatting | ||
| 357 | cargo fmt --check | ||
| 358 | |||
| 359 | # Lint | ||
| 360 | cargo clippy | ||
| 361 | |||
| 362 | # Lint with all features | ||
| 363 | cargo clippy --all-features -- -D warnings | ||
| 364 | ``` | ||
| 365 | |||
| 366 | ## Debugging Tips | ||
| 367 | |||
| 368 | ### Enable Detailed Logging | ||
| 369 | |||
| 370 | ```bash | ||
| 371 | RUST_LOG=trace cargo run | ||
| 372 | ``` | ||
| 373 | |||
| 374 | ### Test with Real Git Client | ||
| 375 | |||
| 376 | ```bash | ||
| 377 | # In another terminal, after server is running | ||
| 378 | mkdir test-repo && cd test-repo | ||
| 379 | git init | ||
| 380 | echo "test" > README.md | ||
| 381 | git add . && git commit -m "test" | ||
| 382 | |||
| 383 | # Try to push (will fail without Nostr setup) | ||
| 384 | git remote add origin http://localhost:8080/npub.../test.git | ||
| 385 | git push origin main | ||
| 386 | ``` | ||
| 387 | |||
| 388 | ### Use curl for HTTP Testing | ||
| 389 | |||
| 390 | ```bash | ||
| 391 | # Test info/refs endpoint | ||
| 392 | curl -v http://localhost:8080/npub.../test.git/info/refs?service=git-upload-pack | ||
| 393 | ``` | ||
| 394 | |||
| 395 | ## Common Issues | ||
| 396 | |||
| 397 | ### "Repository not found" | ||
| 398 | - Check that repository announcement was sent to Nostr relay | ||
| 399 | - Verify repository was created in git_data_path | ||
| 400 | - Check logs for repo creation | ||
| 401 | |||
| 402 | ### "Push rejected" | ||
| 403 | - Verify state event exists on relay | ||
| 404 | - Check state event matches push refs | ||
| 405 | - Verify maintainer list includes pusher | ||
| 406 | |||
| 407 | ### "Cannot connect to relay" | ||
| 408 | - Check relay is running | ||
| 409 | - Verify WebSocket endpoint | ||
| 410 | - Check firewall/network settings | ||
| 411 | |||
| 412 | ## Next Steps | ||
| 413 | |||
| 414 | After basic setup: | ||
| 415 | |||
| 416 | 1. Implement pkt-line parser (see [GIT_PROTOCOL.md](GIT_PROTOCOL.md)) | ||
| 417 | 2. Add comprehensive tests | ||
| 418 | 3. Implement Nostr relay policies | ||
| 419 | 4. Add authorization logic | ||
| 420 | 5. Test with ngit CLI | ||
| 421 | |||
| 422 | ## Resources | ||
| 423 | |||
| 424 | - [ARCHITECTURE.md](ARCHITECTURE.md) - Detailed design | ||
| 425 | - [GIT_PROTOCOL.md](GIT_PROTOCOL.md) - Git protocol reference | ||
| 426 | - [actix-web docs](https://actix.rs/docs/) | ||
| 427 | - [nostr-sdk docs](https://docs.rs/nostr-sdk/) | ||
| 428 | - [tokio docs](https://docs.rs/tokio/) | ||
| 429 | |||
| 430 | ## Getting Help | ||
| 431 | |||
| 432 | - Check existing documentation in `docs/` | ||
| 433 | - Review reference implementation at `../ngit-relay` | ||
| 434 | - Open an issue for questions | ||
| 435 | - Read GRASP protocol spec | ||
| 436 | |||
| 437 | Good luck! 🚀 | ||
diff --git a/docs/GIT_PROTOCOL.md b/docs/GIT_PROTOCOL.md new file mode 100644 index 0000000..172a7bc --- /dev/null +++ b/docs/GIT_PROTOCOL.md | |||
| @@ -0,0 +1,435 @@ | |||
| 1 | # Git Smart HTTP Protocol Reference | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | This document explains the Git Smart HTTP protocol as it relates to our inline authorization implementation. | ||
| 6 | |||
| 7 | ## Protocol Flow | ||
| 8 | |||
| 9 | ### Clone/Fetch (Upload Pack) | ||
| 10 | |||
| 11 | ``` | ||
| 12 | 1. Client → GET /repo.git/info/refs?service=git-upload-pack | ||
| 13 | Server → 200 OK with pack advertisement | ||
| 14 | |||
| 15 | 2. Client → POST /repo.git/git-upload-pack | ||
| 16 | Body: want/have negotiation | ||
| 17 | Server → 200 OK with pack stream | ||
| 18 | ``` | ||
| 19 | |||
| 20 | **Authorization**: Not needed for public repositories. For GRASP-01, all repos are public. | ||
| 21 | |||
| 22 | ### Push (Receive Pack) | ||
| 23 | |||
| 24 | ``` | ||
| 25 | 1. Client → GET /repo.git/info/refs?service=git-receive-pack | ||
| 26 | Server → 200 OK with ref advertisement | ||
| 27 | |||
| 28 | 2. Client → POST /repo.git/git-receive-pack | ||
| 29 | Body: ref updates + pack data | ||
| 30 | Server → 200 OK with status | ||
| 31 | ``` | ||
| 32 | |||
| 33 | **Authorization**: THIS IS WHERE WE VALIDATE! Step 2 is where inline auth happens. | ||
| 34 | |||
| 35 | ## Receive Pack Request Format | ||
| 36 | |||
| 37 | The POST body to `git-receive-pack` has this structure: | ||
| 38 | |||
| 39 | ``` | ||
| 40 | [ref-updates] | ||
| 41 | [pack-data] | ||
| 42 | ``` | ||
| 43 | |||
| 44 | ### Ref Updates Format | ||
| 45 | |||
| 46 | Each ref update is in **pkt-line** format: | ||
| 47 | |||
| 48 | ``` | ||
| 49 | <4-byte-length><old-oid> <new-oid> <ref-name>\0<capabilities>\n | ||
| 50 | <4-byte-length><old-oid> <new-oid> <ref-name>\n | ||
| 51 | ... | ||
| 52 | 0000 | ||
| 53 | ``` | ||
| 54 | |||
| 55 | **Example** (hex representation): | ||
| 56 | |||
| 57 | ``` | ||
| 58 | 00a20000000000000000000000000000000000000000 a1b2c3d4e5f6... refs/heads/main\0 report-status side-band-64k | ||
| 59 | 003f0000000000000000000000000000000000000000 f6e5d4c3b2a1... refs/heads/dev\n | ||
| 60 | 0000 | ||
| 61 | ``` | ||
| 62 | |||
| 63 | ### Pkt-line Format | ||
| 64 | |||
| 65 | A pkt-line is: | ||
| 66 | - 4 hex digits: length of entire line (including the 4 digits) | ||
| 67 | - Payload data | ||
| 68 | - `0000` = flush packet (end of section) | ||
| 69 | |||
| 70 | **Length calculation**: | ||
| 71 | ``` | ||
| 72 | length = 4 (for length itself) + payload.len() | ||
| 73 | ``` | ||
| 74 | |||
| 75 | **Examples**: | ||
| 76 | ``` | ||
| 77 | "0006a\n" → length=6, payload="a\n" | ||
| 78 | "0000" → flush packet | ||
| 79 | "000bfoobar\n" → length=11, payload="foobar\n" | ||
| 80 | ``` | ||
| 81 | |||
| 82 | ### Parsing Ref Updates | ||
| 83 | |||
| 84 | ```rust | ||
| 85 | pub struct RefUpdate { | ||
| 86 | pub old_oid: String, // 40 hex chars | ||
| 87 | pub new_oid: String, // 40 hex chars | ||
| 88 | pub ref_name: String, // e.g., "refs/heads/main" | ||
| 89 | } | ||
| 90 | |||
| 91 | pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> { | ||
| 92 | let mut updates = Vec::new(); | ||
| 93 | let mut offset = 0; | ||
| 94 | |||
| 95 | loop { | ||
| 96 | // Read pkt-line length | ||
| 97 | if offset + 4 > body.len() { | ||
| 98 | break; | ||
| 99 | } | ||
| 100 | |||
| 101 | let length_str = std::str::from_utf8(&body[offset..offset+4])?; | ||
| 102 | let length = u16::from_str_radix(length_str, 16)? as usize; | ||
| 103 | |||
| 104 | // Check for flush packet | ||
| 105 | if length == 0 { | ||
| 106 | break; | ||
| 107 | } | ||
| 108 | |||
| 109 | // Extract payload | ||
| 110 | let payload_end = offset + length; | ||
| 111 | if payload_end > body.len() { | ||
| 112 | return Err(Error::InvalidPktLine); | ||
| 113 | } | ||
| 114 | |||
| 115 | let payload = &body[offset+4..payload_end]; | ||
| 116 | |||
| 117 | // Parse ref update from payload | ||
| 118 | // Format: "<old-oid> <new-oid> <ref-name>[\0<capabilities>]\n" | ||
| 119 | let payload_str = std::str::from_utf8(payload)?; | ||
| 120 | |||
| 121 | // Remove trailing newline | ||
| 122 | let line = payload_str.trim_end_matches('\n'); | ||
| 123 | |||
| 124 | // Split on null byte (first line has capabilities) | ||
| 125 | let parts: Vec<&str> = line.split('\0').collect(); | ||
| 126 | let ref_line = parts[0]; | ||
| 127 | |||
| 128 | // Parse old-oid, new-oid, ref-name | ||
| 129 | let tokens: Vec<&str> = ref_line.split_whitespace().collect(); | ||
| 130 | if tokens.len() != 3 { | ||
| 131 | return Err(Error::InvalidRefUpdate); | ||
| 132 | } | ||
| 133 | |||
| 134 | updates.push(RefUpdate { | ||
| 135 | old_oid: tokens[0].to_string(), | ||
| 136 | new_oid: tokens[1].to_string(), | ||
| 137 | ref_name: tokens[2].to_string(), | ||
| 138 | }); | ||
| 139 | |||
| 140 | offset = payload_end; | ||
| 141 | } | ||
| 142 | |||
| 143 | Ok(updates) | ||
| 144 | } | ||
| 145 | ``` | ||
| 146 | |||
| 147 | ## Special OID Values | ||
| 148 | |||
| 149 | - `0000000000000000000000000000000000000000` (40 zeros) = ref creation | ||
| 150 | - When `old_oid` is all zeros: creating a new ref | ||
| 151 | - When `new_oid` is all zeros: deleting a ref | ||
| 152 | |||
| 153 | ## Validation Requirements | ||
| 154 | |||
| 155 | For GRASP-01, we must validate: | ||
| 156 | |||
| 157 | ### 1. Regular Branches/Tags | ||
| 158 | |||
| 159 | ```rust | ||
| 160 | fn validate_regular_ref( | ||
| 161 | state: &RepositoryState, | ||
| 162 | update: &RefUpdate, | ||
| 163 | ) -> Result<()> { | ||
| 164 | // Extract branch/tag name | ||
| 165 | let (ref_type, name) = if update.ref_name.starts_with("refs/heads/") { | ||
| 166 | ("branch", &update.ref_name[11..]) | ||
| 167 | } else if update.ref_name.starts_with("refs/tags/") { | ||
| 168 | ("tag", &update.ref_name[10..]) | ||
| 169 | } else { | ||
| 170 | return Err(Error::InvalidRefName); | ||
| 171 | }; | ||
| 172 | |||
| 173 | // Check against state | ||
| 174 | let expected = if ref_type == "branch" { | ||
| 175 | state.branches.get(name) | ||
| 176 | } else { | ||
| 177 | state.tags.get(name) | ||
| 178 | }; | ||
| 179 | |||
| 180 | match expected { | ||
| 181 | Some(oid) if oid == &update.new_oid => Ok(()), | ||
| 182 | Some(oid) => Err(Error::StateMismatch { | ||
| 183 | ref_name: update.ref_name.clone(), | ||
| 184 | expected: oid.clone(), | ||
| 185 | got: update.new_oid.clone(), | ||
| 186 | }), | ||
| 187 | None => Err(Error::RefNotInState(update.ref_name.clone())), | ||
| 188 | } | ||
| 189 | } | ||
| 190 | ``` | ||
| 191 | |||
| 192 | ### 2. PR Refs (refs/nostr/<event-id>) | ||
| 193 | |||
| 194 | ```rust | ||
| 195 | fn validate_pr_ref(update: &RefUpdate) -> Result<()> { | ||
| 196 | // Extract event ID | ||
| 197 | let event_id = &update.ref_name[11..]; // Skip "refs/nostr/" | ||
| 198 | |||
| 199 | // Validate it's a valid 32-byte hex | ||
| 200 | if event_id.len() != 64 { | ||
| 201 | return Err(Error::InvalidEventId); | ||
| 202 | } | ||
| 203 | |||
| 204 | if !event_id.chars().all(|c| c.is_ascii_hexdigit()) { | ||
| 205 | return Err(Error::InvalidEventId); | ||
| 206 | } | ||
| 207 | |||
| 208 | // TODO: Could optionally verify event exists on relay | ||
| 209 | // TODO: Could verify event references this repository | ||
| 210 | |||
| 211 | Ok(()) | ||
| 212 | } | ||
| 213 | ``` | ||
| 214 | |||
| 215 | ### 3. Reject pr/* Branches | ||
| 216 | |||
| 217 | ```rust | ||
| 218 | fn reject_pr_branches(update: &RefUpdate) -> Result<()> { | ||
| 219 | if update.ref_name.starts_with("refs/heads/pr/") { | ||
| 220 | return Err(Error::InvalidRef( | ||
| 221 | "pr/* branches must use refs/nostr/<event-id>".into() | ||
| 222 | )); | ||
| 223 | } | ||
| 224 | Ok(()) | ||
| 225 | } | ||
| 226 | ``` | ||
| 227 | |||
| 228 | ## Complete Validation Flow | ||
| 229 | |||
| 230 | ```rust | ||
| 231 | pub async fn validate_push( | ||
| 232 | &self, | ||
| 233 | npub: &str, | ||
| 234 | identifier: &str, | ||
| 235 | ref_updates: Vec<RefUpdate>, | ||
| 236 | ) -> Result<()> { | ||
| 237 | // 1. Fetch events from local relay | ||
| 238 | let events = self.fetch_events(identifier).await?; | ||
| 239 | |||
| 240 | // 2. Get pubkey from npub | ||
| 241 | let pubkey = decode_npub(npub)?; | ||
| 242 | |||
| 243 | // 3. Get maintainer set (recursive) | ||
| 244 | let maintainers = get_maintainers(&events, &pubkey, identifier); | ||
| 245 | if maintainers.is_empty() { | ||
| 246 | return Err(Error::NoAnnouncement); | ||
| 247 | } | ||
| 248 | |||
| 249 | // 4. Get latest state from maintainers | ||
| 250 | let state = get_state_from_maintainers(&events, &maintainers)?; | ||
| 251 | |||
| 252 | // 5. Validate each ref update | ||
| 253 | for update in ref_updates { | ||
| 254 | // Check for pr/* branches (reject) | ||
| 255 | reject_pr_branches(&update)?; | ||
| 256 | |||
| 257 | // Handle refs/nostr/* (allow) | ||
| 258 | if update.ref_name.starts_with("refs/nostr/") { | ||
| 259 | validate_pr_ref(&update)?; | ||
| 260 | continue; | ||
| 261 | } | ||
| 262 | |||
| 263 | // Validate against state | ||
| 264 | validate_regular_ref(&state, &update)?; | ||
| 265 | } | ||
| 266 | |||
| 267 | Ok(()) | ||
| 268 | } | ||
| 269 | ``` | ||
| 270 | |||
| 271 | ## Integration with actix-web | ||
| 272 | |||
| 273 | ```rust | ||
| 274 | pub async fn git_receive_pack( | ||
| 275 | req: HttpRequest, | ||
| 276 | mut payload: web::Payload, | ||
| 277 | state: web::Data<AppState>, | ||
| 278 | ) -> Result<HttpResponse> { | ||
| 279 | // 1. Extract repo info from path | ||
| 280 | let path = req.path(); | ||
| 281 | let (npub, identifier) = parse_repo_path(path)?; | ||
| 282 | |||
| 283 | // 2. Check repository exists | ||
| 284 | if !state.repo_manager.exists(&npub, &identifier).await { | ||
| 285 | return Ok(HttpResponse::NotFound().body("Repository not found")); | ||
| 286 | } | ||
| 287 | |||
| 288 | // 3. Read request body (need to buffer for parsing) | ||
| 289 | let mut body = web::BytesMut::new(); | ||
| 290 | while let Some(chunk) = payload.next().await { | ||
| 291 | body.extend_from_slice(&chunk?); | ||
| 292 | } | ||
| 293 | |||
| 294 | // 4. Parse ref updates from body | ||
| 295 | let ref_updates = parse_ref_updates(&body)?; | ||
| 296 | |||
| 297 | // 5. VALIDATE! | ||
| 298 | let validator = PushValidator::new(state.nostr_client.clone()); | ||
| 299 | if let Err(e) = validator.validate_push(&npub, &identifier, ref_updates).await { | ||
| 300 | return Ok(HttpResponse::Forbidden() | ||
| 301 | .content_type("text/plain") | ||
| 302 | .body(format!("error: {}\n", e))); | ||
| 303 | } | ||
| 304 | |||
| 305 | // 6. Valid! Spawn git-receive-pack | ||
| 306 | let repo_path = state.repo_manager.get_path(&npub, &identifier); | ||
| 307 | let mut cmd = Command::new("git"); | ||
| 308 | cmd.arg("receive-pack") | ||
| 309 | .arg("--stateless-rpc") | ||
| 310 | .arg(&repo_path) | ||
| 311 | .stdin(Stdio::piped()) | ||
| 312 | .stdout(Stdio::piped()) | ||
| 313 | .stderr(Stdio::piped()); | ||
| 314 | |||
| 315 | let mut child = cmd.spawn()?; | ||
| 316 | |||
| 317 | // 7. Write body to git stdin | ||
| 318 | let mut stdin = child.stdin.take().unwrap(); | ||
| 319 | stdin.write_all(&body).await?; | ||
| 320 | drop(stdin); | ||
| 321 | |||
| 322 | // 8. Stream git stdout back to client | ||
| 323 | let stdout = child.stdout.take().unwrap(); | ||
| 324 | let stream = FramedRead::new(stdout, BytesCodec::new()); | ||
| 325 | |||
| 326 | Ok(HttpResponse::Ok() | ||
| 327 | .content_type("application/x-git-receive-pack-result") | ||
| 328 | .streaming(stream)) | ||
| 329 | } | ||
| 330 | ``` | ||
| 331 | |||
| 332 | ## Error Responses | ||
| 333 | |||
| 334 | Git clients expect specific error formats: | ||
| 335 | |||
| 336 | ### Success | ||
| 337 | ``` | ||
| 338 | HTTP/1.1 200 OK | ||
| 339 | Content-Type: application/x-git-receive-pack-result | ||
| 340 | |||
| 341 | [git output stream] | ||
| 342 | ``` | ||
| 343 | |||
| 344 | ### Validation Failure | ||
| 345 | ``` | ||
| 346 | HTTP/1.1 403 Forbidden | ||
| 347 | Content-Type: text/plain | ||
| 348 | |||
| 349 | error: cannot push refs/heads/main to a1b2c3d as nostr state event is at f6e5d4c | ||
| 350 | ``` | ||
| 351 | |||
| 352 | The `error:` prefix makes it display nicely in git clients. | ||
| 353 | |||
| 354 | ## Testing | ||
| 355 | |||
| 356 | ```rust | ||
| 357 | #[test] | ||
| 358 | fn test_parse_ref_updates() { | ||
| 359 | let body = b"00820000000000000000000000000000000000000000 \ | ||
| 360 | a1b2c3d4e5f6789012345678901234567890abcd \ | ||
| 361 | refs/heads/main\0 report-status\n\ | ||
| 362 | 0000"; | ||
| 363 | |||
| 364 | let updates = parse_ref_updates(body).unwrap(); | ||
| 365 | assert_eq!(updates.len(), 1); | ||
| 366 | assert_eq!(updates[0].old_oid, "0000000000000000000000000000000000000000"); | ||
| 367 | assert_eq!(updates[0].new_oid, "a1b2c3d4e5f6789012345678901234567890abcd"); | ||
| 368 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 369 | } | ||
| 370 | |||
| 371 | #[tokio::test] | ||
| 372 | async fn test_validate_matching_state() { | ||
| 373 | let state = RepositoryState { | ||
| 374 | branches: HashMap::from([ | ||
| 375 | ("main".into(), "a1b2c3d4...".into()), | ||
| 376 | ]), | ||
| 377 | tags: HashMap::new(), | ||
| 378 | }; | ||
| 379 | |||
| 380 | let update = RefUpdate { | ||
| 381 | old_oid: "0000...".into(), | ||
| 382 | new_oid: "a1b2c3d4...".into(), | ||
| 383 | ref_name: "refs/heads/main".into(), | ||
| 384 | }; | ||
| 385 | |||
| 386 | assert!(validate_regular_ref(&state, &update).is_ok()); | ||
| 387 | } | ||
| 388 | ``` | ||
| 389 | |||
| 390 | ## Performance Considerations | ||
| 391 | |||
| 392 | 1. **Buffering**: We must buffer the entire request body to parse ref updates. For large pushes, this could be memory-intensive. | ||
| 393 | |||
| 394 | **Mitigation**: Limit max request size (e.g., 100MB) | ||
| 395 | |||
| 396 | 2. **Pack Data**: After ref updates, the body contains pack data. We don't need to parse this, just forward it to Git. | ||
| 397 | |||
| 398 | **Optimization**: Could use a streaming parser that only extracts ref updates, then streams the rest | ||
| 399 | |||
| 400 | 3. **Validation Speed**: State lookup and validation should be fast. | ||
| 401 | |||
| 402 | **Optimization**: Cache state events with TTL | ||
| 403 | |||
| 404 | ## Future Enhancements | ||
| 405 | |||
| 406 | ### Streaming Parser | ||
| 407 | |||
| 408 | Instead of buffering entire body: | ||
| 409 | |||
| 410 | ```rust | ||
| 411 | // Read pkt-lines until flush packet | ||
| 412 | let ref_updates = parse_ref_updates_streaming(&mut payload).await?; | ||
| 413 | |||
| 414 | // Now payload is positioned at pack data | ||
| 415 | // Stream directly to git without buffering | ||
| 416 | spawn_git_and_stream(payload, repo_path).await?; | ||
| 417 | ``` | ||
| 418 | |||
| 419 | ### Pack Inspection | ||
| 420 | |||
| 421 | For advanced validation (future): | ||
| 422 | |||
| 423 | ```rust | ||
| 424 | // Parse pack header to get object count | ||
| 425 | let (ref_updates, pack_header) = parse_receive_pack_header(&body)?; | ||
| 426 | |||
| 427 | // Could validate pack contents before accepting | ||
| 428 | validate_pack_contents(&pack_header)?; | ||
| 429 | ``` | ||
| 430 | |||
| 431 | ## References | ||
| 432 | |||
| 433 | - [Git HTTP Protocol Docs](https://git-scm.com/docs/http-protocol) | ||
| 434 | - [Git Pack Protocol](https://git-scm.com/docs/pack-protocol) | ||
| 435 | - [Pkt-line Format](https://git-scm.com/docs/protocol-common#_pkt_line_format) | ||
diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..745211d --- /dev/null +++ b/docs/README.md | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | # ngit-grasp Documentation | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | This directory contains comprehensive documentation for the ngit-grasp project. | ||
| 6 | |||
| 7 | ## Documents | ||
| 8 | |||
| 9 | ### For Review | ||
| 10 | - **[../REVIEW_SUMMARY.md](../REVIEW_SUMMARY.md)** - Start here! Executive summary of the architecture investigation and recommendations | ||
| 11 | |||
| 12 | ### Architecture & Design | ||
| 13 | - **[ARCHITECTURE.md](ARCHITECTURE.md)** - Detailed technical architecture, component design, data flows, and implementation details | ||
| 14 | - **[DECISION_SUMMARY.md](DECISION_SUMMARY.md)** - Why we chose inline authorization over Git hooks | ||
| 15 | - **[COMPARISON.md](COMPARISON.md)** - Side-by-side comparison with the reference implementation (ngit-relay) | ||
| 16 | |||
| 17 | ### Technical References | ||
| 18 | - **[GIT_PROTOCOL.md](GIT_PROTOCOL.md)** - Git Smart HTTP protocol reference, pkt-line format, and parsing examples | ||
| 19 | - **[TEST_STRATEGY.md](TEST_STRATEGY.md)** - Comprehensive testing strategy including reusable GRASP compliance testing tool | ||
| 20 | |||
| 21 | ### Project Files | ||
| 22 | - **[../README.md](../README.md)** - Project overview, quick start, and feature list | ||
| 23 | - **[../.env.example](../.env.example)** - Configuration template | ||
| 24 | - **[../LICENSE](../LICENSE)** - MIT License | ||
| 25 | |||
| 26 | ## Reading Guide | ||
| 27 | |||
| 28 | ### If you want to understand the architecture decision: | ||
| 29 | 1. Read [REVIEW_SUMMARY.md](../REVIEW_SUMMARY.md) - Executive summary | ||
| 30 | 2. Read [DECISION_SUMMARY.md](DECISION_SUMMARY.md) - Detailed rationale | ||
| 31 | 3. Skim [COMPARISON.md](COMPARISON.md) - See how we differ from reference | ||
| 32 | |||
| 33 | ### If you want to implement: | ||
| 34 | 1. Read [ARCHITECTURE.md](ARCHITECTURE.md) - Component design and code structure | ||
| 35 | 2. Read [TEST_STRATEGY.md](TEST_STRATEGY.md) - Testing approach and compliance tool | ||
| 36 | 3. Read [GIT_PROTOCOL.md](GIT_PROTOCOL.md) - Git protocol details | ||
| 37 | 4. Review code examples in ARCHITECTURE.md | ||
| 38 | |||
| 39 | ### If you want to deploy: | ||
| 40 | 1. Read [README.md](../README.md) - Quick start | ||
| 41 | 2. Review [.env.example](../.env.example) - Configuration | ||
| 42 | 3. See deployment section in [ARCHITECTURE.md](ARCHITECTURE.md) | ||
| 43 | |||
| 44 | ### If you're comparing with ngit-relay: | ||
| 45 | 1. Read [COMPARISON.md](COMPARISON.md) - Detailed comparison | ||
| 46 | 2. See architecture diagrams in both COMPARISON.md and ARCHITECTURE.md | ||
| 47 | |||
| 48 | ## Key Concepts | ||
| 49 | |||
| 50 | ### Inline Authorization | ||
| 51 | The core architectural decision: we validate Git pushes **inside the HTTP handler** before spawning Git, rather than using Git's pre-receive hooks. | ||
| 52 | |||
| 53 | **Benefits:** | ||
| 54 | - Better error messages (HTTP responses vs. hook stderr) | ||
| 55 | - Simpler deployment (no hook management) | ||
| 56 | - Easier testing (pure Rust) | ||
| 57 | - Better performance (skip Git for invalid pushes) | ||
| 58 | |||
| 59 | ### GRASP Protocol | ||
| 60 | Git Relays Authorized via Signed-Nostr Proofs - a protocol for hosting Git repositories with Nostr-based authorization. | ||
| 61 | |||
| 62 | **Key Points:** | ||
| 63 | - Repository announcements (NIP-34 kind 30317) | ||
| 64 | - State announcements (NIP-34 kind 30318) | ||
| 65 | - Multi-maintainer support via recursive maintainer sets | ||
| 66 | - Push validation against signed state events | ||
| 67 | |||
| 68 | ### Technology Stack | ||
| 69 | - **actix-web**: HTTP server | ||
| 70 | - **git-http-backend**: Git protocol handling (Rust crate) | ||
| 71 | - **nostr-relay-builder**: Nostr relay infrastructure (rust-nostr) | ||
| 72 | - **tokio**: Async runtime | ||
| 73 | |||
| 74 | ## Status | ||
| 75 | |||
| 76 | **ALPHA** - Architecture design complete, implementation not yet started. | ||
| 77 | |||
| 78 | ## Contributing | ||
| 79 | |||
| 80 | See [../README.md](../README.md) for contribution guidelines. | ||
| 81 | |||
| 82 | ## Questions? | ||
| 83 | |||
| 84 | Open an issue or discussion on the repository. | ||
diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md new file mode 100644 index 0000000..cc1d5b0 --- /dev/null +++ b/docs/TEST_STRATEGY.md | |||
| @@ -0,0 +1,1238 @@ | |||
| 1 | # Test Strategy for ngit-grasp | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | This document outlines the comprehensive testing strategy for ngit-grasp, including a **reusable GRASP compliance testing tool** that can validate any GRASP implementation against the protocol specification. | ||
| 6 | |||
| 7 | ## Testing Philosophy | ||
| 8 | |||
| 9 | 1. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly | ||
| 10 | 2. **Compliance-First**: Every requirement in the spec has a corresponding test | ||
| 11 | 3. **Reusable**: Compliance tests can validate any GRASP implementation | ||
| 12 | 4. **Clear Failures**: Test failures cite exact spec lines/sections | ||
| 13 | 5. **Comprehensive**: Unit, integration, and compliance testing | ||
| 14 | |||
| 15 | ## Test Pyramid | ||
| 16 | |||
| 17 | ``` | ||
| 18 | ╱╲ | ||
| 19 | ╱ ╲ | ||
| 20 | ╱ E2E╲ ~ 10% End-to-end with real Git | ||
| 21 | ╱──────╲ | ||
| 22 | ╱ ╲ | ||
| 23 | ╱Compliance╲ ~ 20% GRASP spec validation | ||
| 24 | ╱────────────╲ | ||
| 25 | ╱ ╲ | ||
| 26 | ╱ Integration ╲ ~ 30% Component interaction | ||
| 27 | ╱──────────────────╲ | ||
| 28 | ╱ ╲ | ||
| 29 | ╱ Unit Tests ╲ ~ 40% Individual functions | ||
| 30 | ╱────────────────────────╲ | ||
| 31 | ``` | ||
| 32 | |||
| 33 | ## GRASP Compliance Testing Tool | ||
| 34 | |||
| 35 | ### Design Goals | ||
| 36 | |||
| 37 | 1. **Reusable**: Can test ngit-grasp or any other GRASP implementation | ||
| 38 | 2. **Spec-Mirrored**: Test structure matches GRASP protocol documents | ||
| 39 | 3. **Clear Reporting**: Failures cite exact spec requirements | ||
| 40 | 4. **Automated**: Can run in CI/CD | ||
| 41 | 5. **Extensible**: Easy to add new GRASP versions (GRASP-02, GRASP-05) | ||
| 42 | |||
| 43 | ### Project Structure | ||
| 44 | |||
| 45 | ``` | ||
| 46 | grasp-compliance-tests/ | ||
| 47 | ├── Cargo.toml # Standalone crate | ||
| 48 | ├── README.md # Usage instructions | ||
| 49 | ├── src/ | ||
| 50 | │ ├── lib.rs # Public API | ||
| 51 | │ ├── client.rs # Test client utilities | ||
| 52 | │ ├── assertions.rs # Spec-based assertions | ||
| 53 | │ └── specs/ | ||
| 54 | │ ├── mod.rs # Spec registry | ||
| 55 | │ ├── grasp_01.rs # GRASP-01 tests | ||
| 56 | │ ├── grasp_02.rs # GRASP-02 tests | ||
| 57 | │ └── grasp_05.rs # GRASP-05 tests | ||
| 58 | ├── fixtures/ | ||
| 59 | │ ├── repos/ # Test repositories | ||
| 60 | │ ├── events/ # Nostr event fixtures | ||
| 61 | │ └── keys/ # Test keypairs | ||
| 62 | └── examples/ | ||
| 63 | └── test_implementation.rs # Example usage | ||
| 64 | ``` | ||
| 65 | |||
| 66 | ### Spec-Mirrored Test Structure | ||
| 67 | |||
| 68 | Each GRASP spec document maps to a test module with identical structure: | ||
| 69 | |||
| 70 | ```rust | ||
| 71 | // src/specs/grasp_01.rs | ||
| 72 | |||
| 73 | use crate::{TestContext, SpecRequirement, ComplianceResult}; | ||
| 74 | |||
| 75 | /// GRASP-01 - Core Service Requirements | ||
| 76 | /// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md | ||
| 77 | pub struct Grasp01Spec; | ||
| 78 | |||
| 79 | impl Grasp01Spec { | ||
| 80 | /// Run all GRASP-01 compliance tests | ||
| 81 | pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult { | ||
| 82 | let mut results = ComplianceResult::new("GRASP-01"); | ||
| 83 | |||
| 84 | // Section: Nostr Relay | ||
| 85 | results.add(Self::test_nostr_relay_nip01_compliance(ctx).await); | ||
| 86 | results.add(Self::test_accepts_repository_announcements(ctx).await); | ||
| 87 | results.add(Self::test_accepts_repository_state_announcements(ctx).await); | ||
| 88 | results.add(Self::test_rejects_unlisted_announcements(ctx).await); | ||
| 89 | results.add(Self::test_accepts_related_events(ctx).await); | ||
| 90 | results.add(Self::test_serves_nip11_document(ctx).await); | ||
| 91 | results.add(Self::test_nip11_has_supported_grasps(ctx).await); | ||
| 92 | results.add(Self::test_nip11_has_repo_acceptance_criteria(ctx).await); | ||
| 93 | results.add(Self::test_nip11_has_curation_policy(ctx).await); | ||
| 94 | |||
| 95 | // Section: Git Smart HTTP Service | ||
| 96 | results.add(Self::test_serves_git_at_correct_path(ctx).await); | ||
| 97 | results.add(Self::test_accepts_matching_pushes(ctx).await); | ||
| 98 | results.add(Self::test_rejects_mismatched_pushes(ctx).await); | ||
| 99 | results.add(Self::test_respects_recursive_maintainers(ctx).await); | ||
| 100 | results.add(Self::test_sets_head_from_state(ctx).await); | ||
| 101 | results.add(Self::test_accepts_nostr_refs(ctx).await); | ||
| 102 | results.add(Self::test_rejects_pr_branches(ctx).await); | ||
| 103 | results.add(Self::test_deletes_orphaned_nostr_refs(ctx).await); | ||
| 104 | results.add(Self::test_allows_reachable_sha1_in_want(ctx).await); | ||
| 105 | results.add(Self::test_allows_tip_sha1_in_want(ctx).await); | ||
| 106 | results.add(Self::test_serves_webpage(ctx).await); | ||
| 107 | |||
| 108 | // Section: CORS Support | ||
| 109 | results.add(Self::test_cors_allow_origin(ctx).await); | ||
| 110 | results.add(Self::test_cors_allow_methods(ctx).await); | ||
| 111 | results.add(Self::test_cors_allow_headers(ctx).await); | ||
| 112 | results.add(Self::test_cors_options_request(ctx).await); | ||
| 113 | |||
| 114 | results | ||
| 115 | } | ||
| 116 | |||
| 117 | // ================================================================ | ||
| 118 | // NOSTR RELAY TESTS | ||
| 119 | // ================================================================ | ||
| 120 | |||
| 121 | /// MUST serve a NIP-01 compliant nostr relay at `/` | ||
| 122 | /// | ||
| 123 | /// Spec: GRASP-01, Line 9-10 | ||
| 124 | /// > MUST serve a [NIP-01](https://nips.nostr.com/1) compliant nostr | ||
| 125 | /// > relay at `/` that accepts [git repository announcements]... | ||
| 126 | async fn test_nostr_relay_nip01_compliance(ctx: &TestContext) -> TestResult { | ||
| 127 | TestResult::new( | ||
| 128 | "nostr_relay_nip01_compliance", | ||
| 129 | "GRASP-01:9-10", | ||
| 130 | "MUST serve a NIP-01 compliant nostr relay at `/`", | ||
| 131 | ) | ||
| 132 | .run(async { | ||
| 133 | // Test WebSocket upgrade at / | ||
| 134 | let ws = ctx.connect_websocket("/").await?; | ||
| 135 | |||
| 136 | // Test NIP-01 REQ/EVENT/CLOSE/NOTICE messages | ||
| 137 | ws.send_req("test-sub", vec![]).await?; | ||
| 138 | let response = ws.recv().await?; | ||
| 139 | assert_nip01_eose(response)?; | ||
| 140 | |||
| 141 | Ok(()) | ||
| 142 | }) | ||
| 143 | .await | ||
| 144 | } | ||
| 145 | |||
| 146 | /// MUST reject announcements that do not list the service in both | ||
| 147 | /// `clone` and `relays` tags unless implementing `GRASP-05` | ||
| 148 | /// | ||
| 149 | /// Spec: GRASP-01, Line 12-13 | ||
| 150 | /// > MUST reject [git repository announcements] that do not list the | ||
| 151 | /// > service in both `clone` and `relays` tags unless implementing `GRASP-05`. | ||
| 152 | async fn test_rejects_unlisted_announcements(ctx: &TestContext) -> TestResult { | ||
| 153 | TestResult::new( | ||
| 154 | "rejects_unlisted_announcements", | ||
| 155 | "GRASP-01:12-13", | ||
| 156 | "MUST reject announcements not listing service in clone and relays", | ||
| 157 | ) | ||
| 158 | .run(async { | ||
| 159 | let event = ctx.create_announcement() | ||
| 160 | .without_clone_tag(ctx.domain()) | ||
| 161 | .build() | ||
| 162 | .await?; | ||
| 163 | |||
| 164 | let result = ctx.send_event(event).await?; | ||
| 165 | |||
| 166 | assert_eq!( | ||
| 167 | result.ok, false, | ||
| 168 | "Expected rejection of announcement without clone tag" | ||
| 169 | ); | ||
| 170 | assert!( | ||
| 171 | result.message.contains("clone") || result.message.contains("relays"), | ||
| 172 | "Expected rejection message to mention clone/relays requirement" | ||
| 173 | ); | ||
| 174 | |||
| 175 | Ok(()) | ||
| 176 | }) | ||
| 177 | .await | ||
| 178 | } | ||
| 179 | |||
| 180 | /// MUST accept other events that tag, or are tagged by, accepted announcements | ||
| 181 | /// | ||
| 182 | /// Spec: GRASP-01, Line 17-20 | ||
| 183 | /// > MUST accept other events that tag, or are tagged by, either: | ||
| 184 | /// > 1. accepted [git repository announcements]; or | ||
| 185 | /// > 2. accepted [issues] or [patches] | ||
| 186 | async fn test_accepts_related_events(ctx: &TestContext) -> TestResult { | ||
| 187 | TestResult::new( | ||
| 188 | "accepts_related_events", | ||
| 189 | "GRASP-01:17-20", | ||
| 190 | "MUST accept events that tag or are tagged by accepted announcements", | ||
| 191 | ) | ||
| 192 | .run(async { | ||
| 193 | // First, create and accept an announcement | ||
| 194 | let announcement = ctx.create_announcement() | ||
| 195 | .with_clone_tag(ctx.domain()) | ||
| 196 | .with_relay_tag(ctx.domain()) | ||
| 197 | .build() | ||
| 198 | .await?; | ||
| 199 | |||
| 200 | ctx.send_event(announcement.clone()).await?; | ||
| 201 | |||
| 202 | // Now send an issue that tags the announcement | ||
| 203 | let issue = ctx.create_issue() | ||
| 204 | .tag_announcement(&announcement) | ||
| 205 | .build() | ||
| 206 | .await?; | ||
| 207 | |||
| 208 | let result = ctx.send_event(issue).await?; | ||
| 209 | |||
| 210 | assert_eq!( | ||
| 211 | result.ok, true, | ||
| 212 | "Expected acceptance of issue tagging accepted announcement" | ||
| 213 | ); | ||
| 214 | |||
| 215 | Ok(()) | ||
| 216 | }) | ||
| 217 | .await | ||
| 218 | } | ||
| 219 | |||
| 220 | /// MUST serve a NIP-11 document with required fields | ||
| 221 | /// | ||
| 222 | /// Spec: GRASP-01, Line 24-27 | ||
| 223 | /// > MUST serve a [NIP-11] document: | ||
| 224 | /// > 1. MUST list each supported GRASP under `supported_grasps` | ||
| 225 | /// > 2. MUST list repository acceptance criteria under `repo_acceptance_criteria` | ||
| 226 | /// > 3. MUST list curation policy under `curation` if events are curated | ||
| 227 | async fn test_serves_nip11_document(ctx: &TestContext) -> TestResult { | ||
| 228 | TestResult::new( | ||
| 229 | "serves_nip11_document", | ||
| 230 | "GRASP-01:24-27", | ||
| 231 | "MUST serve a NIP-11 document", | ||
| 232 | ) | ||
| 233 | .run(async { | ||
| 234 | let nip11 = ctx.fetch_nip11().await?; | ||
| 235 | |||
| 236 | assert!( | ||
| 237 | nip11.contains_key("supported_nips"), | ||
| 238 | "NIP-11 document must have supported_nips" | ||
| 239 | ); | ||
| 240 | |||
| 241 | Ok(()) | ||
| 242 | }) | ||
| 243 | .await | ||
| 244 | } | ||
| 245 | |||
| 246 | /// NIP-11 MUST list supported GRASPs | ||
| 247 | /// | ||
| 248 | /// Spec: GRASP-01, Line 25 | ||
| 249 | /// > 1. MUST list each supported GRASP under `supported_grasps` | ||
| 250 | /// > in format `GRASP-XX` eg `GRASP-01` as a string array | ||
| 251 | async fn test_nip11_has_supported_grasps(ctx: &TestContext) -> TestResult { | ||
| 252 | TestResult::new( | ||
| 253 | "nip11_has_supported_grasps", | ||
| 254 | "GRASP-01:25", | ||
| 255 | "NIP-11 MUST list supported_grasps as string array", | ||
| 256 | ) | ||
| 257 | .run(async { | ||
| 258 | let nip11 = ctx.fetch_nip11().await?; | ||
| 259 | |||
| 260 | let grasps = nip11.get("supported_grasps") | ||
| 261 | .ok_or("NIP-11 missing supported_grasps field")? | ||
| 262 | .as_array() | ||
| 263 | .ok_or("supported_grasps must be an array")?; | ||
| 264 | |||
| 265 | assert!( | ||
| 266 | grasps.iter().any(|g| g.as_str() == Some("GRASP-01")), | ||
| 267 | "supported_grasps must include 'GRASP-01'" | ||
| 268 | ); | ||
| 269 | |||
| 270 | // Validate format: GRASP-XX | ||
| 271 | for grasp in grasps { | ||
| 272 | let s = grasp.as_str().ok_or("GRASP must be a string")?; | ||
| 273 | assert!( | ||
| 274 | s.starts_with("GRASP-") && s.len() >= 8, | ||
| 275 | "GRASP format must be 'GRASP-XX', got: {}", s | ||
| 276 | ); | ||
| 277 | } | ||
| 278 | |||
| 279 | Ok(()) | ||
| 280 | }) | ||
| 281 | .await | ||
| 282 | } | ||
| 283 | |||
| 284 | // ================================================================ | ||
| 285 | // GIT SMART HTTP SERVICE TESTS | ||
| 286 | // ================================================================ | ||
| 287 | |||
| 288 | /// MUST serve a git repository via git smart http at /<npub>/<identifier>.git | ||
| 289 | /// | ||
| 290 | /// Spec: GRASP-01, Line 31-32 | ||
| 291 | /// > MUST serve a git repository via an unauthenticated [git smart http service] | ||
| 292 | /// > at `/<npub>/<identifier>.git` for each accepted announcement | ||
| 293 | async fn test_serves_git_at_correct_path(ctx: &TestContext) -> TestResult { | ||
| 294 | TestResult::new( | ||
| 295 | "serves_git_at_correct_path", | ||
| 296 | "GRASP-01:31-32", | ||
| 297 | "MUST serve git at /<npub>/<identifier>.git", | ||
| 298 | ) | ||
| 299 | .run(async { | ||
| 300 | // Create and send announcement | ||
| 301 | let announcement = ctx.create_announcement() | ||
| 302 | .with_identifier("test-repo") | ||
| 303 | .with_clone_tag(ctx.domain()) | ||
| 304 | .with_relay_tag(ctx.domain()) | ||
| 305 | .build() | ||
| 306 | .await?; | ||
| 307 | |||
| 308 | let npub = announcement.author_npub(); | ||
| 309 | ctx.send_event(announcement).await?; | ||
| 310 | |||
| 311 | // Wait for repo creation | ||
| 312 | tokio::time::sleep(Duration::from_secs(2)).await; | ||
| 313 | |||
| 314 | // Test git info/refs endpoint | ||
| 315 | let path = format!("/{}/test-repo.git/info/refs?service=git-upload-pack", npub); | ||
| 316 | let response = ctx.http_get(&path).await?; | ||
| 317 | |||
| 318 | assert_eq!( | ||
| 319 | response.status(), 200, | ||
| 320 | "Git info/refs must return 200 OK" | ||
| 321 | ); | ||
| 322 | |||
| 323 | assert_eq!( | ||
| 324 | response.headers().get("content-type").unwrap(), | ||
| 325 | "application/x-git-upload-pack-advertisement", | ||
| 326 | "Git info/refs must have correct content-type" | ||
| 327 | ); | ||
| 328 | |||
| 329 | Ok(()) | ||
| 330 | }) | ||
| 331 | .await | ||
| 332 | } | ||
| 333 | |||
| 334 | /// MUST accept pushes that match the latest state announcement | ||
| 335 | /// | ||
| 336 | /// Spec: GRASP-01, Line 34-35 | ||
| 337 | /// > MUST accept pushes via this service that match the latest | ||
| 338 | /// > [repo state announcement] on the relay, respecting the recursive maintainer set. | ||
| 339 | async fn test_accepts_matching_pushes(ctx: &TestContext) -> TestResult { | ||
| 340 | TestResult::new( | ||
| 341 | "accepts_matching_pushes", | ||
| 342 | "GRASP-01:34-35", | ||
| 343 | "MUST accept pushes matching latest state announcement", | ||
| 344 | ) | ||
| 345 | .run(async { | ||
| 346 | // Setup: Create repo with announcement and state | ||
| 347 | let (announcement, state) = ctx.create_repo_with_state() | ||
| 348 | .branch("main", "a1b2c3d4...") | ||
| 349 | .build() | ||
| 350 | .await?; | ||
| 351 | |||
| 352 | // Push matching state | ||
| 353 | let result = ctx.git_push(&announcement, "main", "a1b2c3d4...").await?; | ||
| 354 | |||
| 355 | assert!( | ||
| 356 | result.success, | ||
| 357 | "Push matching state must succeed, got: {}", result.stderr | ||
| 358 | ); | ||
| 359 | |||
| 360 | Ok(()) | ||
| 361 | }) | ||
| 362 | .await | ||
| 363 | } | ||
| 364 | |||
| 365 | /// MUST reject pushes that don't match the state announcement | ||
| 366 | /// | ||
| 367 | /// Spec: GRASP-01, Line 34-35 (inverse requirement) | ||
| 368 | /// Implied by "MUST accept pushes... that match" | ||
| 369 | async fn test_rejects_mismatched_pushes(ctx: &TestContext) -> TestResult { | ||
| 370 | TestResult::new( | ||
| 371 | "rejects_mismatched_pushes", | ||
| 372 | "GRASP-01:34-35", | ||
| 373 | "MUST reject pushes not matching state announcement", | ||
| 374 | ) | ||
| 375 | .run(async { | ||
| 376 | // Setup: Create repo with state pointing to commit A | ||
| 377 | let (announcement, state) = ctx.create_repo_with_state() | ||
| 378 | .branch("main", "aaaa1111...") | ||
| 379 | .build() | ||
| 380 | .await?; | ||
| 381 | |||
| 382 | // Try to push different commit B | ||
| 383 | let result = ctx.git_push(&announcement, "main", "bbbb2222...").await; | ||
| 384 | |||
| 385 | assert!( | ||
| 386 | result.is_err() || !result.unwrap().success, | ||
| 387 | "Push not matching state must be rejected" | ||
| 388 | ); | ||
| 389 | |||
| 390 | Ok(()) | ||
| 391 | }) | ||
| 392 | .await | ||
| 393 | } | ||
| 394 | |||
| 395 | /// MUST accept pushes to refs/nostr/<event-id> | ||
| 396 | /// | ||
| 397 | /// Spec: GRASP-01, Line 42-44 | ||
| 398 | /// > MUST accept pushes via this service to `refs/nostr/<event-id>` but | ||
| 399 | /// > SHOULD reject if event exists on relay listing a different tip | ||
| 400 | async fn test_accepts_nostr_refs(ctx: &TestContext) -> TestResult { | ||
| 401 | TestResult::new( | ||
| 402 | "accepts_nostr_refs", | ||
| 403 | "GRASP-01:42-44", | ||
| 404 | "MUST accept pushes to refs/nostr/<event-id>", | ||
| 405 | ) | ||
| 406 | .run(async { | ||
| 407 | let (announcement, _) = ctx.create_repo_with_state().build().await?; | ||
| 408 | |||
| 409 | // Create a PR event | ||
| 410 | let pr_event = ctx.create_pr_event() | ||
| 411 | .for_repo(&announcement) | ||
| 412 | .build() | ||
| 413 | .await?; | ||
| 414 | |||
| 415 | let event_id = pr_event.id(); | ||
| 416 | |||
| 417 | // Push to refs/nostr/<event-id> | ||
| 418 | let result = ctx.git_push( | ||
| 419 | &announcement, | ||
| 420 | &format!("refs/nostr/{}", event_id), | ||
| 421 | "commit-sha..." | ||
| 422 | ).await?; | ||
| 423 | |||
| 424 | assert!( | ||
| 425 | result.success, | ||
| 426 | "Push to refs/nostr/<event-id> must succeed" | ||
| 427 | ); | ||
| 428 | |||
| 429 | Ok(()) | ||
| 430 | }) | ||
| 431 | .await | ||
| 432 | } | ||
| 433 | |||
| 434 | /// MUST reject pr/* branches | ||
| 435 | /// | ||
| 436 | /// Spec: GRASP-01, Line 42-44 (implied) | ||
| 437 | /// PRs should use refs/nostr/, not refs/heads/pr/* | ||
| 438 | async fn test_rejects_pr_branches(ctx: &TestContext) -> TestResult { | ||
| 439 | TestResult::new( | ||
| 440 | "rejects_pr_branches", | ||
| 441 | "GRASP-01:42-44", | ||
| 442 | "MUST reject refs/heads/pr/* (use refs/nostr/ instead)", | ||
| 443 | ) | ||
| 444 | .run(async { | ||
| 445 | let (announcement, _) = ctx.create_repo_with_state().build().await?; | ||
| 446 | |||
| 447 | // Try to push to pr/* branch | ||
| 448 | let result = ctx.git_push( | ||
| 449 | &announcement, | ||
| 450 | "refs/heads/pr/123", | ||
| 451 | "commit-sha..." | ||
| 452 | ).await; | ||
| 453 | |||
| 454 | assert!( | ||
| 455 | result.is_err() || !result.unwrap().success, | ||
| 456 | "Push to refs/heads/pr/* must be rejected" | ||
| 457 | ); | ||
| 458 | |||
| 459 | Ok(()) | ||
| 460 | }) | ||
| 461 | .await | ||
| 462 | } | ||
| 463 | |||
| 464 | /// MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want | ||
| 465 | /// | ||
| 466 | /// Spec: GRASP-01, Line 48-49 | ||
| 467 | /// > MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` | ||
| 468 | /// > in advertisement and serve available oids. | ||
| 469 | async fn test_allows_tip_sha1_in_want(ctx: &TestContext) -> TestResult { | ||
| 470 | TestResult::new( | ||
| 471 | "allows_tip_sha1_in_want", | ||
| 472 | "GRASP-01:48-49", | ||
| 473 | "MUST advertise and support allow-tip-sha1-in-want", | ||
| 474 | ) | ||
| 475 | .run(async { | ||
| 476 | let (announcement, _) = ctx.create_repo_with_state() | ||
| 477 | .branch("main", "a1b2c3d4...") | ||
| 478 | .build() | ||
| 479 | .await?; | ||
| 480 | |||
| 481 | // Fetch git capabilities | ||
| 482 | let caps = ctx.git_capabilities(&announcement).await?; | ||
| 483 | |||
| 484 | assert!( | ||
| 485 | caps.contains("allow-tip-sha1-in-want"), | ||
| 486 | "Git advertisement must include allow-tip-sha1-in-want" | ||
| 487 | ); | ||
| 488 | |||
| 489 | assert!( | ||
| 490 | caps.contains("allow-reachable-sha1-in-want"), | ||
| 491 | "Git advertisement must include allow-reachable-sha1-in-want" | ||
| 492 | ); | ||
| 493 | |||
| 494 | Ok(()) | ||
| 495 | }) | ||
| 496 | .await | ||
| 497 | } | ||
| 498 | |||
| 499 | // ================================================================ | ||
| 500 | // CORS SUPPORT TESTS | ||
| 501 | // ================================================================ | ||
| 502 | |||
| 503 | /// MUST set Access-Control-Allow-Origin: * on ALL responses | ||
| 504 | /// | ||
| 505 | /// Spec: GRASP-01, Line 57 | ||
| 506 | /// > 1. Set `Access-Control-Allow-Origin: *` on ALL responses | ||
| 507 | async fn test_cors_allow_origin(ctx: &TestContext) -> TestResult { | ||
| 508 | TestResult::new( | ||
| 509 | "cors_allow_origin", | ||
| 510 | "GRASP-01:57", | ||
| 511 | "MUST set Access-Control-Allow-Origin: * on ALL responses", | ||
| 512 | ) | ||
| 513 | .run(async { | ||
| 514 | let paths = vec![ | ||
| 515 | "/", | ||
| 516 | "/test-npub/test-repo.git/info/refs?service=git-upload-pack", | ||
| 517 | ]; | ||
| 518 | |||
| 519 | for path in paths { | ||
| 520 | let response = ctx.http_get(path).await?; | ||
| 521 | |||
| 522 | assert_eq!( | ||
| 523 | response.headers().get("access-control-allow-origin").unwrap(), | ||
| 524 | "*", | ||
| 525 | "Path {} must have Access-Control-Allow-Origin: *", path | ||
| 526 | ); | ||
| 527 | } | ||
| 528 | |||
| 529 | Ok(()) | ||
| 530 | }) | ||
| 531 | .await | ||
| 532 | } | ||
| 533 | |||
| 534 | /// MUST respond to OPTIONS requests with 204 No Content | ||
| 535 | /// | ||
| 536 | /// Spec: GRASP-01, Line 60 | ||
| 537 | /// > 4. Respond to OPTIONS requests with 204 No Content | ||
| 538 | async fn test_cors_options_request(ctx: &TestContext) -> TestResult { | ||
| 539 | TestResult::new( | ||
| 540 | "cors_options_request", | ||
| 541 | "GRASP-01:60", | ||
| 542 | "MUST respond to OPTIONS with 204 No Content", | ||
| 543 | ) | ||
| 544 | .run(async { | ||
| 545 | let response = ctx.http_options("/test-npub/test-repo.git/info/refs").await?; | ||
| 546 | |||
| 547 | assert_eq!( | ||
| 548 | response.status(), 204, | ||
| 549 | "OPTIONS request must return 204 No Content" | ||
| 550 | ); | ||
| 551 | |||
| 552 | Ok(()) | ||
| 553 | }) | ||
| 554 | .await | ||
| 555 | } | ||
| 556 | } | ||
| 557 | ``` | ||
| 558 | |||
| 559 | ### Test Result Reporting | ||
| 560 | |||
| 561 | ```rust | ||
| 562 | /// Test result with spec citation | ||
| 563 | pub struct TestResult { | ||
| 564 | pub name: String, | ||
| 565 | pub spec_ref: String, // e.g., "GRASP-01:12-13" | ||
| 566 | pub requirement: String, // Exact text from spec | ||
| 567 | pub passed: bool, | ||
| 568 | pub error: Option<String>, | ||
| 569 | pub duration: Duration, | ||
| 570 | } | ||
| 571 | |||
| 572 | impl TestResult { | ||
| 573 | /// Create a new test result | ||
| 574 | pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { | ||
| 575 | TestResult { | ||
| 576 | name: name.to_string(), | ||
| 577 | spec_ref: spec_ref.to_string(), | ||
| 578 | requirement: requirement.to_string(), | ||
| 579 | passed: false, | ||
| 580 | error: None, | ||
| 581 | duration: Duration::default(), | ||
| 582 | } | ||
| 583 | } | ||
| 584 | |||
| 585 | /// Run the test | ||
| 586 | pub async fn run<F, Fut>(mut self, test_fn: F) -> Self | ||
| 587 | where | ||
| 588 | F: FnOnce() -> Fut, | ||
| 589 | Fut: Future<Output = Result<(), String>>, | ||
| 590 | { | ||
| 591 | let start = Instant::now(); | ||
| 592 | |||
| 593 | match test_fn().await { | ||
| 594 | Ok(()) => { | ||
| 595 | self.passed = true; | ||
| 596 | } | ||
| 597 | Err(e) => { | ||
| 598 | self.passed = false; | ||
| 599 | self.error = Some(e); | ||
| 600 | } | ||
| 601 | } | ||
| 602 | |||
| 603 | self.duration = start.elapsed(); | ||
| 604 | self | ||
| 605 | } | ||
| 606 | } | ||
| 607 | |||
| 608 | /// Collection of test results for a spec | ||
| 609 | pub struct ComplianceResult { | ||
| 610 | pub spec: String, | ||
| 611 | pub results: Vec<TestResult>, | ||
| 612 | } | ||
| 613 | |||
| 614 | impl ComplianceResult { | ||
| 615 | pub fn report(&self) -> String { | ||
| 616 | let mut output = String::new(); | ||
| 617 | |||
| 618 | output.push_str(&format!("\n{} Compliance Report\n", self.spec)); | ||
| 619 | output.push_str(&"=".repeat(60)); | ||
| 620 | output.push_str("\n\n"); | ||
| 621 | |||
| 622 | let passed = self.results.iter().filter(|r| r.passed).count(); | ||
| 623 | let total = self.results.len(); | ||
| 624 | |||
| 625 | output.push_str(&format!("Results: {}/{} passed\n\n", passed, total)); | ||
| 626 | |||
| 627 | for result in &self.results { | ||
| 628 | let status = if result.passed { "✓" } else { "✗" }; | ||
| 629 | |||
| 630 | output.push_str(&format!( | ||
| 631 | "{} {} ({})\n", | ||
| 632 | status, result.name, result.spec_ref | ||
| 633 | )); | ||
| 634 | |||
| 635 | output.push_str(&format!(" Requirement: {}\n", result.requirement)); | ||
| 636 | |||
| 637 | if let Some(error) = &result.error { | ||
| 638 | output.push_str(&format!(" Error: {}\n", error)); | ||
| 639 | } | ||
| 640 | |||
| 641 | output.push_str(&format!(" Duration: {:?}\n\n", result.duration)); | ||
| 642 | } | ||
| 643 | |||
| 644 | output | ||
| 645 | } | ||
| 646 | } | ||
| 647 | ``` | ||
| 648 | |||
| 649 | ### Usage Example | ||
| 650 | |||
| 651 | ```rust | ||
| 652 | // examples/test_implementation.rs | ||
| 653 | |||
| 654 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | ||
| 655 | |||
| 656 | #[tokio::main] | ||
| 657 | async fn main() { | ||
| 658 | // Configure the implementation to test | ||
| 659 | let ctx = TestContext::builder() | ||
| 660 | .base_url("http://localhost:8080") | ||
| 661 | .websocket_url("ws://localhost:8080") | ||
| 662 | .domain("localhost:8080") | ||
| 663 | .build(); | ||
| 664 | |||
| 665 | // Run GRASP-01 compliance tests | ||
| 666 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 667 | |||
| 668 | // Print report | ||
| 669 | println!("{}", results.report()); | ||
| 670 | |||
| 671 | // Exit with error if any tests failed | ||
| 672 | if !results.all_passed() { | ||
| 673 | std::process::exit(1); | ||
| 674 | } | ||
| 675 | } | ||
| 676 | ``` | ||
| 677 | |||
| 678 | ### Integration with ngit-grasp | ||
| 679 | |||
| 680 | In `ngit-grasp/tests/compliance.rs`: | ||
| 681 | |||
| 682 | ```rust | ||
| 683 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | ||
| 684 | |||
| 685 | #[tokio::test] | ||
| 686 | async fn test_grasp_01_compliance() { | ||
| 687 | // Start test server | ||
| 688 | let server = start_test_server().await; | ||
| 689 | |||
| 690 | // Configure test context | ||
| 691 | let ctx = TestContext::builder() | ||
| 692 | .base_url(&server.url()) | ||
| 693 | .websocket_url(&server.ws_url()) | ||
| 694 | .domain(&server.domain()) | ||
| 695 | .build(); | ||
| 696 | |||
| 697 | // Run compliance tests | ||
| 698 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 699 | |||
| 700 | // Assert all tests passed | ||
| 701 | assert!( | ||
| 702 | results.all_passed(), | ||
| 703 | "GRASP-01 compliance failed:\n{}", | ||
| 704 | results.report() | ||
| 705 | ); | ||
| 706 | } | ||
| 707 | ``` | ||
| 708 | |||
| 709 | ## Unit Testing Strategy | ||
| 710 | |||
| 711 | ### Git Module Tests | ||
| 712 | |||
| 713 | ```rust | ||
| 714 | // src/git/parser.rs tests | ||
| 715 | |||
| 716 | #[cfg(test)] | ||
| 717 | mod tests { | ||
| 718 | use super::*; | ||
| 719 | |||
| 720 | #[test] | ||
| 721 | fn test_parse_pkt_line() { | ||
| 722 | let data = b"0006a\n"; | ||
| 723 | let (length, payload) = parse_pkt_line(data).unwrap(); | ||
| 724 | assert_eq!(length, 6); | ||
| 725 | assert_eq!(payload, b"a\n"); | ||
| 726 | } | ||
| 727 | |||
| 728 | #[test] | ||
| 729 | fn test_parse_flush_packet() { | ||
| 730 | let data = b"0000"; | ||
| 731 | let result = parse_pkt_line(data).unwrap(); | ||
| 732 | assert_eq!(result.0, 0); | ||
| 733 | } | ||
| 734 | |||
| 735 | #[test] | ||
| 736 | fn test_parse_ref_updates() { | ||
| 737 | let body = b"00820000000000000000000000000000000000000000 \ | ||
| 738 | a1b2c3d4e5f6789012345678901234567890abcd \ | ||
| 739 | refs/heads/main\0 report-status\n\ | ||
| 740 | 0000"; | ||
| 741 | |||
| 742 | let updates = parse_ref_updates(body).unwrap(); | ||
| 743 | assert_eq!(updates.len(), 1); | ||
| 744 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 745 | } | ||
| 746 | } | ||
| 747 | ``` | ||
| 748 | |||
| 749 | ### Authorization Module Tests | ||
| 750 | |||
| 751 | ```rust | ||
| 752 | // src/git/authorization.rs tests | ||
| 753 | |||
| 754 | #[cfg(test)] | ||
| 755 | mod tests { | ||
| 756 | use super::*; | ||
| 757 | |||
| 758 | #[test] | ||
| 759 | fn test_get_maintainers_single() { | ||
| 760 | let events = vec![ | ||
| 761 | create_test_announcement("alice", "repo1", vec![]), | ||
| 762 | ]; | ||
| 763 | |||
| 764 | let maintainers = get_maintainers(&events, "alice", "repo1"); | ||
| 765 | assert_eq!(maintainers, vec!["alice"]); | ||
| 766 | } | ||
| 767 | |||
| 768 | #[test] | ||
| 769 | fn test_get_maintainers_recursive() { | ||
| 770 | let events = vec![ | ||
| 771 | create_test_announcement("alice", "repo1", vec!["bob"]), | ||
| 772 | create_test_announcement("bob", "repo1", vec![]), | ||
| 773 | ]; | ||
| 774 | |||
| 775 | let maintainers = get_maintainers(&events, "alice", "repo1"); | ||
| 776 | assert!(maintainers.contains(&"alice".to_string())); | ||
| 777 | assert!(maintainers.contains(&"bob".to_string())); | ||
| 778 | } | ||
| 779 | |||
| 780 | #[test] | ||
| 781 | fn test_get_maintainers_circular() { | ||
| 782 | let events = vec![ | ||
| 783 | create_test_announcement("alice", "repo1", vec!["bob"]), | ||
| 784 | create_test_announcement("bob", "repo1", vec!["alice"]), | ||
| 785 | ]; | ||
| 786 | |||
| 787 | let maintainers = get_maintainers(&events, "alice", "repo1"); | ||
| 788 | assert_eq!(maintainers.len(), 2); | ||
| 789 | } | ||
| 790 | |||
| 791 | #[test] | ||
| 792 | fn test_validate_state_ref_matching() { | ||
| 793 | let state = RepositoryState { | ||
| 794 | branches: HashMap::from([ | ||
| 795 | ("main".into(), "a1b2c3d4...".into()), | ||
| 796 | ]), | ||
| 797 | tags: HashMap::new(), | ||
| 798 | }; | ||
| 799 | |||
| 800 | let update = RefUpdate { | ||
| 801 | old_oid: "0000...".into(), | ||
| 802 | new_oid: "a1b2c3d4...".into(), | ||
| 803 | ref_name: "refs/heads/main".into(), | ||
| 804 | }; | ||
| 805 | |||
| 806 | assert!(validate_state_ref(&state, &update).is_ok()); | ||
| 807 | } | ||
| 808 | |||
| 809 | #[test] | ||
| 810 | fn test_validate_state_ref_mismatch() { | ||
| 811 | let state = RepositoryState { | ||
| 812 | branches: HashMap::from([ | ||
| 813 | ("main".into(), "aaaa1111...".into()), | ||
| 814 | ]), | ||
| 815 | tags: HashMap::new(), | ||
| 816 | }; | ||
| 817 | |||
| 818 | let update = RefUpdate { | ||
| 819 | old_oid: "0000...".into(), | ||
| 820 | new_oid: "bbbb2222...".into(), | ||
| 821 | ref_name: "refs/heads/main".into(), | ||
| 822 | }; | ||
| 823 | |||
| 824 | assert!(validate_state_ref(&state, &update).is_err()); | ||
| 825 | } | ||
| 826 | } | ||
| 827 | ``` | ||
| 828 | |||
| 829 | ## Integration Testing Strategy | ||
| 830 | |||
| 831 | ### Repository Lifecycle Tests | ||
| 832 | |||
| 833 | ```rust | ||
| 834 | // tests/integration/repository_lifecycle.rs | ||
| 835 | |||
| 836 | #[tokio::test] | ||
| 837 | async fn test_repository_creation_on_announcement() { | ||
| 838 | let app = test_app().await; | ||
| 839 | |||
| 840 | // Send repository announcement | ||
| 841 | let announcement = create_announcement() | ||
| 842 | .with_identifier("test-repo") | ||
| 843 | .with_clone_tag(app.domain()) | ||
| 844 | .with_relay_tag(app.domain()) | ||
| 845 | .sign() | ||
| 846 | .await; | ||
| 847 | |||
| 848 | app.send_event(announcement).await.unwrap(); | ||
| 849 | |||
| 850 | // Wait for async processing | ||
| 851 | tokio::time::sleep(Duration::from_secs(1)).await; | ||
| 852 | |||
| 853 | // Verify repository was created | ||
| 854 | let repo_path = app.git_data_path() | ||
| 855 | .join(announcement.author_npub()) | ||
| 856 | .join("test-repo.git"); | ||
| 857 | |||
| 858 | assert!(repo_path.exists()); | ||
| 859 | assert!(repo_path.join("HEAD").exists()); | ||
| 860 | assert!(repo_path.join("config").exists()); | ||
| 861 | } | ||
| 862 | |||
| 863 | #[tokio::test] | ||
| 864 | async fn test_push_validation_flow() { | ||
| 865 | let app = test_app().await; | ||
| 866 | |||
| 867 | // Create repository with state | ||
| 868 | let (announcement, state) = app.create_repo_with_state() | ||
| 869 | .branch("main", "commit-sha-123") | ||
| 870 | .build() | ||
| 871 | .await; | ||
| 872 | |||
| 873 | // Attempt push matching state | ||
| 874 | let result = app.git_push("main", "commit-sha-123").await; | ||
| 875 | assert!(result.success); | ||
| 876 | |||
| 877 | // Attempt push NOT matching state | ||
| 878 | let result = app.git_push("main", "different-sha-456").await; | ||
| 879 | assert!(!result.success); | ||
| 880 | assert!(result.stderr.contains("state event")); | ||
| 881 | } | ||
| 882 | ``` | ||
| 883 | |||
| 884 | ### Multi-Maintainer Tests | ||
| 885 | |||
| 886 | ```rust | ||
| 887 | #[tokio::test] | ||
| 888 | async fn test_multi_maintainer_push() { | ||
| 889 | let app = test_app().await; | ||
| 890 | |||
| 891 | // Alice creates repo, lists Bob as maintainer | ||
| 892 | let alice_announcement = create_announcement() | ||
| 893 | .author("alice") | ||
| 894 | .maintainers(vec!["bob"]) | ||
| 895 | .build(); | ||
| 896 | |||
| 897 | app.send_event(alice_announcement).await.unwrap(); | ||
| 898 | |||
| 899 | // Bob creates state event | ||
| 900 | let bob_state = create_state() | ||
| 901 | .author("bob") | ||
| 902 | .branch("main", "commit-123") | ||
| 903 | .build(); | ||
| 904 | |||
| 905 | app.send_event(bob_state).await.unwrap(); | ||
| 906 | |||
| 907 | // Bob's push should succeed | ||
| 908 | let result = app.git_push_as("bob", "main", "commit-123").await; | ||
| 909 | assert!(result.success); | ||
| 910 | } | ||
| 911 | ``` | ||
| 912 | |||
| 913 | ## End-to-End Testing | ||
| 914 | |||
| 915 | ### Real Git Client Tests | ||
| 916 | |||
| 917 | ```rust | ||
| 918 | // tests/e2e/git_client.rs | ||
| 919 | |||
| 920 | #[tokio::test] | ||
| 921 | async fn test_real_git_clone() { | ||
| 922 | let app = test_app().await; | ||
| 923 | |||
| 924 | // Setup repository | ||
| 925 | let (announcement, _) = app.create_repo_with_commits() | ||
| 926 | .commit("Initial commit", "file.txt", "content") | ||
| 927 | .build() | ||
| 928 | .await; | ||
| 929 | |||
| 930 | // Clone with real git client | ||
| 931 | let temp_dir = TempDir::new().unwrap(); | ||
| 932 | let clone_url = format!( | ||
| 933 | "http://{}/{}/{}.git", | ||
| 934 | app.domain(), | ||
| 935 | announcement.author_npub(), | ||
| 936 | announcement.identifier() | ||
| 937 | ); | ||
| 938 | |||
| 939 | let output = Command::new("git") | ||
| 940 | .args(&["clone", &clone_url]) | ||
| 941 | .current_dir(&temp_dir) | ||
| 942 | .output() | ||
| 943 | .await | ||
| 944 | .unwrap(); | ||
| 945 | |||
| 946 | assert!(output.status.success()); | ||
| 947 | assert!(temp_dir.path().join(announcement.identifier()).exists()); | ||
| 948 | } | ||
| 949 | |||
| 950 | #[tokio::test] | ||
| 951 | async fn test_real_git_push() { | ||
| 952 | let app = test_app().await; | ||
| 953 | |||
| 954 | // Create repository | ||
| 955 | let (announcement, keys) = app.create_repo().await; | ||
| 956 | |||
| 957 | // Clone it | ||
| 958 | let temp_dir = TempDir::new().unwrap(); | ||
| 959 | git_clone(&app, &announcement, &temp_dir).await; | ||
| 960 | |||
| 961 | // Make changes | ||
| 962 | let repo_dir = temp_dir.path().join(announcement.identifier()); | ||
| 963 | tokio::fs::write(repo_dir.join("new-file.txt"), "content").await.unwrap(); | ||
| 964 | |||
| 965 | // Commit | ||
| 966 | git_commit(&repo_dir, "Add new file").await; | ||
| 967 | |||
| 968 | // Send state event for new commit | ||
| 969 | let new_commit = git_rev_parse(&repo_dir, "HEAD").await; | ||
| 970 | app.send_state(&announcement, "main", &new_commit, &keys).await; | ||
| 971 | |||
| 972 | // Push | ||
| 973 | let output = Command::new("git") | ||
| 974 | .args(&["push", "origin", "main"]) | ||
| 975 | .current_dir(&repo_dir) | ||
| 976 | .output() | ||
| 977 | .await | ||
| 978 | .unwrap(); | ||
| 979 | |||
| 980 | assert!(output.status.success()); | ||
| 981 | } | ||
| 982 | ``` | ||
| 983 | |||
| 984 | ## Performance Testing | ||
| 985 | |||
| 986 | ### Load Tests | ||
| 987 | |||
| 988 | ```rust | ||
| 989 | // tests/performance/load.rs | ||
| 990 | |||
| 991 | #[tokio::test] | ||
| 992 | async fn test_concurrent_pushes() { | ||
| 993 | let app = test_app().await; | ||
| 994 | |||
| 995 | let num_concurrent = 100; | ||
| 996 | let mut handles = vec![]; | ||
| 997 | |||
| 998 | for i in 0..num_concurrent { | ||
| 999 | let app = app.clone(); | ||
| 1000 | let handle = tokio::spawn(async move { | ||
| 1001 | let (announcement, state) = app.create_repo_with_state() | ||
| 1002 | .branch("main", &format!("commit-{}", i)) | ||
| 1003 | .build() | ||
| 1004 | .await; | ||
| 1005 | |||
| 1006 | app.git_push("main", &format!("commit-{}", i)).await | ||
| 1007 | }); | ||
| 1008 | handles.push(handle); | ||
| 1009 | } | ||
| 1010 | |||
| 1011 | let results = futures::future::join_all(handles).await; | ||
| 1012 | |||
| 1013 | // All should succeed | ||
| 1014 | for result in results { | ||
| 1015 | assert!(result.unwrap().success); | ||
| 1016 | } | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | #[tokio::test] | ||
| 1020 | async fn test_event_ingestion_throughput() { | ||
| 1021 | let app = test_app().await; | ||
| 1022 | |||
| 1023 | let num_events = 1000; | ||
| 1024 | let start = Instant::now(); | ||
| 1025 | |||
| 1026 | for i in 0..num_events { | ||
| 1027 | let event = create_announcement() | ||
| 1028 | .with_identifier(&format!("repo-{}", i)) | ||
| 1029 | .build(); | ||
| 1030 | app.send_event(event).await.unwrap(); | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | let duration = start.elapsed(); | ||
| 1034 | let throughput = num_events as f64 / duration.as_secs_f64(); | ||
| 1035 | |||
| 1036 | println!("Event throughput: {:.2} events/sec", throughput); | ||
| 1037 | assert!(throughput > 100.0, "Throughput too low"); | ||
| 1038 | } | ||
| 1039 | ``` | ||
| 1040 | |||
| 1041 | ## Test Utilities | ||
| 1042 | |||
| 1043 | ### Test Fixtures | ||
| 1044 | |||
| 1045 | ```rust | ||
| 1046 | // tests/common/fixtures.rs | ||
| 1047 | |||
| 1048 | pub struct TestEventBuilder { | ||
| 1049 | kind: Kind, | ||
| 1050 | content: String, | ||
| 1051 | tags: Vec<Tag>, | ||
| 1052 | keys: Option<Keys>, | ||
| 1053 | } | ||
| 1054 | |||
| 1055 | impl TestEventBuilder { | ||
| 1056 | pub fn announcement() -> Self { | ||
| 1057 | TestEventBuilder { | ||
| 1058 | kind: Kind::RepositoryAnnouncement, | ||
| 1059 | content: String::new(), | ||
| 1060 | tags: vec![], | ||
| 1061 | keys: None, | ||
| 1062 | } | ||
| 1063 | } | ||
| 1064 | |||
| 1065 | pub fn with_identifier(mut self, id: &str) -> Self { | ||
| 1066 | self.tags.push(Tag::Identifier(id.to_string())); | ||
| 1067 | self | ||
| 1068 | } | ||
| 1069 | |||
| 1070 | pub fn with_clone_tag(mut self, url: &str) -> Self { | ||
| 1071 | self.tags.push(Tag::new("clone", vec![url])); | ||
| 1072 | self | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | pub async fn build(self) -> Event { | ||
| 1076 | let keys = self.keys.unwrap_or_else(|| Keys::generate()); | ||
| 1077 | EventBuilder::new(self.kind, self.content, self.tags) | ||
| 1078 | .to_event(&keys) | ||
| 1079 | .await | ||
| 1080 | .unwrap() | ||
| 1081 | } | ||
| 1082 | } | ||
| 1083 | ``` | ||
| 1084 | |||
| 1085 | ### Test Server | ||
| 1086 | |||
| 1087 | ```rust | ||
| 1088 | // tests/common/server.rs | ||
| 1089 | |||
| 1090 | pub struct TestServer { | ||
| 1091 | addr: SocketAddr, | ||
| 1092 | handle: JoinHandle<()>, | ||
| 1093 | } | ||
| 1094 | |||
| 1095 | impl TestServer { | ||
| 1096 | pub async fn start() -> Self { | ||
| 1097 | let config = Config { | ||
| 1098 | domain: "localhost:0".to_string(), | ||
| 1099 | git_data_path: TempDir::new().unwrap().into_path(), | ||
| 1100 | relay_data_path: TempDir::new().unwrap().into_path(), | ||
| 1101 | // ... other config | ||
| 1102 | }; | ||
| 1103 | |||
| 1104 | let app = create_app(config).await; | ||
| 1105 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
| 1106 | let addr = listener.local_addr().unwrap(); | ||
| 1107 | |||
| 1108 | let handle = tokio::spawn(async move { | ||
| 1109 | axum::serve(listener, app).await.unwrap(); | ||
| 1110 | }); | ||
| 1111 | |||
| 1112 | // Wait for server to be ready | ||
| 1113 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 1114 | |||
| 1115 | TestServer { addr, handle } | ||
| 1116 | } | ||
| 1117 | |||
| 1118 | pub fn url(&self) -> String { | ||
| 1119 | format!("http://{}", self.addr) | ||
| 1120 | } | ||
| 1121 | |||
| 1122 | pub fn ws_url(&self) -> String { | ||
| 1123 | format!("ws://{}", self.addr) | ||
| 1124 | } | ||
| 1125 | } | ||
| 1126 | ``` | ||
| 1127 | |||
| 1128 | ## CI/CD Integration | ||
| 1129 | |||
| 1130 | ### GitHub Actions Workflow | ||
| 1131 | |||
| 1132 | ```yaml | ||
| 1133 | # .github/workflows/test.yml | ||
| 1134 | |||
| 1135 | name: Test | ||
| 1136 | |||
| 1137 | on: [push, pull_request] | ||
| 1138 | |||
| 1139 | jobs: | ||
| 1140 | unit-tests: | ||
| 1141 | runs-on: ubuntu-latest | ||
| 1142 | steps: | ||
| 1143 | - uses: actions/checkout@v3 | ||
| 1144 | - uses: actions-rs/toolchain@v1 | ||
| 1145 | with: | ||
| 1146 | toolchain: stable | ||
| 1147 | - name: Run unit tests | ||
| 1148 | run: cargo test --lib | ||
| 1149 | |||
| 1150 | integration-tests: | ||
| 1151 | runs-on: ubuntu-latest | ||
| 1152 | steps: | ||
| 1153 | - uses: actions/checkout@v3 | ||
| 1154 | - uses: actions-rs/toolchain@v1 | ||
| 1155 | with: | ||
| 1156 | toolchain: stable | ||
| 1157 | - name: Install Git | ||
| 1158 | run: sudo apt-get install -y git | ||
| 1159 | - name: Run integration tests | ||
| 1160 | run: cargo test --test '*' | ||
| 1161 | |||
| 1162 | compliance-tests: | ||
| 1163 | runs-on: ubuntu-latest | ||
| 1164 | steps: | ||
| 1165 | - uses: actions/checkout@v3 | ||
| 1166 | - uses: actions-rs/toolchain@v1 | ||
| 1167 | with: | ||
| 1168 | toolchain: stable | ||
| 1169 | - name: Run GRASP-01 compliance tests | ||
| 1170 | run: cargo test --test compliance | ||
| 1171 | - name: Generate compliance report | ||
| 1172 | run: cargo run --example compliance-report > compliance-report.txt | ||
| 1173 | - name: Upload compliance report | ||
| 1174 | uses: actions/upload-artifact@v3 | ||
| 1175 | with: | ||
| 1176 | name: compliance-report | ||
| 1177 | path: compliance-report.txt | ||
| 1178 | ``` | ||
| 1179 | |||
| 1180 | ## Test Coverage | ||
| 1181 | |||
| 1182 | ### Target Coverage | ||
| 1183 | |||
| 1184 | - **Unit Tests**: >80% line coverage | ||
| 1185 | - **Integration Tests**: All critical paths | ||
| 1186 | - **Compliance Tests**: 100% of GRASP-01 requirements | ||
| 1187 | - **E2E Tests**: Key user workflows | ||
| 1188 | |||
| 1189 | ### Measuring Coverage | ||
| 1190 | |||
| 1191 | ```bash | ||
| 1192 | # Install tarpaulin | ||
| 1193 | cargo install cargo-tarpaulin | ||
| 1194 | |||
| 1195 | # Run with coverage | ||
| 1196 | cargo tarpaulin --out Html --output-dir coverage | ||
| 1197 | |||
| 1198 | # View report | ||
| 1199 | open coverage/index.html | ||
| 1200 | ``` | ||
| 1201 | |||
| 1202 | ## Documentation Testing | ||
| 1203 | |||
| 1204 | ### Doc Tests | ||
| 1205 | |||
| 1206 | ```rust | ||
| 1207 | /// Parse a pkt-line from Git protocol | ||
| 1208 | /// | ||
| 1209 | /// # Examples | ||
| 1210 | /// | ||
| 1211 | /// ``` | ||
| 1212 | /// use ngit_grasp::git::parse_pkt_line; | ||
| 1213 | /// | ||
| 1214 | /// let data = b"0006a\n"; | ||
| 1215 | /// let (length, payload) = parse_pkt_line(data).unwrap(); | ||
| 1216 | /// assert_eq!(length, 6); | ||
| 1217 | /// assert_eq!(payload, b"a\n"); | ||
| 1218 | /// ``` | ||
| 1219 | pub fn parse_pkt_line(data: &[u8]) -> Result<(usize, &[u8])> { | ||
| 1220 | // implementation | ||
| 1221 | } | ||
| 1222 | ``` | ||
| 1223 | |||
| 1224 | ## Summary | ||
| 1225 | |||
| 1226 | This comprehensive test strategy ensures: | ||
| 1227 | |||
| 1228 | 1. **Spec Compliance**: Every GRASP requirement has a corresponding test | ||
| 1229 | 2. **Reusability**: Compliance tests can validate any GRASP implementation | ||
| 1230 | 3. **Clear Failures**: Test failures cite exact spec lines | ||
| 1231 | 4. **Comprehensive Coverage**: Unit, integration, compliance, and E2E tests | ||
| 1232 | 5. **Maintainability**: Tests mirror spec structure for easy updates | ||
| 1233 | |||
| 1234 | The compliance testing tool is a standalone crate that can be: | ||
| 1235 | - Used by ngit-grasp for self-validation | ||
| 1236 | - Published for other GRASP implementations to use | ||
| 1237 | - Updated as new GRASP specs are released (GRASP-02, GRASP-05) | ||
| 1238 | - Run in CI/CD for continuous compliance verification | ||