upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 14:09:26 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 14:09:26 +0000
commit5148479d76f0958e4a1989a6225a4690292b428f (patch)
tree1af972d49be57c8097cb189f87d9c862ebb0b373 /grasp-audit
parent817ce37a5ee8d6279a44cf8cce3cc6a1e4bab576 (diff)
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
Diffstat (limited to 'grasp-audit')
-rw-r--r--grasp-audit/src/bin/grasp-audit.rs13
-rw-r--r--grasp-audit/src/specs/grasp01/git_filter.rs400
-rw-r--r--grasp-audit/src/specs/grasp01/mod.rs3
-rw-r--r--grasp-audit/src/specs/mod.rs4
4 files changed, 416 insertions, 4 deletions
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 {
27 #[arg(short, long, default_value = "shared")] 27 #[arg(short, long, default_value = "shared")]
28 mode: String, 28 mode: String,
29 29
30 /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, push-auth, repo-creation, all) 30 /// Spec to test (nip01-smoke, nip11, event-acceptance, cors, git-clone, git-filter, push-auth, repo-creation, all)
31 #[arg(short, long, default_value = "all")] 31 #[arg(short, long, default_value = "all")]
32 spec: String, 32 spec: String,
33 33
@@ -124,6 +124,10 @@ async fn main() -> Result<()> {
124 println!("Running Git clone tests...\n"); 124 println!("Running Git clone tests...\n");
125 specs::GitCloneTests::run_all(&client, &relay_domain).await 125 specs::GitCloneTests::run_all(&client, &relay_domain).await
126 } 126 }
127 "git-filter" => {
128 println!("Running Git filter capability tests...\n");
129 specs::GitFilterTests::run_all(&client, &relay_domain).await
130 }
127 "push-auth" => { 131 "push-auth" => {
128 println!("Running push authorization tests...\n"); 132 println!("Running push authorization tests...\n");
129 specs::PushAuthorizationTests::run_all(&client, &relay_domain).await 133 specs::PushAuthorizationTests::run_all(&client, &relay_domain).await
@@ -146,6 +150,11 @@ async fn main() -> Result<()> {
146 let clone_results = specs::GitCloneTests::run_all(&client, &relay_domain).await; 150 let clone_results = specs::GitCloneTests::run_all(&client, &relay_domain).await;
147 all_results.merge(clone_results); 151 all_results.merge(clone_results);
148 152
153 // Git filter capability tests
154 println!(" → Git filter capability tests...");
155 let filter_results = specs::GitFilterTests::run_all(&client, &relay_domain).await;
156 all_results.merge(filter_results);
157
149 // Push authorization tests 158 // Push authorization tests
150 println!(" → Push authorization tests..."); 159 println!(" → Push authorization tests...");
151 let push_results = specs::PushAuthorizationTests::run_all(&client, &relay_domain).await; 160 let push_results = specs::PushAuthorizationTests::run_all(&client, &relay_domain).await;
@@ -176,7 +185,7 @@ async fn main() -> Result<()> {
176 } 185 }
177 _ => { 186 _ => {
178 return Err(anyhow!( 187 return Err(anyhow!(
179 "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'push-auth', 'repo-creation', or 'all'", 188 "Unknown spec: {}. Use 'nip01-smoke', 'nip11', 'event-acceptance', 'cors', 'git-clone', 'git-filter', 'push-auth', 'repo-creation', or 'all'",
180 spec 189 spec
181 )) 190 ))
182 } 191 }
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 @@
1//! GRASP-01 Git Filter Capability Tests
2//!
3//! Tests that verify uploadpack.allowFilter support for partial clone operations.
4//!
5//! ## Test Coverage
6//!
7//! - Filter capability advertisement in info/refs
8//! - Filtered clone with blob:none works correctly
9//! - Filtered fetch with tree:0 works correctly
10//!
11//! ## Specification Reference
12//!
13//! Per GRASP-01 line 36-43, implementations MUST:
14//! - Include `allow-reachable-sha1-in-want` in advertisement
15//! - Include `allow-tip-sha1-in-want` in advertisement
16//! - Include uploadpack.allowFilter in advertisement
17//! - Serve available oids and filtered requests
18//!
19//! ## Running Tests
20//!
21//! ```bash
22//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test
23//! ```
24
25use crate::{AuditClient, FixtureKind, TestContext, TestResult};
26use nostr_sdk::prelude::*;
27use std::fs;
28use std::process::Command;
29
30/// Test suite for Git filter capability operations
31pub struct GitFilterTests;
32
33impl GitFilterTests {
34 /// Run all Git filter tests
35 pub async fn run_all(client: &AuditClient, relay_domain: &str) -> crate::AuditResult {
36 let mut results = crate::AuditResult::new("GRASP-01 Git Filter Tests");
37
38 results.add(Self::test_filter_capability_advertised(client, relay_domain).await);
39 results.add(Self::test_filtered_clone_succeeds(client, relay_domain).await);
40 results.add(Self::test_filtered_fetch_succeeds(client, relay_domain).await);
41
42 results
43 }
44
45 /// Test that filter capability is advertised in git-upload-pack
46 ///
47 /// Spec: Line 36 of ../grasp/01.md (updated requirement)
48 /// GRASP-01 requires:
49 /// "MUST include `allow-reachable-sha1-in-want`, `allow-tip-sha1-in-want`,
50 /// and uploadpack.allowFilter in advertisement and serve available oids and
51 /// filtered requests."
52 ///
53 /// This test verifies:
54 /// 1. The info/refs endpoint returns the filter capability
55 /// 2. The capability appears in the advertisement
56 pub async fn test_filter_capability_advertised(
57 client: &AuditClient,
58 relay_domain: &str,
59 ) -> TestResult {
60 let test_name = "test_filter_capability_advertised";
61 let ctx = TestContext::new(client);
62
63 // Create repository announcement
64 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
65 Ok(r) => r,
66 Err(e) => {
67 return TestResult::new(
68 test_name,
69 "GRASP-01:git-http:42",
70 "MUST include uploadpack.allowFilter in advertisement",
71 )
72 .fail(format!("Failed to create repo fixture: {}", e))
73 }
74 };
75
76 // Wait for repository creation
77 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
78
79 // Extract repo identifier and npub
80 let repo_id = match repo
81 .tags
82 .iter()
83 .find(|t| t.kind() == TagKind::d())
84 .and_then(|t| t.content())
85 {
86 Some(id) => id.to_string(),
87 None => {
88 return TestResult::new(
89 test_name,
90 "GRASP-01:git-http:42",
91 "MUST include uploadpack.allowFilter in advertisement",
92 )
93 .fail("Repository announcement missing d tag")
94 }
95 };
96
97 let npub = match repo.pubkey.to_bech32() {
98 Ok(n) => n,
99 Err(e) => {
100 return TestResult::new(
101 test_name,
102 "GRASP-01:git-http:42",
103 "MUST include uploadpack.allowFilter in advertisement",
104 )
105 .fail(format!("Failed to convert pubkey to npub: {}", e))
106 }
107 };
108
109 // Build info/refs URL for git-upload-pack service
110 let info_refs_url = format!(
111 "http://{}/{}/{}.git/info/refs?service=git-upload-pack",
112 relay_domain, npub, repo_id
113 );
114
115 // Make HTTP request to get the advertisement
116 let http_client = reqwest::Client::new();
117 let response = match http_client.get(&info_refs_url).send().await {
118 Ok(r) => r,
119 Err(e) => {
120 return TestResult::new(
121 test_name,
122 "GRASP-01:git-http:42",
123 "MUST include uploadpack.allowFilter in advertisement",
124 )
125 .fail(format!("HTTP request failed: {}", e))
126 }
127 };
128
129 if !response.status().is_success() {
130 return TestResult::new(
131 test_name,
132 "GRASP-01:git-http:42",
133 "MUST include uploadpack.allowFilter in advertisement",
134 )
135 .fail(format!(
136 "info/refs request failed with status: {}",
137 response.status()
138 ));
139 }
140
141 // Get response body
142 let body = match response.text().await {
143 Ok(b) => b,
144 Err(e) => {
145 return TestResult::new(
146 test_name,
147 "GRASP-01:git-http:42",
148 "MUST include uploadpack.allowFilter in advertisement",
149 )
150 .fail(format!("Failed to read response body: {}", e))
151 }
152 };
153
154 // Check for filter capability
155 if !body.contains("filter") {
156 return TestResult::new(
157 test_name,
158 "GRASP-01:git-http:42",
159 "MUST include uploadpack.allowFilter in advertisement",
160 )
161 .fail("Missing capability: filter");
162 }
163
164 TestResult::new(
165 test_name,
166 "GRASP-01:git-http:42",
167 "MUST include uploadpack.allowFilter in advertisement",
168 )
169 .pass()
170 }
171
172 /// Test that filtered clone with blob:none works
173 ///
174 /// Spec: Line 36 of ../grasp/01.md
175 /// This test verifies:
176 /// 1. A repository can be cloned with --filter=blob:none
177 /// 2. The clone succeeds without downloading blob objects
178 /// 3. The cloned repository structure is valid
179 pub async fn test_filtered_clone_succeeds(
180 client: &AuditClient,
181 relay_domain: &str,
182 ) -> TestResult {
183 let test_name = "test_filtered_clone_succeeds";
184 let ctx = TestContext::new(client);
185
186 // Create repository announcement
187 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
188 Ok(r) => r,
189 Err(e) => {
190 return TestResult::new(
191 test_name,
192 "GRASP-01:git-http:42",
193 "MUST serve filtered clone requests",
194 )
195 .fail(format!("Failed to create repo fixture: {}", e))
196 }
197 };
198
199 // Wait for repository creation
200 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
201
202 let repo_id = repo
203 .tags
204 .iter()
205 .find(|t| t.kind() == TagKind::d())
206 .and_then(|t| t.content())
207 .ok_or("Missing d tag")
208 .unwrap()
209 .to_string();
210
211 let npub = repo.pubkey.to_bech32().unwrap();
212
213 // Create a test clone directory
214 let temp_base = std::env::temp_dir();
215 let clone_dir_name = format!("grasp-test-filter-clone-{}", uuid::Uuid::new_v4());
216 let clone_path = temp_base.join(&clone_dir_name);
217
218 // Ensure clean state
219 let _ = fs::remove_dir_all(&clone_path);
220
221 // Build clone URL
222 let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
223
224 // Attempt filtered clone with blob:none
225 let output = Command::new("git")
226 .args([
227 "clone",
228 "--filter=blob:none",
229 &clone_url,
230 clone_path.to_str().unwrap(),
231 ])
232 .env("GIT_TERMINAL_PROMPT", "0")
233 .output();
234
235 // Clean up
236 let cleanup = || {
237 let _ = fs::remove_dir_all(&clone_path);
238 };
239
240 let output = match output {
241 Ok(o) => o,
242 Err(e) => {
243 cleanup();
244 return TestResult::new(
245 test_name,
246 "GRASP-01:git-http:42",
247 "MUST serve filtered clone requests",
248 )
249 .fail(format!("Failed to execute git clone: {}", e));
250 }
251 };
252
253 if !output.status.success() {
254 cleanup();
255 let stderr = String::from_utf8_lossy(&output.stderr);
256 return TestResult::new(
257 test_name,
258 "GRASP-01:git-http:42",
259 "MUST serve filtered clone requests",
260 )
261 .fail(format!("Filtered git clone failed: {}", stderr));
262 }
263
264 // Verify clone succeeded
265 if !clone_path.join(".git").is_dir() {
266 cleanup();
267 return TestResult::new(
268 test_name,
269 "GRASP-01:git-http:42",
270 "MUST serve filtered clone requests",
271 )
272 .fail("Filtered clone missing .git directory");
273 }
274
275 cleanup();
276 TestResult::new(
277 test_name,
278 "GRASP-01:git-http:42",
279 "MUST serve filtered clone requests",
280 )
281 .pass()
282 }
283
284 /// Test that filtered fetch with tree:0 works
285 ///
286 /// Spec: Line 36 of ../grasp/01.md
287 /// This test verifies:
288 /// 1. An existing repository can fetch with --filter=tree:0
289 /// 2. The fetch succeeds without downloading tree objects
290 pub async fn test_filtered_fetch_succeeds(
291 client: &AuditClient,
292 relay_domain: &str,
293 ) -> TestResult {
294 let test_name = "test_filtered_fetch_succeeds";
295 let ctx = TestContext::new(client);
296
297 // Create repository announcement
298 let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
299 Ok(r) => r,
300 Err(e) => {
301 return TestResult::new(
302 test_name,
303 "GRASP-01:git-http:42",
304 "MUST serve filtered fetch requests",
305 )
306 .fail(format!("Failed to create repo fixture: {}", e))
307 }
308 };
309
310 // Wait for repository creation
311 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
312
313 let repo_id = repo
314 .tags
315 .iter()
316 .find(|t| t.kind() == TagKind::d())
317 .and_then(|t| t.content())
318 .ok_or("Missing d tag")
319 .unwrap()
320 .to_string();
321
322 let npub = repo.pubkey.to_bech32().unwrap();
323
324 // Create a test clone directory
325 let temp_base = std::env::temp_dir();
326 let clone_dir_name = format!("grasp-test-filter-fetch-{}", uuid::Uuid::new_v4());
327 let clone_path = temp_base.join(&clone_dir_name);
328
329 // Ensure clean state
330 let _ = fs::remove_dir_all(&clone_path);
331
332 // Build clone URL
333 let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
334
335 // First do a shallow clone to have a repository to fetch into
336 let clone_output = Command::new("git")
337 .args([
338 "clone",
339 "--depth=1",
340 &clone_url,
341 clone_path.to_str().unwrap(),
342 ])
343 .env("GIT_TERMINAL_PROMPT", "0")
344 .output();
345
346 // Clean up
347 let cleanup = || {
348 let _ = fs::remove_dir_all(&clone_path);
349 };
350
351 if clone_output.is_err() || !clone_output.as_ref().unwrap().status.success() {
352 cleanup();
353 return TestResult::new(
354 test_name,
355 "GRASP-01:git-http:42",
356 "MUST serve filtered fetch requests",
357 )
358 .fail("Failed to create initial shallow clone for fetch test");
359 }
360
361 // Now attempt a filtered fetch
362 let output = Command::new("git")
363 .args(["fetch", "--filter=tree:0", "origin"])
364 .current_dir(&clone_path)
365 .env("GIT_TERMINAL_PROMPT", "0")
366 .output();
367
368 let output = match output {
369 Ok(o) => o,
370 Err(e) => {
371 cleanup();
372 return TestResult::new(
373 test_name,
374 "GRASP-01:git-http:42",
375 "MUST serve filtered fetch requests",
376 )
377 .fail(format!("Failed to execute git fetch: {}", e));
378 }
379 };
380
381 if !output.status.success() {
382 cleanup();
383 let stderr = String::from_utf8_lossy(&output.stderr);
384 return TestResult::new(
385 test_name,
386 "GRASP-01:git-http:42",
387 "MUST serve filtered fetch requests",
388 )
389 .fail(format!("Filtered git fetch failed: {}", stderr));
390 }
391
392 cleanup();
393 TestResult::new(
394 test_name,
395 "GRASP-01:git-http:42",
396 "MUST serve filtered fetch requests",
397 )
398 .pass()
399 }
400}
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 @@
9//! - [`EventAcceptancePolicyTests`] - Event acceptance rules (WebSocket-only) 9//! - [`EventAcceptancePolicyTests`] - Event acceptance rules (WebSocket-only)
10//! - [`CorsTests`] - CORS headers on Git HTTP endpoints (requires git-data-dir) 10//! - [`CorsTests`] - CORS headers on Git HTTP endpoints (requires git-data-dir)
11//! - [`GitCloneTests`] - Git clone operations (requires git-data-dir) 11//! - [`GitCloneTests`] - Git clone operations (requires git-data-dir)
12//! - [`GitFilterTests`] - Git filter capability for partial clone (requires git-data-dir)
12//! - [`PushAuthorizationTests`] - Push authorization (requires git-data-dir) 13//! - [`PushAuthorizationTests`] - Push authorization (requires git-data-dir)
13//! - [`RepositoryCreationTests`] - Repository creation (requires git-data-dir) 14//! - [`RepositoryCreationTests`] - Repository creation (requires git-data-dir)
14 15
15pub mod cors; 16pub mod cors;
16pub mod event_acceptance_policy; 17pub mod event_acceptance_policy;
17pub mod git_clone; 18pub mod git_clone;
19pub mod git_filter;
18pub mod nip01_smoke; 20pub mod nip01_smoke;
19pub mod nip11_document; 21pub mod nip11_document;
20pub mod push_authorization; 22pub mod push_authorization;
@@ -24,6 +26,7 @@ pub mod spec_requirements;
24pub use cors::CorsTests; 26pub use cors::CorsTests;
25pub use event_acceptance_policy::EventAcceptancePolicyTests; 27pub use event_acceptance_policy::EventAcceptancePolicyTests;
26pub use git_clone::GitCloneTests; 28pub use git_clone::GitCloneTests;
29pub use git_filter::GitFilterTests;
27pub use nip01_smoke::Nip01SmokeTests; 30pub use nip01_smoke::Nip01SmokeTests;
28pub use nip11_document::Nip11DocumentTests; 31pub use nip11_document::Nip11DocumentTests;
29pub use push_authorization::PushAuthorizationTests; 32pub 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;
6 6
7// Re-export all test structs from grasp01 module 7// Re-export all test structs from grasp01 module
8pub use grasp01::{ 8pub use grasp01::{
9 CorsTests, EventAcceptancePolicyTests, GitCloneTests, Nip01SmokeTests, Nip11DocumentTests, 9 CorsTests, EventAcceptancePolicyTests, GitCloneTests, GitFilterTests, Nip01SmokeTests,
10 PushAuthorizationTests, RepositoryCreationTests, 10 Nip11DocumentTests, PushAuthorizationTests, RepositoryCreationTests,
11}; 11};