diff options
| -rw-r--r-- | grasp-audit/src/bin/grasp-audit.rs | 54 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/git_clone.rs | 154 | ||||
| -rw-r--r-- | src/git/subprocess.rs | 9 | ||||
| -rw-r--r-- | tests/git_clone.rs | 3 |
4 files changed, 199 insertions, 21 deletions
diff --git a/grasp-audit/src/bin/grasp-audit.rs b/grasp-audit/src/bin/grasp-audit.rs index 760ed55..c0a9273 100644 --- a/grasp-audit/src/bin/grasp-audit.rs +++ b/grasp-audit/src/bin/grasp-audit.rs | |||
| @@ -48,6 +48,22 @@ async fn main() -> Result<()> { | |||
| 48 | 48 | ||
| 49 | match cli.command { | 49 | match cli.command { |
| 50 | Commands::Audit { relay, mode, spec, git_data_dir } => { | 50 | Commands::Audit { relay, mode, spec, git_data_dir } => { |
| 51 | // Early validation: check if --git-data-dir is required for the spec | ||
| 52 | let specs_requiring_git_dir = ["all", "git-clone", "push-auth", "repo-creation"]; | ||
| 53 | if specs_requiring_git_dir.contains(&spec.as_str()) && git_data_dir.is_none() { | ||
| 54 | return Err(anyhow!( | ||
| 55 | "The '{}' spec requires --git-data-dir to be specified.\n\ | ||
| 56 | \n\ | ||
| 57 | This directory should point to the relay's git data storage.\n\ | ||
| 58 | Example: --git-data-dir /path/to/relay/repos\n\ | ||
| 59 | \n\ | ||
| 60 | If using Docker, mount a volume and pass that path:\n\ | ||
| 61 | docker run -v /tmp/repos:/srv/ngit-relay/repos ...\n\ | ||
| 62 | cargo run -- audit --relay ws://localhost:8080 --git-data-dir /tmp/repos", | ||
| 63 | spec | ||
| 64 | )); | ||
| 65 | } | ||
| 66 | |||
| 51 | let mut config = match mode.as_str() { | 67 | let mut config = match mode.as_str() { |
| 52 | "ci" => AuditConfig::ci(), | 68 | "ci" => AuditConfig::ci(), |
| 53 | "production" => AuditConfig::production(), | 69 | "production" => AuditConfig::production(), |
| @@ -87,7 +103,7 @@ async fn main() -> Result<()> { | |||
| 87 | 103 | ||
| 88 | println!("✓ Connected\n"); | 104 | println!("✓ Connected\n"); |
| 89 | 105 | ||
| 90 | // Helper to check if git_data_dir is required | 106 | // Helper to check if git_data_dir is required for individual specs |
| 91 | let require_git_data_dir = |spec_name: &str| -> Result<PathBuf> { | 107 | let require_git_data_dir = |spec_name: &str| -> Result<PathBuf> { |
| 92 | git_data_dir.clone().ok_or_else(|| { | 108 | git_data_dir.clone().ok_or_else(|| { |
| 93 | anyhow!( | 109 | anyhow!( |
| @@ -130,6 +146,9 @@ async fn main() -> Result<()> { | |||
| 130 | specs::RepositoryCreationTests::run_all(&client, &dir).await | 146 | specs::RepositoryCreationTests::run_all(&client, &dir).await |
| 131 | } | 147 | } |
| 132 | "all" => { | 148 | "all" => { |
| 149 | // git_data_dir is guaranteed by early validation | ||
| 150 | let dir = git_data_dir.clone().expect("git_data_dir validated earlier"); | ||
| 151 | |||
| 133 | println!("Running all tests...\n"); | 152 | println!("Running all tests...\n"); |
| 134 | let mut all_results = AuditResult::new("All GRASP-01 Tests"); | 153 | let mut all_results = AuditResult::new("All GRASP-01 Tests"); |
| 135 | 154 | ||
| @@ -153,25 +172,20 @@ async fn main() -> Result<()> { | |||
| 153 | let cors_results = specs::CorsTests::run_all(&client, &relay_domain).await; | 172 | let cors_results = specs::CorsTests::run_all(&client, &relay_domain).await; |
| 154 | all_results.merge(cors_results); | 173 | all_results.merge(cors_results); |
| 155 | 174 | ||
| 156 | // Tests that require git_data_dir | 175 | // Git clone tests |
| 157 | if let Some(ref dir) = git_data_dir { | 176 | println!(" → Git clone tests..."); |
| 158 | // Git clone tests | 177 | let clone_results = specs::GitCloneTests::run_all(&client, &dir, &relay_domain).await; |
| 159 | println!(" → Git clone tests..."); | 178 | all_results.merge(clone_results); |
| 160 | let clone_results = specs::GitCloneTests::run_all(&client, dir, &relay_domain).await; | 179 | |
| 161 | all_results.merge(clone_results); | 180 | // Push authorization tests |
| 162 | 181 | println!(" → Push authorization tests..."); | |
| 163 | // Push authorization tests | 182 | let push_results = specs::PushAuthorizationTests::run_all(&client, &dir, &relay_domain).await; |
| 164 | println!(" → Push authorization tests..."); | 183 | all_results.merge(push_results); |
| 165 | let push_results = specs::PushAuthorizationTests::run_all(&client, dir, &relay_domain).await; | 184 | |
| 166 | all_results.merge(push_results); | 185 | // Repository creation tests |
| 167 | 186 | println!(" → Repository creation tests..."); | |
| 168 | // Repository creation tests | 187 | let repo_results = specs::RepositoryCreationTests::run_all(&client, &dir).await; |
| 169 | println!(" → Repository creation tests..."); | 188 | all_results.merge(repo_results); |
| 170 | let repo_results = specs::RepositoryCreationTests::run_all(&client, dir).await; | ||
| 171 | all_results.merge(repo_results); | ||
| 172 | } else { | ||
| 173 | println!(" ⚠ Skipping git-clone, push-auth, repo-creation tests (no --git-data-dir)"); | ||
| 174 | } | ||
| 175 | 189 | ||
| 176 | println!(); | 190 | println!(); |
| 177 | all_results | 191 | all_results |
diff --git a/grasp-audit/src/specs/grasp01/git_clone.rs b/grasp-audit/src/specs/grasp01/git_clone.rs index 8c91c04..4666a40 100644 --- a/grasp-audit/src/specs/grasp01/git_clone.rs +++ b/grasp-audit/src/specs/grasp01/git_clone.rs | |||
| @@ -7,6 +7,7 @@ | |||
| 7 | //! - Basic clone operation via HTTP | 7 | //! - Basic clone operation via HTTP |
| 8 | //! - Cloned repository structure validation | 8 | //! - Cloned repository structure validation |
| 9 | //! - Clone URL format verification | 9 | //! - Clone URL format verification |
| 10 | //! - SHA1 capability advertisement verification | ||
| 10 | //! | 11 | //! |
| 11 | //! ## Running Tests | 12 | //! ## Running Tests |
| 12 | //! | 13 | //! |
| @@ -34,6 +35,7 @@ impl GitCloneTests { | |||
| 34 | 35 | ||
| 35 | results.add(Self::test_basic_git_clone(client, git_data_dir, relay_domain).await); | 36 | results.add(Self::test_basic_git_clone(client, git_data_dir, relay_domain).await); |
| 36 | results.add(Self::test_clone_url_format(client, git_data_dir, relay_domain).await); | 37 | results.add(Self::test_clone_url_format(client, git_data_dir, relay_domain).await); |
| 38 | results.add(Self::test_sha1_capabilities_advertised(client, git_data_dir, relay_domain).await); | ||
| 37 | 39 | ||
| 38 | results | 40 | results |
| 39 | } | 41 | } |
| @@ -277,6 +279,158 @@ impl GitCloneTests { | |||
| 277 | ) | 279 | ) |
| 278 | .pass() | 280 | .pass() |
| 279 | } | 281 | } |
| 282 | |||
| 283 | /// Test that SHA1 capabilities are advertised in git-upload-pack | ||
| 284 | /// | ||
| 285 | /// GRASP-01 requires: | ||
| 286 | /// "MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` | ||
| 287 | /// in advertisement and serve available oids." | ||
| 288 | /// | ||
| 289 | /// This test verifies: | ||
| 290 | /// 1. The info/refs endpoint returns the capabilities | ||
| 291 | /// 2. Both allow-reachable-sha1-in-want and allow-tip-sha1-in-want are present | ||
| 292 | pub async fn test_sha1_capabilities_advertised( | ||
| 293 | client: &AuditClient, | ||
| 294 | git_data_dir: &Path, | ||
| 295 | relay_domain: &str, | ||
| 296 | ) -> TestResult { | ||
| 297 | let test_name = "test_sha1_capabilities_advertised"; | ||
| 298 | let ctx = TestContext::new(client); | ||
| 299 | |||
| 300 | // Create repository announcement | ||
| 301 | let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { | ||
| 302 | Ok(r) => r, | ||
| 303 | Err(e) => { | ||
| 304 | return TestResult::new( | ||
| 305 | test_name, | ||
| 306 | "GRASP-01", | ||
| 307 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 308 | ) | ||
| 309 | .fail(&format!("Failed to create repo fixture: {}", e)) | ||
| 310 | } | ||
| 311 | }; | ||
| 312 | |||
| 313 | // Wait for repository creation | ||
| 314 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | ||
| 315 | |||
| 316 | // Extract repo identifier and npub | ||
| 317 | let repo_id = match repo | ||
| 318 | .tags | ||
| 319 | .iter() | ||
| 320 | .find(|t| t.kind() == TagKind::d()) | ||
| 321 | .and_then(|t| t.content()) | ||
| 322 | { | ||
| 323 | Some(id) => id.to_string(), | ||
| 324 | None => { | ||
| 325 | return TestResult::new( | ||
| 326 | test_name, | ||
| 327 | "GRASP-01", | ||
| 328 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 329 | ) | ||
| 330 | .fail("Repository announcement missing d tag") | ||
| 331 | } | ||
| 332 | }; | ||
| 333 | |||
| 334 | let npub = match repo.pubkey.to_bech32() { | ||
| 335 | Ok(n) => n, | ||
| 336 | Err(e) => { | ||
| 337 | return TestResult::new( | ||
| 338 | test_name, | ||
| 339 | "GRASP-01", | ||
| 340 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 341 | ) | ||
| 342 | .fail(&format!("Failed to convert pubkey to npub: {}", e)) | ||
| 343 | } | ||
| 344 | }; | ||
| 345 | |||
| 346 | // Verify repository exists | ||
| 347 | let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); | ||
| 348 | if !repo_path.exists() { | ||
| 349 | return TestResult::new( | ||
| 350 | test_name, | ||
| 351 | "GRASP-01", | ||
| 352 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 353 | ) | ||
| 354 | .fail(&format!( | ||
| 355 | "Repository not found at: {}", | ||
| 356 | repo_path.display() | ||
| 357 | )); | ||
| 358 | } | ||
| 359 | |||
| 360 | // Build info/refs URL for git-upload-pack service | ||
| 361 | let info_refs_url = format!( | ||
| 362 | "http://{}/{}/{}.git/info/refs?service=git-upload-pack", | ||
| 363 | relay_domain, npub, repo_id | ||
| 364 | ); | ||
| 365 | |||
| 366 | // Make HTTP request to get the advertisement | ||
| 367 | let http_client = reqwest::Client::new(); | ||
| 368 | let response = match http_client.get(&info_refs_url).send().await { | ||
| 369 | Ok(r) => r, | ||
| 370 | Err(e) => { | ||
| 371 | return TestResult::new( | ||
| 372 | test_name, | ||
| 373 | "GRASP-01", | ||
| 374 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 375 | ) | ||
| 376 | .fail(&format!("HTTP request failed: {}", e)) | ||
| 377 | } | ||
| 378 | }; | ||
| 379 | |||
| 380 | if !response.status().is_success() { | ||
| 381 | return TestResult::new( | ||
| 382 | test_name, | ||
| 383 | "GRASP-01", | ||
| 384 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 385 | ) | ||
| 386 | .fail(&format!( | ||
| 387 | "info/refs request failed with status: {}", | ||
| 388 | response.status() | ||
| 389 | )); | ||
| 390 | } | ||
| 391 | |||
| 392 | // Get response body | ||
| 393 | let body = match response.text().await { | ||
| 394 | Ok(b) => b, | ||
| 395 | Err(e) => { | ||
| 396 | return TestResult::new( | ||
| 397 | test_name, | ||
| 398 | "GRASP-01", | ||
| 399 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 400 | ) | ||
| 401 | .fail(&format!("Failed to read response body: {}", e)) | ||
| 402 | } | ||
| 403 | }; | ||
| 404 | |||
| 405 | // Check for required capabilities | ||
| 406 | let has_allow_reachable = body.contains("allow-reachable-sha1-in-want"); | ||
| 407 | let has_allow_tip = body.contains("allow-tip-sha1-in-want"); | ||
| 408 | |||
| 409 | if !has_allow_reachable { | ||
| 410 | return TestResult::new( | ||
| 411 | test_name, | ||
| 412 | "GRASP-01", | ||
| 413 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 414 | ) | ||
| 415 | .fail("Missing capability: allow-reachable-sha1-in-want"); | ||
| 416 | } | ||
| 417 | |||
| 418 | if !has_allow_tip { | ||
| 419 | return TestResult::new( | ||
| 420 | test_name, | ||
| 421 | "GRASP-01", | ||
| 422 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 423 | ) | ||
| 424 | .fail("Missing capability: allow-tip-sha1-in-want"); | ||
| 425 | } | ||
| 426 | |||
| 427 | TestResult::new( | ||
| 428 | test_name, | ||
| 429 | "GRASP-01", | ||
| 430 | "MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want in advertisement", | ||
| 431 | ) | ||
| 432 | .pass() | ||
| 433 | } | ||
| 280 | } | 434 | } |
| 281 | 435 | ||
| 282 | #[cfg(test)] | 436 | #[cfg(test)] |
diff --git a/src/git/subprocess.rs b/src/git/subprocess.rs index dac3ace..c95bce5 100644 --- a/src/git/subprocess.rs +++ b/src/git/subprocess.rs | |||
| @@ -30,6 +30,15 @@ impl GitSubprocess { | |||
| 30 | let repo_path = repo_path.as_ref(); | 30 | let repo_path = repo_path.as_ref(); |
| 31 | 31 | ||
| 32 | let mut cmd = Command::new("git"); | 32 | let mut cmd = Command::new("git"); |
| 33 | |||
| 34 | // GRASP-01 requirement: MUST include `allow-reachable-sha1-in-want` and | ||
| 35 | // `allow-tip-sha1-in-want` in advertisement and serve available oids. | ||
| 36 | // These config options must be passed before the command name. | ||
| 37 | cmd.arg("-c"); | ||
| 38 | cmd.arg("uploadpack.allowReachableSHA1InWant=true"); | ||
| 39 | cmd.arg("-c"); | ||
| 40 | cmd.arg("uploadpack.allowTipSHA1InWant=true"); | ||
| 41 | |||
| 33 | cmd.arg(service.command_name()); | 42 | cmd.arg(service.command_name()); |
| 34 | 43 | ||
| 35 | if advertise { | 44 | if advertise { |
diff --git a/tests/git_clone.rs b/tests/git_clone.rs index 076a4d6..c6be3f6 100644 --- a/tests/git_clone.rs +++ b/tests/git_clone.rs | |||
| @@ -63,4 +63,5 @@ macro_rules! isolated_test { | |||
| 63 | 63 | ||
| 64 | // Generate isolated tests for all git clone tests | 64 | // Generate isolated tests for all git clone tests |
| 65 | isolated_test!(test_basic_git_clone); | 65 | isolated_test!(test_basic_git_clone); |
| 66 | isolated_test!(test_clone_url_format); \ No newline at end of file | 66 | isolated_test!(test_clone_url_format); |
| 67 | isolated_test!(test_sha1_capabilities_advertised); \ No newline at end of file | ||