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 /src | |
| parent | a005132ab806b7177d4eb3e3306914841704ffec (diff) | |
feat: push authorization from state event
Diffstat (limited to 'src')
| -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 |
5 files changed, 883 insertions, 39 deletions
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), "") |