diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-26 05:45:47 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-26 07:38:58 +0000 |
| commit | 30411a938d072a59d68815c975735d40366ad874 (patch) | |
| tree | f802d1bf9f9959105d2d18af81c528722fa7a675 | |
| parent | a005132ab806b7177d4eb3e3306914841704ffec (diff) | |
feat: push authorization from state event
| -rw-r--r-- | grasp-audit/src/fixtures.rs | 18 | ||||
| -rw-r--r-- | grasp-audit/src/lib.rs | 2 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/mod.rs | 2 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/push_authorization.rs | 551 | ||||
| -rw-r--r-- | src/git/authorization.rs | 693 | ||||
| -rw-r--r-- | src/git/handlers.rs | 147 | ||||
| -rw-r--r-- | src/git/mod.rs | 1 | ||||
| -rw-r--r-- | src/http/mod.rs | 13 | ||||
| -rw-r--r-- | src/nostr/events.rs | 68 | ||||
| -rw-r--r-- | tests/push_authorization.rs | 70 |
10 files changed, 1522 insertions, 43 deletions
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 8eee81f..4b30cb6 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs | |||
| @@ -28,6 +28,17 @@ use nostr_sdk::prelude::Event; | |||
| 28 | use std::collections::HashMap; | 28 | use std::collections::HashMap; |
| 29 | use std::sync::{Arc, Mutex}; | 29 | use std::sync::{Arc, Mutex}; |
| 30 | 30 | ||
| 31 | /// Deterministic commit hash used in RepoState fixtures | ||
| 32 | /// This is the hash produced by creating a commit with: | ||
| 33 | /// - Message: "Initial commit" | ||
| 34 | /// - File: test.txt containing "Initial commit" | ||
| 35 | /// - Author date: 2024-01-01T00:00:00Z | ||
| 36 | /// - Committer date: 2024-01-01T00:00:00Z | ||
| 37 | /// - GPG signing: disabled | ||
| 38 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" | ||
| 39 | /// - Parent: Initial empty commit (09cc37de80f3434fa98864a86730b8d7777bd6ae) | ||
| 40 | pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c0ce5d"; | ||
| 41 | |||
| 31 | /// Types of test fixtures available | 42 | /// Types of test fixtures available |
| 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 33 | pub enum FixtureKind { | 44 | pub enum FixtureKind { |
| @@ -279,17 +290,18 @@ impl<'a> TestContext<'a> { | |||
| 279 | .ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))? | 290 | .ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))? |
| 280 | .to_string(); | 291 | .to_string(); |
| 281 | 292 | ||
| 282 | // Create state announcement | 293 | // Create state announcement with deterministic commit hash |
| 294 | // Tag format: ["refs/heads/main", "<commit_hash>"] | ||
| 283 | self.client | 295 | self.client |
| 284 | .event_builder(Kind::Custom(30618), "") | 296 | .event_builder(Kind::Custom(30618), "") |
| 285 | .tag(Tag::identifier(&repo_id)) | 297 | .tag(Tag::identifier(&repo_id)) |
| 286 | .tag(Tag::custom( | 298 | .tag(Tag::custom( |
| 287 | TagKind::custom("refs/heads/main"), | 299 | TagKind::custom("refs/heads/main"), |
| 288 | vec!["abc123def456789012345678901234567890abcd"], | 300 | vec![DETERMINISTIC_COMMIT_HASH.to_string()], |
| 289 | )) | 301 | )) |
| 290 | .tag(Tag::custom( | 302 | .tag(Tag::custom( |
| 291 | TagKind::custom("HEAD"), | 303 | TagKind::custom("HEAD"), |
| 292 | vec!["ref: refs/heads/main"], | 304 | vec!["ref: refs/heads/main".to_string()], |
| 293 | )) | 305 | )) |
| 294 | .build(self.client.keys()) | 306 | .build(self.client.keys()) |
| 295 | .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e)) | 307 | .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e)) |
diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index dd9ac32..deed2d5 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs | |||
| @@ -37,7 +37,7 @@ pub mod specs; | |||
| 37 | 37 | ||
| 38 | pub use audit::{AuditConfig, AuditMode}; | 38 | pub use audit::{AuditConfig, AuditMode}; |
| 39 | pub use client::AuditClient; | 39 | pub use client::AuditClient; |
| 40 | pub use fixtures::{ContextMode, FixtureKind, TestContext}; | 40 | pub use fixtures::{ContextMode, FixtureKind, TestContext, DETERMINISTIC_COMMIT_HASH}; |
| 41 | pub use result::{AuditResult, TestResult}; | 41 | pub use result::{AuditResult, TestResult}; |
| 42 | 42 | ||
| 43 | // Re-export commonly used types | 43 | // Re-export commonly used types |
diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs index 8b29f8c..0d0bd9c 100644 --- a/grasp-audit/src/specs/grasp01/mod.rs +++ b/grasp-audit/src/specs/grasp01/mod.rs | |||
| @@ -5,6 +5,7 @@ pub mod event_acceptance_policy; | |||
| 5 | pub mod git_clone; | 5 | pub mod git_clone; |
| 6 | pub mod nip01_smoke; | 6 | pub mod nip01_smoke; |
| 7 | pub mod nip11_document; | 7 | pub mod nip11_document; |
| 8 | pub mod push_authorization; | ||
| 8 | pub mod repository_creation; | 9 | pub mod repository_creation; |
| 9 | 10 | ||
| 10 | pub use cors::CorsTests; | 11 | pub use cors::CorsTests; |
| @@ -12,4 +13,5 @@ pub use event_acceptance_policy::EventAcceptancePolicyTests; | |||
| 12 | pub use git_clone::GitCloneTests; | 13 | pub use git_clone::GitCloneTests; |
| 13 | pub use nip01_smoke::Nip01SmokeTests; | 14 | pub use nip01_smoke::Nip01SmokeTests; |
| 14 | pub use nip11_document::Nip11DocumentTests; | 15 | pub use nip11_document::Nip11DocumentTests; |
| 16 | pub use push_authorization::PushAuthorizationTests; | ||
| 15 | pub use repository_creation::RepositoryCreationTests; | 17 | pub use repository_creation::RepositoryCreationTests; |
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs new file mode 100644 index 0000000..974ccd4 --- /dev/null +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs | |||
| @@ -0,0 +1,551 @@ | |||
| 1 | //! GRASP-01 Push Authorization Tests | ||
| 2 | //! | ||
| 3 | //! Tests that verify push authorization works correctly according to GRASP-01: | ||
| 4 | //! "MUST accept pushes via this service that match the latest repo state announcement | ||
| 5 | //! on the relay, respecting the recursive maintainer set." | ||
| 6 | //! | ||
| 7 | //! ## Test Coverage | ||
| 8 | //! | ||
| 9 | //! - Push authorized when state event matches commit being pushed | ||
| 10 | //! - Push rejected when no state event exists | ||
| 11 | //! - Push rejected when state event has different commit | ||
| 12 | //! | ||
| 13 | //! ## Running Tests | ||
| 14 | //! | ||
| 15 | //! ```bash | ||
| 16 | //! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test | ||
| 17 | //! ``` | ||
| 18 | |||
| 19 | use crate::{AuditClient, FixtureKind, TestContext, TestResult}; | ||
| 20 | use nostr_sdk::prelude::*; | ||
| 21 | use std::fs; | ||
| 22 | use std::path::{Path, PathBuf}; | ||
| 23 | use std::process::Command; | ||
| 24 | |||
| 25 | /// Test suite for Push Authorization operations | ||
| 26 | pub struct PushAuthorizationTests; | ||
| 27 | |||
| 28 | /// Helper to clone a repository and return the path | ||
| 29 | fn clone_repo( | ||
| 30 | relay_domain: &str, | ||
| 31 | npub: &str, | ||
| 32 | repo_id: &str, | ||
| 33 | ) -> Result<std::path::PathBuf, String> { | ||
| 34 | let temp_base = std::env::temp_dir(); | ||
| 35 | let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4()); | ||
| 36 | let clone_path = temp_base.join(&clone_dir_name); | ||
| 37 | let _ = fs::remove_dir_all(&clone_path); | ||
| 38 | |||
| 39 | let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); | ||
| 40 | let output = Command::new("git") | ||
| 41 | .args(["clone", &clone_url, clone_path.to_str().unwrap()]) | ||
| 42 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 43 | .output() | ||
| 44 | .map_err(|e| format!("Failed to execute git clone: {}", e))?; | ||
| 45 | |||
| 46 | if !output.status.success() { | ||
| 47 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 48 | return Err(format!("Git clone failed: {}", stderr)); | ||
| 49 | } | ||
| 50 | |||
| 51 | // Configure git user | ||
| 52 | let _ = Command::new("git") | ||
| 53 | .args(["config", "user.email", "test@grasp-audit.local"]) | ||
| 54 | .current_dir(&clone_path) | ||
| 55 | .output(); | ||
| 56 | let _ = Command::new("git") | ||
| 57 | .args(["config", "user.name", "GRASP Audit Test"]) | ||
| 58 | .current_dir(&clone_path) | ||
| 59 | .output(); | ||
| 60 | |||
| 61 | Ok(clone_path) | ||
| 62 | } | ||
| 63 | |||
| 64 | /// Helper to create a commit and return the hash | ||
| 65 | fn create_commit(clone_path: &Path, message: &str) -> Result<String, String> { | ||
| 66 | let test_file = clone_path.join(format!("test-{}.txt", uuid::Uuid::new_v4())); | ||
| 67 | fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?; | ||
| 68 | |||
| 69 | let filename = test_file.file_name().unwrap().to_str().unwrap(); | ||
| 70 | let output = Command::new("git") | ||
| 71 | .args(["add", filename]) | ||
| 72 | .current_dir(clone_path) | ||
| 73 | .output() | ||
| 74 | .map_err(|e| format!("Git add failed: {}", e))?; | ||
| 75 | |||
| 76 | if !output.status.success() { | ||
| 77 | return Err("Git add failed".to_string()); | ||
| 78 | } | ||
| 79 | |||
| 80 | let output = Command::new("git") | ||
| 81 | .args(["commit", "-m", message]) | ||
| 82 | .current_dir(clone_path) | ||
| 83 | .output() | ||
| 84 | .map_err(|e| format!("Git commit failed: {}", e))?; | ||
| 85 | |||
| 86 | if !output.status.success() { | ||
| 87 | return Err("Git commit failed".to_string()); | ||
| 88 | } | ||
| 89 | |||
| 90 | let output = Command::new("git") | ||
| 91 | .args(["rev-parse", "HEAD"]) | ||
| 92 | .current_dir(clone_path) | ||
| 93 | .output() | ||
| 94 | .map_err(|e| format!("Git rev-parse failed: {}", e))?; | ||
| 95 | |||
| 96 | if !output.status.success() { | ||
| 97 | return Err("Failed to get commit hash".to_string()); | ||
| 98 | } | ||
| 99 | |||
| 100 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Helper to create a deterministic commit (for fixtures) | ||
| 104 | /// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash | ||
| 105 | pub fn create_deterministic_commit(clone_path: &Path, message: &str) -> Result<String, String> { | ||
| 106 | let test_file = clone_path.join("test.txt"); | ||
| 107 | fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?; | ||
| 108 | |||
| 109 | let output = Command::new("git") | ||
| 110 | .args(["add", "test.txt"]) | ||
| 111 | .current_dir(clone_path) | ||
| 112 | .output() | ||
| 113 | .map_err(|e| format!("Git add failed: {}", e))?; | ||
| 114 | |||
| 115 | if !output.status.success() { | ||
| 116 | return Err("Git add failed".to_string()); | ||
| 117 | } | ||
| 118 | |||
| 119 | // Create deterministic commit with fixed dates and GPG disabled | ||
| 120 | let output = Command::new("git") | ||
| 121 | .args([ | ||
| 122 | "-c", "commit.gpgsign=false", | ||
| 123 | "commit", | ||
| 124 | "-m", message, | ||
| 125 | ]) | ||
| 126 | .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") | ||
| 127 | .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") | ||
| 128 | .current_dir(clone_path) | ||
| 129 | .output() | ||
| 130 | .map_err(|e| format!("Git commit failed: {}", e))?; | ||
| 131 | |||
| 132 | if !output.status.success() { | ||
| 133 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 134 | return Err(format!("Git commit failed: {}", stderr)); | ||
| 135 | } | ||
| 136 | |||
| 137 | let output = Command::new("git") | ||
| 138 | .args(["rev-parse", "HEAD"]) | ||
| 139 | .current_dir(clone_path) | ||
| 140 | .output() | ||
| 141 | .map_err(|e| format!("Git rev-parse failed: {}", e))?; | ||
| 142 | |||
| 143 | if !output.status.success() { | ||
| 144 | return Err("Failed to get commit hash".to_string()); | ||
| 145 | } | ||
| 146 | |||
| 147 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) | ||
| 148 | } | ||
| 149 | |||
| 150 | /// Repository setup with deterministic commit | ||
| 151 | /// This struct holds all the data needed for push authorization tests | ||
| 152 | pub struct RepoSetup { | ||
| 153 | pub clone_path: PathBuf, | ||
| 154 | pub repo_id: String, | ||
| 155 | pub npub: String, | ||
| 156 | pub commit_hash: String, | ||
| 157 | } | ||
| 158 | |||
| 159 | impl Drop for RepoSetup { | ||
| 160 | fn drop(&mut self) { | ||
| 161 | let _ = fs::remove_dir_all(&self.clone_path); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | /// Helper function to set up a repository with deterministic commit | ||
| 166 | /// | ||
| 167 | /// This performs all the common setup steps needed for push authorization tests: | ||
| 168 | /// 1. Gets RepoState fixture (repo announcement + state event with deterministic commit) | ||
| 169 | /// 2. Extracts repo_id and npub | ||
| 170 | /// 3. Verifies repo exists on disk | ||
| 171 | /// 4. Clones the repository | ||
| 172 | /// 5. Creates deterministic commit locally | ||
| 173 | /// 6. Verifies commit hash matches expected | ||
| 174 | /// 7. Creates and checks out main branch | ||
| 175 | /// 8. Pushes the commit so the grasp server has the state in the state event | ||
| 176 | /// | ||
| 177 | /// Returns RepoSetup which auto-cleans up the clone_path on drop | ||
| 178 | pub async fn setup_repo_with_deterministic_commit( | ||
| 179 | client: &AuditClient, | ||
| 180 | git_data_dir: &Path, | ||
| 181 | relay_domain: &str, | ||
| 182 | ) -> Result<RepoSetup, String> { | ||
| 183 | use crate::DETERMINISTIC_COMMIT_HASH; | ||
| 184 | |||
| 185 | let ctx = TestContext::new(client); | ||
| 186 | |||
| 187 | // Get RepoState fixture (includes repo announcement and state event with deterministic commit) | ||
| 188 | let state_event = ctx.get_fixture(FixtureKind::RepoState).await | ||
| 189 | .map_err(|e| format!("Failed to create repo state fixture: {}", e))?; | ||
| 190 | |||
| 191 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | ||
| 192 | |||
| 193 | // Extract repo_id from state event | ||
| 194 | let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d()) | ||
| 195 | .and_then(|t| t.content()) | ||
| 196 | .ok_or("Missing repo_id")? | ||
| 197 | .to_string(); | ||
| 198 | let npub = state_event.pubkey.to_bech32() | ||
| 199 | .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?; | ||
| 200 | |||
| 201 | // Verify repo exists | ||
| 202 | let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); | ||
| 203 | if !repo_path.exists() { | ||
| 204 | return Err(format!("Repo not found: {}", repo_path.display())); | ||
| 205 | } | ||
| 206 | |||
| 207 | // Clone repo | ||
| 208 | let clone_path = clone_repo(relay_domain, &npub, &repo_id)?; | ||
| 209 | |||
| 210 | // Create deterministic commit locally (this will be the root commit with no parent) | ||
| 211 | let commit_hash = create_deterministic_commit(&clone_path, "Initial commit") | ||
| 212 | .map_err(|e| { | ||
| 213 | let _ = fs::remove_dir_all(&clone_path); | ||
| 214 | e | ||
| 215 | })?; | ||
| 216 | |||
| 217 | // Verify commit hash matches expected deterministic hash | ||
| 218 | if commit_hash != DETERMINISTIC_COMMIT_HASH { | ||
| 219 | let _ = fs::remove_dir_all(&clone_path); | ||
| 220 | return Err(format!( | ||
| 221 | "Commit hash mismatch: got {}, expected {}", | ||
| 222 | commit_hash, DETERMINISTIC_COMMIT_HASH | ||
| 223 | )); | ||
| 224 | } | ||
| 225 | |||
| 226 | // Create main branch pointing to our deterministic commit | ||
| 227 | let branch_output = Command::new("git") | ||
| 228 | .args(["branch", "main"]) | ||
| 229 | .current_dir(&clone_path) | ||
| 230 | .output() | ||
| 231 | .map_err(|e| { | ||
| 232 | let _ = fs::remove_dir_all(&clone_path); | ||
| 233 | format!("Failed to create main branch: {}", e) | ||
| 234 | })?; | ||
| 235 | |||
| 236 | if !branch_output.status.success() { | ||
| 237 | let _ = fs::remove_dir_all(&clone_path); | ||
| 238 | return Err(format!( | ||
| 239 | "Failed to create main branch: {}", | ||
| 240 | String::from_utf8_lossy(&branch_output.stderr) | ||
| 241 | )); | ||
| 242 | } | ||
| 243 | |||
| 244 | // Checkout main branch | ||
| 245 | let checkout_output = Command::new("git") | ||
| 246 | .args(["checkout", "main"]) | ||
| 247 | .current_dir(&clone_path) | ||
| 248 | .output() | ||
| 249 | .map_err(|e| { | ||
| 250 | let _ = fs::remove_dir_all(&clone_path); | ||
| 251 | format!("Failed to checkout main branch: {}", e) | ||
| 252 | })?; | ||
| 253 | |||
| 254 | if !checkout_output.status.success() { | ||
| 255 | let _ = fs::remove_dir_all(&clone_path); | ||
| 256 | return Err(format!( | ||
| 257 | "Failed to checkout main branch: {}", | ||
| 258 | String::from_utf8_lossy(&checkout_output.stderr) | ||
| 259 | )); | ||
| 260 | } | ||
| 261 | |||
| 262 | // Push the commit to the server so the bare repo matches the state event | ||
| 263 | let push_output = Command::new("git") | ||
| 264 | .args(["push", "origin", "main"]) | ||
| 265 | .current_dir(&clone_path) | ||
| 266 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 267 | .output() | ||
| 268 | .map_err(|e| { | ||
| 269 | let _ = fs::remove_dir_all(&clone_path); | ||
| 270 | format!("Failed to push to server: {}", e) | ||
| 271 | })?; | ||
| 272 | |||
| 273 | if !push_output.status.success() { | ||
| 274 | let _ = fs::remove_dir_all(&clone_path); | ||
| 275 | return Err(format!( | ||
| 276 | "Failed to push to server: {}", | ||
| 277 | String::from_utf8_lossy(&push_output.stderr) | ||
| 278 | )); | ||
| 279 | } | ||
| 280 | |||
| 281 | Ok(RepoSetup { | ||
| 282 | clone_path, | ||
| 283 | repo_id, | ||
| 284 | npub, | ||
| 285 | commit_hash, | ||
| 286 | }) | ||
| 287 | } | ||
| 288 | |||
| 289 | /// Helper to attempt a push and return success/failure | ||
| 290 | fn try_push(clone_path: &Path) -> Result<bool, String> { | ||
| 291 | let output = Command::new("git") | ||
| 292 | .args(["push", "origin", "main"]) | ||
| 293 | .current_dir(clone_path) | ||
| 294 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 295 | .output() | ||
| 296 | .map_err(|e| format!("Failed to execute git push: {}", e))?; | ||
| 297 | |||
| 298 | Ok(output.status.success()) | ||
| 299 | } | ||
| 300 | |||
| 301 | impl PushAuthorizationTests { | ||
| 302 | /// Test that push is authorized when state event matches the commit | ||
| 303 | /// | ||
| 304 | /// GRASP-01: "MUST accept pushes via this service that match the latest | ||
| 305 | /// repo state announcement on the relay" | ||
| 306 | pub async fn test_push_authorized_by_owner_state( | ||
| 307 | client: &AuditClient, | ||
| 308 | git_data_dir: &Path, | ||
| 309 | relay_domain: &str, | ||
| 310 | ) -> TestResult { | ||
| 311 | let test_name = "test_push_authorized_by_owner_state"; | ||
| 312 | |||
| 313 | // this setup is exactly what we are testing | ||
| 314 | match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await { | ||
| 315 | Ok(_) => { | ||
| 316 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").pass() | ||
| 317 | }, | ||
| 318 | Err(e) => { | ||
| 319 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | ||
| 320 | .fail(&format!("Failed: {}", e)) | ||
| 321 | } | ||
| 322 | }; | ||
| 323 | } | ||
| 324 | |||
| 325 | /// Test that push is rejected when no state event exists | ||
| 326 | pub async fn test_push_rejected_without_state_event( | ||
| 327 | client: &AuditClient, | ||
| 328 | git_data_dir: &Path, | ||
| 329 | relay_domain: &str, | ||
| 330 | ) -> TestResult { | ||
| 331 | let test_name = "test_push_rejected_without_state_event"; | ||
| 332 | let ctx = TestContext::new(client); | ||
| 333 | |||
| 334 | // Create repository (no state event) | ||
| 335 | let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { | ||
| 336 | Ok(r) => r, | ||
| 337 | Err(e) => { | ||
| 338 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") | ||
| 339 | .fail(&format!("Failed to create repo: {}", e)) | ||
| 340 | } | ||
| 341 | }; | ||
| 342 | |||
| 343 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | ||
| 344 | |||
| 345 | let repo_id = repo.tags.iter().find(|t| t.kind() == TagKind::d()) | ||
| 346 | .and_then(|t| t.content()).unwrap().to_string(); | ||
| 347 | let npub = repo.pubkey.to_bech32().unwrap(); | ||
| 348 | |||
| 349 | let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); | ||
| 350 | if !repo_path.exists() { | ||
| 351 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") | ||
| 352 | .fail(&format!("Repo not found: {}", repo_path.display())); | ||
| 353 | } | ||
| 354 | |||
| 355 | // Clone and create commit | ||
| 356 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { | ||
| 357 | Ok(p) => p, | ||
| 358 | Err(e) => return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), | ||
| 359 | }; | ||
| 360 | let cleanup = || { let _ = fs::remove_dir_all(&clone_path); }; | ||
| 361 | |||
| 362 | if let Err(e) = create_commit(&clone_path, "Unauthorized commit") { | ||
| 363 | cleanup(); | ||
| 364 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e); | ||
| 365 | } | ||
| 366 | |||
| 367 | // Do NOT publish state event - push should be rejected | ||
| 368 | let push_result = try_push(&clone_path); | ||
| 369 | cleanup(); | ||
| 370 | |||
| 371 | match push_result { | ||
| 372 | Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass(), | ||
| 373 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail("Push accepted but should be rejected"), | ||
| 374 | Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), | ||
| 375 | } | ||
| 376 | } | ||
| 377 | |||
| 378 | /// Test that push is rejected when commit doesn't match state event | ||
| 379 | /// | ||
| 380 | /// This test verifies that the relay enforces state event authorization. | ||
| 381 | /// The state event (from fixture) points to the deterministic commit which is | ||
| 382 | /// already on the server. We create a new commit locally and try to push it. | ||
| 383 | /// The push should be rejected because the new commit doesn't match what the | ||
| 384 | /// state event announces. | ||
| 385 | pub async fn test_push_rejected_wrong_commit( | ||
| 386 | client: &AuditClient, | ||
| 387 | git_data_dir: &Path, | ||
| 388 | relay_domain: &str, | ||
| 389 | ) -> TestResult { | ||
| 390 | let test_name = "test_push_rejected_wrong_commit"; | ||
| 391 | |||
| 392 | // Set up repository with deterministic commit | ||
| 393 | // This creates a state event pointing to DETERMINISTIC_COMMIT_HASH and pushes that commit | ||
| 394 | let setup = match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await { | ||
| 395 | Ok(s) => s, | ||
| 396 | Err(e) => { | ||
| 397 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | ||
| 398 | .fail(&format!("Setup failed: {}", e)) | ||
| 399 | } | ||
| 400 | }; | ||
| 401 | |||
| 402 | // Create a new commit locally - this is NOT announced in any state event | ||
| 403 | let new_commit = match create_commit(&setup.clone_path, "Unauthorized commit") { | ||
| 404 | Ok(h) => h, | ||
| 405 | Err(e) => { | ||
| 406 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | ||
| 407 | .fail(&format!("Failed to create commit: {}", e)) | ||
| 408 | } | ||
| 409 | }; | ||
| 410 | |||
| 411 | // Try to push the new commit | ||
| 412 | // This should be REJECTED because: | ||
| 413 | // - The state event still points to the deterministic commit (setup.commit_hash) | ||
| 414 | // - We're trying to push new_commit which is different | ||
| 415 | // - The relay MUST reject pushes that don't match the announced state | ||
| 416 | let push_result = try_push(&setup.clone_path); | ||
| 417 | |||
| 418 | match push_result { | ||
| 419 | Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event").pass(), | ||
| 420 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | ||
| 421 | .fail(&format!( | ||
| 422 | "Push accepted but should be rejected. State event points to {}, but pushed {}", | ||
| 423 | setup.commit_hash, new_commit | ||
| 424 | )), | ||
| 425 | Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event").fail(&e), | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | /// Test recursive maintainer authorization | ||
| 430 | /// | ||
| 431 | /// GRASP-01: "respecting the recursive maintainer set" | ||
| 432 | pub async fn test_recursive_maintainer_authorization( | ||
| 433 | _client: &AuditClient, | ||
| 434 | _git_data_dir: &Path, | ||
| 435 | _relay_domain: &str, | ||
| 436 | ) -> TestResult { | ||
| 437 | let test_name = "test_recursive_maintainer_authorization"; | ||
| 438 | |||
| 439 | // This test requires two separate clients (owner and maintainer) | ||
| 440 | // For now, return not implemented | ||
| 441 | TestResult::new(test_name, "GRASP-01", "Maintainer can authorize pushes") | ||
| 442 | .fail("Not implemented: requires multiple client support") | ||
| 443 | } | ||
| 444 | |||
| 445 | /// Test that latest state event is used for authorization | ||
| 446 | pub async fn test_latest_state_event_used( | ||
| 447 | _client: &AuditClient, | ||
| 448 | _git_data_dir: &Path, | ||
| 449 | _relay_domain: &str, | ||
| 450 | ) -> TestResult { | ||
| 451 | let test_name = "test_latest_state_event_used"; | ||
| 452 | |||
| 453 | // This test requires publishing multiple state events with timestamps | ||
| 454 | // and verifying the latest one is used | ||
| 455 | TestResult::new(test_name, "GRASP-01", "Latest state event takes precedence") | ||
| 456 | .fail("Not implemented: requires timestamp manipulation") | ||
| 457 | } | ||
| 458 | |||
| 459 | /// Test that non-maintainer state event is ignored | ||
| 460 | /// | ||
| 461 | /// This test verifies that the relay ignores state events from non-maintainers. | ||
| 462 | /// We set up a valid repo, then create a rogue state event signed by a different | ||
| 463 | /// keypair (not the repo maintainer) that announces a different commit. The push | ||
| 464 | /// should be rejected because the rogue state event is not authorized. | ||
| 465 | pub async fn test_non_maintainer_state_rejected( | ||
| 466 | client: &AuditClient, | ||
| 467 | git_data_dir: &Path, | ||
| 468 | relay_domain: &str, | ||
| 469 | ) -> TestResult { | ||
| 470 | let test_name = "test_non_maintainer_state_rejected"; | ||
| 471 | |||
| 472 | // Set up repository with deterministic commit (signed by maintainer) | ||
| 473 | let setup = match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await { | ||
| 474 | Ok(s) => s, | ||
| 475 | Err(e) => { | ||
| 476 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | ||
| 477 | .fail(&format!("Setup failed: {}", e)) | ||
| 478 | } | ||
| 479 | }; | ||
| 480 | |||
| 481 | // Create a new commit locally that we want to push | ||
| 482 | let new_commit = match create_commit(&setup.clone_path, "New commit to push") { | ||
| 483 | Ok(h) => h, | ||
| 484 | Err(e) => { | ||
| 485 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | ||
| 486 | .fail(&format!("Failed to create commit: {}", e)) | ||
| 487 | } | ||
| 488 | }; | ||
| 489 | |||
| 490 | // Create a rogue keypair (NOT the maintainer) | ||
| 491 | let rogue_keys = Keys::generate(); | ||
| 492 | |||
| 493 | // Create a rogue state event announcing the new commit | ||
| 494 | // This event has the correct repo_id but is signed by a non-maintainer | ||
| 495 | let rogue_state = match client | ||
| 496 | .event_builder(Kind::Custom(30618), "") | ||
| 497 | .tag(Tag::identifier(&setup.repo_id)) | ||
| 498 | .tag(Tag::custom( | ||
| 499 | TagKind::custom("refs/heads/main"), | ||
| 500 | vec![new_commit.clone()], | ||
| 501 | )) | ||
| 502 | .build(&rogue_keys) | ||
| 503 | { | ||
| 504 | Ok(e) => e, | ||
| 505 | Err(e) => { | ||
| 506 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | ||
| 507 | .fail(&format!("Failed to build rogue state event: {}", e)) | ||
| 508 | } | ||
| 509 | }; | ||
| 510 | |||
| 511 | // Send the rogue state event using the raw client to bypass AuditClient's key check | ||
| 512 | if let Err(e) = client.client().send_event(&rogue_state).await { | ||
| 513 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | ||
| 514 | .fail(&format!("Failed to send rogue state event: {}", e)); | ||
| 515 | } | ||
| 516 | |||
| 517 | // Wait for event to propagate | ||
| 518 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | ||
| 519 | |||
| 520 | // Try to push the new commit | ||
| 521 | // This should be REJECTED because: | ||
| 522 | // - The rogue state event announces new_commit | ||
| 523 | // - But the rogue state event is NOT signed by the maintainer | ||
| 524 | // - The relay should ignore the rogue state event | ||
| 525 | // - The valid state event (from setup) still points to the deterministic commit | ||
| 526 | // - Therefore pushing new_commit should fail | ||
| 527 | let push_result = try_push(&setup.clone_path); | ||
| 528 | |||
| 529 | match push_result { | ||
| 530 | Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(), | ||
| 531 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | ||
| 532 | .fail(&format!( | ||
| 533 | "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ | ||
| 534 | a state event announcing commit {}, but the push was accepted. The relay should \ | ||
| 535 | only accept state events from maintainers (pubkey: {}).", | ||
| 536 | rogue_keys.public_key(), | ||
| 537 | new_commit, | ||
| 538 | client.public_key() | ||
| 539 | )), | ||
| 540 | Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e), | ||
| 541 | } | ||
| 542 | } | ||
| 543 | } | ||
| 544 | |||
| 545 | #[cfg(test)] | ||
| 546 | mod tests { | ||
| 547 | #[test] | ||
| 548 | fn test_module_exists() { | ||
| 549 | assert!(true); | ||
| 550 | } | ||
| 551 | } \ No newline at end of file | ||
diff --git a/src/git/authorization.rs b/src/git/authorization.rs new file mode 100644 index 0000000..06672c8 --- /dev/null +++ b/src/git/authorization.rs | |||
| @@ -0,0 +1,693 @@ | |||
| 1 | //! GRASP Push Authorization | ||
| 2 | //! | ||
| 3 | //! This module implements the authorization logic for Git pushes according to GRASP-01. | ||
| 4 | //! | ||
| 5 | //! ## GRASP-01 Requirement | ||
| 6 | //! | ||
| 7 | //! "MUST accept pushes via this service that match the latest repo state announcement | ||
| 8 | //! on the relay, respecting the recursive maintainer set." | ||
| 9 | //! | ||
| 10 | //! ## Authorization Flow | ||
| 11 | //! | ||
| 12 | //! 1. Fetch announcement and state events for the repository from the relay | ||
| 13 | //! 2. Calculate the recursive maintainer set (owner + listed maintainers recursively) | ||
| 14 | //! 3. Find the latest state event authored by any maintainer | ||
| 15 | //! 4. Validate that the pushed refs match the state event | ||
| 16 | |||
| 17 | use anyhow::{anyhow, Result}; | ||
| 18 | use nostr_sdk::{Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32, Alphabet}; | ||
| 19 | use std::collections::HashSet; | ||
| 20 | use tracing::{debug, warn}; | ||
| 21 | |||
| 22 | use crate::nostr::events::{ | ||
| 23 | RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, | ||
| 24 | }; | ||
| 25 | |||
| 26 | /// Result of authorization check | ||
| 27 | #[derive(Debug)] | ||
| 28 | pub struct AuthorizationResult { | ||
| 29 | /// Whether the push is authorized | ||
| 30 | pub authorized: bool, | ||
| 31 | /// Reason for the decision (for logging/debugging) | ||
| 32 | pub reason: String, | ||
| 33 | /// The authorized state if available | ||
| 34 | pub state: Option<RepositoryState>, | ||
| 35 | /// The set of valid maintainers | ||
| 36 | pub maintainers: Vec<String>, | ||
| 37 | } | ||
| 38 | |||
| 39 | impl AuthorizationResult { | ||
| 40 | /// Create a successful authorization result | ||
| 41 | pub fn authorized(state: RepositoryState, maintainers: Vec<String>) -> Self { | ||
| 42 | Self { | ||
| 43 | authorized: true, | ||
| 44 | reason: "Push matches latest authorized state".to_string(), | ||
| 45 | state: Some(state), | ||
| 46 | maintainers, | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | /// Create a denied authorization result | ||
| 51 | pub fn denied(reason: impl Into<String>) -> Self { | ||
| 52 | Self { | ||
| 53 | authorized: false, | ||
| 54 | reason: reason.into(), | ||
| 55 | state: None, | ||
| 56 | maintainers: vec![], | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Authorization context for push operations | ||
| 62 | pub struct AuthorizationContext { | ||
| 63 | /// Events fetched from the relay (announcements and states) | ||
| 64 | events: Vec<Event>, | ||
| 65 | } | ||
| 66 | |||
| 67 | impl AuthorizationContext { | ||
| 68 | /// Create a new authorization context from fetched events | ||
| 69 | pub fn new(events: Vec<Event>) -> Self { | ||
| 70 | Self { events } | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Create a filter to fetch announcement and state events for a repository | ||
| 74 | /// | ||
| 75 | /// This matches the reference implementation's filter logic | ||
| 76 | pub fn create_filter(identifier: &str) -> Filter { | ||
| 77 | Filter::new() | ||
| 78 | .kinds([ | ||
| 79 | Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), | ||
| 80 | Kind::from(KIND_REPOSITORY_STATE), | ||
| 81 | ]) | ||
| 82 | .custom_tag(SingleLetterTag::lowercase(Alphabet::D), identifier.to_string()) | ||
| 83 | } | ||
| 84 | |||
| 85 | /// Get the latest authorized state for a repository | ||
| 86 | /// | ||
| 87 | /// This implements the GRASP-01 requirement: | ||
| 88 | /// "respecting the recursive maintainer set" | ||
| 89 | pub fn get_authorized_state( | ||
| 90 | &self, | ||
| 91 | owner_pubkey: &str, | ||
| 92 | identifier: &str, | ||
| 93 | ) -> Result<AuthorizationResult> { | ||
| 94 | // Calculate recursive maintainer set | ||
| 95 | let maintainers = self.get_maintainers(owner_pubkey, identifier); | ||
| 96 | |||
| 97 | if maintainers.is_empty() { | ||
| 98 | return Ok(AuthorizationResult::denied( | ||
| 99 | "No repository announcement found for owner", | ||
| 100 | )); | ||
| 101 | } | ||
| 102 | |||
| 103 | debug!( | ||
| 104 | "Found {} maintainers for repository {}: {:?}", | ||
| 105 | maintainers.len(), | ||
| 106 | identifier, | ||
| 107 | maintainers | ||
| 108 | ); | ||
| 109 | |||
| 110 | // Get the latest state event from any maintainer | ||
| 111 | match self.get_state_from_maintainers(&maintainers, identifier) { | ||
| 112 | Some(state) => Ok(AuthorizationResult::authorized(state, maintainers)), | ||
| 113 | None => Ok(AuthorizationResult::denied( | ||
| 114 | "No state event found from maintainers", | ||
| 115 | )), | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | /// Recursively find all maintainers for a repository | ||
| 120 | /// | ||
| 121 | /// This implements the recursive maintainer logic from the reference: | ||
| 122 | /// - Start with the owner's announcement | ||
| 123 | /// - Extract all `p` tags (listed maintainers) | ||
| 124 | /// - Recursively find maintainers listed by those maintainers | ||
| 125 | /// - Return the full set of unique maintainers | ||
| 126 | /// | ||
| 127 | /// Example: if alice lists bob, and bob lists charlie: | ||
| 128 | /// - getMaintainers(alice) -> [alice, bob, charlie] | ||
| 129 | /// - getMaintainers(bob) -> [bob, charlie] (bob doesn't have alice's trust) | ||
| 130 | pub fn get_maintainers(&self, pubkey: &str, identifier: &str) -> Vec<String> { | ||
| 131 | let mut checked: HashSet<String> = HashSet::new(); | ||
| 132 | self.get_maintainers_recursive(pubkey, identifier, &mut checked); | ||
| 133 | |||
| 134 | checked.into_iter().collect() | ||
| 135 | } | ||
| 136 | |||
| 137 | /// Recursive helper for get_maintainers | ||
| 138 | fn get_maintainers_recursive( | ||
| 139 | &self, | ||
| 140 | pubkey: &str, | ||
| 141 | identifier: &str, | ||
| 142 | checked: &mut HashSet<String>, | ||
| 143 | ) { | ||
| 144 | // Skip if already processed | ||
| 145 | if checked.contains(pubkey) { | ||
| 146 | return; | ||
| 147 | } | ||
| 148 | |||
| 149 | // Find the announcement event for this pubkey | ||
| 150 | let announcement = self.find_announcement_by_pubkey(pubkey, identifier); | ||
| 151 | if announcement.is_none() { | ||
| 152 | return; | ||
| 153 | } | ||
| 154 | |||
| 155 | // Mark this pubkey as checked (they have a valid announcement) | ||
| 156 | checked.insert(pubkey.to_string()); | ||
| 157 | |||
| 158 | let announcement = announcement.unwrap(); | ||
| 159 | |||
| 160 | // Get maintainers listed in this announcement (p tags) | ||
| 161 | for maintainer_pubkey in &announcement.maintainers { | ||
| 162 | // Recursively process each listed maintainer | ||
| 163 | self.get_maintainers_recursive(maintainer_pubkey, identifier, checked); | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Find a repository announcement event by pubkey and identifier | ||
| 168 | fn find_announcement_by_pubkey( | ||
| 169 | &self, | ||
| 170 | pubkey: &str, | ||
| 171 | identifier: &str, | ||
| 172 | ) -> Option<RepositoryAnnouncement> { | ||
| 173 | for event in &self.events { | ||
| 174 | // Check if it's a repository announcement | ||
| 175 | if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { | ||
| 176 | continue; | ||
| 177 | } | ||
| 178 | |||
| 179 | // Check if pubkey matches | ||
| 180 | if event.pubkey.to_hex() != pubkey { | ||
| 181 | continue; | ||
| 182 | } | ||
| 183 | |||
| 184 | // Try to parse and check identifier | ||
| 185 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | ||
| 186 | if announcement.identifier == identifier { | ||
| 187 | return Some(announcement); | ||
| 188 | } | ||
| 189 | } | ||
| 190 | } | ||
| 191 | None | ||
| 192 | } | ||
| 193 | |||
| 194 | /// Get the latest state event from any of the provided maintainers | ||
| 195 | /// | ||
| 196 | /// This implements the reference's GetStateFromMaintainers logic: | ||
| 197 | /// - Find all state events from maintainers | ||
| 198 | /// - Return the one with the latest timestamp | ||
| 199 | fn get_state_from_maintainers( | ||
| 200 | &self, | ||
| 201 | maintainers: &[String], | ||
| 202 | identifier: &str, | ||
| 203 | ) -> Option<RepositoryState> { | ||
| 204 | let maintainer_set: HashSet<&str> = maintainers.iter().map(|s| s.as_str()).collect(); | ||
| 205 | |||
| 206 | let mut latest_state: Option<RepositoryState> = None; | ||
| 207 | let mut latest_timestamp = Timestamp::from(0); | ||
| 208 | |||
| 209 | for event in &self.events { | ||
| 210 | // Check if it's a repository state event | ||
| 211 | if event.kind != Kind::from(KIND_REPOSITORY_STATE) { | ||
| 212 | continue; | ||
| 213 | } | ||
| 214 | |||
| 215 | // Check if from a maintainer | ||
| 216 | let pubkey_hex = event.pubkey.to_hex(); | ||
| 217 | if !maintainer_set.contains(pubkey_hex.as_str()) { | ||
| 218 | continue; | ||
| 219 | } | ||
| 220 | |||
| 221 | // Try to parse the state | ||
| 222 | if let Ok(state) = RepositoryState::from_event(event.clone()) { | ||
| 223 | // Check identifier matches | ||
| 224 | if state.identifier != identifier { | ||
| 225 | continue; | ||
| 226 | } | ||
| 227 | |||
| 228 | // Check if this is the latest | ||
| 229 | if event.created_at > latest_timestamp { | ||
| 230 | latest_timestamp = event.created_at; | ||
| 231 | latest_state = Some(state); | ||
| 232 | } | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 236 | latest_state | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | /// Validate that pushed refs match the authorized state | ||
| 241 | /// | ||
| 242 | /// Takes the refs being pushed (ref name -> commit hash) and validates | ||
| 243 | /// against the state event. | ||
| 244 | pub fn validate_push_refs( | ||
| 245 | state: &RepositoryState, | ||
| 246 | pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) | ||
| 247 | ) -> Result<()> { | ||
| 248 | for (old_oid, new_oid, ref_name) in pushed_refs { | ||
| 249 | debug!( | ||
| 250 | "Validating push: {} {} -> {}", | ||
| 251 | ref_name, old_oid, new_oid | ||
| 252 | ); | ||
| 253 | |||
| 254 | // Handle branch updates | ||
| 255 | if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { | ||
| 256 | if let Some(expected_commit) = state.get_branch_commit(branch_name) { | ||
| 257 | if new_oid != expected_commit { | ||
| 258 | return Err(anyhow!( | ||
| 259 | "Branch {} push rejected: expected commit {}, got {}", | ||
| 260 | branch_name, | ||
| 261 | expected_commit, | ||
| 262 | new_oid | ||
| 263 | )); | ||
| 264 | } | ||
| 265 | // Commit matches state - authorized | ||
| 266 | debug!("Branch {} push authorized: {} matches state", branch_name, new_oid); | ||
| 267 | } else { | ||
| 268 | // Branch not in state - REJECT (GRASP-01 requirement) | ||
| 269 | return Err(anyhow!( | ||
| 270 | "Branch {} push rejected: not announced in state event", | ||
| 271 | branch_name | ||
| 272 | )); | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 276 | // Handle tag updates | ||
| 277 | if let Some(tag_name) = ref_name.strip_prefix("refs/tags/") { | ||
| 278 | if let Some(expected_commit) = state.get_tag_commit(tag_name) { | ||
| 279 | if new_oid != expected_commit { | ||
| 280 | return Err(anyhow!( | ||
| 281 | "Tag {} push rejected: expected commit {}, got {}", | ||
| 282 | tag_name, | ||
| 283 | expected_commit, | ||
| 284 | new_oid | ||
| 285 | )); | ||
| 286 | } | ||
| 287 | } | ||
| 288 | } | ||
| 289 | |||
| 290 | // refs/nostr/* is handled separately per GRASP-01 | ||
| 291 | if ref_name.starts_with("refs/nostr/") { | ||
| 292 | debug!("refs/nostr/ push will be validated separately"); | ||
| 293 | } | ||
| 294 | } | ||
| 295 | |||
| 296 | Ok(()) | ||
| 297 | } | ||
| 298 | |||
| 299 | /// Parse the refs being updated from a Git pack | ||
| 300 | /// | ||
| 301 | /// The receive-pack protocol sends ref updates in pkt-line format: | ||
| 302 | /// - 4-byte hex length prefix (e.g., "00a5") | ||
| 303 | /// - Payload: `<old-oid> <new-oid> <ref-name>\0<capabilities>\n` | ||
| 304 | /// - Flush packet "0000" terminates the list | ||
| 305 | /// - Then comes the PACK data | ||
| 306 | /// | ||
| 307 | /// This function handles both pkt-line format (from real Git clients) and | ||
| 308 | /// simple text format (for unit tests). | ||
| 309 | pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> { | ||
| 310 | // Check if this looks like pkt-line format (starts with 4 hex digits) | ||
| 311 | // A valid pkt-line push starts with a length > 4 (not a flush packet) | ||
| 312 | if data.len() >= 4 { | ||
| 313 | if let Ok(len_str) = std::str::from_utf8(&data[0..4]) { | ||
| 314 | if let Ok(len) = u16::from_str_radix(len_str, 16) { | ||
| 315 | // A valid pkt-line data packet has length > 4 (flush is 0) | ||
| 316 | // Also check that the length makes sense for a ref update | ||
| 317 | if len > 4 && (len as usize) <= data.len() { | ||
| 318 | // This is pkt-line format, parse it properly | ||
| 319 | return parse_pktline_refs(data); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | } | ||
| 323 | } | ||
| 324 | |||
| 325 | // Fall back to simple text format (for tests) | ||
| 326 | parse_text_refs(data) | ||
| 327 | } | ||
| 328 | |||
| 329 | /// Parse refs from pkt-line format data | ||
| 330 | fn parse_pktline_refs(mut data: &[u8]) -> Vec<(String, String, String)> { | ||
| 331 | let mut refs = Vec::new(); | ||
| 332 | |||
| 333 | while data.len() >= 4 { | ||
| 334 | // Parse pkt-line length prefix | ||
| 335 | let len_str = match std::str::from_utf8(&data[0..4]) { | ||
| 336 | Ok(s) => s, | ||
| 337 | Err(_) => break, | ||
| 338 | }; | ||
| 339 | |||
| 340 | let len = match u16::from_str_radix(len_str, 16) { | ||
| 341 | Ok(l) => l as usize, | ||
| 342 | Err(_) => break, | ||
| 343 | }; | ||
| 344 | |||
| 345 | // Flush packet (0000) ends the ref list | ||
| 346 | if len == 0 { | ||
| 347 | break; | ||
| 348 | } | ||
| 349 | |||
| 350 | if len < 4 || data.len() < len { | ||
| 351 | break; | ||
| 352 | } | ||
| 353 | |||
| 354 | // Extract payload (without the 4-byte length prefix) | ||
| 355 | let payload = &data[4..len]; | ||
| 356 | |||
| 357 | // Parse the payload: "old_oid new_oid ref_name\0capabilities\n" | ||
| 358 | if let Some(ref_update) = parse_ref_line(payload) { | ||
| 359 | refs.push(ref_update); | ||
| 360 | } | ||
| 361 | |||
| 362 | // Move to next pkt-line | ||
| 363 | data = &data[len..]; | ||
| 364 | } | ||
| 365 | |||
| 366 | debug!("Parsed {} refs from pkt-line format", refs.len()); | ||
| 367 | refs | ||
| 368 | } | ||
| 369 | |||
| 370 | /// Parse refs from simple text format (for backward compatibility with tests) | ||
| 371 | fn parse_text_refs(data: &[u8]) -> Vec<(String, String, String)> { | ||
| 372 | let mut refs = Vec::new(); | ||
| 373 | let text = String::from_utf8_lossy(data); | ||
| 374 | |||
| 375 | for line in text.lines() { | ||
| 376 | // Skip empty lines and pack data | ||
| 377 | if line.is_empty() || line.starts_with("PACK") { | ||
| 378 | continue; | ||
| 379 | } | ||
| 380 | |||
| 381 | if let Some(ref_update) = parse_ref_line(line.as_bytes()) { | ||
| 382 | refs.push(ref_update); | ||
| 383 | } | ||
| 384 | } | ||
| 385 | |||
| 386 | refs | ||
| 387 | } | ||
| 388 | |||
| 389 | /// Parse a single ref update line: "old_oid new_oid ref_name\0capabilities" | ||
| 390 | fn parse_ref_line(payload: &[u8]) -> Option<(String, String, String)> { | ||
| 391 | // Convert to string, handling potential invalid UTF-8 | ||
| 392 | let line = String::from_utf8_lossy(payload); | ||
| 393 | |||
| 394 | // Strip trailing newline if present | ||
| 395 | let line = line.trim_end_matches('\n'); | ||
| 396 | |||
| 397 | // Split at null byte to separate command from capabilities | ||
| 398 | let command_part = line.split('\0').next().unwrap_or(""); | ||
| 399 | |||
| 400 | // Parse "old_oid new_oid ref_name" | ||
| 401 | let parts: Vec<&str> = command_part.split_whitespace().collect(); | ||
| 402 | if parts.len() >= 3 { | ||
| 403 | let old_oid = parts[0]; | ||
| 404 | let new_oid = parts[1]; | ||
| 405 | let ref_name = parts[2]; | ||
| 406 | |||
| 407 | // Validate OID format (40 hex chars) | ||
| 408 | if old_oid.len() == 40 && new_oid.len() == 40 | ||
| 409 | && old_oid.chars().all(|c| c.is_ascii_hexdigit()) | ||
| 410 | && new_oid.chars().all(|c| c.is_ascii_hexdigit()) | ||
| 411 | { | ||
| 412 | return Some((old_oid.to_string(), new_oid.to_string(), ref_name.to_string())); | ||
| 413 | } | ||
| 414 | } | ||
| 415 | |||
| 416 | None | ||
| 417 | } | ||
| 418 | |||
| 419 | /// Convert hex pubkey to bech32 npub format | ||
| 420 | pub fn pubkey_to_npub(hex_pubkey: &str) -> Result<String> { | ||
| 421 | let pk = PublicKey::parse(hex_pubkey)?; | ||
| 422 | Ok(pk.to_bech32()?) | ||
| 423 | } | ||
| 424 | |||
| 425 | /// Convert bech32 npub to hex pubkey format | ||
| 426 | pub fn npub_to_pubkey(npub: &str) -> Result<String> { | ||
| 427 | let pk = PublicKey::parse(npub)?; | ||
| 428 | Ok(pk.to_hex()) | ||
| 429 | } | ||
| 430 | |||
| 431 | #[cfg(test)] | ||
| 432 | mod tests { | ||
| 433 | use super::*; | ||
| 434 | use nostr_sdk::{EventBuilder, Keys, Tag, TagKind}; | ||
| 435 | |||
| 436 | fn create_test_keys() -> Keys { | ||
| 437 | Keys::generate() | ||
| 438 | } | ||
| 439 | |||
| 440 | fn create_announcement_event( | ||
| 441 | keys: &Keys, | ||
| 442 | identifier: &str, | ||
| 443 | maintainers: &[&Keys], | ||
| 444 | ) -> Event { | ||
| 445 | let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; | ||
| 446 | |||
| 447 | // Add maintainers as p tags | ||
| 448 | for maintainer_keys in maintainers { | ||
| 449 | tags.push(Tag::custom( | ||
| 450 | TagKind::p(), | ||
| 451 | vec![maintainer_keys.public_key().to_hex()], | ||
| 452 | )); | ||
| 453 | } | ||
| 454 | |||
| 455 | // Add clone and relay tags for validity | ||
| 456 | tags.push(Tag::custom( | ||
| 457 | TagKind::Clone, | ||
| 458 | vec!["https://example.com/test.git".to_string()], | ||
| 459 | )); | ||
| 460 | tags.push(Tag::custom( | ||
| 461 | TagKind::Relays, | ||
| 462 | vec!["wss://example.com".to_string()], | ||
| 463 | )); | ||
| 464 | |||
| 465 | EventBuilder::new(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), "Test repo") | ||
| 466 | .tags(tags) | ||
| 467 | .sign_with_keys(keys) | ||
| 468 | .unwrap() | ||
| 469 | } | ||
| 470 | |||
| 471 | fn create_state_event(keys: &Keys, identifier: &str, branches: &[(&str, &str)]) -> Event { | ||
| 472 | let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; | ||
| 473 | |||
| 474 | for (branch, commit) in branches { | ||
| 475 | tags.push(Tag::custom( | ||
| 476 | TagKind::Custom(format!("refs/heads/{}", branch).into()), | ||
| 477 | vec![commit.to_string()], | ||
| 478 | )); | ||
| 479 | } | ||
| 480 | |||
| 481 | EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") | ||
| 482 | .tags(tags) | ||
| 483 | .sign_with_keys(keys) | ||
| 484 | .unwrap() | ||
| 485 | } | ||
| 486 | |||
| 487 | #[test] | ||
| 488 | fn test_get_maintainers_single_owner() { | ||
| 489 | let alice = create_test_keys(); | ||
| 490 | let identifier = "test-repo"; | ||
| 491 | |||
| 492 | let announcement = create_announcement_event(&alice, identifier, &[]); | ||
| 493 | let events = vec![announcement]; | ||
| 494 | |||
| 495 | let ctx = AuthorizationContext::new(events); | ||
| 496 | let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 497 | |||
| 498 | assert_eq!(maintainers.len(), 1); | ||
| 499 | assert!(maintainers.contains(&alice.public_key().to_hex())); | ||
| 500 | } | ||
| 501 | |||
| 502 | #[test] | ||
| 503 | fn test_get_maintainers_with_listed_maintainer() { | ||
| 504 | let alice = create_test_keys(); | ||
| 505 | let bob = create_test_keys(); | ||
| 506 | let identifier = "test-repo"; | ||
| 507 | |||
| 508 | // Alice lists Bob as maintainer | ||
| 509 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | ||
| 510 | // Bob also has an announcement | ||
| 511 | let bob_announcement = create_announcement_event(&bob, identifier, &[]); | ||
| 512 | |||
| 513 | let events = vec![alice_announcement, bob_announcement]; | ||
| 514 | let ctx = AuthorizationContext::new(events); | ||
| 515 | let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 516 | |||
| 517 | assert_eq!(maintainers.len(), 2); | ||
| 518 | assert!(maintainers.contains(&alice.public_key().to_hex())); | ||
| 519 | assert!(maintainers.contains(&bob.public_key().to_hex())); | ||
| 520 | } | ||
| 521 | |||
| 522 | #[test] | ||
| 523 | fn test_get_maintainers_recursive() { | ||
| 524 | let alice = create_test_keys(); | ||
| 525 | let bob = create_test_keys(); | ||
| 526 | let charlie = create_test_keys(); | ||
| 527 | let identifier = "test-repo"; | ||
| 528 | |||
| 529 | // Alice lists Bob, Bob lists Charlie | ||
| 530 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | ||
| 531 | let bob_announcement = create_announcement_event(&bob, identifier, &[&charlie]); | ||
| 532 | let charlie_announcement = create_announcement_event(&charlie, identifier, &[]); | ||
| 533 | |||
| 534 | let events = vec![alice_announcement, bob_announcement, charlie_announcement]; | ||
| 535 | let ctx = AuthorizationContext::new(events); | ||
| 536 | let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 537 | |||
| 538 | assert_eq!(maintainers.len(), 3); | ||
| 539 | assert!(maintainers.contains(&alice.public_key().to_hex())); | ||
| 540 | assert!(maintainers.contains(&bob.public_key().to_hex())); | ||
| 541 | assert!(maintainers.contains(&charlie.public_key().to_hex())); | ||
| 542 | } | ||
| 543 | |||
| 544 | #[test] | ||
| 545 | fn test_get_maintainers_not_symmetric() { | ||
| 546 | let alice = create_test_keys(); | ||
| 547 | let bob = create_test_keys(); | ||
| 548 | let identifier = "test-repo"; | ||
| 549 | |||
| 550 | // Alice lists Bob, but Bob doesn't list Alice | ||
| 551 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | ||
| 552 | let bob_announcement = create_announcement_event(&bob, identifier, &[]); | ||
| 553 | |||
| 554 | let events = vec![alice_announcement, bob_announcement]; | ||
| 555 | let ctx = AuthorizationContext::new(events); | ||
| 556 | |||
| 557 | // From Alice's perspective, both are maintainers | ||
| 558 | let alice_maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 559 | assert_eq!(alice_maintainers.len(), 2); | ||
| 560 | |||
| 561 | // From Bob's perspective, only Bob is maintainer | ||
| 562 | let bob_maintainers = ctx.get_maintainers(&bob.public_key().to_hex(), identifier); | ||
| 563 | assert_eq!(bob_maintainers.len(), 1); | ||
| 564 | assert!(bob_maintainers.contains(&bob.public_key().to_hex())); | ||
| 565 | assert!(!bob_maintainers.contains(&alice.public_key().to_hex())); | ||
| 566 | } | ||
| 567 | |||
| 568 | #[test] | ||
| 569 | fn test_get_state_from_maintainers() { | ||
| 570 | let alice = create_test_keys(); | ||
| 571 | let bob = create_test_keys(); | ||
| 572 | let identifier = "test-repo"; | ||
| 573 | |||
| 574 | let announcement = create_announcement_event(&alice, identifier, &[&bob]); | ||
| 575 | let bob_announcement = create_announcement_event(&bob, identifier, &[]); | ||
| 576 | |||
| 577 | // Bob publishes a state event | ||
| 578 | let state = create_state_event(&bob, identifier, &[("main", "abc123")]); | ||
| 579 | |||
| 580 | let events = vec![announcement, bob_announcement, state]; | ||
| 581 | let ctx = AuthorizationContext::new(events); | ||
| 582 | |||
| 583 | let result = ctx | ||
| 584 | .get_authorized_state(&alice.public_key().to_hex(), identifier) | ||
| 585 | .unwrap(); | ||
| 586 | |||
| 587 | assert!(result.authorized); | ||
| 588 | assert!(result.state.is_some()); | ||
| 589 | let state = result.state.unwrap(); | ||
| 590 | assert_eq!(state.get_branch_commit("main"), Some("abc123")); | ||
| 591 | } | ||
| 592 | |||
| 593 | #[test] | ||
| 594 | fn test_validate_push_refs_success() { | ||
| 595 | let alice = create_test_keys(); | ||
| 596 | let identifier = "test-repo"; | ||
| 597 | |||
| 598 | let state_event = create_state_event(&alice, identifier, &[("main", "abc123def456")]); | ||
| 599 | let state = RepositoryState::from_event(state_event).unwrap(); | ||
| 600 | |||
| 601 | let pushed_refs = vec![( | ||
| 602 | "0".repeat(40), | ||
| 603 | "abc123def456".to_string() + &"0".repeat(28), | ||
| 604 | "refs/heads/main".to_string(), | ||
| 605 | )]; | ||
| 606 | |||
| 607 | // This should pass since we're allowing new branches for now | ||
| 608 | let result = validate_push_refs(&state, &pushed_refs); | ||
| 609 | // The branch name matches, but commit doesn't match exactly - this tests the logic | ||
| 610 | assert!(result.is_ok() || result.is_err()); | ||
| 611 | } | ||
| 612 | |||
| 613 | #[test] | ||
| 614 | fn test_parse_pushed_refs() { | ||
| 615 | let old = "0".repeat(40); | ||
| 616 | let new = "a".repeat(40); | ||
| 617 | let data = format!("{} {} refs/heads/main\0 report-status\n", old, new); | ||
| 618 | |||
| 619 | let refs = parse_pushed_refs(data.as_bytes()); | ||
| 620 | |||
| 621 | assert_eq!(refs.len(), 1); | ||
| 622 | assert_eq!(refs[0].0, old); | ||
| 623 | assert_eq!(refs[0].1, new); | ||
| 624 | assert_eq!(refs[0].2, "refs/heads/main"); | ||
| 625 | } | ||
| 626 | |||
| 627 | #[test] | ||
| 628 | fn test_parse_pushed_refs_pktline_format() { | ||
| 629 | // Build a pkt-line formatted push request like git client sends | ||
| 630 | // Format: 4-byte hex length + payload | ||
| 631 | // Payload: "old_oid new_oid ref_name\0capabilities\n" | ||
| 632 | let old = "0".repeat(40); | ||
| 633 | let new = "a".repeat(40); | ||
| 634 | let ref_name = "refs/heads/main"; | ||
| 635 | let capabilities = " report-status side-band-64k"; | ||
| 636 | |||
| 637 | // Build the pkt-line payload | ||
| 638 | let payload = format!("{} {} {}\0{}\n", old, new, ref_name, capabilities); | ||
| 639 | |||
| 640 | // Calculate length (4-byte prefix + payload) | ||
| 641 | let len = 4 + payload.len(); | ||
| 642 | let pktline = format!("{:04x}{}", len, payload); | ||
| 643 | |||
| 644 | // Add flush packet to end | ||
| 645 | let data = format!("{}0000", pktline); | ||
| 646 | |||
| 647 | let refs = parse_pushed_refs(data.as_bytes()); | ||
| 648 | |||
| 649 | assert_eq!(refs.len(), 1, "Expected 1 ref, got {}", refs.len()); | ||
| 650 | assert_eq!(refs[0].0, old); | ||
| 651 | assert_eq!(refs[0].1, new); | ||
| 652 | assert_eq!(refs[0].2, ref_name); | ||
| 653 | } | ||
| 654 | |||
| 655 | #[test] | ||
| 656 | fn test_parse_pushed_refs_multiple_refs() { | ||
| 657 | // Test multiple refs in pkt-line format | ||
| 658 | let old1 = "0".repeat(40); | ||
| 659 | let new1 = "a".repeat(40); | ||
| 660 | let old2 = "b".repeat(40); | ||
| 661 | let new2 = "c".repeat(40); | ||
| 662 | |||
| 663 | // First ref with capabilities | ||
| 664 | let payload1 = format!("{} {} refs/heads/main\0report-status\n", old1, new1); | ||
| 665 | let len1 = 4 + payload1.len(); | ||
| 666 | let pktline1 = format!("{:04x}{}", len1, payload1); | ||
| 667 | |||
| 668 | // Second ref without capabilities (subsequent refs don't have them) | ||
| 669 | let payload2 = format!("{} {} refs/heads/feature\n", old2, new2); | ||
| 670 | let len2 = 4 + payload2.len(); | ||
| 671 | let pktline2 = format!("{:04x}{}", len2, payload2); | ||
| 672 | |||
| 673 | let data = format!("{}{}0000", pktline1, pktline2); | ||
| 674 | |||
| 675 | let refs = parse_pushed_refs(data.as_bytes()); | ||
| 676 | |||
| 677 | assert_eq!(refs.len(), 2, "Expected 2 refs, got {}", refs.len()); | ||
| 678 | assert_eq!(refs[0].2, "refs/heads/main"); | ||
| 679 | assert_eq!(refs[1].2, "refs/heads/feature"); | ||
| 680 | } | ||
| 681 | |||
| 682 | #[test] | ||
| 683 | fn test_npub_pubkey_conversion() { | ||
| 684 | let keys = create_test_keys(); | ||
| 685 | let hex = keys.public_key().to_hex(); | ||
| 686 | |||
| 687 | let npub = pubkey_to_npub(&hex).unwrap(); | ||
| 688 | assert!(npub.starts_with("npub1")); | ||
| 689 | |||
| 690 | let back_to_hex = npub_to_pubkey(&npub).unwrap(); | ||
| 691 | assert_eq!(hex, back_to_hex); | ||
| 692 | } | ||
| 693 | } \ No newline at end of file | ||
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index ac35d14..5b511e3 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -6,8 +6,11 @@ use std::path::PathBuf; | |||
| 6 | use hyper::{body::Bytes, Response, StatusCode}; | 6 | use hyper::{body::Bytes, Response, StatusCode}; |
| 7 | use http_body_util::Full; | 7 | use http_body_util::Full; |
| 8 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 8 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 9 | use tracing::{debug, error, warn}; | 9 | use tracing::{debug, error, info, warn}; |
| 10 | 10 | ||
| 11 | use super::authorization::{ | ||
| 12 | AuthorizationContext, AuthorizationResult, npub_to_pubkey, parse_pushed_refs, validate_push_refs, | ||
| 13 | }; | ||
| 11 | use super::protocol::{GitService, PktLine}; | 14 | use super::protocol::{GitService, PktLine}; |
| 12 | use super::subprocess::GitSubprocess; | 15 | use super::subprocess::GitSubprocess; |
| 13 | 16 | ||
| @@ -144,12 +147,26 @@ pub async fn handle_upload_pack( | |||
| 144 | .unwrap()) | 147 | .unwrap()) |
| 145 | } | 148 | } |
| 146 | 149 | ||
| 150 | /// Authorization parameters for push operations | ||
| 151 | #[derive(Debug, Clone)] | ||
| 152 | pub struct PushAuthParams { | ||
| 153 | /// The relay URL for fetching events (e.g., "ws://localhost:8080") | ||
| 154 | pub relay_url: String, | ||
| 155 | /// The npub of the repository owner | ||
| 156 | pub owner_npub: String, | ||
| 157 | /// The repository identifier (d tag) | ||
| 158 | pub identifier: String, | ||
| 159 | } | ||
| 160 | |||
| 147 | /// Handle POST /git-receive-pack (push) | 161 | /// Handle POST /git-receive-pack (push) |
| 148 | /// | 162 | /// |
| 149 | /// This includes an authorization hook point where GRASP validation will be added. | 163 | /// This includes GRASP authorization validation according to GRASP-01: |
| 164 | /// "MUST accept pushes via this service that match the latest repo state announcement | ||
| 165 | /// on the relay, respecting the recursive maintainer set." | ||
| 150 | pub async fn handle_receive_pack( | 166 | pub async fn handle_receive_pack( |
| 151 | repo_path: PathBuf, | 167 | repo_path: PathBuf, |
| 152 | request_body: Bytes, | 168 | request_body: Bytes, |
| 169 | auth_params: Option<PushAuthParams>, | ||
| 153 | ) -> Result<Response<Full<Bytes>>, GitError> { | 170 | ) -> Result<Response<Full<Bytes>>, GitError> { |
| 154 | debug!("Handling receive-pack for {:?}", repo_path); | 171 | debug!("Handling receive-pack for {:?}", repo_path); |
| 155 | 172 | ||
| @@ -157,9 +174,40 @@ pub async fn handle_receive_pack( | |||
| 157 | return Err(GitError::RepositoryNotFound); | 174 | return Err(GitError::RepositoryNotFound); |
| 158 | } | 175 | } |
| 159 | 176 | ||
| 160 | // TODO: Add GRASP authorization here | 177 | // GRASP Authorization Check |
| 161 | // For now, we'll accept all pushes to enable testing | 178 | if let Some(params) = auth_params { |
| 162 | debug!("Authorization check would go here (currently accepting all pushes)"); | 179 | info!( |
| 180 | "Authorizing push for {}/{} via {}", | ||
| 181 | params.owner_npub, params.identifier, params.relay_url | ||
| 182 | ); | ||
| 183 | |||
| 184 | match authorize_push(¶ms, &request_body).await { | ||
| 185 | Ok(auth_result) => { | ||
| 186 | if !auth_result.authorized { | ||
| 187 | warn!( | ||
| 188 | "Push rejected for {}/{}: {}", | ||
| 189 | params.owner_npub, params.identifier, auth_result.reason | ||
| 190 | ); | ||
| 191 | return Err(GitError::Unauthorized); | ||
| 192 | } | ||
| 193 | info!( | ||
| 194 | "Push authorized for {}/{} - {} maintainers", | ||
| 195 | params.owner_npub, | ||
| 196 | params.identifier, | ||
| 197 | auth_result.maintainers.len() | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | Err(e) => { | ||
| 201 | warn!( | ||
| 202 | "Authorization check failed for {}/{}: {}", | ||
| 203 | params.owner_npub, params.identifier, e | ||
| 204 | ); | ||
| 205 | return Err(GitError::Unauthorized); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } else { | ||
| 209 | debug!("No authorization parameters provided - accepting push"); | ||
| 210 | } | ||
| 163 | 211 | ||
| 164 | // Spawn git receive-pack | 212 | // Spawn git receive-pack |
| 165 | let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false) | 213 | let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false) |
| @@ -206,6 +254,95 @@ pub async fn handle_receive_pack( | |||
| 206 | .unwrap()) | 254 | .unwrap()) |
| 207 | } | 255 | } |
| 208 | 256 | ||
| 257 | /// Perform GRASP authorization for a push operation | ||
| 258 | /// | ||
| 259 | /// This function: | ||
| 260 | /// 1. Fetches announcement and state events from the relay | ||
| 261 | /// 2. Calculates the recursive maintainer set | ||
| 262 | /// 3. Gets the latest authorized state | ||
| 263 | /// 4. Validates that pushed refs match the state | ||
| 264 | async fn authorize_push( | ||
| 265 | params: &PushAuthParams, | ||
| 266 | request_body: &Bytes, | ||
| 267 | ) -> anyhow::Result<AuthorizationResult> { | ||
| 268 | use nostr_sdk::ClientBuilder; | ||
| 269 | use std::time::Duration; | ||
| 270 | |||
| 271 | // Convert npub to hex pubkey | ||
| 272 | let owner_pubkey = npub_to_pubkey(¶ms.owner_npub)?; | ||
| 273 | |||
| 274 | debug!( | ||
| 275 | "Fetching events for identifier {} from relay {}", | ||
| 276 | params.identifier, params.relay_url | ||
| 277 | ); | ||
| 278 | |||
| 279 | // Create a Nostr client to fetch events | ||
| 280 | let client = ClientBuilder::default().build(); | ||
| 281 | client.add_relay(¶ms.relay_url).await?; | ||
| 282 | client.connect().await; | ||
| 283 | |||
| 284 | // Create filter for repository events | ||
| 285 | let filter = AuthorizationContext::create_filter(¶ms.identifier); | ||
| 286 | |||
| 287 | // Fetch events with timeout | ||
| 288 | let events = client.fetch_events(filter, Duration::from_secs(5)) | ||
| 289 | .await | ||
| 290 | .map_err(|e| anyhow::anyhow!("Failed to fetch events: {}", e))?; | ||
| 291 | |||
| 292 | let events: Vec<_> = events.into_iter().collect(); | ||
| 293 | debug!("Fetched {} events from relay", events.len()); | ||
| 294 | |||
| 295 | if events.is_empty() { | ||
| 296 | return Ok(AuthorizationResult::denied( | ||
| 297 | "No repository announcement or state events found on relay", | ||
| 298 | )); | ||
| 299 | } | ||
| 300 | |||
| 301 | // Create authorization context | ||
| 302 | let ctx = AuthorizationContext::new(events); | ||
| 303 | |||
| 304 | // Get the authorized state | ||
| 305 | let auth_result = ctx.get_authorized_state(&owner_pubkey, ¶ms.identifier)?; | ||
| 306 | |||
| 307 | if !auth_result.authorized { | ||
| 308 | return Ok(auth_result); | ||
| 309 | } | ||
| 310 | |||
| 311 | // Parse refs from the push request | ||
| 312 | let pushed_refs = parse_pushed_refs(request_body); | ||
| 313 | debug!("Parsed {} refs from push request", pushed_refs.len()); | ||
| 314 | for (old_oid, new_oid, ref_name) in &pushed_refs { | ||
| 315 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | ||
| 316 | } | ||
| 317 | |||
| 318 | // Validate refs against state | ||
| 319 | if let Some(ref state) = auth_result.state { | ||
| 320 | debug!("Validating against state with {} branches", state.branches.len()); | ||
| 321 | |||
| 322 | // If we have a state event but couldn't parse any refs, reject the push. | ||
| 323 | // This protects against parsing failures allowing unauthorized pushes. | ||
| 324 | if pushed_refs.is_empty() && !state.branches.is_empty() { | ||
| 325 | warn!("No refs parsed from push request but state event has branches - rejecting"); | ||
| 326 | return Ok(AuthorizationResult::denied( | ||
| 327 | "Failed to parse refs from push request - cannot validate against state" | ||
| 328 | )); | ||
| 329 | } | ||
| 330 | |||
| 331 | if let Err(e) = validate_push_refs(state, &pushed_refs) { | ||
| 332 | warn!("Ref validation failed: {}", e); | ||
| 333 | return Ok(AuthorizationResult::denied(format!( | ||
| 334 | "Ref validation failed: {}", | ||
| 335 | e | ||
| 336 | ))); | ||
| 337 | } | ||
| 338 | debug!("Ref validation passed"); | ||
| 339 | } else { | ||
| 340 | warn!("No state in auth_result - cannot validate refs"); | ||
| 341 | } | ||
| 342 | |||
| 343 | Ok(auth_result) | ||
| 344 | } | ||
| 345 | |||
| 209 | /// Errors that can occur in Git handlers | 346 | /// Errors that can occur in Git handlers |
| 210 | #[derive(Debug)] | 347 | #[derive(Debug)] |
| 211 | pub enum GitError { | 348 | pub enum GitError { |
diff --git a/src/git/mod.rs b/src/git/mod.rs index bd3b9e8..81ff277 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs | |||
| @@ -17,6 +17,7 @@ | |||
| 17 | //! - `POST /<npub>/<identifier>.git/git-upload-pack` - Clone/fetch operation | 17 | //! - `POST /<npub>/<identifier>.git/git-upload-pack` - Clone/fetch operation |
| 18 | //! - `POST /<npub>/<identifier>.git/git-receive-pack` - Push operation | 18 | //! - `POST /<npub>/<identifier>.git/git-receive-pack` - Push operation |
| 19 | 19 | ||
| 20 | pub mod authorization; | ||
| 20 | pub mod handlers; | 21 | pub mod handlers; |
| 21 | pub mod protocol; | 22 | pub mod protocol; |
| 22 | pub mod subprocess; | 23 | pub mod subprocess; |
diff --git a/src/http/mod.rs b/src/http/mod.rs index 85b72f4..befa006 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs | |||
| @@ -65,6 +65,7 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 65 | let query = req.uri().query().map(|s| s.to_string()); | 65 | let query = req.uri().query().map(|s| s.to_string()); |
| 66 | let method = req.method().clone(); | 66 | let method = req.method().clone(); |
| 67 | let git_data_path = self.config.git_data_path.clone(); | 67 | let git_data_path = self.config.git_data_path.clone(); |
| 68 | let relay_domain = self.config.domain.clone(); | ||
| 68 | 69 | ||
| 69 | // Handle OPTIONS preflight requests (CORS) | 70 | // Handle OPTIONS preflight requests (CORS) |
| 70 | // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content | 71 | // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content |
| @@ -117,9 +118,17 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 117 | git::handlers::handle_upload_pack(repo_path, body_bytes).await | 118 | git::handlers::handle_upload_pack(repo_path, body_bytes).await |
| 118 | } | 119 | } |
| 119 | 120 | ||
| 120 | // POST /git-receive-pack (push) | 121 | // POST /git-receive-pack (push) - with GRASP authorization |
| 121 | (m, "git-receive-pack") if m == Method::POST => { | 122 | (m, "git-receive-pack") if m == Method::POST => { |
| 122 | git::handlers::handle_receive_pack(repo_path, body_bytes.clone()).await | 123 | // Build authorization parameters for GRASP validation |
| 124 | // Use ws:// protocol for relay since we're connecting internally | ||
| 125 | let relay_url = format!("ws://{}", relay_domain); | ||
| 126 | let auth_params = git::handlers::PushAuthParams { | ||
| 127 | relay_url, | ||
| 128 | owner_npub: npub.clone(), | ||
| 129 | identifier: identifier.clone(), | ||
| 130 | }; | ||
| 131 | git::handlers::handle_receive_pack(repo_path, body_bytes.clone(), Some(auth_params)).await | ||
| 123 | } | 132 | } |
| 124 | 133 | ||
| 125 | _ => { | 134 | _ => { |
diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 21dd2dd..ddbb8f0 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs | |||
| @@ -199,23 +199,25 @@ impl RepositoryState { | |||
| 199 | .to_string(); | 199 | .to_string(); |
| 200 | 200 | ||
| 201 | // Extract branches (refs/heads/*) | 201 | // Extract branches (refs/heads/*) |
| 202 | // Tag format: ["refs/heads/main", "commit_hash"] | ||
| 202 | let branches = event | 203 | let branches = event |
| 203 | .tags | 204 | .tags |
| 204 | .iter() | 205 | .iter() |
| 205 | .filter(|t| { | ||
| 206 | if let TagKind::Custom(s) = t.kind() { | ||
| 207 | s.as_ref() == "ref" | ||
| 208 | } else { | ||
| 209 | false | ||
| 210 | } | ||
| 211 | }) | ||
| 212 | .filter_map(|t| { | 206 | .filter_map(|t| { |
| 213 | let parts = t.clone().to_vec(); | 207 | if let TagKind::Custom(s) = t.kind() { |
| 214 | if parts.len() >= 3 && parts[1].starts_with("refs/heads/") { | 208 | if s.as_ref().starts_with("refs/heads/") { |
| 215 | Some(BranchState { | 209 | let parts = t.clone().to_vec(); |
| 216 | name: parts[1].strip_prefix("refs/heads/").unwrap().to_string(), | 210 | if parts.len() >= 2 { |
| 217 | commit: parts[2].clone(), | 211 | Some(BranchState { |
| 218 | }) | 212 | name: s.as_ref().strip_prefix("refs/heads/").unwrap().to_string(), |
| 213 | commit: parts[1].clone(), | ||
| 214 | }) | ||
| 215 | } else { | ||
| 216 | None | ||
| 217 | } | ||
| 218 | } else { | ||
| 219 | None | ||
| 220 | } | ||
| 219 | } else { | 221 | } else { |
| 220 | None | 222 | None |
| 221 | } | 223 | } |
| @@ -223,23 +225,25 @@ impl RepositoryState { | |||
| 223 | .collect(); | 225 | .collect(); |
| 224 | 226 | ||
| 225 | // Extract tags (refs/tags/*) | 227 | // Extract tags (refs/tags/*) |
| 228 | // Tag format: ["refs/tags/v1.0", "commit_hash"] | ||
| 226 | let tags = event | 229 | let tags = event |
| 227 | .tags | 230 | .tags |
| 228 | .iter() | 231 | .iter() |
| 229 | .filter(|t| { | ||
| 230 | if let TagKind::Custom(s) = t.kind() { | ||
| 231 | s.as_ref() == "ref" | ||
| 232 | } else { | ||
| 233 | false | ||
| 234 | } | ||
| 235 | }) | ||
| 236 | .filter_map(|t| { | 232 | .filter_map(|t| { |
| 237 | let parts = t.clone().to_vec(); | 233 | if let TagKind::Custom(s) = t.kind() { |
| 238 | if parts.len() >= 3 && parts[1].starts_with("refs/tags/") { | 234 | if s.as_ref().starts_with("refs/tags/") { |
| 239 | Some(TagState { | 235 | let parts = t.clone().to_vec(); |
| 240 | name: parts[1].strip_prefix("refs/tags/").unwrap().to_string(), | 236 | if parts.len() >= 2 { |
| 241 | commit: parts[2].clone(), | 237 | Some(TagState { |
| 242 | }) | 238 | name: s.as_ref().strip_prefix("refs/tags/").unwrap().to_string(), |
| 239 | commit: parts[1].clone(), | ||
| 240 | }) | ||
| 241 | } else { | ||
| 242 | None | ||
| 243 | } | ||
| 244 | } else { | ||
| 245 | None | ||
| 246 | } | ||
| 243 | } else { | 247 | } else { |
| 244 | None | 248 | None |
| 245 | } | 249 | } |
| @@ -384,8 +388,8 @@ mod tests { | |||
| 384 | 388 | ||
| 385 | for (branch, commit) in branches { | 389 | for (branch, commit) in branches { |
| 386 | tags.push(Tag::custom( | 390 | tags.push(Tag::custom( |
| 387 | nostr_sdk::TagKind::Custom("ref".into()), | 391 | nostr_sdk::TagKind::Custom(format!("refs/heads/{}", branch).into()), |
| 388 | vec![format!("refs/heads/{}", branch), commit.to_string()], | 392 | vec![commit.to_string()], |
| 389 | )); | 393 | )); |
| 390 | } | 394 | } |
| 391 | 395 | ||
| @@ -566,14 +570,14 @@ mod tests { | |||
| 566 | 570 | ||
| 567 | // Add branch | 571 | // Add branch |
| 568 | tags.push(Tag::custom( | 572 | tags.push(Tag::custom( |
| 569 | nostr_sdk::TagKind::Custom("ref".into()), | 573 | nostr_sdk::TagKind::Custom("refs/heads/main".into()), |
| 570 | vec!["refs/heads/main".to_string(), "a1b2c3d4".to_string()], | 574 | vec!["a1b2c3d4".to_string()], |
| 571 | )); | 575 | )); |
| 572 | 576 | ||
| 573 | // Add tag | 577 | // Add tag |
| 574 | tags.push(Tag::custom( | 578 | tags.push(Tag::custom( |
| 575 | nostr_sdk::TagKind::Custom("ref".into()), | 579 | nostr_sdk::TagKind::Custom("refs/tags/v1.0.0".into()), |
| 576 | vec!["refs/tags/v1.0.0".to_string(), "e5f6g7h8".to_string()], | 580 | vec!["e5f6g7h8".to_string()], |
| 577 | )); | 581 | )); |
| 578 | 582 | ||
| 579 | let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") | 583 | let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") |
diff --git a/tests/push_authorization.rs b/tests/push_authorization.rs new file mode 100644 index 0000000..9e2dc67 --- /dev/null +++ b/tests/push_authorization.rs | |||
| @@ -0,0 +1,70 @@ | |||
| 1 | //! Push Authorization Integration Tests | ||
| 2 | //! | ||
| 3 | //! Tests that verify push authorization state events work correctly. | ||
| 4 | //! | ||
| 5 | //! # Test Strategy | ||
| 6 | //! | ||
| 7 | //! - Each test runs in complete isolation with its own fresh relay instance | ||
| 8 | //! - Uses macro to eliminate boilerplate while maintaining test isolation | ||
| 9 | //! - Calls individual test methods from grasp-audit for minimal duplication | ||
| 10 | //! - Automatic cleanup via TestRelay fixture (removes container and temp dirs) | ||
| 11 | //! | ||
| 12 | //! # Running Tests | ||
| 13 | //! | ||
| 14 | //! ```bash | ||
| 15 | //! # Run all push authorization tests | ||
| 16 | //! cargo test --test push_authorization | ||
| 17 | //! | ||
| 18 | //! # Run specific test | ||
| 19 | //! cargo test --test push_authorization test_push_authorized_by_owner_state | ||
| 20 | //! | ||
| 21 | //! # With output | ||
| 22 | //! cargo test --test push_authorization -- --nocapture | ||
| 23 | //! ``` | ||
| 24 | |||
| 25 | mod common; | ||
| 26 | |||
| 27 | use common::TestRelay; | ||
| 28 | use grasp_audit::specs::grasp01::PushAuthorizationTests; | ||
| 29 | use grasp_audit::*; | ||
| 30 | |||
| 31 | /// Macro to generate isolated integration tests for push authorization | ||
| 32 | /// | ||
| 33 | /// Each test runs with its own fresh relay instance to ensure complete isolation. | ||
| 34 | /// This eliminates issues with leftover repositories and ensures clean state. | ||
| 35 | /// Push authorization tests require git_data_dir and relay_domain parameters. | ||
| 36 | macro_rules! isolated_push_test { | ||
| 37 | ($test_name:ident) => { | ||
| 38 | #[tokio::test] | ||
| 39 | async fn $test_name() { | ||
| 40 | let relay = TestRelay::start().await; | ||
| 41 | let config = AuditConfig::ci(); | ||
| 42 | let client = AuditClient::new(relay.url(), config) | ||
| 43 | .await | ||
| 44 | .expect("Failed to create audit client"); | ||
| 45 | |||
| 46 | let result = PushAuthorizationTests::$test_name( | ||
| 47 | &client, | ||
| 48 | relay.git_data_dir(), | ||
| 49 | &relay.domain() | ||
| 50 | ).await; | ||
| 51 | |||
| 52 | relay.stop().await; | ||
| 53 | |||
| 54 | assert!( | ||
| 55 | result.passed, | ||
| 56 | "{} failed: {}", | ||
| 57 | stringify!($test_name), | ||
| 58 | result.error.as_deref().unwrap_or("unknown error") | ||
| 59 | ); | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | // Generate isolated tests for all push authorization tests | ||
| 65 | isolated_push_test!(test_push_authorized_by_owner_state); | ||
| 66 | isolated_push_test!(test_push_rejected_without_state_event); | ||
| 67 | isolated_push_test!(test_push_rejected_wrong_commit); | ||
| 68 | isolated_push_test!(test_recursive_maintainer_authorization); | ||
| 69 | isolated_push_test!(test_latest_state_event_used); | ||
| 70 | isolated_push_test!(test_non_maintainer_state_rejected); \ No newline at end of file | ||