upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git/authorization.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/git/authorization.rs')
-rw-r--r--src/git/authorization.rs411
1 files changed, 203 insertions, 208 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
17use anyhow::{anyhow, Result}; 24use anyhow::{anyhow, Result};
18use nostr_sdk::{Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32, Alphabet}; 25use nostr_sdk::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32};
19use std::collections::HashSet; 26use std::collections::HashSet;
20use tracing::debug; 27use 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
349fn parse_pktline_refs(mut data: &[u8]) -> Vec<(String, String, String)> { 327fn 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)> {
409fn parse_ref_line(payload: &[u8]) -> Option<(String, String, String)> { 387fn 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");