diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-27 15:38:42 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-27 15:38:42 +0000 |
| commit | 6a77173127b5915c4c1b9219924e793795e0d051 (patch) | |
| tree | 9e7f3d4d023920c5a9cb3a24a871e05abcc1d3b7 /src/git | |
| parent | 09025b8435f673779ce109e2fb72ce48a13bf28e (diff) | |
refactor(auth): simplify state auth with single-query approach
Diffstat (limited to 'src/git')
| -rw-r--r-- | src/git/authorization.rs | 411 | ||||
| -rw-r--r-- | src/git/handlers.rs | 11 |
2 files changed, 207 insertions, 215 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs index e9f59c7..16498c1 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs | |||
| @@ -5,17 +5,24 @@ | |||
| 5 | //! ## GRASP-01 Requirement | 5 | //! ## GRASP-01 Requirement |
| 6 | //! | 6 | //! |
| 7 | //! "MUST accept pushes via this service that match the latest repo state announcement | 7 | //! "MUST accept pushes via this service that match the latest repo state announcement |
| 8 | //! on the relay, respecting the recursive maintainer set." | 8 | //! on the relay, respecting the maintainer set." |
| 9 | //! | 9 | //! |
| 10 | //! ## Authorization Flow | 10 | //! ## Authorization Flow (Efficient Single-Query Approach) |
| 11 | //! | 11 | //! |
| 12 | //! 1. Fetch announcement and state events for the repository from the relay | 12 | //! 1. Fetch announcement and state events for the repository from the relay |
| 13 | //! 2. Calculate the recursive maintainer set (owner + listed maintainers recursively) | 13 | //! 2. Collect all authorized publishers: announcement authors + listed maintainers |
| 14 | //! 3. Find the latest state event authored by any maintainer | 14 | //! 3. Find the latest state event authored by any authorized publisher |
| 15 | //! 4. Validate that the pushed refs match the state event | 15 | //! 4. Validate that the pushed refs match the state event |
| 16 | //! | ||
| 17 | //! ## Authorization Logic | ||
| 18 | //! | ||
| 19 | //! A pubkey is authorized to publish state events if, for ANY announcement with the | ||
| 20 | //! same identifier: | ||
| 21 | //! - They are the author of that announcement, OR | ||
| 22 | //! - They are listed in the "maintainers" tag of that announcement | ||
| 16 | 23 | ||
| 17 | use anyhow::{anyhow, Result}; | 24 | use anyhow::{anyhow, Result}; |
| 18 | use nostr_sdk::{Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32, Alphabet}; | 25 | use nostr_sdk::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32}; |
| 19 | use std::collections::HashSet; | 26 | use std::collections::HashSet; |
| 20 | use tracing::debug; | 27 | use tracing::debug; |
| 21 | 28 | ||
| @@ -32,7 +39,7 @@ pub struct AuthorizationResult { | |||
| 32 | pub reason: String, | 39 | pub reason: String, |
| 33 | /// The authorized state if available | 40 | /// The authorized state if available |
| 34 | pub state: Option<RepositoryState>, | 41 | pub state: Option<RepositoryState>, |
| 35 | /// The set of valid maintainers | 42 | /// The set of valid maintainers (authorized publishers) |
| 36 | pub maintainers: Vec<String>, | 43 | pub maintainers: Vec<String>, |
| 37 | } | 44 | } |
| 38 | 45 | ||
| @@ -79,180 +86,148 @@ impl AuthorizationContext { | |||
| 79 | Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), | 86 | Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), |
| 80 | Kind::from(KIND_REPOSITORY_STATE), | 87 | Kind::from(KIND_REPOSITORY_STATE), |
| 81 | ]) | 88 | ]) |
| 82 | .custom_tag(SingleLetterTag::lowercase(Alphabet::D), identifier.to_string()) | 89 | .custom_tag( |
| 90 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 91 | identifier.to_string(), | ||
| 92 | ) | ||
| 83 | } | 93 | } |
| 84 | 94 | ||
| 85 | /// Get the latest authorized state for a repository | 95 | /// Get the latest authorized state for a repository |
| 86 | /// | 96 | /// |
| 87 | /// This implements the GRASP-01 requirement: | 97 | /// This implements the GRASP-01 requirement using an efficient single-query approach: |
| 88 | /// "respecting the recursive maintainer set" | 98 | /// - Collect all authorized publishers from announcements |
| 89 | pub fn get_authorized_state( | 99 | /// - Find the latest state event from any authorized publisher |
| 90 | &self, | 100 | /// |
| 91 | owner_pubkey: &str, | 101 | /// No owner_pubkey needed - authorization is determined by announcements themselves. |
| 92 | identifier: &str, | 102 | pub fn get_authorized_state(&self, identifier: &str) -> Result<AuthorizationResult> { |
| 93 | ) -> Result<AuthorizationResult> { | 103 | // Collect all authorized publishers (single pass through announcements) |
| 94 | // Calculate recursive maintainer set | 104 | let authorized_publishers = self.get_authorized_publishers(identifier); |
| 95 | let maintainers = self.get_maintainers(owner_pubkey, identifier); | 105 | |
| 96 | 106 | if authorized_publishers.is_empty() { | |
| 97 | if maintainers.is_empty() { | ||
| 98 | return Ok(AuthorizationResult::denied( | 107 | return Ok(AuthorizationResult::denied( |
| 99 | "No repository announcement found for owner", | 108 | "No repository announcement found", |
| 100 | )); | 109 | )); |
| 101 | } | 110 | } |
| 102 | 111 | ||
| 103 | debug!( | 112 | debug!( |
| 104 | "Found {} maintainers for repository {}: {:?}", | 113 | "Found {} authorized publishers for repository {}: {:?}", |
| 105 | maintainers.len(), | 114 | authorized_publishers.len(), |
| 106 | identifier, | 115 | identifier, |
| 107 | maintainers | 116 | authorized_publishers |
| 108 | ); | 117 | ); |
| 109 | 118 | ||
| 110 | // Get the latest state event from any maintainer | 119 | // Find the latest state event from any authorized publisher |
| 111 | match self.get_state_from_maintainers(&maintainers, identifier) { | 120 | let mut latest_state: Option<RepositoryState> = None; |
| 112 | Some(state) => Ok(AuthorizationResult::authorized(state, maintainers)), | 121 | let mut latest_timestamp = Timestamp::from(0); |
| 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 visited: HashSet<String> = HashSet::new(); | ||
| 132 | let mut maintainers: HashSet<String> = HashSet::new(); | ||
| 133 | self.get_maintainers_recursive(pubkey, identifier, &mut visited, &mut maintainers); | ||
| 134 | |||
| 135 | maintainers.into_iter().collect() | ||
| 136 | } | ||
| 137 | |||
| 138 | /// Recursive helper for get_maintainers | ||
| 139 | /// | ||
| 140 | /// The key insight is that a pubkey is a valid maintainer if: | ||
| 141 | /// 1. They have their own accepted announcement for this repo, OR | ||
| 142 | /// 2. They are listed in the "maintainers" tag of an accepted announcement | ||
| 143 | /// | ||
| 144 | /// This allows maintainers to publish state events without needing their own | ||
| 145 | /// announcement - they're authorized by being listed in the owner's announcement. | ||
| 146 | /// | ||
| 147 | /// We use separate sets: | ||
| 148 | /// - `visited`: Tracks which pubkeys we've already processed (cycle prevention) | ||
| 149 | /// - `maintainers`: The result set of valid maintainers | ||
| 150 | fn get_maintainers_recursive( | ||
| 151 | &self, | ||
| 152 | pubkey: &str, | ||
| 153 | identifier: &str, | ||
| 154 | visited: &mut HashSet<String>, | ||
| 155 | maintainers: &mut HashSet<String>, | ||
| 156 | ) { | ||
| 157 | // Skip if already visited (prevents infinite loops) | ||
| 158 | if visited.contains(pubkey) { | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | visited.insert(pubkey.to_string()); | ||
| 162 | 122 | ||
| 163 | // Find the announcement event for this pubkey | 123 | for event in &self.events { |
| 164 | let announcement = self.find_announcement_by_pubkey(pubkey, identifier); | 124 | // Check if it's a repository state event |
| 125 | if event.kind != Kind::from(KIND_REPOSITORY_STATE) { | ||
| 126 | continue; | ||
| 127 | } | ||
| 165 | 128 | ||
| 166 | if let Some(announcement) = announcement { | 129 | // Check if from an authorized publisher |
| 167 | // This pubkey has an announcement - they are a valid maintainer | 130 | let pubkey_hex = event.pubkey.to_hex(); |
| 168 | maintainers.insert(pubkey.to_string()); | 131 | if !authorized_publishers.contains(&pubkey_hex) { |
| 132 | debug!( | ||
| 133 | "Skipping state event from unauthorized publisher: {}", | ||
| 134 | pubkey_hex | ||
| 135 | ); | ||
| 136 | continue; | ||
| 137 | } | ||
| 169 | 138 | ||
| 170 | // Get maintainers listed in this announcement (maintainers tag) | 139 | // Try to parse the state |
| 171 | // These are ALSO valid maintainers, even without their own announcement | 140 | if let Ok(state) = RepositoryState::from_event(event.clone()) { |
| 172 | for maintainer_pubkey in &announcement.maintainers { | 141 | // Check identifier matches |
| 173 | // Add them to the maintainer set immediately - they're authorized | 142 | if state.identifier != identifier { |
| 174 | // by being listed in an accepted announcement | 143 | continue; |
| 175 | maintainers.insert(maintainer_pubkey.clone()); | 144 | } |
| 176 | 145 | ||
| 177 | // Recursively check if they have their own announcement | 146 | // Check if this is the latest |
| 178 | // to get any maintainers THEY list (recursive maintainer chain) | 147 | if event.created_at > latest_timestamp { |
| 179 | self.get_maintainers_recursive(maintainer_pubkey, identifier, visited, maintainers); | 148 | latest_timestamp = event.created_at; |
| 149 | latest_state = Some(state); | ||
| 150 | } | ||
| 180 | } | 151 | } |
| 181 | } | 152 | } |
| 182 | // If no announcement found, they can still be valid if they were | 153 | |
| 183 | // added to maintainers by their parent caller | 154 | match latest_state { |
| 155 | Some(state) => Ok(AuthorizationResult::authorized( | ||
| 156 | state, | ||
| 157 | authorized_publishers.into_iter().collect(), | ||
| 158 | )), | ||
| 159 | None => Ok(AuthorizationResult::denied( | ||
| 160 | "No state event found from authorized publishers", | ||
| 161 | )), | ||
| 162 | } | ||
| 184 | } | 163 | } |
| 185 | 164 | ||
| 186 | /// Find a repository announcement event by pubkey and identifier | 165 | /// Get all pubkeys authorized to publish state for an identifier |
| 187 | fn find_announcement_by_pubkey( | 166 | /// |
| 188 | &self, | 167 | /// A pubkey is authorized if for ANY announcement with the same identifier: |
| 189 | pubkey: &str, | 168 | /// - They are the author of that announcement, OR |
| 190 | identifier: &str, | 169 | /// - They are listed in the "maintainers" tag of that announcement |
| 191 | ) -> Option<RepositoryAnnouncement> { | 170 | /// |
| 171 | /// This is a simple O(n) single pass - no recursion needed. | ||
| 172 | fn get_authorized_publishers(&self, identifier: &str) -> HashSet<String> { | ||
| 173 | let mut authorized = HashSet::new(); | ||
| 174 | |||
| 192 | for event in &self.events { | 175 | for event in &self.events { |
| 193 | // Check if it's a repository announcement | 176 | // Only look at announcements |
| 194 | if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { | 177 | if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { |
| 195 | continue; | 178 | continue; |
| 196 | } | 179 | } |
| 197 | 180 | ||
| 198 | // Check if pubkey matches | ||
| 199 | if event.pubkey.to_hex() != pubkey { | ||
| 200 | continue; | ||
| 201 | } | ||
| 202 | |||
| 203 | // Try to parse and check identifier | 181 | // Try to parse and check identifier |
| 204 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | 182 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { |
| 205 | if announcement.identifier == identifier { | 183 | if announcement.identifier != identifier { |
| 206 | return Some(announcement); | 184 | continue; |
| 185 | } | ||
| 186 | |||
| 187 | // Announcement author is authorized | ||
| 188 | authorized.insert(event.pubkey.to_hex()); | ||
| 189 | |||
| 190 | // All listed maintainers are also authorized | ||
| 191 | for maintainer in &announcement.maintainers { | ||
| 192 | authorized.insert(maintainer.clone()); | ||
| 207 | } | 193 | } |
| 208 | } | 194 | } |
| 209 | } | 195 | } |
| 210 | None | 196 | |
| 197 | authorized | ||
| 211 | } | 198 | } |
| 212 | 199 | ||
| 213 | /// Get the latest state event from any of the provided maintainers | 200 | /// Check if a specific pubkey is authorized to publish state for an identifier |
| 214 | /// | 201 | /// |
| 215 | /// This implements the reference's GetStateFromMaintainers logic: | 202 | /// A pubkey is authorized if for ANY announcement with the same identifier: |
| 216 | /// - Find all state events from maintainers | 203 | /// - They are the author of that announcement, OR |
| 217 | /// - Return the one with the latest timestamp | 204 | /// - They are listed in the "maintainers" tag of that announcement |
| 218 | fn get_state_from_maintainers( | 205 | #[allow(dead_code)] |
| 219 | &self, | 206 | pub fn is_state_authorized(&self, state_pubkey: &str, identifier: &str) -> bool { |
| 220 | maintainers: &[String], | ||
| 221 | identifier: &str, | ||
| 222 | ) -> Option<RepositoryState> { | ||
| 223 | let maintainer_set: HashSet<&str> = maintainers.iter().map(|s| s.as_str()).collect(); | ||
| 224 | |||
| 225 | let mut latest_state: Option<RepositoryState> = None; | ||
| 226 | let mut latest_timestamp = Timestamp::from(0); | ||
| 227 | |||
| 228 | for event in &self.events { | 207 | for event in &self.events { |
| 229 | // Check if it's a repository state event | 208 | // Only look at announcements |
| 230 | if event.kind != Kind::from(KIND_REPOSITORY_STATE) { | 209 | if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { |
| 231 | continue; | ||
| 232 | } | ||
| 233 | |||
| 234 | // Check if from a maintainer | ||
| 235 | let pubkey_hex = event.pubkey.to_hex(); | ||
| 236 | if !maintainer_set.contains(pubkey_hex.as_str()) { | ||
| 237 | continue; | 210 | continue; |
| 238 | } | 211 | } |
| 239 | 212 | ||
| 240 | // Try to parse the state | 213 | // Try to parse and check identifier |
| 241 | if let Ok(state) = RepositoryState::from_event(event.clone()) { | 214 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { |
| 242 | // Check identifier matches | 215 | if announcement.identifier != identifier { |
| 243 | if state.identifier != identifier { | ||
| 244 | continue; | 216 | continue; |
| 245 | } | 217 | } |
| 246 | 218 | ||
| 247 | // Check if this is the latest | 219 | // Check 1: Is state author the announcement author? |
| 248 | if event.created_at > latest_timestamp { | 220 | if event.pubkey.to_hex() == state_pubkey { |
| 249 | latest_timestamp = event.created_at; | 221 | return true; |
| 250 | latest_state = Some(state); | 222 | } |
| 223 | |||
| 224 | // Check 2: Is state author in this announcement's maintainers? | ||
| 225 | if announcement.maintainers.contains(&state_pubkey.to_string()) { | ||
| 226 | return true; | ||
| 251 | } | 227 | } |
| 252 | } | 228 | } |
| 253 | } | 229 | } |
| 254 | 230 | false | |
| 255 | latest_state | ||
| 256 | } | 231 | } |
| 257 | } | 232 | } |
| 258 | 233 | ||
| @@ -282,7 +257,10 @@ pub fn validate_push_refs( | |||
| 282 | )); | 257 | )); |
| 283 | } | 258 | } |
| 284 | // Commit matches state - authorized | 259 | // Commit matches state - authorized |
| 285 | debug!("Branch {} push authorized: {} matches state", branch_name, new_oid); | 260 | debug!( |
| 261 | "Branch {} push authorized: {} matches state", | ||
| 262 | branch_name, new_oid | ||
| 263 | ); | ||
| 286 | } else { | 264 | } else { |
| 287 | // Branch not in state - REJECT (GRASP-01 requirement) | 265 | // Branch not in state - REJECT (GRASP-01 requirement) |
| 288 | return Err(anyhow!( | 266 | return Err(anyhow!( |
| @@ -340,7 +318,7 @@ pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> { | |||
| 340 | } | 318 | } |
| 341 | } | 319 | } |
| 342 | } | 320 | } |
| 343 | 321 | ||
| 344 | // Fall back to simple text format (for tests) | 322 | // Fall back to simple text format (for tests) |
| 345 | parse_text_refs(data) | 323 | parse_text_refs(data) |
| 346 | } | 324 | } |
| @@ -348,40 +326,40 @@ pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> { | |||
| 348 | /// Parse refs from pkt-line format data | 326 | /// Parse refs from pkt-line format data |
| 349 | fn parse_pktline_refs(mut data: &[u8]) -> Vec<(String, String, String)> { | 327 | fn parse_pktline_refs(mut data: &[u8]) -> Vec<(String, String, String)> { |
| 350 | let mut refs = Vec::new(); | 328 | let mut refs = Vec::new(); |
| 351 | 329 | ||
| 352 | while data.len() >= 4 { | 330 | while data.len() >= 4 { |
| 353 | // Parse pkt-line length prefix | 331 | // Parse pkt-line length prefix |
| 354 | let len_str = match std::str::from_utf8(&data[0..4]) { | 332 | let len_str = match std::str::from_utf8(&data[0..4]) { |
| 355 | Ok(s) => s, | 333 | Ok(s) => s, |
| 356 | Err(_) => break, | 334 | Err(_) => break, |
| 357 | }; | 335 | }; |
| 358 | 336 | ||
| 359 | let len = match u16::from_str_radix(len_str, 16) { | 337 | let len = match u16::from_str_radix(len_str, 16) { |
| 360 | Ok(l) => l as usize, | 338 | Ok(l) => l as usize, |
| 361 | Err(_) => break, | 339 | Err(_) => break, |
| 362 | }; | 340 | }; |
| 363 | 341 | ||
| 364 | // Flush packet (0000) ends the ref list | 342 | // Flush packet (0000) ends the ref list |
| 365 | if len == 0 { | 343 | if len == 0 { |
| 366 | break; | 344 | break; |
| 367 | } | 345 | } |
| 368 | 346 | ||
| 369 | if len < 4 || data.len() < len { | 347 | if len < 4 || data.len() < len { |
| 370 | break; | 348 | break; |
| 371 | } | 349 | } |
| 372 | 350 | ||
| 373 | // Extract payload (without the 4-byte length prefix) | 351 | // Extract payload (without the 4-byte length prefix) |
| 374 | let payload = &data[4..len]; | 352 | let payload = &data[4..len]; |
| 375 | 353 | ||
| 376 | // Parse the payload: "old_oid new_oid ref_name\0capabilities\n" | 354 | // Parse the payload: "old_oid new_oid ref_name\0capabilities\n" |
| 377 | if let Some(ref_update) = parse_ref_line(payload) { | 355 | if let Some(ref_update) = parse_ref_line(payload) { |
| 378 | refs.push(ref_update); | 356 | refs.push(ref_update); |
| 379 | } | 357 | } |
| 380 | 358 | ||
| 381 | // Move to next pkt-line | 359 | // Move to next pkt-line |
| 382 | data = &data[len..]; | 360 | data = &data[len..]; |
| 383 | } | 361 | } |
| 384 | 362 | ||
| 385 | debug!("Parsed {} refs from pkt-line format", refs.len()); | 363 | debug!("Parsed {} refs from pkt-line format", refs.len()); |
| 386 | refs | 364 | refs |
| 387 | } | 365 | } |
| @@ -409,29 +387,34 @@ fn parse_text_refs(data: &[u8]) -> Vec<(String, String, String)> { | |||
| 409 | fn parse_ref_line(payload: &[u8]) -> Option<(String, String, String)> { | 387 | fn parse_ref_line(payload: &[u8]) -> Option<(String, String, String)> { |
| 410 | // Convert to string, handling potential invalid UTF-8 | 388 | // Convert to string, handling potential invalid UTF-8 |
| 411 | let line = String::from_utf8_lossy(payload); | 389 | let line = String::from_utf8_lossy(payload); |
| 412 | 390 | ||
| 413 | // Strip trailing newline if present | 391 | // Strip trailing newline if present |
| 414 | let line = line.trim_end_matches('\n'); | 392 | let line = line.trim_end_matches('\n'); |
| 415 | 393 | ||
| 416 | // Split at null byte to separate command from capabilities | 394 | // Split at null byte to separate command from capabilities |
| 417 | let command_part = line.split('\0').next().unwrap_or(""); | 395 | let command_part = line.split('\0').next().unwrap_or(""); |
| 418 | 396 | ||
| 419 | // Parse "old_oid new_oid ref_name" | 397 | // Parse "old_oid new_oid ref_name" |
| 420 | let parts: Vec<&str> = command_part.split_whitespace().collect(); | 398 | let parts: Vec<&str> = command_part.split_whitespace().collect(); |
| 421 | if parts.len() >= 3 { | 399 | if parts.len() >= 3 { |
| 422 | let old_oid = parts[0]; | 400 | let old_oid = parts[0]; |
| 423 | let new_oid = parts[1]; | 401 | let new_oid = parts[1]; |
| 424 | let ref_name = parts[2]; | 402 | let ref_name = parts[2]; |
| 425 | 403 | ||
| 426 | // Validate OID format (40 hex chars) | 404 | // Validate OID format (40 hex chars) |
| 427 | if old_oid.len() == 40 && new_oid.len() == 40 | 405 | if old_oid.len() == 40 |
| 406 | && new_oid.len() == 40 | ||
| 428 | && old_oid.chars().all(|c| c.is_ascii_hexdigit()) | 407 | && old_oid.chars().all(|c| c.is_ascii_hexdigit()) |
| 429 | && new_oid.chars().all(|c| c.is_ascii_hexdigit()) | 408 | && new_oid.chars().all(|c| c.is_ascii_hexdigit()) |
| 430 | { | 409 | { |
| 431 | return Some((old_oid.to_string(), new_oid.to_string(), ref_name.to_string())); | 410 | return Some(( |
| 411 | old_oid.to_string(), | ||
| 412 | new_oid.to_string(), | ||
| 413 | ref_name.to_string(), | ||
| 414 | )); | ||
| 432 | } | 415 | } |
| 433 | } | 416 | } |
| 434 | 417 | ||
| 435 | None | 418 | None |
| 436 | } | 419 | } |
| 437 | 420 | ||
| @@ -456,11 +439,7 @@ mod tests { | |||
| 456 | Keys::generate() | 439 | Keys::generate() |
| 457 | } | 440 | } |
| 458 | 441 | ||
| 459 | fn create_announcement_event( | 442 | fn create_announcement_event(keys: &Keys, identifier: &str, maintainers: &[&Keys]) -> Event { |
| 460 | keys: &Keys, | ||
| 461 | identifier: &str, | ||
| 462 | maintainers: &[&Keys], | ||
| 463 | ) -> Event { | ||
| 464 | let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; | 443 | let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; |
| 465 | 444 | ||
| 466 | // Add maintainers as a single "maintainers" tag per NIP-34 | 445 | // Add maintainers as a single "maintainers" tag per NIP-34 |
| @@ -509,7 +488,7 @@ mod tests { | |||
| 509 | } | 488 | } |
| 510 | 489 | ||
| 511 | #[test] | 490 | #[test] |
| 512 | fn test_get_maintainers_single_owner() { | 491 | fn test_authorized_publishers_single_owner() { |
| 513 | let alice = create_test_keys(); | 492 | let alice = create_test_keys(); |
| 514 | let identifier = "test-repo"; | 493 | let identifier = "test-repo"; |
| 515 | 494 | ||
| @@ -517,34 +496,30 @@ mod tests { | |||
| 517 | let events = vec![announcement]; | 496 | let events = vec![announcement]; |
| 518 | 497 | ||
| 519 | let ctx = AuthorizationContext::new(events); | 498 | let ctx = AuthorizationContext::new(events); |
| 520 | let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 521 | 499 | ||
| 522 | assert_eq!(maintainers.len(), 1); | 500 | // Alice should be authorized |
| 523 | assert!(maintainers.contains(&alice.public_key().to_hex())); | 501 | assert!(ctx.is_state_authorized(&alice.public_key().to_hex(), identifier)); |
| 524 | } | 502 | } |
| 525 | 503 | ||
| 526 | #[test] | 504 | #[test] |
| 527 | fn test_get_maintainers_with_listed_maintainer() { | 505 | fn test_authorized_publishers_with_listed_maintainer() { |
| 528 | let alice = create_test_keys(); | 506 | let alice = create_test_keys(); |
| 529 | let bob = create_test_keys(); | 507 | let bob = create_test_keys(); |
| 530 | let identifier = "test-repo"; | 508 | let identifier = "test-repo"; |
| 531 | 509 | ||
| 532 | // Alice lists Bob as maintainer | 510 | // Alice lists Bob as maintainer |
| 533 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | 511 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); |
| 534 | // Bob also has an announcement | ||
| 535 | let bob_announcement = create_announcement_event(&bob, identifier, &[]); | ||
| 536 | 512 | ||
| 537 | let events = vec![alice_announcement, bob_announcement]; | 513 | let events = vec![alice_announcement]; |
| 538 | let ctx = AuthorizationContext::new(events); | 514 | let ctx = AuthorizationContext::new(events); |
| 539 | let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 540 | 515 | ||
| 541 | assert_eq!(maintainers.len(), 2); | 516 | // Both Alice and Bob should be authorized |
| 542 | assert!(maintainers.contains(&alice.public_key().to_hex())); | 517 | assert!(ctx.is_state_authorized(&alice.public_key().to_hex(), identifier)); |
| 543 | assert!(maintainers.contains(&bob.public_key().to_hex())); | 518 | assert!(ctx.is_state_authorized(&bob.public_key().to_hex(), identifier)); |
| 544 | } | 519 | } |
| 545 | 520 | ||
| 546 | #[test] | 521 | #[test] |
| 547 | fn test_get_maintainers_recursive() { | 522 | fn test_authorized_publishers_multiple_announcements() { |
| 548 | let alice = create_test_keys(); | 523 | let alice = create_test_keys(); |
| 549 | let bob = create_test_keys(); | 524 | let bob = create_test_keys(); |
| 550 | let charlie = create_test_keys(); | 525 | let charlie = create_test_keys(); |
| @@ -553,60 +528,48 @@ mod tests { | |||
| 553 | // Alice lists Bob, Bob lists Charlie | 528 | // Alice lists Bob, Bob lists Charlie |
| 554 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | 529 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); |
| 555 | let bob_announcement = create_announcement_event(&bob, identifier, &[&charlie]); | 530 | let bob_announcement = create_announcement_event(&bob, identifier, &[&charlie]); |
| 556 | let charlie_announcement = create_announcement_event(&charlie, identifier, &[]); | ||
| 557 | 531 | ||
| 558 | let events = vec![alice_announcement, bob_announcement, charlie_announcement]; | 532 | let events = vec![alice_announcement, bob_announcement]; |
| 559 | let ctx = AuthorizationContext::new(events); | 533 | let ctx = AuthorizationContext::new(events); |
| 560 | let maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | ||
| 561 | 534 | ||
| 562 | assert_eq!(maintainers.len(), 3); | 535 | // All three should be authorized (Alice, Bob from announcements; Bob, Charlie from maintainers) |
| 563 | assert!(maintainers.contains(&alice.public_key().to_hex())); | 536 | assert!(ctx.is_state_authorized(&alice.public_key().to_hex(), identifier)); |
| 564 | assert!(maintainers.contains(&bob.public_key().to_hex())); | 537 | assert!(ctx.is_state_authorized(&bob.public_key().to_hex(), identifier)); |
| 565 | assert!(maintainers.contains(&charlie.public_key().to_hex())); | 538 | assert!(ctx.is_state_authorized(&charlie.public_key().to_hex(), identifier)); |
| 566 | } | 539 | } |
| 567 | 540 | ||
| 568 | #[test] | 541 | #[test] |
| 569 | fn test_get_maintainers_not_symmetric() { | 542 | fn test_unauthorized_pubkey() { |
| 570 | let alice = create_test_keys(); | 543 | let alice = create_test_keys(); |
| 571 | let bob = create_test_keys(); | 544 | let bob = create_test_keys(); |
| 545 | let eve = create_test_keys(); // Not authorized | ||
| 572 | let identifier = "test-repo"; | 546 | let identifier = "test-repo"; |
| 573 | 547 | ||
| 574 | // Alice lists Bob, but Bob doesn't list Alice | 548 | // Alice lists Bob as maintainer |
| 575 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | 549 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); |
| 576 | let bob_announcement = create_announcement_event(&bob, identifier, &[]); | ||
| 577 | 550 | ||
| 578 | let events = vec![alice_announcement, bob_announcement]; | 551 | let events = vec![alice_announcement]; |
| 579 | let ctx = AuthorizationContext::new(events); | 552 | let ctx = AuthorizationContext::new(events); |
| 580 | 553 | ||
| 581 | // From Alice's perspective, both are maintainers | 554 | // Eve should NOT be authorized |
| 582 | let alice_maintainers = ctx.get_maintainers(&alice.public_key().to_hex(), identifier); | 555 | assert!(!ctx.is_state_authorized(&eve.public_key().to_hex(), identifier)); |
| 583 | assert_eq!(alice_maintainers.len(), 2); | ||
| 584 | |||
| 585 | // From Bob's perspective, only Bob is maintainer | ||
| 586 | let bob_maintainers = ctx.get_maintainers(&bob.public_key().to_hex(), identifier); | ||
| 587 | assert_eq!(bob_maintainers.len(), 1); | ||
| 588 | assert!(bob_maintainers.contains(&bob.public_key().to_hex())); | ||
| 589 | assert!(!bob_maintainers.contains(&alice.public_key().to_hex())); | ||
| 590 | } | 556 | } |
| 591 | 557 | ||
| 592 | #[test] | 558 | #[test] |
| 593 | fn test_get_state_from_maintainers() { | 559 | fn test_get_authorized_state_with_maintainer() { |
| 594 | let alice = create_test_keys(); | 560 | let alice = create_test_keys(); |
| 595 | let bob = create_test_keys(); | 561 | let bob = create_test_keys(); |
| 596 | let identifier = "test-repo"; | 562 | let identifier = "test-repo"; |
| 597 | 563 | ||
| 598 | let announcement = create_announcement_event(&alice, identifier, &[&bob]); | 564 | let announcement = create_announcement_event(&alice, identifier, &[&bob]); |
| 599 | let bob_announcement = create_announcement_event(&bob, identifier, &[]); | ||
| 600 | 565 | ||
| 601 | // Bob publishes a state event | 566 | // Bob publishes a state event |
| 602 | let state = create_state_event(&bob, identifier, &[("main", "abc123")]); | 567 | let state = create_state_event(&bob, identifier, &[("main", "abc123")]); |
| 603 | 568 | ||
| 604 | let events = vec![announcement, bob_announcement, state]; | 569 | let events = vec![announcement, state]; |
| 605 | let ctx = AuthorizationContext::new(events); | 570 | let ctx = AuthorizationContext::new(events); |
| 606 | 571 | ||
| 607 | let result = ctx | 572 | let result = ctx.get_authorized_state(identifier).unwrap(); |
| 608 | .get_authorized_state(&alice.public_key().to_hex(), identifier) | ||
| 609 | .unwrap(); | ||
| 610 | 573 | ||
| 611 | assert!(result.authorized); | 574 | assert!(result.authorized); |
| 612 | assert!(result.state.is_some()); | 575 | assert!(result.state.is_some()); |
| @@ -615,6 +578,38 @@ mod tests { | |||
| 615 | } | 578 | } |
| 616 | 579 | ||
| 617 | #[test] | 580 | #[test] |
| 581 | fn test_get_authorized_state_no_announcement() { | ||
| 582 | let identifier = "test-repo"; | ||
| 583 | |||
| 584 | let events = vec![]; | ||
| 585 | let ctx = AuthorizationContext::new(events); | ||
| 586 | |||
| 587 | let result = ctx.get_authorized_state(identifier).unwrap(); | ||
| 588 | |||
| 589 | assert!(!result.authorized); | ||
| 590 | assert_eq!(result.reason, "No repository announcement found"); | ||
| 591 | } | ||
| 592 | |||
| 593 | #[test] | ||
| 594 | fn test_get_authorized_state_no_state_event() { | ||
| 595 | let alice = create_test_keys(); | ||
| 596 | let identifier = "test-repo"; | ||
| 597 | |||
| 598 | let announcement = create_announcement_event(&alice, identifier, &[]); | ||
| 599 | |||
| 600 | let events = vec![announcement]; | ||
| 601 | let ctx = AuthorizationContext::new(events); | ||
| 602 | |||
| 603 | let result = ctx.get_authorized_state(identifier).unwrap(); | ||
| 604 | |||
| 605 | assert!(!result.authorized); | ||
| 606 | assert_eq!( | ||
| 607 | result.reason, | ||
| 608 | "No state event found from authorized publishers" | ||
| 609 | ); | ||
| 610 | } | ||
| 611 | |||
| 612 | #[test] | ||
| 618 | fn test_validate_push_refs_success() { | 613 | fn test_validate_push_refs_success() { |
| 619 | let alice = create_test_keys(); | 614 | let alice = create_test_keys(); |
| 620 | let identifier = "test-repo"; | 615 | let identifier = "test-repo"; |
| @@ -657,19 +652,19 @@ mod tests { | |||
| 657 | let new = "a".repeat(40); | 652 | let new = "a".repeat(40); |
| 658 | let ref_name = "refs/heads/main"; | 653 | let ref_name = "refs/heads/main"; |
| 659 | let capabilities = " report-status side-band-64k"; | 654 | let capabilities = " report-status side-band-64k"; |
| 660 | 655 | ||
| 661 | // Build the pkt-line payload | 656 | // Build the pkt-line payload |
| 662 | let payload = format!("{} {} {}\0{}\n", old, new, ref_name, capabilities); | 657 | let payload = format!("{} {} {}\0{}\n", old, new, ref_name, capabilities); |
| 663 | 658 | ||
| 664 | // Calculate length (4-byte prefix + payload) | 659 | // Calculate length (4-byte prefix + payload) |
| 665 | let len = 4 + payload.len(); | 660 | let len = 4 + payload.len(); |
| 666 | let pktline = format!("{:04x}{}", len, payload); | 661 | let pktline = format!("{:04x}{}", len, payload); |
| 667 | 662 | ||
| 668 | // Add flush packet to end | 663 | // Add flush packet to end |
| 669 | let data = format!("{}0000", pktline); | 664 | let data = format!("{}0000", pktline); |
| 670 | 665 | ||
| 671 | let refs = parse_pushed_refs(data.as_bytes()); | 666 | let refs = parse_pushed_refs(data.as_bytes()); |
| 672 | 667 | ||
| 673 | assert_eq!(refs.len(), 1, "Expected 1 ref, got {}", refs.len()); | 668 | assert_eq!(refs.len(), 1, "Expected 1 ref, got {}", refs.len()); |
| 674 | assert_eq!(refs[0].0, old); | 669 | assert_eq!(refs[0].0, old); |
| 675 | assert_eq!(refs[0].1, new); | 670 | assert_eq!(refs[0].1, new); |
| @@ -683,21 +678,21 @@ mod tests { | |||
| 683 | let new1 = "a".repeat(40); | 678 | let new1 = "a".repeat(40); |
| 684 | let old2 = "b".repeat(40); | 679 | let old2 = "b".repeat(40); |
| 685 | let new2 = "c".repeat(40); | 680 | let new2 = "c".repeat(40); |
| 686 | 681 | ||
| 687 | // First ref with capabilities | 682 | // First ref with capabilities |
| 688 | let payload1 = format!("{} {} refs/heads/main\0report-status\n", old1, new1); | 683 | let payload1 = format!("{} {} refs/heads/main\0report-status\n", old1, new1); |
| 689 | let len1 = 4 + payload1.len(); | 684 | let len1 = 4 + payload1.len(); |
| 690 | let pktline1 = format!("{:04x}{}", len1, payload1); | 685 | let pktline1 = format!("{:04x}{}", len1, payload1); |
| 691 | 686 | ||
| 692 | // Second ref without capabilities (subsequent refs don't have them) | 687 | // Second ref without capabilities (subsequent refs don't have them) |
| 693 | let payload2 = format!("{} {} refs/heads/feature\n", old2, new2); | 688 | let payload2 = format!("{} {} refs/heads/feature\n", old2, new2); |
| 694 | let len2 = 4 + payload2.len(); | 689 | let len2 = 4 + payload2.len(); |
| 695 | let pktline2 = format!("{:04x}{}", len2, payload2); | 690 | let pktline2 = format!("{:04x}{}", len2, payload2); |
| 696 | 691 | ||
| 697 | let data = format!("{}{}0000", pktline1, pktline2); | 692 | let data = format!("{}{}0000", pktline1, pktline2); |
| 698 | 693 | ||
| 699 | let refs = parse_pushed_refs(data.as_bytes()); | 694 | let refs = parse_pushed_refs(data.as_bytes()); |
| 700 | 695 | ||
| 701 | assert_eq!(refs.len(), 2, "Expected 2 refs, got {}", refs.len()); | 696 | assert_eq!(refs.len(), 2, "Expected 2 refs, got {}", refs.len()); |
| 702 | assert_eq!(refs[0].2, "refs/heads/main"); | 697 | assert_eq!(refs[0].2, "refs/heads/main"); |
| 703 | assert_eq!(refs[1].2, "refs/heads/feature"); | 698 | assert_eq!(refs[1].2, "refs/heads/feature"); |
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 5b511e3..27bec76 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -9,7 +9,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |||
| 9 | use tracing::{debug, error, info, warn}; | 9 | use tracing::{debug, error, info, warn}; |
| 10 | 10 | ||
| 11 | use super::authorization::{ | 11 | use super::authorization::{ |
| 12 | AuthorizationContext, AuthorizationResult, npub_to_pubkey, parse_pushed_refs, validate_push_refs, | 12 | AuthorizationContext, AuthorizationResult, parse_pushed_refs, validate_push_refs, |
| 13 | }; | 13 | }; |
| 14 | use super::protocol::{GitService, PktLine}; | 14 | use super::protocol::{GitService, PktLine}; |
| 15 | use super::subprocess::GitSubprocess; | 15 | use super::subprocess::GitSubprocess; |
| @@ -258,7 +258,7 @@ pub async fn handle_receive_pack( | |||
| 258 | /// | 258 | /// |
| 259 | /// This function: | 259 | /// This function: |
| 260 | /// 1. Fetches announcement and state events from the relay | 260 | /// 1. Fetches announcement and state events from the relay |
| 261 | /// 2. Calculates the recursive maintainer set | 261 | /// 2. Collects all authorized publishers from announcements |
| 262 | /// 3. Gets the latest authorized state | 262 | /// 3. Gets the latest authorized state |
| 263 | /// 4. Validates that pushed refs match the state | 263 | /// 4. Validates that pushed refs match the state |
| 264 | async fn authorize_push( | 264 | async fn authorize_push( |
| @@ -268,9 +268,6 @@ async fn authorize_push( | |||
| 268 | use nostr_sdk::ClientBuilder; | 268 | use nostr_sdk::ClientBuilder; |
| 269 | use std::time::Duration; | 269 | use std::time::Duration; |
| 270 | 270 | ||
| 271 | // Convert npub to hex pubkey | ||
| 272 | let owner_pubkey = npub_to_pubkey(¶ms.owner_npub)?; | ||
| 273 | |||
| 274 | debug!( | 271 | debug!( |
| 275 | "Fetching events for identifier {} from relay {}", | 272 | "Fetching events for identifier {} from relay {}", |
| 276 | params.identifier, params.relay_url | 273 | params.identifier, params.relay_url |
| @@ -301,8 +298,8 @@ async fn authorize_push( | |||
| 301 | // Create authorization context | 298 | // Create authorization context |
| 302 | let ctx = AuthorizationContext::new(events); | 299 | let ctx = AuthorizationContext::new(events); |
| 303 | 300 | ||
| 304 | // Get the authorized state | 301 | // Get the authorized state (no owner_pubkey needed - self-contained check) |
| 305 | let auth_result = ctx.get_authorized_state(&owner_pubkey, ¶ms.identifier)?; | 302 | let auth_result = ctx.get_authorized_state(¶ms.identifier)?; |
| 306 | 303 | ||
| 307 | if !auth_result.authorized { | 304 | if !auth_result.authorized { |
| 308 | return Ok(auth_result); | 305 | return Ok(auth_result); |