From 5148479d76f0958e4a1989a6225a4690292b428f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 14:09:26 +0000 Subject: [PATCH] feat(grasp-audit): add filter capability compliance tests Add comprehensive GRASP-01 compliance tests for uploadpack.allowFilter capability to the grasp-audit test suite. These tests can be run against ANY GRASP implementation (ngit-relay, ngit-grasp, or others) to verify filter support. New test module: grasp-audit/src/specs/grasp01/git_filter.rs Tests added: - test_filter_capability_advertised: Verifies filter appears in info/refs - test_filtered_clone_succeeds: Tests git clone --filter=blob:none - test_filtered_fetch_succeeds: Tests git fetch --filter=tree:0 Usage: cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test cd grasp-audit && nix develop -c cargo run -- audit -r ws://localhost:8080 -s git-filter --- grasp-audit/src/bin/grasp-audit.rs | 13 +- grasp-audit/src/specs/grasp01/git_filter.rs | 400 ++++++++++++++++++++ grasp-audit/src/specs/grasp01/mod.rs | 3 + grasp-audit/src/specs/mod.rs | 4 +- 4 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 grasp-audit/src/specs/grasp01/git_filter.rs diff --git a/grasp-audit/src/bin/grasp-audit.rs b/grasp-audit/src/bin/grasp-audit.rs index 08a92c7..99e3c35 100644 --- a/grasp-audit/src/bin/grasp-audit.rs +++ b/grasp-audit/src/bin/grasp-audit.rs @@ -27,7 +27,7 @@ enum Commands { #[arg(short, long, default_value = "shared")] mode: String, - /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, push-auth, repo-creation, all) + /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, git-filter, push-auth, repo-creation, all) #[arg(short, long, default_value = "all")] spec: String, @@ -124,6 +124,10 @@ async fn main() -> Result<()> { println!("Running Git clone tests...\n"); specs::GitCloneTests::run_all(&client, &relay_domain).await } + "git-filter" => { + println!("Running Git filter capability tests...\n"); + specs::GitFilterTests::run_all(&client, &relay_domain).await + } "push-auth" => { println!("Running push authorization tests...\n"); specs::PushAuthorizationTests::run_all(&client, &relay_domain).await @@ -146,6 +150,11 @@ async fn main() -> Result<()> { let clone_results = specs::GitCloneTests::run_all(&client, &relay_domain).await; all_results.merge(clone_results); + // Git filter capability tests + println!(" → Git filter capability tests..."); + let filter_results = specs::GitFilterTests::run_all(&client, &relay_domain).await; + all_results.merge(filter_results); + // Push authorization tests println!(" → Push authorization tests..."); let push_results = specs::PushAuthorizationTests::run_all(&client, &relay_domain).await; @@ -176,7 +185,7 @@ async fn main() -> Result<()> { } _ => { return Err(anyhow!( - "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'push-auth', 'repo-creation', or 'all'", + "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'git-filter', 'push-auth', 'repo-creation', or 'all'", spec )) } diff --git a/grasp-audit/src/specs/grasp01/git_filter.rs b/grasp-audit/src/specs/grasp01/git_filter.rs new file mode 100644 index 0000000..21bab0a --- /dev/null +++ b/grasp-audit/src/specs/grasp01/git_filter.rs @@ -0,0 +1,400 @@ +//! GRASP-01 Git Filter Capability Tests +//! +//! Tests that verify uploadpack.allowFilter support for partial clone operations. +//! +//! ## Test Coverage +//! +//! - Filter capability advertisement in info/refs +//! - Filtered clone with blob:none works correctly +//! - Filtered fetch with tree:0 works correctly +//! +//! ## Specification Reference +//! +//! Per GRASP-01 line 36-43, implementations MUST: +//! - Include `allow-reachable-sha1-in-want` in advertisement +//! - Include `allow-tip-sha1-in-want` in advertisement +//! - Include uploadpack.allowFilter in advertisement +//! - Serve available oids and filtered requests +//! +//! ## Running Tests +//! +//! ```bash +//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test +//! ``` + +use crate::{AuditClient, FixtureKind, TestContext, TestResult}; +use nostr_sdk::prelude::*; +use std::fs; +use std::process::Command; + +/// Test suite for Git filter capability operations +pub struct GitFilterTests; + +impl GitFilterTests { + /// Run all Git filter tests + pub async fn run_all(client: &AuditClient, relay_domain: &str) -> crate::AuditResult { + let mut results = crate::AuditResult::new("GRASP-01 Git Filter Tests"); + + results.add(Self::test_filter_capability_advertised(client, relay_domain).await); + results.add(Self::test_filtered_clone_succeeds(client, relay_domain).await); + results.add(Self::test_filtered_fetch_succeeds(client, relay_domain).await); + + results + } + + /// Test that filter capability is advertised in git-upload-pack + /// + /// Spec: Line 36 of ../grasp/01.md (updated requirement) + /// GRASP-01 requires: + /// "MUST include `allow-reachable-sha1-in-want`, `allow-tip-sha1-in-want`, + /// and uploadpack.allowFilter in advertisement and serve available oids and + /// filtered requests." + /// + /// This test verifies: + /// 1. The info/refs endpoint returns the filter capability + /// 2. The capability appears in the advertisement + pub async fn test_filter_capability_advertised( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_filter_capability_advertised"; + let ctx = TestContext::new(client); + + // Create repository announcement + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail(format!("Failed to create repo fixture: {}", e)) + } + }; + + // Wait for repository creation + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo identifier and npub + let repo_id = match repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail("Repository announcement missing d tag") + } + }; + + let npub = match repo.pubkey.to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail(format!("Failed to convert pubkey to npub: {}", e)) + } + }; + + // Build info/refs URL for git-upload-pack service + let info_refs_url = format!( + "http://{}/{}/{}.git/info/refs?service=git-upload-pack", + relay_domain, npub, repo_id + ); + + // Make HTTP request to get the advertisement + let http_client = reqwest::Client::new(); + let response = match http_client.get(&info_refs_url).send().await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail(format!("HTTP request failed: {}", e)) + } + }; + + if !response.status().is_success() { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail(format!( + "info/refs request failed with status: {}", + response.status() + )); + } + + // Get response body + let body = match response.text().await { + Ok(b) => b, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail(format!("Failed to read response body: {}", e)) + } + }; + + // Check for filter capability + if !body.contains("filter") { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .fail("Missing capability: filter"); + } + + TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST include uploadpack.allowFilter in advertisement", + ) + .pass() + } + + /// Test that filtered clone with blob:none works + /// + /// Spec: Line 36 of ../grasp/01.md + /// This test verifies: + /// 1. A repository can be cloned with --filter=blob:none + /// 2. The clone succeeds without downloading blob objects + /// 3. The cloned repository structure is valid + pub async fn test_filtered_clone_succeeds( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_filtered_clone_succeeds"; + let ctx = TestContext::new(client); + + // Create repository announcement + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered clone requests", + ) + .fail(format!("Failed to create repo fixture: {}", e)) + } + }; + + // Wait for repository creation + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag") + .unwrap() + .to_string(); + + let npub = repo.pubkey.to_bech32().unwrap(); + + // Create a test clone directory + let temp_base = std::env::temp_dir(); + let clone_dir_name = format!("grasp-test-filter-clone-{}", uuid::Uuid::new_v4()); + let clone_path = temp_base.join(&clone_dir_name); + + // Ensure clean state + let _ = fs::remove_dir_all(&clone_path); + + // Build clone URL + let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); + + // Attempt filtered clone with blob:none + let output = Command::new("git") + .args([ + "clone", + "--filter=blob:none", + &clone_url, + clone_path.to_str().unwrap(), + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + + // Clean up + let cleanup = || { + let _ = fs::remove_dir_all(&clone_path); + }; + + let output = match output { + Ok(o) => o, + Err(e) => { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered clone requests", + ) + .fail(format!("Failed to execute git clone: {}", e)); + } + }; + + if !output.status.success() { + cleanup(); + let stderr = String::from_utf8_lossy(&output.stderr); + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered clone requests", + ) + .fail(format!("Filtered git clone failed: {}", stderr)); + } + + // Verify clone succeeded + if !clone_path.join(".git").is_dir() { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered clone requests", + ) + .fail("Filtered clone missing .git directory"); + } + + cleanup(); + TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered clone requests", + ) + .pass() + } + + /// Test that filtered fetch with tree:0 works + /// + /// Spec: Line 36 of ../grasp/01.md + /// This test verifies: + /// 1. An existing repository can fetch with --filter=tree:0 + /// 2. The fetch succeeds without downloading tree objects + pub async fn test_filtered_fetch_succeeds( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_filtered_fetch_succeeds"; + let ctx = TestContext::new(client); + + // Create repository announcement + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered fetch requests", + ) + .fail(format!("Failed to create repo fixture: {}", e)) + } + }; + + // Wait for repository creation + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag") + .unwrap() + .to_string(); + + let npub = repo.pubkey.to_bech32().unwrap(); + + // Create a test clone directory + let temp_base = std::env::temp_dir(); + let clone_dir_name = format!("grasp-test-filter-fetch-{}", uuid::Uuid::new_v4()); + let clone_path = temp_base.join(&clone_dir_name); + + // Ensure clean state + let _ = fs::remove_dir_all(&clone_path); + + // Build clone URL + let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); + + // First do a shallow clone to have a repository to fetch into + let clone_output = Command::new("git") + .args([ + "clone", + "--depth=1", + &clone_url, + clone_path.to_str().unwrap(), + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + + // Clean up + let cleanup = || { + let _ = fs::remove_dir_all(&clone_path); + }; + + if clone_output.is_err() || !clone_output.as_ref().unwrap().status.success() { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered fetch requests", + ) + .fail("Failed to create initial shallow clone for fetch test"); + } + + // Now attempt a filtered fetch + let output = Command::new("git") + .args(["fetch", "--filter=tree:0", "origin"]) + .current_dir(&clone_path) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + + let output = match output { + Ok(o) => o, + Err(e) => { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered fetch requests", + ) + .fail(format!("Failed to execute git fetch: {}", e)); + } + }; + + if !output.status.success() { + cleanup(); + let stderr = String::from_utf8_lossy(&output.stderr); + return TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered fetch requests", + ) + .fail(format!("Filtered git fetch failed: {}", stderr)); + } + + cleanup(); + TestResult::new( + test_name, + "GRASP-01:git-http:42", + "MUST serve filtered fetch requests", + ) + .pass() + } +} diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs index fa05f35..0a819ee 100644 --- a/grasp-audit/src/specs/grasp01/mod.rs +++ b/grasp-audit/src/specs/grasp01/mod.rs @@ -9,12 +9,14 @@ //! - [`EventAcceptancePolicyTests`] - Event acceptance rules (WebSocket-only) //! - [`CorsTests`] - CORS headers on Git HTTP endpoints (requires git-data-dir) //! - [`GitCloneTests`] - Git clone operations (requires git-data-dir) +//! - [`GitFilterTests`] - Git filter capability for partial clone (requires git-data-dir) //! - [`PushAuthorizationTests`] - Push authorization (requires git-data-dir) //! - [`RepositoryCreationTests`] - Repository creation (requires git-data-dir) pub mod cors; pub mod event_acceptance_policy; pub mod git_clone; +pub mod git_filter; pub mod nip01_smoke; pub mod nip11_document; pub mod push_authorization; @@ -24,6 +26,7 @@ pub mod spec_requirements; pub use cors::CorsTests; pub use event_acceptance_policy::EventAcceptancePolicyTests; pub use git_clone::GitCloneTests; +pub use git_filter::GitFilterTests; pub use nip01_smoke::Nip01SmokeTests; pub use nip11_document::Nip11DocumentTests; pub use push_authorization::PushAuthorizationTests; diff --git a/grasp-audit/src/specs/mod.rs b/grasp-audit/src/specs/mod.rs index 1444c80..bf711fa 100644 --- a/grasp-audit/src/specs/mod.rs +++ b/grasp-audit/src/specs/mod.rs @@ -6,6 +6,6 @@ pub mod grasp01; // Re-export all test structs from grasp01 module pub use grasp01::{ - CorsTests, EventAcceptancePolicyTests, GitCloneTests, Nip01SmokeTests, Nip11DocumentTests, - PushAuthorizationTests, RepositoryCreationTests, + CorsTests, EventAcceptancePolicyTests, GitCloneTests, GitFilterTests, Nip01SmokeTests, + Nip11DocumentTests, PushAuthorizationTests, RepositoryCreationTests, };