upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--grasp-audit/src/client.rs23
-rw-r--r--grasp-audit/src/fixtures.rs172
-rw-r--r--grasp-audit/src/specs/grasp01/push_authorization.rs708
-rw-r--r--src/git/authorization.rs142
-rw-r--r--src/git/handlers.rs211
-rw-r--r--src/git/mod.rs210
-rw-r--r--src/nostr/builder.rs389
-rw-r--r--src/nostr/events.rs6
8 files changed, 1373 insertions, 488 deletions
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs
index ed76a34..e4e9f07 100644
--- a/grasp-audit/src/client.rs
+++ b/grasp-audit/src/client.rs
@@ -129,7 +129,9 @@ impl AuditClient {
129 /// Get the relay URL 129 /// Get the relay URL
130 pub async fn relay_url(&self) -> Result<String> { 130 pub async fn relay_url(&self) -> Result<String> {
131 let relays = self.client.relays().await; 131 let relays = self.client.relays().await;
132 let relay = relays.values().next() 132 let relay = relays
133 .values()
134 .next()
133 .ok_or_else(|| anyhow!("No relays configured"))?; 135 .ok_or_else(|| anyhow!("No relays configured"))?;
134 Ok(relay.url().to_string()) 136 Ok(relay.url().to_string())
135 } 137 }
@@ -522,12 +524,14 @@ mod tests {
522 let keys = Keys::generate(); 524 let keys = Keys::generate();
523 let maintainer_keys = Keys::generate(); 525 let maintainer_keys = Keys::generate();
524 let recursive_maintainer_keys = Keys::generate(); 526 let recursive_maintainer_keys = Keys::generate();
527 let pr_author_keys = Keys::generate();
525 let client = AuditClient { 528 let client = AuditClient {
526 client: Client::new(keys.clone()), 529 client: Client::new(keys.clone()),
527 config: config.clone(), 530 config: config.clone(),
528 keys: keys.clone(), 531 keys: keys.clone(),
529 maintainer_keys, 532 maintainer_keys,
530 recursive_maintainer_keys, 533 recursive_maintainer_keys,
534 pr_author_keys,
531 fixture_cache: Arc::new(Mutex::new(HashMap::new())), 535 fixture_cache: Arc::new(Mutex::new(HashMap::new())),
532 }; 536 };
533 537
@@ -543,12 +547,14 @@ mod tests {
543 let keys = Keys::generate(); 547 let keys = Keys::generate();
544 let maintainer_keys = Keys::generate(); 548 let maintainer_keys = Keys::generate();
545 let recursive_maintainer_keys = Keys::generate(); 549 let recursive_maintainer_keys = Keys::generate();
550 let pr_author_keys = Keys::generate();
546 let client = AuditClient { 551 let client = AuditClient {
547 client: Client::new(keys.clone()), 552 client: Client::new(keys.clone()),
548 config: config.clone(), 553 config: config.clone(),
549 keys: keys.clone(), 554 keys: keys.clone(),
550 maintainer_keys, 555 maintainer_keys,
551 recursive_maintainer_keys, 556 recursive_maintainer_keys,
557 pr_author_keys,
552 fixture_cache: Arc::new(Mutex::new(HashMap::new())), 558 fixture_cache: Arc::new(Mutex::new(HashMap::new())),
553 }; 559 };
554 560
@@ -610,13 +616,10 @@ mod tests {
610 // Note: We can't test create_repo_announcement_with_maintainers directly in unit tests 616 // Note: We can't test create_repo_announcement_with_maintainers directly in unit tests
611 // because it requires a connected relay. Instead, we test the underlying event building 617 // because it requires a connected relay. Instead, we test the underlying event building
612 // with maintainers tag to verify the tag format is correct. 618 // with maintainers tag to verify the tag format is correct.
613 619
614 // Build an event with maintainers tag directly to test the tag format 620 // Build an event with maintainers tag directly to test the tag format
615 let event = client 621 let event = client
616 .event_builder( 622 .event_builder(Kind::GitRepoAnnouncement, "Test repository")
617 Kind::GitRepoAnnouncement,
618 "Test repository",
619 )
620 .tag(Tag::identifier("test-repo")) 623 .tag(Tag::identifier("test-repo"))
621 .tag(Tag::custom( 624 .tag(Tag::custom(
622 TagKind::custom("maintainers"), 625 TagKind::custom("maintainers"),
@@ -639,10 +642,14 @@ mod tests {
639 // Verify the tag contains the maintainer pubkeys 642 // Verify the tag contains the maintainer pubkeys
640 let tag = maintainers_tag.unwrap(); 643 let tag = maintainers_tag.unwrap();
641 let tag_vec: Vec<String> = tag.clone().to_vec(); 644 let tag_vec: Vec<String> = tag.clone().to_vec();
642 645
643 // First element is "maintainers", rest are the pubkeys 646 // First element is "maintainers", rest are the pubkeys
644 assert_eq!(tag_vec[0], "maintainers"); 647 assert_eq!(tag_vec[0], "maintainers");
645 assert_eq!(tag_vec.len(), 3, "Expected 3 elements: tag name + 2 pubkeys"); 648 assert_eq!(
649 tag_vec.len(),
650 3,
651 "Expected 3 elements: tag name + 2 pubkeys"
652 );
646 assert_eq!(tag_vec[1], maintainer_pubkeys[0]); 653 assert_eq!(tag_vec[1], maintainer_pubkeys[0]);
647 assert_eq!(tag_vec[2], maintainer_pubkeys[1]); 654 assert_eq!(tag_vec[2], maintainer_pubkeys[1]);
648 } 655 }
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs
index dc4e638..4b5014d 100644
--- a/grasp-audit/src/fixtures.rs
+++ b/grasp-audit/src/fixtures.rs
@@ -59,7 +59,7 @@ pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c
59/// - GPG signing: disabled 59/// - GPG signing: disabled
60/// - User: "GRASP Audit Test <test@grasp-audit.local>" 60/// - User: "GRASP Audit Test <test@grasp-audit.local>"
61/// - Parent: none (root commit) 61/// - Parent: none (root commit)
62/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content 62/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content
63pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464"; 63pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464";
64 64
65/// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant) 65/// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant)
@@ -71,8 +71,9 @@ pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a6650
71/// - GPG signing: disabled 71/// - GPG signing: disabled
72/// - User: "GRASP Audit Test <test@grasp-audit.local>" 72/// - User: "GRASP Audit Test <test@grasp-audit.local>"
73/// - Parent: none (root commit) 73/// - Parent: none (root commit)
74/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content 74/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content
75pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; 75pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str =
76 "05939b82de66fbdb9c077d0a64fc68522f3cb8e0";
76 77
77/// Deterministic commit hash for PR test fixtures (PRTestCommit variant) 78/// Deterministic commit hash for PR test fixtures (PRTestCommit variant)
78/// This is the hash produced by creating a commit with: 79/// This is the hash produced by creating a commit with:
@@ -83,7 +84,7 @@ pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fb
83/// - GPG signing: disabled 84/// - GPG signing: disabled
84/// - User: "GRASP Audit Test <test@grasp-audit.local>" 85/// - User: "GRASP Audit Test <test@grasp-audit.local>"
85/// - Parent: none (root commit) 86/// - Parent: none (root commit)
86pub const PR_TEST_COMMIT_HASH: &str = "8935183ff722bf04e861928c6a7e50868c6ca4a6"; 87pub const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb";
87 88
88/// Types of test fixtures available 89/// Types of test fixtures available
89/// 90///
@@ -157,10 +158,10 @@ pub enum FixtureKind {
157 /// - Timestamp: 2 seconds in the past (most recent) 158 /// - Timestamp: 2 seconds in the past (most recent)
158 RecursiveMaintainerRepoAndState, 159 RecursiveMaintainerRepoAndState,
159 160
160 /// PR (Patch/Pull Request) event for the SAME repo_id as ValidRepo 161 /// PR (Pull Request) event for the SAME repo_id as ValidRepo
161 /// - Requires ValidRepo (uses same repo_id) 162 /// - Requires ValidRepo (uses same repo_id)
162 /// - Signed by `client.pr_author_keys()` 163 /// - Signed by `client.pr_author_keys()`
163 /// - Kind 1617 (NIP-34 patch) 164 /// - Kind 1618 (NIP-34 PR)
164 /// - Includes `a` tag referencing the repo 165 /// - Includes `a` tag referencing the repo
165 /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH 166 /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH
166 /// - Timestamp: 1 second in the past 167 /// - Timestamp: 1 second in the past
@@ -290,6 +291,37 @@ impl<'a> TestContext<'a> {
290 self.mode 291 self.mode
291 } 292 }
292 293
294 /// Build a fixture event WITHOUT publishing it to the relay.
295 ///
296 /// This is useful for tests that need to get a fixture's event ID before
297 /// actually publishing it. For example, testing refs/nostr/<event-id>
298 /// behavior before the corresponding event exists on the relay.
299 ///
300 /// Note: This may still create and publish dependencies (e.g., ValidRepo
301 /// will be created/published if PREvent needs it), but the requested
302 /// fixture itself will NOT be published.
303 ///
304 /// # Example
305 ///
306 /// ```no_run
307 /// # use grasp_audit::*;
308 /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
309 /// // Build PR event to get its ID without publishing
310 /// let pr_event = ctx.build_fixture_only(FixtureKind::PREvent).await?;
311 /// let pr_event_id = pr_event.id.to_hex();
312 ///
313 /// // Now push to refs/nostr/<pr_event_id> before event exists
314 /// // ... git push ...
315 ///
316 /// // Later, publish the PR event when ready
317 /// ctx.client().send_event(pr_event).await?;
318 /// # Ok(())
319 /// # }
320 /// ```
321 pub async fn build_fixture_only(&self, kind: FixtureKind) -> Result<Event> {
322 self.build_fixture(kind).await
323 }
324
293 /// Create a fresh fixture (always creates new) 325 /// Create a fresh fixture (always creates new)
294 async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> { 326 async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> {
295 let event = self 327 let event = self
@@ -348,7 +380,11 @@ impl<'a> TestContext<'a> {
348 { 380 {
349 let mut cache = self.client.fixture_cache().lock().unwrap(); 381 let mut cache = self.client.fixture_cache().lock().unwrap();
350 cache.insert(kind, event.clone()); 382 cache.insert(kind, event.clone());
351 tracing::debug!("get_or_create_shared({:?}) stored in client cache ({} entries)", kind, cache.len()); 383 tracing::debug!(
384 "get_or_create_shared({:?}) stored in client cache ({} entries)",
385 kind,
386 cache.len()
387 );
352 } 388 }
353 389
354 Ok(event) 390 Ok(event)
@@ -398,12 +434,17 @@ impl<'a> TestContext<'a> {
398 FixtureKind::ValidRepo, 434 FixtureKind::ValidRepo,
399 &uuid::Uuid::new_v4().to_string()[..8] 435 &uuid::Uuid::new_v4().to_string()[..8]
400 ); 436 );
401 437
402 let repo = self.client.create_repo_announcement(&test_name).await 438 let repo = self
439 .client
440 .create_repo_announcement(&test_name)
441 .await
403 .with_context(|| format!("create_repo_announcement failed for {}", test_name))?; 442 .with_context(|| format!("create_repo_announcement failed for {}", test_name))?;
404 443
405 // Send it 444 // Send it
406 self.client.send_event(repo.clone()).await 445 self.client
446 .send_event(repo.clone())
447 .await
407 .with_context(|| "Failed to send repo announcement to relay")?; 448 .with_context(|| "Failed to send repo announcement to relay")?;
408 449
409 // Store in the appropriate cache based on mode 450 // Store in the appropriate cache based on mode
@@ -412,13 +453,19 @@ impl<'a> TestContext<'a> {
412 // Store in local cache for within-test fixture dependencies 453 // Store in local cache for within-test fixture dependencies
413 let mut cache = self.local_cache.lock().unwrap(); 454 let mut cache = self.local_cache.lock().unwrap();
414 cache.insert(FixtureKind::ValidRepo, repo.clone()); 455 cache.insert(FixtureKind::ValidRepo, repo.clone());
415 tracing::debug!("get_or_create_repo() stored in local cache ({} entries)", cache.len()); 456 tracing::debug!(
457 "get_or_create_repo() stored in local cache ({} entries)",
458 cache.len()
459 );
416 } 460 }
417 ContextMode::Shared => { 461 ContextMode::Shared => {
418 // Store in client cache for cross-test sharing 462 // Store in client cache for cross-test sharing
419 let mut cache = self.client.fixture_cache().lock().unwrap(); 463 let mut cache = self.client.fixture_cache().lock().unwrap();
420 cache.insert(FixtureKind::ValidRepo, repo.clone()); 464 cache.insert(FixtureKind::ValidRepo, repo.clone());
421 tracing::debug!("get_or_create_repo() stored in client cache ({} entries)", cache.len()); 465 tracing::debug!(
466 "get_or_create_repo() stored in client cache ({} entries)",
467 cache.len()
468 );
422 } 469 }
423 } 470 }
424 471
@@ -448,12 +495,9 @@ impl<'a> TestContext<'a> {
448 let repo = self.get_or_create_repo().await?; 495 let repo = self.get_or_create_repo().await?;
449 496
450 // Create the issue 497 // Create the issue
451 let issue = self.client.create_issue( 498 let issue =
452 &repo, 499 self.client
453 "Test Issue", 500 .create_issue(&repo, "Test Issue", "Issue content for testing", vec![])?;
454 "Issue content for testing",
455 vec![],
456 )?;
457 501
458 // Send it 502 // Send it
459 self.client.send_event(issue.clone()).await?; 503 self.client.send_event(issue.clone()).await?;
@@ -555,7 +599,7 @@ impl<'a> TestContext<'a> {
555 599
556 // Get the owner's repo to use the SAME repo_id 600 // Get the owner's repo to use the SAME repo_id
557 let owner_repo = self.get_or_create_repo().await?; 601 let owner_repo = self.get_or_create_repo().await?;
558 602
559 // Extract repo_id from owner's repo announcement 603 // Extract repo_id from owner's repo announcement
560 let repo_id = owner_repo 604 let repo_id = owner_repo
561 .tags 605 .tags
@@ -573,7 +617,7 @@ impl<'a> TestContext<'a> {
573 617
574 // Get the owner's repo to use the SAME repo_id 618 // Get the owner's repo to use the SAME repo_id
575 let owner_repo = self.get_or_create_repo().await?; 619 let owner_repo = self.get_or_create_repo().await?;
576 620
577 // Extract repo_id from owner's repo announcement 621 // Extract repo_id from owner's repo announcement
578 let repo_id = owner_repo 622 let repo_id = owner_repo
579 .tags 623 .tags
@@ -593,7 +637,7 @@ impl<'a> TestContext<'a> {
593 637
594 // Get the owner's repo to use the SAME repo_id 638 // Get the owner's repo to use the SAME repo_id
595 let owner_repo = self.get_or_create_repo().await?; 639 let owner_repo = self.get_or_create_repo().await?;
596 640
597 // Extract repo_id from owner's repo announcement 641 // Extract repo_id from owner's repo announcement
598 let repo_id = owner_repo 642 let repo_id = owner_repo
599 .tags 643 .tags
@@ -611,7 +655,7 @@ impl<'a> TestContext<'a> {
611 655
612 // Get the owner's repo to use the SAME repo_id 656 // Get the owner's repo to use the SAME repo_id
613 let owner_repo = self.get_or_create_repo().await?; 657 let owner_repo = self.get_or_create_repo().await?;
614 658
615 // Extract repo_id from owner's repo announcement 659 // Extract repo_id from owner's repo announcement
616 let repo_id = owner_repo 660 let repo_id = owner_repo
617 .tags 661 .tags
@@ -630,7 +674,7 @@ impl<'a> TestContext<'a> {
630 674
631 // Get the owner's repo to use the SAME repo_id 675 // Get the owner's repo to use the SAME repo_id
632 let owner_repo = self.get_or_create_repo().await?; 676 let owner_repo = self.get_or_create_repo().await?;
633 677
634 // Extract repo_id from owner's repo announcement 678 // Extract repo_id from owner's repo announcement
635 let repo_id = owner_repo 679 let repo_id = owner_repo
636 .tags 680 .tags
@@ -647,8 +691,12 @@ impl<'a> TestContext<'a> {
647 self.client.send_event(maintainer_announcement).await?; 691 self.client.send_event(maintainer_announcement).await?;
648 692
649 // Build and send the recursive maintainer's repo announcement 693 // Build and send the recursive maintainer's repo announcement
650 let recursive_maintainer_announcement = self.build_recursive_maintainer_announcement(&repo_id).await?; 694 let recursive_maintainer_announcement = self
651 self.client.send_event(recursive_maintainer_announcement).await?; 695 .build_recursive_maintainer_announcement(&repo_id)
696 .await?;
697 self.client
698 .send_event(recursive_maintainer_announcement)
699 .await?;
652 700
653 // Return the state event (caller will send it) 701 // Return the state event (caller will send it)
654 self.build_recursive_maintainer_state(&repo_id) 702 self.build_recursive_maintainer_state(&repo_id)
@@ -672,10 +720,10 @@ impl<'a> TestContext<'a> {
672 let base_time = Timestamp::now().as_u64(); 720 let base_time = Timestamp::now().as_u64();
673 let pr_timestamp = Timestamp::from(base_time - 1); 721 let pr_timestamp = Timestamp::from(base_time - 1);
674 722
675 // Build NIP-34 patch event (kind 1617) 723 // Build NIP-34 PR event (kind 1618)
676 self.client 724 self.client
677 .event_builder( 725 .event_builder(
678 Kind::Custom(1617), // NIP-34 patch/PR kind 726 Kind::Custom(1618), // NIP-34 PR kind (has 'c' tag for commit)
679 "Test PR for GRASP validation", 727 "Test PR for GRASP validation",
680 ) 728 )
681 .tag(Tag::custom( 729 .tag(Tag::custom(
@@ -702,7 +750,8 @@ impl<'a> TestContext<'a> {
702 use nostr_sdk::prelude::*; 750 use nostr_sdk::prelude::*;
703 751
704 // Get relay URL for clone tag 752 // Get relay URL for clone tag
705 let relay_url = self.client 753 let relay_url = self
754 .client
706 .client() 755 .client()
707 .relays() 756 .relays()
708 .await 757 .await
@@ -715,7 +764,8 @@ impl<'a> TestContext<'a> {
715 .replace("wss://", "https://"); 764 .replace("wss://", "https://");
716 765
717 // Create maintainer's repo announcement for the SAME repo_id 766 // Create maintainer's repo announcement for the SAME repo_id
718 let maintainer_npub = self.client 767 let maintainer_npub = self
768 .client
719 .maintainer_keys() 769 .maintainer_keys()
720 .public_key() 770 .public_key()
721 .to_bech32() 771 .to_bech32()
@@ -735,10 +785,7 @@ impl<'a> TestContext<'a> {
735 TagKind::custom("clone"), 785 TagKind::custom("clone"),
736 vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], 786 vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)],
737 )) 787 ))
738 .tag(Tag::custom( 788 .tag(Tag::custom(TagKind::custom("relays"), vec![relay_url]))
739 TagKind::custom("relays"),
740 vec![relay_url],
741 ))
742 .tag(Tag::custom( 789 .tag(Tag::custom(
743 TagKind::custom("maintainers"), 790 TagKind::custom("maintainers"),
744 vec![self.client.recursive_maintainer_pubkey_hex()], 791 vec![self.client.recursive_maintainer_pubkey_hex()],
@@ -776,7 +823,8 @@ impl<'a> TestContext<'a> {
776 use nostr_sdk::prelude::*; 823 use nostr_sdk::prelude::*;
777 824
778 // Get relay URL for clone tag 825 // Get relay URL for clone tag
779 let relay_url = self.client 826 let relay_url = self
827 .client
780 .client() 828 .client()
781 .relays() 829 .relays()
782 .await 830 .await
@@ -789,7 +837,8 @@ impl<'a> TestContext<'a> {
789 .replace("wss://", "https://"); 837 .replace("wss://", "https://");
790 838
791 // Create recursive maintainer's repo announcement for the SAME repo_id 839 // Create recursive maintainer's repo announcement for the SAME repo_id
792 let recursive_maintainer_npub = self.client 840 let recursive_maintainer_npub = self
841 .client
793 .recursive_maintainer_keys() 842 .recursive_maintainer_keys()
794 .public_key() 843 .public_key()
795 .to_bech32() 844 .to_bech32()
@@ -807,12 +856,12 @@ impl<'a> TestContext<'a> {
807 )) 856 ))
808 .tag(Tag::custom( 857 .tag(Tag::custom(
809 TagKind::custom("clone"), 858 TagKind::custom("clone"),
810 vec![format!("{}/{}/{}.git", http_url, recursive_maintainer_npub, repo_id)], 859 vec![format!(
811 )) 860 "{}/{}/{}.git",
812 .tag(Tag::custom( 861 http_url, recursive_maintainer_npub, repo_id
813 TagKind::custom("relays"), 862 )],
814 vec![relay_url],
815 )) 863 ))
864 .tag(Tag::custom(TagKind::custom("relays"), vec![relay_url]))
816 .tag(Tag::custom( 865 .tag(Tag::custom(
817 TagKind::custom("maintainers"), 866 TagKind::custom("maintainers"),
818 vec![ 867 vec![
@@ -821,7 +870,12 @@ impl<'a> TestContext<'a> {
821 ], 870 ],
822 )) 871 ))
823 .build(self.client.recursive_maintainer_keys()) 872 .build(self.client.recursive_maintainer_keys())
824 .map_err(|e| anyhow::anyhow!("Failed to build recursive maintainer repo announcement: {}", e)) 873 .map_err(|e| {
874 anyhow::anyhow!(
875 "Failed to build recursive maintainer repo announcement: {}",
876 e
877 )
878 })
825 } 879 }
826 880
827 /// Build recursive maintainer state event for the given repo_id 881 /// Build recursive maintainer state event for the given repo_id
@@ -898,7 +952,7 @@ pub async fn send_and_verify_accepted(
898) -> Result<(), String> { 952) -> Result<(), String> {
899 use nostr_sdk::prelude::Filter; 953 use nostr_sdk::prelude::Filter;
900 use std::time::Duration; 954 use std::time::Duration;
901 955
902 let event_id = event.id; 956 let event_id = event.id;
903 957
904 client 958 client
@@ -952,12 +1006,12 @@ pub async fn send_and_verify_rejected(
952) -> Result<(), String> { 1006) -> Result<(), String> {
953 use nostr_sdk::prelude::Filter; 1007 use nostr_sdk::prelude::Filter;
954 use std::time::Duration; 1008 use std::time::Duration;
955 1009
956 let event_id = event.id; 1010 let event_id = event.id;
957 1011
958 // Try to send event - rejection may cause send_event to fail with an error 1012 // Try to send event - rejection may cause send_event to fail with an error
959 let send_result = client.send_event(event).await; 1013 let send_result = client.send_event(event).await;
960 1014
961 // If send succeeded, the relay might have accepted it (we'll verify below) 1015 // If send succeeded, the relay might have accepted it (we'll verify below)
962 // If send failed, check if it's a rejection error (expected) 1016 // If send failed, check if it's a rejection error (expected)
963 if let Err(e) = send_result { 1017 if let Err(e) = send_result {
@@ -966,7 +1020,7 @@ pub async fn send_and_verify_rejected(
966 if err_msg.contains("rejected") || err_msg.contains("blocked") { 1020 if err_msg.contains("rejected") || err_msg.contains("blocked") {
967 // Expected rejection - verify event is NOT in database 1021 // Expected rejection - verify event is NOT in database
968 tokio::time::sleep(Duration::from_millis(100)).await; 1022 tokio::time::sleep(Duration::from_millis(100)).await;
969 1023
970 let filter = Filter::new().id(event_id); 1024 let filter = Filter::new().id(event_id);
971 let events = client 1025 let events = client
972 .query(filter) 1026 .query(filter)
@@ -974,9 +1028,12 @@ pub async fn send_and_verify_rejected(
974 .map_err(|e| format!("Failed to query relay for verification: {}", e))?; 1028 .map_err(|e| format!("Failed to query relay for verification: {}", e))?;
975 1029
976 if !events.is_empty() { 1030 if !events.is_empty() {
977 return Err(format!("Event was rejected but still stored: {}", description)); 1031 return Err(format!(
1032 "Event was rejected but still stored: {}",
1033 description
1034 ));
978 } 1035 }
979 1036
980 return Ok(()); // Rejected as expected 1037 return Ok(()); // Rejected as expected
981 } else { 1038 } else {
982 // Unexpected error (network, etc.) 1039 // Unexpected error (network, etc.)
@@ -1029,11 +1086,7 @@ use std::process::Command;
1029/// # Ok(()) 1086/// # Ok(())
1030/// # } 1087/// # }
1031/// ``` 1088/// ```
1032pub fn clone_repo( 1089pub fn clone_repo(relay_domain: &str, npub: &str, repo_id: &str) -> Result<PathBuf, String> {
1033 relay_domain: &str,
1034 npub: &str,
1035 repo_id: &str,
1036) -> Result<PathBuf, String> {
1037 let temp_base = std::env::temp_dir(); 1090 let temp_base = std::env::temp_dir();
1038 let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4()); 1091 let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4());
1039 let clone_path = temp_base.join(&clone_dir_name); 1092 let clone_path = temp_base.join(&clone_dir_name);
@@ -1146,7 +1199,7 @@ impl CommitVariant {
1146 CommitVariant::PRTestCommit => "PR test deterministic commit", 1199 CommitVariant::PRTestCommit => "PR test deterministic commit",
1147 } 1200 }
1148 } 1201 }
1149 1202
1150 /// Get the commit message for this variant 1203 /// Get the commit message for this variant
1151 pub fn commit_message(&self) -> &'static str { 1204 pub fn commit_message(&self) -> &'static str {
1152 match self { 1205 match self {
@@ -1172,11 +1225,14 @@ impl CommitVariant {
1172/// # Returns 1225/// # Returns
1173/// * `Ok(String)` - The deterministic commit hash 1226/// * `Ok(String)` - The deterministic commit hash
1174/// * `Err(String)` - Error message if commit failed 1227/// * `Err(String)` - Error message if commit failed
1175pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result<String, String> { 1228pub fn create_deterministic_commit_with_variant(
1229 clone_path: &Path,
1230 variant: CommitVariant,
1231) -> Result<String, String> {
1176 let test_file = clone_path.join("test.txt"); 1232 let test_file = clone_path.join("test.txt");
1177 let content = variant.file_content(); 1233 let content = variant.file_content();
1178 let message = variant.commit_message(); 1234 let message = variant.commit_message();
1179 1235
1180 fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?; 1236 fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?;
1181 1237
1182 let output = Command::new("git") 1238 let output = Command::new("git")
@@ -1191,11 +1247,7 @@ pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: Comm
1191 1247
1192 // Create deterministic commit with fixed dates and GPG disabled 1248 // Create deterministic commit with fixed dates and GPG disabled
1193 let output = Command::new("git") 1249 let output = Command::new("git")
1194 .args([ 1250 .args(["-c", "commit.gpgsign=false", "commit", "-m", message])
1195 "-c", "commit.gpgsign=false",
1196 "commit",
1197 "-m", message,
1198 ])
1199 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") 1251 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z")
1200 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") 1252 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z")
1201 .current_dir(clone_path) 1253 .current_dir(clone_path)
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs
index 97d068c..d8652ae 100644
--- a/grasp-audit/src/specs/grasp01/push_authorization.rs
+++ b/grasp-audit/src/specs/grasp01/push_authorization.rs
@@ -21,7 +21,7 @@
21/// This hash is produced by creating a commit with: 21/// This hash is produced by creating a commit with:
22/// - File: test.txt containing "PR test deterministic commit" 22/// - File: test.txt containing "PR test deterministic commit"
23/// - Message: "PR test deterministic commit" 23/// - Message: "PR test deterministic commit"
24/// - Author: "PR Test Author <pr-test@example.com>" 24/// - Author: "GRASP Audit Test <test@grasp-audit.local>"
25/// - Author date: 2024-01-01T00:00:00Z 25/// - Author date: 2024-01-01T00:00:00Z
26/// - Committer date: 2024-01-01T00:00:00Z 26/// - Committer date: 2024-01-01T00:00:00Z
27/// - GPG signing: disabled 27/// - GPG signing: disabled
@@ -29,13 +29,13 @@
29/// 29///
30/// Run `test_pr_test_commit_hash_discovery` to discover/verify this value. 30/// Run `test_pr_test_commit_hash_discovery` to discover/verify this value.
31#[allow(dead_code)] 31#[allow(dead_code)]
32const PR_TEST_COMMIT_HASH: &str = "8935183ff722bf04e861928c6a7e50868c6ca4a6"; 32const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb";
33 33
34use crate::{ 34use crate::{
35 clone_repo, create_commit, create_deterministic_commit, create_deterministic_commit_with_variant, 35 clone_repo, create_commit, create_deterministic_commit,
36 try_push, try_push_to_ref, AuditClient, CommitVariant, FixtureKind, TestContext, TestResult, 36 create_deterministic_commit_with_variant, try_push, try_push_to_ref, AuditClient,
37 DETERMINISTIC_COMMIT_HASH, MAINTAINER_DETERMINISTIC_COMMIT_HASH, 37 CommitVariant, FixtureKind, TestContext, TestResult, DETERMINISTIC_COMMIT_HASH,
38 RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, 38 MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH,
39}; 39};
40use nostr_sdk::prelude::*; 40use nostr_sdk::prelude::*;
41use std::fs; 41use std::fs;
@@ -63,37 +63,121 @@ use std::process::Command;
63/// * `Ok(String)` - The commit hash (should match PR_TEST_COMMIT_HASH) 63/// * `Ok(String)` - The commit hash (should match PR_TEST_COMMIT_HASH)
64/// * `Err(String)` - Error message if commit creation failed 64/// * `Err(String)` - Error message if commit creation failed
65fn create_pr_test_commit(clone_path: &Path) -> Result<String, String> { 65fn create_pr_test_commit(clone_path: &Path) -> Result<String, String> {
66 // Step 1: Create orphan branch (removes all history) 66 // Step 1: Clean up any tracked files in the working directory
67 // This ensures we start with a clean slate
67 let _ = Command::new("git") 68 let _ = Command::new("git")
68 .args(["checkout", "--orphan", "pr-test-branch"]) 69 .args(["clean", "-fd"])
69 .current_dir(clone_path) 70 .current_dir(clone_path)
70 .output(); 71 .output();
71 72
72 // Step 2: Clear staged files (orphan keeps files staged from previous branch) 73 // Step 2: Create orphan branch (removes all history)
73 let _ = Command::new("git") 74 let output = Command::new("git")
75 .args(["checkout", "--orphan", "pr-test-branch"])
76 .current_dir(clone_path)
77 .output()
78 .map_err(|e| format!("Failed to execute git checkout --orphan: {}", e))?;
79
80 if !output.status.success() {
81 return Err(format!(
82 "git checkout --orphan failed: {}",
83 String::from_utf8_lossy(&output.stderr)
84 ));
85 }
86
87 // Step 3: Remove ALL files from the index (staging area)
88 let output = Command::new("git")
74 .args(["rm", "-rf", "--cached", "."]) 89 .args(["rm", "-rf", "--cached", "."])
75 .current_dir(clone_path) 90 .current_dir(clone_path)
76 .output(); 91 .output()
92 .map_err(|e| format!("Failed to execute git rm: {}", e))?;
93
94 // Note: git rm may return error if there are no files to remove, that's OK
95 if !output.status.success() {
96 let stderr = String::from_utf8_lossy(&output.stderr);
97 // Ignore "did not match any files" errors
98 if !stderr.contains("did not match any files") {
99 return Err(format!("git rm -rf --cached . failed: {}", stderr));
100 }
101 }
102
103 // Step 4: Remove ALL files from working directory (except .git)
104 // This ensures only test.txt will be in the commit
105 for entry in fs::read_dir(clone_path).map_err(|e| format!("Failed to read dir: {}", e))? {
106 let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
107 let path = entry.path();
108 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
109 if file_name != ".git" {
110 if path.is_dir() {
111 fs::remove_dir_all(&path)
112 .map_err(|e| format!("Failed to remove dir {}: {}", path.display(), e))?;
113 } else {
114 fs::remove_file(&path)
115 .map_err(|e| format!("Failed to remove file {}: {}", path.display(), e))?;
116 }
117 }
118 }
119
120 // Step 5: Create deterministic commit using existing function
121 let commit_hash =
122 create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?;
123
124 // Step 6: Verify this is actually a root commit (no parent)
125 let output = Command::new("git")
126 .args(["rev-list", "--max-parents=0", "HEAD"])
127 .current_dir(clone_path)
128 .output()
129 .map_err(|e| format!("Failed to check root commit: {}", e))?;
77 130
78 // Step 3: Create deterministic commit using existing function 131 let root_commits = String::from_utf8_lossy(&output.stdout);
79 let commit_hash = create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?; 132 if !root_commits.trim().contains(&commit_hash) {
133 return Err(format!(
134 "Commit {} is not a root commit (has parent). Root commits: {}",
135 commit_hash,
136 root_commits.trim()
137 ));
138 }
80 139
81 // Step 4: Replace main branch with our new orphan branch 140 // Step 7: Replace main branch with our new orphan branch
82 let _ = Command::new("git") 141 let _ = Command::new("git")
83 .args(["branch", "-D", "main"]) 142 .args(["branch", "-D", "main"])
84 .current_dir(clone_path) 143 .current_dir(clone_path)
85 .output(); 144 .output();
86 145
87 let _ = Command::new("git") 146 let output = Command::new("git")
88 .args(["branch", "-m", "main"]) 147 .args(["branch", "-m", "main"])
89 .current_dir(clone_path) 148 .current_dir(clone_path)
90 .output(); 149 .output()
150 .map_err(|e| format!("Failed to rename branch: {}", e))?;
151
152 if !output.status.success() {
153 return Err(format!(
154 "Failed to rename branch to main: {}",
155 String::from_utf8_lossy(&output.stderr)
156 ));
157 }
91 158
92 // Verify commit hash matches expected 159 // Step 8: Verify commit hash matches expected
93 if commit_hash != PR_TEST_COMMIT_HASH { 160 if commit_hash != PR_TEST_COMMIT_HASH {
161 // Debug: Show what's in the commit
162 let tree_output = Command::new("git")
163 .args(["ls-tree", "-r", "HEAD"])
164 .current_dir(clone_path)
165 .output();
166 let tree_info = tree_output
167 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
168 .unwrap_or_else(|_| "Failed to get tree".to_string());
169
170 let cat_output = Command::new("git")
171 .args(["cat-file", "-p", "HEAD"])
172 .current_dir(clone_path)
173 .output();
174 let commit_info = cat_output
175 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
176 .unwrap_or_else(|_| "Failed to get commit".to_string());
177
94 return Err(format!( 178 return Err(format!(
95 "PR test commit hash mismatch: got {}, expected {}", 179 "PR test commit hash mismatch: got {}, expected {}\nTree contents:\n{}\nCommit info:\n{}",
96 commit_hash, PR_TEST_COMMIT_HASH 180 commit_hash, PR_TEST_COMMIT_HASH, tree_info, commit_info
97 )); 181 ));
98 } 182 }
99 183
@@ -166,6 +250,8 @@ struct PrRefTestSetup {
166 repo_id: String, 250 repo_id: String,
167 owner_npub: String, 251 owner_npub: String,
168 wrong_commit_hash: String, 252 wrong_commit_hash: String,
253 /// The unpublished PR event - store it so we can publish the SAME event later
254 pr_event: Event,
169} 255}
170 256
171impl PrRefTestSetup { 257impl PrRefTestSetup {
@@ -192,17 +278,18 @@ async fn setup_repo_with_wrong_commit_pushed(
192 ctx: &TestContext<'_>, 278 ctx: &TestContext<'_>,
193 relay_domain: &str, 279 relay_domain: &str,
194) -> Result<PrRefTestSetup, String> { 280) -> Result<PrRefTestSetup, String> {
195 // Get fixtures (PREvent fixture creates the event but doesn't publish until we call get_fixture) 281 // Get ValidRepo fixture (publishes repo announcement to relay)
196 let repo_event = ctx 282 let repo_event = ctx
197 .get_fixture(FixtureKind::ValidRepo) 283 .get_fixture(FixtureKind::ValidRepo)
198 .await 284 .await
199 .map_err(|e| format!("Failed to get repo announcement: {}", e))?; 285 .map_err(|e| format!("Failed to get repo announcement: {}", e))?;
200 286
201 // Get PR event fixture (creates event object but doesn't publish to relay yet) 287 // Build PR event WITHOUT publishing - we need its ID before the event exists on relay
288 // This allows testing refs/nostr/<event-id> push behavior before the event is received
202 let pr_event = ctx 289 let pr_event = ctx
203 .get_fixture(FixtureKind::PREvent) 290 .build_fixture_only(FixtureKind::PREvent)
204 .await 291 .await
205 .map_err(|e| format!("Failed to get PR event fixture: {}", e))?; 292 .map_err(|e| format!("Failed to build PR event fixture: {}", e))?;
206 293
207 let repo_id = repo_event 294 let repo_id = repo_event
208 .tags 295 .tags
@@ -219,7 +306,8 @@ async fn setup_repo_with_wrong_commit_pushed(
219 let clone_path = clone_repo(relay_domain, &owner_npub, &repo_id)?; 306 let clone_path = clone_repo(relay_domain, &owner_npub, &repo_id)?;
220 307
221 // Create a WRONG commit (not the one expected by PR event) 308 // Create a WRONG commit (not the one expected by PR event)
222 let wrong_commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner)?; 309 let wrong_commit_hash =
310 create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner)?;
223 311
224 // Verify it's actually different from expected 312 // Verify it's actually different from expected
225 if wrong_commit_hash == PR_TEST_COMMIT_HASH { 313 if wrong_commit_hash == PR_TEST_COMMIT_HASH {
@@ -229,7 +317,11 @@ async fn setup_repo_with_wrong_commit_pushed(
229 317
230 // Push to refs/nostr/<pr-event-id> (no event published yet, should succeed) 318 // Push to refs/nostr/<pr-event-id> (no event published yet, should succeed)
231 let push_output = Command::new("git") 319 let push_output = Command::new("git")
232 .args(["push", "origin", &format!("main:refs/nostr/{}", pr_event_id)]) 320 .args([
321 "push",
322 "origin",
323 &format!("master:refs/nostr/{}", pr_event_id),
324 ])
233 .current_dir(&clone_path) 325 .current_dir(&clone_path)
234 .output() 326 .output()
235 .map_err(|e| format!("Failed to execute git push: {}", e))?; 327 .map_err(|e| format!("Failed to execute git push: {}", e))?;
@@ -249,23 +341,30 @@ async fn setup_repo_with_wrong_commit_pushed(
249 repo_id, 341 repo_id,
250 owner_npub, 342 owner_npub,
251 wrong_commit_hash, 343 wrong_commit_hash,
344 pr_event,
252 }) 345 })
253} 346}
254 347
255/// Publishes the PR event fixture and waits for relay to process it. 348/// Publishes the SAME PR event that was built during setup.
256/// Call this after setup_repo_with_wrong_commit_pushed to test post-event behavior. 349/// Call this after setup_repo_with_wrong_commit_pushed to test post-event behavior.
350///
351/// IMPORTANT: We must publish the EXACT same event that was used during setup,
352/// otherwise the event ID won't match the refs/nostr/<event-id> ref that was pushed.
257#[allow(dead_code)] 353#[allow(dead_code)]
258async fn publish_pr_event_and_wait(ctx: &TestContext<'_>) -> Result<Event, String> { 354async fn publish_pr_event_and_wait(
259 // Publishing the PR event - get_fixture publishes if not already published 355 ctx: &TestContext<'_>,
260 let pr_event = ctx 356 pr_event: &Event,
261 .get_fixture(FixtureKind::PREvent) 357) -> Result<(), String> {
358 // Publish the exact same PR event that was created during setup
359 ctx.client()
360 .send_event(pr_event.clone())
262 .await 361 .await
263 .map_err(|e| format!("Failed to publish PR event: {}", e))?; 362 .map_err(|e| format!("Failed to publish PR event: {}", e))?;
264 363
265 // Wait for relay to process 364 // Wait for relay to process
266 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; 365 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
267 366
268 Ok(pr_event) 367 Ok(())
269} 368}
270 369
271/// Creates the correct PR test commit (matching PR_TEST_COMMIT_HASH) in an existing clone. 370/// Creates the correct PR test commit (matching PR_TEST_COMMIT_HASH) in an existing clone.
@@ -281,7 +380,12 @@ fn reset_to_correct_pr_commit(clone_path: &Path) -> Result<String, String> {
281#[allow(dead_code)] 380#[allow(dead_code)]
282fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result<bool, String> { 381fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result<bool, String> {
283 let push_output = Command::new("git") 382 let push_output = Command::new("git")
284 .args(["push", "--force", "origin", &format!("main:refs/nostr/{}", pr_event_id)]) 383 .args([
384 "push",
385 "--force",
386 "origin",
387 &format!("HEAD:refs/nostr/{}", pr_event_id),
388 ])
285 .current_dir(clone_path) 389 .current_dir(clone_path)
286 .output() 390 .output()
287 .map_err(|e| format!("Failed to execute git push: {}", e))?; 391 .map_err(|e| format!("Failed to execute git push: {}", e))?;
@@ -307,22 +411,48 @@ pub struct PushAuthorizationTests;
307 411
308impl PushAuthorizationTests { 412impl PushAuthorizationTests {
309 /// Run all push authorization tests 413 /// Run all push authorization tests
310 pub async fn run_all( 414 pub async fn run_all(client: &AuditClient, relay_domain: &str) -> crate::AuditResult {
311 client: &AuditClient,
312 relay_domain: &str,
313 ) -> crate::AuditResult {
314 let mut results = crate::AuditResult::new("GRASP-01 Push Authorization Tests"); 415 let mut results = crate::AuditResult::new("GRASP-01 Push Authorization Tests");
315 416
316 results.add(Self::test_push_rejected_without_state_event(client, relay_domain).await); 417 results.add(Self::test_push_rejected_without_state_event(client, relay_domain).await);
317 results.add(Self::test_push_authorized_by_owner_state(client, relay_domain).await); 418 results.add(Self::test_push_authorized_by_owner_state(client, relay_domain).await);
318 results.add(Self::test_push_rejected_wrong_commit(client, relay_domain).await); 419 results.add(Self::test_push_rejected_wrong_commit(client, relay_domain).await);
319 results.add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await); 420 results
320 results.add(Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await); 421 .add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await);
321 results.add(Self::test_push_to_nostr_ref_with_invalid_event_id_rejected(client, relay_domain).await); 422 results.add(
322 results.add(Self::test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received(client, relay_domain).await); 423 Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await,
323 results.add(Self::test_pr_event_published_removes_nostr_ref_at_incorrect_commit(client, relay_domain).await); 424 );
324 results.add(Self::test_push_to_nostr_ref_with_wrong_commit_after_event_received_rejected(client, relay_domain).await); 425 results.add(
325 results.add(Self::test_push_to_nostr_ref_with_correct_commit_after_event_received_accepted(client, relay_domain).await); 426 Self::test_push_to_nostr_ref_with_invalid_event_id_rejected(client, relay_domain).await,
427 );
428 results.add(
429 Self::test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received(
430 client,
431 relay_domain,
432 )
433 .await,
434 );
435 results.add(
436 Self::test_pr_event_published_removes_nostr_ref_at_incorrect_commit(
437 client,
438 relay_domain,
439 )
440 .await,
441 );
442 results.add(
443 Self::test_push_to_nostr_ref_with_wrong_commit_after_event_received_rejected(
444 client,
445 relay_domain,
446 )
447 .await,
448 );
449 results.add(
450 Self::test_push_to_nostr_ref_with_correct_commit_after_event_received_accepted(
451 client,
452 relay_domain,
453 )
454 .await,
455 );
326 456
327 results 457 results
328 } 458 }
@@ -340,26 +470,37 @@ impl PushAuthorizationTests {
340 Ok(r) => r, 470 Ok(r) => r,
341 Err(e) => { 471 Err(e) => {
342 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") 472 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event")
343 .fail(&format!("Failed to create repo: {}", e)) 473 .fail(format!("Failed to create repo: {}", e))
344 } 474 }
345 }; 475 };
346 476
347 tokio::time::sleep(std::time::Duration::from_millis(200)).await; 477 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
348 478
349 let repo_id = repo.tags.iter().find(|t| t.kind() == TagKind::d()) 479 let repo_id = repo
350 .and_then(|t| t.content()).unwrap().to_string(); 480 .tags
481 .iter()
482 .find(|t| t.kind() == TagKind::d())
483 .and_then(|t| t.content())
484 .unwrap()
485 .to_string();
351 let npub = repo.pubkey.to_bech32().unwrap(); 486 let npub = repo.pubkey.to_bech32().unwrap();
352 487
353 // Clone and create commit 488 // Clone and create commit
354 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { 489 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
355 Ok(p) => p, 490 Ok(p) => p,
356 Err(e) => return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), 491 Err(e) => {
492 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event")
493 .fail(&e)
494 }
495 };
496 let cleanup = || {
497 let _ = fs::remove_dir_all(&clone_path);
357 }; 498 };
358 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
359 499
360 if let Err(e) = create_commit(&clone_path, "Unauthorized commit") { 500 if let Err(e) = create_commit(&clone_path, "Unauthorized commit") {
361 cleanup(); 501 cleanup();
362 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e); 502 return TestResult::new(test_name, "GRASP-01", "Push rejected without state event")
503 .fail(&e);
363 } 504 }
364 505
365 // Do NOT publish state event - push should be rejected 506 // Do NOT publish state event - push should be rejected
@@ -367,9 +508,14 @@ impl PushAuthorizationTests {
367 cleanup(); 508 cleanup();
368 509
369 match push_result { 510 match push_result {
370 Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass(), 511 Ok(false) => {
371 Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail("Push accepted but should be rejected"), 512 TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass()
372 Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), 513 }
514 Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event")
515 .fail("Push accepted but should be rejected"),
516 Err(e) => {
517 TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e)
518 }
373 } 519 }
374 } 520 }
375 521
@@ -400,8 +546,12 @@ impl PushAuthorizationTests {
400 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { 546 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await {
401 Ok(e) => e, 547 Ok(e) => e,
402 Err(e) => { 548 Err(e) => {
403 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 549 return TestResult::new(
404 .fail(&format!("Failed to create RepoState fixture: {}", e)); 550 test_name,
551 "GRASP-01",
552 "Push authorized with matching state",
553 )
554 .fail(format!("Failed to create RepoState fixture: {}", e));
405 } 555 }
406 }; 556 };
407 557
@@ -416,16 +566,24 @@ impl PushAuthorizationTests {
416 { 566 {
417 Some(id) => id.to_string(), 567 Some(id) => id.to_string(),
418 None => { 568 None => {
419 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 569 return TestResult::new(
420 .fail("Missing repo_id in state event"); 570 test_name,
571 "GRASP-01",
572 "Push authorized with matching state",
573 )
574 .fail("Missing repo_id in state event");
421 } 575 }
422 }; 576 };
423 577
424 let npub = match state_event.pubkey.to_bech32() { 578 let npub = match state_event.pubkey.to_bech32() {
425 Ok(n) => n, 579 Ok(n) => n,
426 Err(e) => { 580 Err(e) => {
427 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 581 return TestResult::new(
428 .fail(&format!("Failed to convert pubkey to bech32: {}", e)); 582 test_name,
583 "GRASP-01",
584 "Push authorized with matching state",
585 )
586 .fail(format!("Failed to convert pubkey to bech32: {}", e));
429 } 587 }
430 }; 588 };
431 589
@@ -435,8 +593,12 @@ impl PushAuthorizationTests {
435 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { 593 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
436 Ok(p) => p, 594 Ok(p) => p,
437 Err(e) => { 595 Err(e) => {
438 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 596 return TestResult::new(
439 .fail(&format!("Failed to clone repo: {}", e)); 597 test_name,
598 "GRASP-01",
599 "Push authorized with matching state",
600 )
601 .fail(format!("Failed to clone repo: {}", e));
440 } 602 }
441 }; 603 };
442 604
@@ -450,8 +612,12 @@ impl PushAuthorizationTests {
450 Ok(h) => h, 612 Ok(h) => h,
451 Err(e) => { 613 Err(e) => {
452 cleanup(); 614 cleanup();
453 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 615 return TestResult::new(
454 .fail(&format!("Failed to create deterministic commit: {}", e)); 616 test_name,
617 "GRASP-01",
618 "Push authorized with matching state",
619 )
620 .fail(format!("Failed to create deterministic commit: {}", e));
455 } 621 }
456 }; 622 };
457 623
@@ -459,7 +625,7 @@ impl PushAuthorizationTests {
459 if commit_hash != DETERMINISTIC_COMMIT_HASH { 625 if commit_hash != DETERMINISTIC_COMMIT_HASH {
460 cleanup(); 626 cleanup();
461 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 627 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state")
462 .fail(&format!( 628 .fail(format!(
463 "Commit hash mismatch: got {}, expected {}", 629 "Commit hash mismatch: got {}, expected {}",
464 commit_hash, DETERMINISTIC_COMMIT_HASH 630 commit_hash, DETERMINISTIC_COMMIT_HASH
465 )); 631 ));
@@ -474,16 +640,24 @@ impl PushAuthorizationTests {
474 match branch_output { 640 match branch_output {
475 Err(e) => { 641 Err(e) => {
476 cleanup(); 642 cleanup();
477 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 643 return TestResult::new(
478 .fail(&format!("Failed to create main branch: {}", e)); 644 test_name,
645 "GRASP-01",
646 "Push authorized with matching state",
647 )
648 .fail(format!("Failed to create main branch: {}", e));
479 } 649 }
480 Ok(output) if !output.status.success() => { 650 Ok(output) if !output.status.success() => {
481 cleanup(); 651 cleanup();
482 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 652 return TestResult::new(
483 .fail(&format!( 653 test_name,
484 "Failed to create main branch: {}", 654 "GRASP-01",
485 String::from_utf8_lossy(&output.stderr) 655 "Push authorized with matching state",
486 )); 656 )
657 .fail(format!(
658 "Failed to create main branch: {}",
659 String::from_utf8_lossy(&output.stderr)
660 ));
487 } 661 }
488 _ => {} 662 _ => {}
489 } 663 }
@@ -497,16 +671,24 @@ impl PushAuthorizationTests {
497 match checkout_output { 671 match checkout_output {
498 Err(e) => { 672 Err(e) => {
499 cleanup(); 673 cleanup();
500 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 674 return TestResult::new(
501 .fail(&format!("Failed to checkout main branch: {}", e)); 675 test_name,
676 "GRASP-01",
677 "Push authorized with matching state",
678 )
679 .fail(format!("Failed to checkout main branch: {}", e));
502 } 680 }
503 Ok(output) if !output.status.success() => { 681 Ok(output) if !output.status.success() => {
504 cleanup(); 682 cleanup();
505 return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 683 return TestResult::new(
506 .fail(&format!( 684 test_name,
507 "Failed to checkout main branch: {}", 685 "GRASP-01",
508 String::from_utf8_lossy(&output.stderr) 686 "Push authorized with matching state",
509 )); 687 )
688 .fail(format!(
689 "Failed to checkout main branch: {}",
690 String::from_utf8_lossy(&output.stderr)
691 ));
510 } 692 }
511 _ => {} 693 _ => {}
512 } 694 }
@@ -524,17 +706,15 @@ impl PushAuthorizationTests {
524 } 706 }
525 Ok(false) => { 707 Ok(false) => {
526 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").fail( 708 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").fail(
527 &format!( 709 format!(
528 "Push was rejected but should have been accepted. \ 710 "Push was rejected but should have been accepted. \
529 The state event points to commit {} which matches the pushed commit.", 711 The state event points to commit {} which matches the pushed commit.",
530 DETERMINISTIC_COMMIT_HASH 712 DETERMINISTIC_COMMIT_HASH
531 ), 713 ),
532 ) 714 )
533 } 715 }
534 Err(e) => { 716 Err(e) => TestResult::new(test_name, "GRASP-01", "Push authorized with matching state")
535 TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") 717 .fail(format!("Push error: {}", e)),
536 .fail(&format!("Push error: {}", e))
537 }
538 } 718 }
539 } 719 }
540 720
@@ -571,8 +751,12 @@ impl PushAuthorizationTests {
571 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { 751 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await {
572 Ok(e) => e, 752 Ok(e) => e,
573 Err(e) => { 753 Err(e) => {
574 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 754 return TestResult::new(
575 .fail(&format!("Failed to create RepoState fixture: {}", e)); 755 test_name,
756 "GRASP-01",
757 "Push rejected when commit not in state event",
758 )
759 .fail(format!("Failed to create RepoState fixture: {}", e));
576 } 760 }
577 }; 761 };
578 762
@@ -587,16 +771,24 @@ impl PushAuthorizationTests {
587 { 771 {
588 Some(id) => id.to_string(), 772 Some(id) => id.to_string(),
589 None => { 773 None => {
590 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 774 return TestResult::new(
591 .fail("Missing repo_id in state event"); 775 test_name,
776 "GRASP-01",
777 "Push rejected when commit not in state event",
778 )
779 .fail("Missing repo_id in state event");
592 } 780 }
593 }; 781 };
594 782
595 let npub = match state_event.pubkey.to_bech32() { 783 let npub = match state_event.pubkey.to_bech32() {
596 Ok(n) => n, 784 Ok(n) => n,
597 Err(e) => { 785 Err(e) => {
598 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 786 return TestResult::new(
599 .fail(&format!("Failed to convert pubkey to bech32: {}", e)); 787 test_name,
788 "GRASP-01",
789 "Push rejected when commit not in state event",
790 )
791 .fail(format!("Failed to convert pubkey to bech32: {}", e));
600 } 792 }
601 }; 793 };
602 794
@@ -607,8 +799,12 @@ impl PushAuthorizationTests {
607 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { 799 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
608 Ok(p) => p, 800 Ok(p) => p,
609 Err(e) => { 801 Err(e) => {
610 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 802 return TestResult::new(
611 .fail(&format!("Failed to clone repo: {}", e)); 803 test_name,
804 "GRASP-01",
805 "Push rejected when commit not in state event",
806 )
807 .fail(format!("Failed to clone repo: {}", e));
612 } 808 }
613 }; 809 };
614 810
@@ -626,16 +822,24 @@ impl PushAuthorizationTests {
626 match branch_output { 822 match branch_output {
627 Err(e) => { 823 Err(e) => {
628 cleanup(); 824 cleanup();
629 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 825 return TestResult::new(
630 .fail(&format!("Failed to create/checkout main branch: {}", e)); 826 test_name,
827 "GRASP-01",
828 "Push rejected when commit not in state event",
829 )
830 .fail(format!("Failed to create/checkout main branch: {}", e));
631 } 831 }
632 Ok(output) if !output.status.success() => { 832 Ok(output) if !output.status.success() => {
633 cleanup(); 833 cleanup();
634 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 834 return TestResult::new(
635 .fail(&format!( 835 test_name,
636 "Failed to create/checkout main branch: {}", 836 "GRASP-01",
637 String::from_utf8_lossy(&output.stderr) 837 "Push rejected when commit not in state event",
638 )); 838 )
839 .fail(format!(
840 "Failed to create/checkout main branch: {}",
841 String::from_utf8_lossy(&output.stderr)
842 ));
639 } 843 }
640 _ => {} 844 _ => {}
641 } 845 }
@@ -644,8 +848,12 @@ impl PushAuthorizationTests {
644 // Any commit hash different from what's authorized in the state event will work 848 // Any commit hash different from what's authorized in the state event will work
645 if let Err(e) = create_commit(&clone_path, "Unauthorized commit - should be rejected") { 849 if let Err(e) = create_commit(&clone_path, "Unauthorized commit - should be rejected") {
646 cleanup(); 850 cleanup();
647 return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") 851 return TestResult::new(
648 .fail(&format!("Failed to create wrong commit: {}", e)); 852 test_name,
853 "GRASP-01",
854 "Push rejected when commit not in state event",
855 )
856 .fail(format!("Failed to create wrong commit: {}", e));
649 } 857 }
650 858
651 // ============================================================ 859 // ============================================================
@@ -703,7 +911,7 @@ impl PushAuthorizationTests {
703 "GRASP-01", 911 "GRASP-01",
704 "Push authorized by maintainer state event only (no announcement)", 912 "Push authorized by maintainer state event only (no announcement)",
705 ) 913 )
706 .fail(&format!("Failed to create RepoState fixture: {}", e)); 914 .fail(format!("Failed to create RepoState fixture: {}", e));
707 } 915 }
708 }; 916 };
709 917
@@ -717,7 +925,7 @@ impl PushAuthorizationTests {
717 "GRASP-01", 925 "GRASP-01",
718 "Push authorized by maintainer state event only (no announcement)", 926 "Push authorized by maintainer state event only (no announcement)",
719 ) 927 )
720 .fail(&format!("Failed to create MaintainerState fixture: {}", e)); 928 .fail(format!("Failed to create MaintainerState fixture: {}", e));
721 } 929 }
722 }; 930 };
723 931
@@ -749,7 +957,7 @@ impl PushAuthorizationTests {
749 "GRASP-01", 957 "GRASP-01",
750 "Push authorized by maintainer state event only (no announcement)", 958 "Push authorized by maintainer state event only (no announcement)",
751 ) 959 )
752 .fail(&format!("Failed to convert pubkey to bech32: {}", e)); 960 .fail(format!("Failed to convert pubkey to bech32: {}", e));
753 } 961 }
754 }; 962 };
755 963
@@ -785,19 +993,21 @@ impl PushAuthorizationTests {
785 .output(); 993 .output();
786 994
787 // Step 3: Create deterministic commit using existing function 995 // Step 3: Create deterministic commit using existing function
788 let commit_hash = 996 let commit_hash = match create_deterministic_commit_with_variant(
789 match create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer) { 997 &clone_path,
790 Ok(h) => h, 998 CommitVariant::Maintainer,
791 Err(e) => { 999 ) {
792 cleanup(); 1000 Ok(h) => h,
793 return TestResult::new( 1001 Err(e) => {
794 test_name, 1002 cleanup();
795 "GRASP-01", 1003 return TestResult::new(
796 "Push authorized by maintainer state event only (no announcement)", 1004 test_name,
797 ) 1005 "GRASP-01",
798 .fail(&format!("Failed to create maintainer commit: {}", e)); 1006 "Push authorized by maintainer state event only (no announcement)",
799 } 1007 )
800 }; 1008 .fail(format!("Failed to create maintainer commit: {}", e));
1009 }
1010 };
801 1011
802 // Step 4: Replace main branch with our new orphan branch 1012 // Step 4: Replace main branch with our new orphan branch
803 let _ = Command::new("git") 1013 let _ = Command::new("git")
@@ -818,7 +1028,7 @@ impl PushAuthorizationTests {
818 "GRASP-01", 1028 "GRASP-01",
819 "Push authorized by maintainer state event only (no announcement)", 1029 "Push authorized by maintainer state event only (no announcement)",
820 ) 1030 )
821 .fail(&format!( 1031 .fail(format!(
822 "Maintainer commit hash mismatch: got {}, expected {}", 1032 "Maintainer commit hash mismatch: got {}, expected {}",
823 commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH 1033 commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH
824 )); 1034 ));
@@ -843,7 +1053,7 @@ impl PushAuthorizationTests {
843 "GRASP-01", 1053 "GRASP-01",
844 "Push authorized by maintainer state event only (no announcement)", 1054 "Push authorized by maintainer state event only (no announcement)",
845 ) 1055 )
846 .fail(&format!( 1056 .fail(format!(
847 "Push was rejected but should have been accepted. \ 1057 "Push was rejected but should have been accepted. \
848 The maintainer published a state event with commit {}, \ 1058 The maintainer published a state event with commit {}, \
849 and even without a separate announcement, the relay should \ 1059 and even without a separate announcement, the relay should \
@@ -856,7 +1066,7 @@ impl PushAuthorizationTests {
856 "GRASP-01", 1066 "GRASP-01",
857 "Push authorized by maintainer state event only (no announcement)", 1067 "Push authorized by maintainer state event only (no announcement)",
858 ) 1068 )
859 .fail(&format!("Push error: {}", e)), 1069 .fail(format!("Push error: {}", e)),
860 } 1070 }
861 } 1071 }
862 1072
@@ -899,7 +1109,7 @@ impl PushAuthorizationTests {
899 "GRASP-01", 1109 "GRASP-01",
900 "Push authorized by recursive maintainer state event", 1110 "Push authorized by recursive maintainer state event",
901 ) 1111 )
902 .fail(&format!("Failed to create RepoState fixture: {}", e)); 1112 .fail(format!("Failed to create RepoState fixture: {}", e));
903 } 1113 }
904 }; 1114 };
905 1115
@@ -912,7 +1122,10 @@ impl PushAuthorizationTests {
912 "GRASP-01", 1122 "GRASP-01",
913 "Push authorized by recursive maintainer state event", 1123 "Push authorized by recursive maintainer state event",
914 ) 1124 )
915 .fail(&format!("Failed to create MaintainerAnnouncement fixture: {}", e)); 1125 .fail(format!(
1126 "Failed to create MaintainerAnnouncement fixture: {}",
1127 e
1128 ));
916 } 1129 }
917 }; 1130 };
918 1131
@@ -925,12 +1138,15 @@ impl PushAuthorizationTests {
925 "GRASP-01", 1138 "GRASP-01",
926 "Push authorized by recursive maintainer state event", 1139 "Push authorized by recursive maintainer state event",
927 ) 1140 )
928 .fail(&format!("Failed to create MaintainerState fixture: {}", e)); 1141 .fail(format!("Failed to create MaintainerState fixture: {}", e));
929 } 1142 }
930 }; 1143 };
931 1144
932 // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain) 1145 // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain)
933 match ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await { 1146 match ctx
1147 .get_fixture(FixtureKind::RecursiveMaintainerRepoAndState)
1148 .await
1149 {
934 Ok(_) => {} 1150 Ok(_) => {}
935 Err(e) => { 1151 Err(e) => {
936 return TestResult::new( 1152 return TestResult::new(
@@ -938,7 +1154,10 @@ impl PushAuthorizationTests {
938 "GRASP-01", 1154 "GRASP-01",
939 "Push authorized by recursive maintainer state event", 1155 "Push authorized by recursive maintainer state event",
940 ) 1156 )
941 .fail(&format!("Failed to create RecursiveMaintainerRepoAndState fixture: {}", e)); 1157 .fail(format!(
1158 "Failed to create RecursiveMaintainerRepoAndState fixture: {}",
1159 e
1160 ));
942 } 1161 }
943 }; 1162 };
944 1163
@@ -970,7 +1189,7 @@ impl PushAuthorizationTests {
970 "GRASP-01", 1189 "GRASP-01",
971 "Push authorized by recursive maintainer state event", 1190 "Push authorized by recursive maintainer state event",
972 ) 1191 )
973 .fail(&format!("Failed to convert pubkey to bech32: {}", e)); 1192 .fail(format!("Failed to convert pubkey to bech32: {}", e));
974 } 1193 }
975 }; 1194 };
976 1195
@@ -1006,19 +1225,24 @@ impl PushAuthorizationTests {
1006 .output(); 1225 .output();
1007 1226
1008 // Step 3: Create recursive maintainer deterministic commit 1227 // Step 3: Create recursive maintainer deterministic commit
1009 let commit_hash = 1228 let commit_hash = match create_deterministic_commit_with_variant(
1010 match create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer) { 1229 &clone_path,
1011 Ok(h) => h, 1230 CommitVariant::RecursiveMaintainer,
1012 Err(e) => { 1231 ) {
1013 cleanup(); 1232 Ok(h) => h,
1014 return TestResult::new( 1233 Err(e) => {
1015 test_name, 1234 cleanup();
1016 "GRASP-01", 1235 return TestResult::new(
1017 "Push authorized by recursive maintainer state event", 1236 test_name,
1018 ) 1237 "GRASP-01",
1019 .fail(&format!("Failed to create recursive maintainer commit: {}", e)); 1238 "Push authorized by recursive maintainer state event",
1020 } 1239 )
1021 }; 1240 .fail(format!(
1241 "Failed to create recursive maintainer commit: {}",
1242 e
1243 ));
1244 }
1245 };
1022 1246
1023 // Step 4: Replace main branch with our new orphan branch 1247 // Step 4: Replace main branch with our new orphan branch
1024 let _ = Command::new("git") 1248 let _ = Command::new("git")
@@ -1039,7 +1263,7 @@ impl PushAuthorizationTests {
1039 "GRASP-01", 1263 "GRASP-01",
1040 "Push authorized by recursive maintainer state event", 1264 "Push authorized by recursive maintainer state event",
1041 ) 1265 )
1042 .fail(&format!( 1266 .fail(format!(
1043 "Recursive maintainer commit hash mismatch: got {}, expected {}", 1267 "Recursive maintainer commit hash mismatch: got {}, expected {}",
1044 commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH 1268 commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
1045 )); 1269 ));
@@ -1064,7 +1288,7 @@ impl PushAuthorizationTests {
1064 "GRASP-01", 1288 "GRASP-01",
1065 "Push authorized by recursive maintainer state event", 1289 "Push authorized by recursive maintainer state event",
1066 ) 1290 )
1067 .fail(&format!( 1291 .fail(format!(
1068 "Push was rejected but should have been accepted. \ 1292 "Push was rejected but should have been accepted. \
1069 The recursive maintainer published a state event with commit {}, \ 1293 The recursive maintainer published a state event with commit {}, \
1070 and the relay should authorize pushes matching this state event \ 1294 and the relay should authorize pushes matching this state event \
@@ -1076,7 +1300,7 @@ impl PushAuthorizationTests {
1076 "GRASP-01", 1300 "GRASP-01",
1077 "Push authorized by recursive maintainer state event", 1301 "Push authorized by recursive maintainer state event",
1078 ) 1302 )
1079 .fail(&format!("Push error: {}", e)), 1303 .fail(format!("Push error: {}", e)),
1080 } 1304 }
1081 } 1305 }
1082 1306
@@ -1109,8 +1333,12 @@ impl PushAuthorizationTests {
1109 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { 1333 let state_event = match ctx.get_fixture(FixtureKind::RepoState).await {
1110 Ok(e) => e, 1334 Ok(e) => e,
1111 Err(e) => { 1335 Err(e) => {
1112 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1336 return TestResult::new(
1113 .fail(&format!("Failed to create RepoState fixture: {}", e)); 1337 test_name,
1338 "GRASP-01",
1339 "Non-maintainer state events ignored",
1340 )
1341 .fail(format!("Failed to create RepoState fixture: {}", e));
1114 } 1342 }
1115 }; 1343 };
1116 1344
@@ -1125,16 +1353,24 @@ impl PushAuthorizationTests {
1125 { 1353 {
1126 Some(id) => id.to_string(), 1354 Some(id) => id.to_string(),
1127 None => { 1355 None => {
1128 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1356 return TestResult::new(
1129 .fail("Missing repo_id in state event"); 1357 test_name,
1358 "GRASP-01",
1359 "Non-maintainer state events ignored",
1360 )
1361 .fail("Missing repo_id in state event");
1130 } 1362 }
1131 }; 1363 };
1132 1364
1133 let npub = match state_event.pubkey.to_bech32() { 1365 let npub = match state_event.pubkey.to_bech32() {
1134 Ok(n) => n, 1366 Ok(n) => n,
1135 Err(e) => { 1367 Err(e) => {
1136 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1368 return TestResult::new(
1137 .fail(&format!("Failed to convert pubkey to bech32: {}", e)); 1369 test_name,
1370 "GRASP-01",
1371 "Non-maintainer state events ignored",
1372 )
1373 .fail(format!("Failed to convert pubkey to bech32: {}", e));
1138 } 1374 }
1139 }; 1375 };
1140 1376
@@ -1145,8 +1381,12 @@ impl PushAuthorizationTests {
1145 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { 1381 let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
1146 Ok(p) => p, 1382 Ok(p) => p,
1147 Err(e) => { 1383 Err(e) => {
1148 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1384 return TestResult::new(
1149 .fail(&format!("Failed to clone repo: {}", e)); 1385 test_name,
1386 "GRASP-01",
1387 "Non-maintainer state events ignored",
1388 )
1389 .fail(format!("Failed to clone repo: {}", e));
1150 } 1390 }
1151 }; 1391 };
1152 1392
@@ -1160,8 +1400,12 @@ impl PushAuthorizationTests {
1160 Ok(h) => h, 1400 Ok(h) => h,
1161 Err(e) => { 1401 Err(e) => {
1162 cleanup(); 1402 cleanup();
1163 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1403 return TestResult::new(
1164 .fail(&format!("Failed to create deterministic commit: {}", e)); 1404 test_name,
1405 "GRASP-01",
1406 "Non-maintainer state events ignored",
1407 )
1408 .fail(format!("Failed to create deterministic commit: {}", e));
1165 } 1409 }
1166 }; 1410 };
1167 1411
@@ -1169,7 +1413,7 @@ impl PushAuthorizationTests {
1169 if commit_hash != DETERMINISTIC_COMMIT_HASH { 1413 if commit_hash != DETERMINISTIC_COMMIT_HASH {
1170 cleanup(); 1414 cleanup();
1171 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1415 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
1172 .fail(&format!( 1416 .fail(format!(
1173 "Commit hash mismatch: got {}, expected {}", 1417 "Commit hash mismatch: got {}, expected {}",
1174 commit_hash, DETERMINISTIC_COMMIT_HASH 1418 commit_hash, DETERMINISTIC_COMMIT_HASH
1175 )); 1419 ));
@@ -1184,16 +1428,24 @@ impl PushAuthorizationTests {
1184 match branch_output { 1428 match branch_output {
1185 Err(e) => { 1429 Err(e) => {
1186 cleanup(); 1430 cleanup();
1187 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1431 return TestResult::new(
1188 .fail(&format!("Failed to create main branch: {}", e)); 1432 test_name,
1433 "GRASP-01",
1434 "Non-maintainer state events ignored",
1435 )
1436 .fail(format!("Failed to create main branch: {}", e));
1189 } 1437 }
1190 Ok(output) if !output.status.success() => { 1438 Ok(output) if !output.status.success() => {
1191 cleanup(); 1439 cleanup();
1192 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1440 return TestResult::new(
1193 .fail(&format!( 1441 test_name,
1194 "Failed to create main branch: {}", 1442 "GRASP-01",
1195 String::from_utf8_lossy(&output.stderr) 1443 "Non-maintainer state events ignored",
1196 )); 1444 )
1445 .fail(format!(
1446 "Failed to create main branch: {}",
1447 String::from_utf8_lossy(&output.stderr)
1448 ));
1197 } 1449 }
1198 _ => {} 1450 _ => {}
1199 } 1451 }
@@ -1207,16 +1459,24 @@ impl PushAuthorizationTests {
1207 match checkout_output { 1459 match checkout_output {
1208 Err(e) => { 1460 Err(e) => {
1209 cleanup(); 1461 cleanup();
1210 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1462 return TestResult::new(
1211 .fail(&format!("Failed to checkout main branch: {}", e)); 1463 test_name,
1464 "GRASP-01",
1465 "Non-maintainer state events ignored",
1466 )
1467 .fail(format!("Failed to checkout main branch: {}", e));
1212 } 1468 }
1213 Ok(output) if !output.status.success() => { 1469 Ok(output) if !output.status.success() => {
1214 cleanup(); 1470 cleanup();
1215 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1471 return TestResult::new(
1216 .fail(&format!( 1472 test_name,
1217 "Failed to checkout main branch: {}", 1473 "GRASP-01",
1218 String::from_utf8_lossy(&output.stderr) 1474 "Non-maintainer state events ignored",
1219 )); 1475 )
1476 .fail(format!(
1477 "Failed to checkout main branch: {}",
1478 String::from_utf8_lossy(&output.stderr)
1479 ));
1220 } 1480 }
1221 _ => {} 1481 _ => {}
1222 } 1482 }
@@ -1231,16 +1491,24 @@ impl PushAuthorizationTests {
1231 match push_output { 1491 match push_output {
1232 Err(e) => { 1492 Err(e) => {
1233 cleanup(); 1493 cleanup();
1234 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1494 return TestResult::new(
1235 .fail(&format!("Failed to push initial commit: {}", e)); 1495 test_name,
1496 "GRASP-01",
1497 "Non-maintainer state events ignored",
1498 )
1499 .fail(format!("Failed to push initial commit: {}", e));
1236 } 1500 }
1237 Ok(output) if !output.status.success() => { 1501 Ok(output) if !output.status.success() => {
1238 cleanup(); 1502 cleanup();
1239 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1503 return TestResult::new(
1240 .fail(&format!( 1504 test_name,
1241 "Failed to push initial commit: {}", 1505 "GRASP-01",
1242 String::from_utf8_lossy(&output.stderr) 1506 "Non-maintainer state events ignored",
1243 )); 1507 )
1508 .fail(format!(
1509 "Failed to push initial commit: {}",
1510 String::from_utf8_lossy(&output.stderr)
1511 ));
1244 } 1512 }
1245 _ => {} 1513 _ => {}
1246 } 1514 }
@@ -1253,14 +1521,18 @@ impl PushAuthorizationTests {
1253 Ok(h) => h, 1521 Ok(h) => h,
1254 Err(e) => { 1522 Err(e) => {
1255 cleanup(); 1523 cleanup();
1256 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1524 return TestResult::new(
1257 .fail(&format!("Failed to create commit: {}", e)); 1525 test_name,
1526 "GRASP-01",
1527 "Non-maintainer state events ignored",
1528 )
1529 .fail(format!("Failed to create commit: {}", e));
1258 } 1530 }
1259 }; 1531 };
1260 1532
1261 // Create a rogue keypair (NOT the maintainer) 1533 // Create a rogue keypair (NOT the maintainer)
1262 let rogue_keys = Keys::generate(); 1534 let rogue_keys = Keys::generate();
1263 1535
1264 // Create a rogue state event announcing the new commit 1536 // Create a rogue state event announcing the new commit
1265 // This event has the correct repo_id but is signed by a non-maintainer 1537 // This event has the correct repo_id but is signed by a non-maintainer
1266 let rogue_state = match client 1538 let rogue_state = match client
@@ -1275,8 +1547,12 @@ impl PushAuthorizationTests {
1275 Ok(e) => e, 1547 Ok(e) => e,
1276 Err(e) => { 1548 Err(e) => {
1277 cleanup(); 1549 cleanup();
1278 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1550 return TestResult::new(
1279 .fail(&format!("Failed to build rogue state event: {}", e)); 1551 test_name,
1552 "GRASP-01",
1553 "Non-maintainer state events ignored",
1554 )
1555 .fail(format!("Failed to build rogue state event: {}", e));
1280 } 1556 }
1281 }; 1557 };
1282 1558
@@ -1284,7 +1560,7 @@ impl PushAuthorizationTests {
1284 if let Err(e) = client.client().send_event(&rogue_state).await { 1560 if let Err(e) = client.client().send_event(&rogue_state).await {
1285 cleanup(); 1561 cleanup();
1286 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1562 return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
1287 .fail(&format!("Failed to send rogue state event: {}", e)); 1563 .fail(format!("Failed to send rogue state event: {}", e));
1288 } 1564 }
1289 1565
1290 // Wait for event to propagate 1566 // Wait for event to propagate
@@ -1300,7 +1576,7 @@ impl PushAuthorizationTests {
1300 match push_result { 1576 match push_result {
1301 Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(), 1577 Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(),
1302 Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") 1578 Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored")
1303 .fail(&format!( 1579 .fail(format!(
1304 "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ 1580 "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \
1305 a state event announcing commit {}, but the push was accepted. The relay should \ 1581 a state event announcing commit {}, but the push was accepted. The relay should \
1306 only accept state events from maintainers (pubkey: {}).", 1582 only accept state events from maintainers (pubkey: {}).",
@@ -1342,7 +1618,7 @@ impl PushAuthorizationTests {
1342 "GRASP-01", 1618 "GRASP-01",
1343 "Push to refs/nostr/<invalid-event-id> rejected", 1619 "Push to refs/nostr/<invalid-event-id> rejected",
1344 ) 1620 )
1345 .fail(&format!("Failed to create repo: {}", e)); 1621 .fail(format!("Failed to create repo: {}", e));
1346 } 1622 }
1347 }; 1623 };
1348 1624
@@ -1408,7 +1684,7 @@ impl PushAuthorizationTests {
1408 "GRASP-01", 1684 "GRASP-01",
1409 "Push to refs/nostr/<invalid-event-id> rejected", 1685 "Push to refs/nostr/<invalid-event-id> rejected",
1410 ) 1686 )
1411 .fail(&format!( 1687 .fail(format!(
1412 "Push to {} was accepted but should be rejected. \ 1688 "Push to {} was accepted but should be rejected. \
1413 The event-id '{}' is NOT a valid 64-character hex string (EventId format). \ 1689 The event-id '{}' is NOT a valid 64-character hex string (EventId format). \
1414 The relay should reject pushes to refs/nostr/ with invalid event-id format.", 1690 The relay should reject pushes to refs/nostr/ with invalid event-id format.",
@@ -1419,7 +1695,7 @@ impl PushAuthorizationTests {
1419 "GRASP-01", 1695 "GRASP-01",
1420 "Push to refs/nostr/<invalid-event-id> rejected", 1696 "Push to refs/nostr/<invalid-event-id> rejected",
1421 ) 1697 )
1422 .fail(&format!("Push error: {}", e)), 1698 .fail(format!("Push error: {}", e)),
1423 } 1699 }
1424 } 1700 }
1425 1701
@@ -1434,7 +1710,8 @@ impl PushAuthorizationTests {
1434 client: &AuditClient, 1710 client: &AuditClient,
1435 relay_domain: &str, 1711 relay_domain: &str,
1436 ) -> TestResult { 1712 ) -> TestResult {
1437 let test_name = "test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received"; 1713 let test_name =
1714 "test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received";
1438 let desc = "Push wrong commit to refs/nostr/<pr-event-id> before PR event (should accept)"; 1715 let desc = "Push wrong commit to refs/nostr/<pr-event-id> before PR event (should accept)";
1439 let ctx = TestContext::new(client); 1716 let ctx = TestContext::new(client);
1440 1717
@@ -1459,15 +1736,13 @@ impl PushAuthorizationTests {
1459 /// the relay should validate any existing refs/nostr/<event-id> refs and 1736 /// the relay should validate any existing refs/nostr/<event-id> refs and
1460 /// delete those that don't match the commit in the PR event's `c` tag. 1737 /// delete those that don't match the commit in the PR event's `c` tag.
1461 /// 1738 ///
1462 /// Currently NOT_IMPLEMENTED - the relay doesn't have this cleanup logic yet.
1463 ///
1464 /// Depends on: `setup_repo_with_wrong_commit_pushed` (wrong commit already pushed) 1739 /// Depends on: `setup_repo_with_wrong_commit_pushed` (wrong commit already pushed)
1465 pub async fn test_pr_event_published_removes_nostr_ref_at_incorrect_commit( 1740 pub async fn test_pr_event_published_removes_nostr_ref_at_incorrect_commit(
1466 client: &AuditClient, 1741 client: &AuditClient,
1467 relay_domain: &str, 1742 relay_domain: &str,
1468 ) -> TestResult { 1743 ) -> TestResult {
1469 let test_name = "test_pr_event_published_removes_nostr_ref_at_incorrect_commit"; 1744 let test_name = "test_pr_event_published_removes_nostr_ref_at_incorrect_commit";
1470 let desc = "Publishing PR event should trigger cleanup of incorrect refs (NOT_IMPLEMENTED)"; 1745 let desc = "Publishing PR event should trigger cleanup of incorrect refs";
1471 let ctx = TestContext::new(client); 1746 let ctx = TestContext::new(client);
1472 1747
1473 // Setup: wrong commit already pushed to refs/nostr/<pr-event-id> 1748 // Setup: wrong commit already pushed to refs/nostr/<pr-event-id>
@@ -1479,7 +1754,7 @@ impl PushAuthorizationTests {
1479 }; 1754 };
1480 1755
1481 // NOW publish the PR event - this should trigger cleanup validation 1756 // NOW publish the PR event - this should trigger cleanup validation
1482 if let Err(e) = publish_pr_event_and_wait(&ctx).await { 1757 if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await {
1483 setup.cleanup(); 1758 setup.cleanup();
1484 return TestResult::new(test_name, "GRASP-01", desc).fail(&e); 1759 return TestResult::new(test_name, "GRASP-01", desc).fail(&e);
1485 } 1760 }
@@ -1496,12 +1771,16 @@ impl PushAuthorizationTests {
1496 1771
1497 setup.cleanup(); 1772 setup.cleanup();
1498 1773
1499 // Document current behavior: relay doesn't implement automatic cleanup yet 1774 // Ref should be deleted since the pushed commit doesn't match the PR event's `c` tag
1500 TestResult::new(test_name, "GRASP-01", desc).fail(&format!( 1775 if refs_exist {
1501 "NOT_IMPLEMENTED: Relay should delete refs/nostr/<event-id> when PR event is published \ 1776 TestResult::new(test_name, "GRASP-01", desc).fail(format!(
1502 with non-matching commit. Currently ref still exists: {}. This requires relay-side validation logic.", 1777 "Expected refs/nostr/{} to be deleted when PR event published with non-matching commit, \
1503 refs_exist 1778 but the ref still exists. The relay should delete refs that don't match the event's `c` tag.",
1504 )) 1779 setup.pr_event_id
1780 ))
1781 } else {
1782 TestResult::new(test_name, "GRASP-01", desc).pass()
1783 }
1505 } 1784 }
1506 1785
1507 /// Test 3: Push wrong commit to refs/nostr/<pr-event-id> AFTER PR event exists 1786 /// Test 3: Push wrong commit to refs/nostr/<pr-event-id> AFTER PR event exists
@@ -1528,7 +1807,7 @@ impl PushAuthorizationTests {
1528 }; 1807 };
1529 1808
1530 // Publish PR event FIRST (before our test push) 1809 // Publish PR event FIRST (before our test push)
1531 if let Err(e) = publish_pr_event_and_wait(&ctx).await { 1810 if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await {
1532 setup.cleanup(); 1811 setup.cleanup();
1533 return TestResult::new(test_name, "GRASP-01", desc).fail(&e); 1812 return TestResult::new(test_name, "GRASP-01", desc).fail(&e);
1534 } 1813 }
@@ -1577,7 +1856,7 @@ impl PushAuthorizationTests {
1577 }; 1856 };
1578 1857
1579 // Publish PR event FIRST 1858 // Publish PR event FIRST
1580 if let Err(e) = publish_pr_event_and_wait(&ctx).await { 1859 if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await {
1581 setup.cleanup(); 1860 setup.cleanup();
1582 return TestResult::new(test_name, "GRASP-01", desc).fail(&e); 1861 return TestResult::new(test_name, "GRASP-01", desc).fail(&e);
1583 } 1862 }
@@ -1626,9 +1905,9 @@ mod tests {
1626 /// Run with: cd grasp-audit && nix develop -c cargo test --lib test_pr_test_commit_hash_discovery -- --nocapture 1905 /// Run with: cd grasp-audit && nix develop -c cargo test --lib test_pr_test_commit_hash_discovery -- --nocapture
1627 #[test] 1906 #[test]
1628 fn test_pr_test_commit_hash_discovery() { 1907 fn test_pr_test_commit_hash_discovery() {
1908 use std::fs;
1629 use std::process::Command; 1909 use std::process::Command;
1630 use tempfile::TempDir; 1910 use tempfile::TempDir;
1631 use std::fs;
1632 1911
1633 let temp_dir = TempDir::new().expect("Failed to create temp dir"); 1912 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1634 let path = temp_dir.path(); 1913 let path = temp_dir.path();
@@ -1639,7 +1918,11 @@ mod tests {
1639 .current_dir(path) 1918 .current_dir(path)
1640 .output() 1919 .output()
1641 .expect("Failed to init git"); 1920 .expect("Failed to init git");
1642 assert!(output.status.success(), "git init failed: {:?}", String::from_utf8_lossy(&output.stderr)); 1921 assert!(
1922 output.status.success(),
1923 "git init failed: {:?}",
1924 String::from_utf8_lossy(&output.stderr)
1925 );
1643 1926
1644 // Configure git user - use PR Test Author identity 1927 // Configure git user - use PR Test Author identity
1645 let output = Command::new("git") 1928 let output = Command::new("git")
@@ -1666,21 +1949,31 @@ mod tests {
1666 .current_dir(path) 1949 .current_dir(path)
1667 .output() 1950 .output()
1668 .expect("git add failed"); 1951 .expect("git add failed");
1669 assert!(output.status.success(), "git add failed: {:?}", String::from_utf8_lossy(&output.stderr)); 1952 assert!(
1953 output.status.success(),
1954 "git add failed: {:?}",
1955 String::from_utf8_lossy(&output.stderr)
1956 );
1670 1957
1671 // Create deterministic commit with fixed dates and GPG disabled 1958 // Create deterministic commit with fixed dates and GPG disabled
1672 let output = Command::new("git") 1959 let output = Command::new("git")
1673 .args([ 1960 .args([
1674 "-c", "commit.gpgsign=false", 1961 "-c",
1962 "commit.gpgsign=false",
1675 "commit", 1963 "commit",
1676 "-m", "PR test deterministic commit", 1964 "-m",
1965 "PR test deterministic commit",
1677 ]) 1966 ])
1678 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") 1967 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z")
1679 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") 1968 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z")
1680 .current_dir(path) 1969 .current_dir(path)
1681 .output() 1970 .output()
1682 .expect("git commit failed"); 1971 .expect("git commit failed");
1683 assert!(output.status.success(), "git commit failed: {:?}", String::from_utf8_lossy(&output.stderr)); 1972 assert!(
1973 output.status.success(),
1974 "git commit failed: {:?}",
1975 String::from_utf8_lossy(&output.stderr)
1976 );
1684 1977
1685 // Get the commit hash 1978 // Get the commit hash
1686 let output = Command::new("git") 1979 let output = Command::new("git")
@@ -1688,7 +1981,11 @@ mod tests {
1688 .current_dir(path) 1981 .current_dir(path)
1689 .output() 1982 .output()
1690 .expect("git rev-parse failed"); 1983 .expect("git rev-parse failed");
1691 assert!(output.status.success(), "git rev-parse failed: {:?}", String::from_utf8_lossy(&output.stderr)); 1984 assert!(
1985 output.status.success(),
1986 "git rev-parse failed: {:?}",
1987 String::from_utf8_lossy(&output.stderr)
1988 );
1692 1989
1693 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); 1990 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
1694 1991
@@ -1698,7 +1995,10 @@ mod tests {
1698 1995
1699 // Verify we got a valid 40-character hex hash 1996 // Verify we got a valid 40-character hex hash
1700 assert_eq!(hash.len(), 40, "Hash should be 40 hex chars, got: {}", hash); 1997 assert_eq!(hash.len(), 40, "Hash should be 40 hex chars, got: {}", hash);
1701 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "Hash should be hex chars only"); 1998 assert!(
1999 hash.chars().all(|c| c.is_ascii_hexdigit()),
2000 "Hash should be hex chars only"
2001 );
1702 2002
1703 // If the constant is not PLACEHOLDER, verify it matches 2003 // If the constant is not PLACEHOLDER, verify it matches
1704 if PR_TEST_COMMIT_HASH != "PLACEHOLDER" { 2004 if PR_TEST_COMMIT_HASH != "PLACEHOLDER" {
@@ -1709,4 +2009,4 @@ mod tests {
1709 ); 2009 );
1710 } 2010 }
1711 } 2011 }
1712} \ No newline at end of file 2012}
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index bb3bd01..3b0e759 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -35,7 +35,8 @@ use std::sync::Arc;
35use tracing::debug; 35use tracing::debug;
36 36
37use crate::nostr::events::{ 37use crate::nostr::events::{
38 RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 38 RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT,
39 KIND_REPOSITORY_STATE,
39}; 40};
40 41
41/// Repository data fetched from the database 42/// Repository data fetched from the database
@@ -172,9 +173,9 @@ fn get_maintainers_recursive(
172 checked.insert(pubkey.to_string()); // Mark as checked 173 checked.insert(pubkey.to_string()); // Mark as checked
173 174
174 // Find the announcement event for this pubkey+identifier 175 // Find the announcement event for this pubkey+identifier
175 let announcement = announcements.iter().find(|a| { 176 let announcement = announcements
176 a.event.pubkey.to_hex() == pubkey && a.identifier == identifier 177 .iter()
177 }); 178 .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier);
178 179
179 let Some(announcement) = announcement else { 180 let Some(announcement) = announcement else {
180 return; // No announcement found for this pubkey 181 return; // No announcement found for this pubkey
@@ -195,19 +196,19 @@ pub fn collect_all_authorized_maintainers(
195) -> HashSet<String> { 196) -> HashSet<String> {
196 let by_owner = collect_authorized_maintainers(announcements); 197 let by_owner = collect_authorized_maintainers(announcements);
197 let mut all_authorized = HashSet::new(); 198 let mut all_authorized = HashSet::new();
198 199
199 for maintainers in by_owner.values() { 200 for maintainers in by_owner.values() {
200 for maintainer in maintainers { 201 for maintainer in maintainers {
201 all_authorized.insert(maintainer.clone()); 202 all_authorized.insert(maintainer.clone());
202 } 203 }
203 } 204 }
204 205
205 debug!( 206 debug!(
206 "Collected {} total authorized maintainers from {} owners", 207 "Collected {} total authorized maintainers from {} owners",
207 all_authorized.len(), 208 all_authorized.len(),
208 by_owner.len() 209 by_owner.len()
209 ); 210 );
210 211
211 all_authorized 212 all_authorized
212} 213}
213 214
@@ -601,10 +602,7 @@ pub fn validate_push_refs(
601 pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) 602 pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name)
602) -> Result<()> { 603) -> Result<()> {
603 for (old_oid, new_oid, ref_name) in pushed_refs { 604 for (old_oid, new_oid, ref_name) in pushed_refs {
604 debug!( 605 debug!("Validating push: {} {} -> {}", ref_name, old_oid, new_oid);
605 "Validating push: {} {} -> {}",
606 ref_name, old_oid, new_oid
607 );
608 606
609 // Handle branch updates 607 // Handle branch updates
610 if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { 608 if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") {
@@ -657,7 +655,10 @@ pub fn validate_push_refs(
657 )); 655 ));
658 } 656 }
659 // Valid EventId format - allow push (skip state event check) 657 // Valid EventId format - allow push (skip state event check)
660 debug!("refs/nostr/{} push authorized (valid EventId)", event_id_str); 658 debug!(
659 "refs/nostr/{} push authorized (valid EventId)",
660 event_id_str
661 );
661 continue; // Skip the rest of ref validation for this ref 662 continue; // Skip the rest of ref validation for this ref
662 } else { 663 } else {
663 return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); 664 return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name));
@@ -805,6 +806,119 @@ pub fn npub_to_pubkey(npub: &str) -> Result<String> {
805 Ok(pk.to_hex()) 806 Ok(pk.to_hex())
806} 807}
807 808
809/// Fetch an event by ID from the database and extract the `c` tag commit hash
810///
811/// This is used for validating pushes to refs/nostr/<event-id>. Per GRASP-01,
812/// if a PR or PR Update event with this ID exists in the database, the pushed
813/// commit must match the commit in the event's `c` tag.
814///
815/// # Returns
816/// - `Ok(Some(commit))` if the event exists and has a valid `c` tag
817/// - `Ok(None)` if the event doesn't exist (push should be allowed)
818/// - `Err(_)` on database errors
819pub async fn get_event_commit_tag(
820 database: &Arc<MemoryDatabase>,
821 event_id: &EventId,
822) -> Result<Option<String>> {
823 // Query for PR (1618) and PR Update (1619) events with this ID
824 let filter = Filter::new()
825 .ids([*event_id])
826 .kinds([Kind::from(KIND_PR), Kind::from(KIND_PR_UPDATE)]);
827
828 let events: Vec<Event> = database
829 .query(filter)
830 .await
831 .map_err(|e| anyhow!("Database query failed: {}", e))?
832 .into_iter()
833 .collect();
834
835 if events.is_empty() {
836 debug!("No PR/PR Update event found with ID {}", event_id);
837 return Ok(None);
838 }
839
840 // Get the first (should be only) event
841 let event = &events[0];
842
843 // Extract the `c` tag (commit hash)
844 // Per NIP-34, PR events have a `c` tag with the head commit
845 let commit = event
846 .tags
847 .iter()
848 .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c"))
849 .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string()));
850
851 debug!(
852 "Found PR event {} with commit tag: {:?}",
853 event_id,
854 commit.as_ref()
855 );
856
857 Ok(commit)
858}
859
860/// Validate refs/nostr/ pushes against existing PR/PR Update events
861///
862/// For each ref being pushed to refs/nostr/<event-id>:
863/// 1. Validate the event ID format (error if invalid)
864/// 2. Check if a corresponding event exists in the database
865/// 3. If event exists, verify the pushed commit matches the `c` tag
866///
867/// # Arguments
868/// * `database` - The nostr database to query
869/// * `pushed_refs` - List of (old_oid, new_oid, ref_name) tuples
870///
871/// # Returns
872/// * `Ok(())` if all refs/nostr/ pushes are valid
873/// * `Err(_)` if any ref has invalid event ID format or fails commit validation
874pub async fn validate_nostr_ref_pushes(
875 database: &Arc<MemoryDatabase>,
876 pushed_refs: &[(String, String, String)],
877) -> Result<()> {
878 for (_, new_oid, ref_name) in pushed_refs {
879 // Only check refs/nostr/ refs
880 if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") {
881 // Parse the event ID - error on invalid format
882 let event_id = EventId::parse(event_id_str).map_err(|_| {
883 anyhow!(
884 "Invalid event ID format '{}' in ref: {}",
885 event_id_str,
886 ref_name
887 )
888 })?;
889
890 // Check if event exists and get commit tag
891 match get_event_commit_tag(database, &event_id).await? {
892 Some(expected_commit) => {
893 // Event exists - verify commit matches
894 if new_oid != &expected_commit {
895 return Err(anyhow!(
896 "Push to {} rejected: event {} specifies commit {}, but push contains {}",
897 ref_name,
898 event_id_str,
899 expected_commit,
900 new_oid
901 ));
902 }
903 debug!(
904 "Push to {} validated: commit {} matches event's c tag",
905 ref_name, new_oid
906 );
907 }
908 None => {
909 // No event exists yet - allow push
910 debug!(
911 "Push to {} allowed: no PR/PR Update event with ID {} found yet",
912 ref_name, event_id_str
913 );
914 }
915 }
916 }
917 }
918
919 Ok(())
920}
921
808#[cfg(test)] 922#[cfg(test)]
809mod tests { 923mod tests {
810 use super::*; 924 use super::*;
@@ -920,7 +1034,7 @@ mod tests {
920 let eve = create_test_keys(); // Not authorized 1034 let eve = create_test_keys(); // Not authorized
921 let identifier = "test-repo"; 1035 let identifier = "test-repo";
922 1036
923 // Alice lists Bob as maintainer 1037 // Alice lists Bob as maintainer
924 let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); 1038 let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]);
925 1039
926 let events = vec![alice_announcement]; 1040 let events = vec![alice_announcement];
@@ -1084,4 +1198,4 @@ mod tests {
1084 let back_to_hex = npub_to_pubkey(&npub).unwrap(); 1198 let back_to_hex = npub_to_pubkey(&npub).unwrap();
1085 assert_eq!(hex, back_to_hex); 1199 assert_eq!(hex, back_to_hex);
1086 } 1200 }
1087} \ No newline at end of file 1201}
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
index 23d4b5b..00f2449 100644
--- a/src/git/handlers.rs
+++ b/src/git/handlers.rs
@@ -2,17 +2,18 @@
2//! 2//!
3//! This module implements the HTTP handlers for Git Smart HTTP protocol. 3//! This module implements the HTTP handlers for Git Smart HTTP protocol.
4 4
5use std::path::PathBuf;
6use std::sync::Arc;
7use hyper::{body::Bytes, Response, StatusCode};
8use http_body_util::Full; 5use http_body_util::Full;
6use hyper::{body::Bytes, Response, StatusCode};
9use nostr_relay_builder::prelude::MemoryDatabase; 7use nostr_relay_builder::prelude::MemoryDatabase;
10use nostr_sdk::EventId; 8use nostr_sdk::EventId;
9use std::path::PathBuf;
10use std::sync::Arc;
11use tokio::io::{AsyncReadExt, AsyncWriteExt}; 11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tracing::{debug, error, info, warn}; 12use tracing::{debug, error, info, warn};
13 13
14use super::authorization::{ 14use super::authorization::{
15 get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult, 15 get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs,
16 AuthorizationResult,
16}; 17};
17use super::protocol::{GitService, PktLine}; 18use super::protocol::{GitService, PktLine};
18use super::subprocess::GitSubprocess; 19use super::subprocess::GitSubprocess;
@@ -27,7 +28,10 @@ pub async fn handle_info_refs(
27 repo_path: PathBuf, 28 repo_path: PathBuf,
28 service: GitService, 29 service: GitService,
29) -> Result<Response<Full<Bytes>>, GitError> { 30) -> Result<Response<Full<Bytes>>, GitError> {
30 debug!("Handling info/refs for {:?} with service {:?}", repo_path, service); 31 debug!(
32 "Handling info/refs for {:?} with service {:?}",
33 repo_path, service
34 );
31 35
32 // Check if repository exists 36 // Check if repository exists
33 if !repo_path.exists() { 37 if !repo_path.exists() {
@@ -36,55 +40,54 @@ pub async fn handle_info_refs(
36 } 40 }
37 41
38 // Spawn git with --advertise-refs 42 // Spawn git with --advertise-refs
39 let mut git = GitSubprocess::spawn(service, &repo_path, true) 43 let mut git = GitSubprocess::spawn(service, &repo_path, true).map_err(|e| {
40 .map_err(|e| { 44 error!("Failed to spawn git process: {}", e);
41 error!("Failed to spawn git process: {}", e); 45 GitError::ProcessSpawnFailed(e)
42 GitError::ProcessSpawnFailed(e) 46 })?;
43 })?;
44 47
45 // Read the output from git 48 // Read the output from git
46 let mut output = Vec::new(); 49 let mut output = Vec::new();
47 let mut stderr_output = Vec::new(); 50 let mut stderr_output = Vec::new();
48 51
49 if let Some(stdout) = git.take_stdout() { 52 if let Some(stdout) = git.take_stdout() {
50 let mut stdout = stdout; 53 let mut stdout = stdout;
51 stdout.read_to_end(&mut output).await 54 stdout.read_to_end(&mut output).await.map_err(|e| {
52 .map_err(|e| { 55 error!("Failed to read git output: {}", e);
53 error!("Failed to read git output: {}", e); 56 GitError::IoError(e)
54 GitError::IoError(e) 57 })?;
55 })?;
56 } 58 }
57 59
58 if let Some(stderr) = git.take_stderr() { 60 if let Some(stderr) = git.take_stderr() {
59 let mut stderr = stderr; 61 let mut stderr = stderr;
60 stderr.read_to_end(&mut stderr_output).await 62 stderr.read_to_end(&mut stderr_output).await.map_err(|e| {
61 .map_err(|e| { 63 error!("Failed to read git stderr: {}", e);
62 error!("Failed to read git stderr: {}", e); 64 GitError::IoError(e)
63 GitError::IoError(e) 65 })?;
64 })?;
65 } 66 }
66 67
67 // Wait for process to complete 68 // Wait for process to complete
68 let status = git.wait().await 69 let status = git.wait().await.map_err(|e| {
69 .map_err(|e| { 70 error!("Failed to wait for git process: {}", e);
70 error!("Failed to wait for git process: {}", e); 71 GitError::IoError(e)
71 GitError::IoError(e) 72 })?;
72 })?;
73 73
74 if !status.success() { 74 if !status.success() {
75 let stderr_str = String::from_utf8_lossy(&stderr_output); 75 let stderr_str = String::from_utf8_lossy(&stderr_output);
76 error!("Git process failed with status: {:?}, stderr: {}", status, stderr_str); 76 error!(
77 "Git process failed with status: {:?}, stderr: {}",
78 status, stderr_str
79 );
77 return Err(GitError::GitFailed(status.code())); 80 return Err(GitError::GitFailed(status.code()));
78 } 81 }
79 82
80 // Build response with pkt-line header 83 // Build response with pkt-line header
81 let mut response_body = Vec::new(); 84 let mut response_body = Vec::new();
82 85
83 // First line: service advertisement 86 // First line: service advertisement
84 let service_line = format!("# service={}\n", service.as_str()); 87 let service_line = format!("# service={}\n", service.as_str());
85 response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); 88 response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode());
86 response_body.extend_from_slice(&PktLine::flush().encode()); 89 response_body.extend_from_slice(&PktLine::flush().encode());
87 90
88 // Then the git output 91 // Then the git output
89 response_body.extend_from_slice(&output); 92 response_body.extend_from_slice(&output);
90 93
@@ -113,7 +116,9 @@ pub async fn handle_upload_pack(
113 116
114 // Write request to git's stdin 117 // Write request to git's stdin
115 if let Some(mut stdin) = git.take_stdin() { 118 if let Some(mut stdin) = git.take_stdin() {
116 stdin.write_all(&request_body).await 119 stdin
120 .write_all(&request_body)
121 .await
117 .map_err(GitError::IoError)?; 122 .map_err(GitError::IoError)?;
118 // Close stdin to signal end of input 123 // Close stdin to signal end of input
119 drop(stdin); 124 drop(stdin);
@@ -122,22 +127,25 @@ pub async fn handle_upload_pack(
122 // Read response from git's stdout 127 // Read response from git's stdout
123 let mut output = Vec::new(); 128 let mut output = Vec::new();
124 let mut stderr_output = Vec::new(); 129 let mut stderr_output = Vec::new();
125 130
126 if let Some(stdout) = git.take_stdout() { 131 if let Some(stdout) = git.take_stdout() {
127 let mut stdout = stdout; 132 let mut stdout = stdout;
128 stdout.read_to_end(&mut output).await 133 stdout
134 .read_to_end(&mut output)
135 .await
129 .map_err(GitError::IoError)?; 136 .map_err(GitError::IoError)?;
130 } 137 }
131 138
132 if let Some(stderr) = git.take_stderr() { 139 if let Some(stderr) = git.take_stderr() {
133 let mut stderr = stderr; 140 let mut stderr = stderr;
134 stderr.read_to_end(&mut stderr_output).await 141 stderr
142 .read_to_end(&mut stderr_output)
143 .await
135 .map_err(GitError::IoError)?; 144 .map_err(GitError::IoError)?;
136 } 145 }
137 146
138 // Wait for process 147 // Wait for process
139 let status = git.wait().await 148 let status = git.wait().await.map_err(GitError::IoError)?;
140 .map_err(GitError::IoError)?;
141 149
142 if !status.success() { 150 if !status.success() {
143 let stderr_str = String::from_utf8_lossy(&stderr_output); 151 let stderr_str = String::from_utf8_lossy(&stderr_output);
@@ -194,10 +202,7 @@ pub async fn handle_receive_pack(
194 match authorize_push(db, identifier, owner_pubkey, &request_body).await { 202 match authorize_push(db, identifier, owner_pubkey, &request_body).await {
195 Ok(auth_result) => { 203 Ok(auth_result) => {
196 if !auth_result.authorized { 204 if !auth_result.authorized {
197 warn!( 205 warn!("Push rejected for {}: {}", identifier, auth_result.reason);
198 "Push rejected for {}: {}",
199 identifier, auth_result.reason
200 );
201 return Err(GitError::Unauthorized); 206 return Err(GitError::Unauthorized);
202 } 207 }
203 info!( 208 info!(
@@ -209,10 +214,7 @@ pub async fn handle_receive_pack(
209 authorized_state = auth_result.state; 214 authorized_state = auth_result.state;
210 } 215 }
211 Err(e) => { 216 Err(e) => {
212 warn!( 217 warn!("Authorization check failed for {}: {}", identifier, e);
213 "Authorization check failed for {}: {}",
214 identifier, e
215 );
216 return Err(GitError::Unauthorized); 218 return Err(GitError::Unauthorized);
217 } 219 }
218 } 220 }
@@ -226,7 +228,9 @@ pub async fn handle_receive_pack(
226 228
227 // Write request to git's stdin 229 // Write request to git's stdin
228 if let Some(mut stdin) = git.take_stdin() { 230 if let Some(mut stdin) = git.take_stdin() {
229 stdin.write_all(&request_body).await 231 stdin
232 .write_all(&request_body)
233 .await
230 .map_err(GitError::IoError)?; 234 .map_err(GitError::IoError)?;
231 drop(stdin); 235 drop(stdin);
232 } 236 }
@@ -234,22 +238,25 @@ pub async fn handle_receive_pack(
234 // Read response from git's stdout 238 // Read response from git's stdout
235 let mut output = Vec::new(); 239 let mut output = Vec::new();
236 let mut stderr_output = Vec::new(); 240 let mut stderr_output = Vec::new();
237 241
238 if let Some(stdout) = git.take_stdout() { 242 if let Some(stdout) = git.take_stdout() {
239 let mut stdout = stdout; 243 let mut stdout = stdout;
240 stdout.read_to_end(&mut output).await 244 stdout
245 .read_to_end(&mut output)
246 .await
241 .map_err(GitError::IoError)?; 247 .map_err(GitError::IoError)?;
242 } 248 }
243 249
244 if let Some(stderr) = git.take_stderr() { 250 if let Some(stderr) = git.take_stderr() {
245 let mut stderr = stderr; 251 let mut stderr = stderr;
246 stderr.read_to_end(&mut stderr_output).await 252 stderr
253 .read_to_end(&mut stderr_output)
254 .await
247 .map_err(GitError::IoError)?; 255 .map_err(GitError::IoError)?;
248 } 256 }
249 257
250 // Wait for process 258 // Wait for process
251 let status = git.wait().await 259 let status = git.wait().await.map_err(GitError::IoError)?;
252 .map_err(GitError::IoError)?;
253 260
254 if !status.success() { 261 if !status.success() {
255 let stderr_str = String::from_utf8_lossy(&stderr_output); 262 let stderr_str = String::from_utf8_lossy(&stderr_output);
@@ -266,10 +273,7 @@ pub async fn handle_receive_pack(
266 if let Some(commit) = state.get_branch_commit(branch_name) { 273 if let Some(commit) = state.get_branch_commit(branch_name) {
267 match try_set_head_if_available(&repo_path, head_ref, commit) { 274 match try_set_head_if_available(&repo_path, head_ref, commit) {
268 Ok(true) => { 275 Ok(true) => {
269 info!( 276 info!("Set HEAD to {} after push to {:?}", head_ref, repo_path);
270 "Set HEAD to {} after push to {:?}",
271 head_ref, repo_path
272 );
273 } 277 }
274 Ok(false) => { 278 Ok(false) => {
275 debug!( 279 debug!(
@@ -278,10 +282,7 @@ pub async fn handle_receive_pack(
278 ); 282 );
279 } 283 }
280 Err(e) => { 284 Err(e) => {
281 warn!( 285 warn!("Failed to set HEAD after push: {}", e);
282 "Failed to set HEAD after push: {}",
283 e
284 );
285 } 286 }
286 } 287 }
287 } 288 }
@@ -291,7 +292,10 @@ pub async fn handle_receive_pack(
291 292
292 Ok(Response::builder() 293 Ok(Response::builder()
293 .status(StatusCode::OK) 294 .status(StatusCode::OK)
294 .header("content-type", GitService::ReceivePack.result_content_type()) 295 .header(
296 "content-type",
297 GitService::ReceivePack.result_content_type(),
298 )
295 .header("cache-control", "no-cache") 299 .header("cache-control", "no-cache")
296 .body(Full::new(Bytes::from(output))) 300 .body(Full::new(Bytes::from(output)))
297 .unwrap()) 301 .unwrap())
@@ -305,6 +309,7 @@ pub async fn handle_receive_pack(
305/// 3. Collects authorized publishers from that announcement (owner + maintainers) 309/// 3. Collects authorized publishers from that announcement (owner + maintainers)
306/// 4. Gets the latest authorized state from those publishers 310/// 4. Gets the latest authorized state from those publishers
307/// 5. Validates that pushed refs match the state 311/// 5. Validates that pushed refs match the state
312/// 6. Validates refs/nostr/<event-id> has valid event id and if event exists, `c` tag matches ref
308async fn authorize_push( 313async fn authorize_push(
309 database: &Arc<MemoryDatabase>, 314 database: &Arc<MemoryDatabase>,
310 identifier: &str, 315 identifier: &str,
@@ -323,59 +328,79 @@ async fn authorize_push(
323 debug!(" {} {} -> {}", ref_name, old_oid, new_oid); 328 debug!(" {} {} -> {}", ref_name, old_oid, new_oid);
324 } 329 }
325 330
326 // Check if ALL pushed refs are to refs/nostr/ with valid EventId format 331 // Separate refs/nostr/ refs from other refs
327 // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" 332 // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`"
328 // These pushes only require EventId format validation, not state validation 333 let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs
329 let all_refs_nostr_valid = !pushed_refs.is_empty() 334 .iter()
330 && pushed_refs.iter().all(|(_, _, ref_name)| { 335 .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/"));
331 if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { 336
332 // Validate it parses as a valid EventId 337 // Validate refs/nostr/ refs if any exist
333 EventId::parse(event_id_str).is_ok() 338 if !nostr_refs.is_empty() {
334 } else { 339 debug!(
335 false 340 "Found {} refs/nostr/ refs - validating against events",
336 } 341 nostr_refs.len()
337 }); 342 );
338 343
339 if all_refs_nostr_valid { 344 // Validate refs/nostr/ pushes: checks event ID format and commit matching
340 debug!("All refs are refs/nostr/ with valid EventId format - authorized without state check"); 345 let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs
341 // Return success for refs/nostr/ pushes without requiring state 346 .into_iter()
347 .map(|(a, b, c)| (a.clone(), b.clone(), c.clone()))
348 .collect();
349 if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await {
350 warn!("refs/nostr/ validation failed: {}", e);
351 return Ok(AuthorizationResult::denied(format!(
352 "refs/nostr/ validation failed: {}",
353 e
354 )));
355 }
356 debug!("refs/nostr/ push validated successfully");
357 }
358
359 // If only refs/nostr/ refs, we're done - return success
360 if other_refs.is_empty() {
361 debug!("Only refs/nostr/ refs in push - authorization complete");
342 return Ok(AuthorizationResult { 362 return Ok(AuthorizationResult {
343 authorized: true, 363 authorized: true,
344 reason: "Push to refs/nostr/ with valid EventId format".to_string(), 364 reason: "Push to refs/nostr/ validated against events".to_string(),
345 state: None, 365 state: None,
346 maintainers: vec![], 366 maintainers: vec![],
347 }); 367 });
348 } 368 }
349 369
350 // For non-refs/nostr/ pushes, require state validation as normal 370 // For non-refs/nostr/ refs, require state validation
351 debug!("Non-refs/nostr/ push detected - checking state authorization"); 371 debug!(
372 "Found {} non-refs/nostr/ refs - checking state authorization",
373 other_refs.len()
374 );
352 let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; 375 let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?;
353 376
354 if !auth_result.authorized { 377 if !auth_result.authorized {
355 return Ok(auth_result); 378 return Ok(auth_result);
356 } 379 }
357 380
358 // Parse refs from the push request 381 // Convert other_refs for validation
359 let pushed_refs = parse_pushed_refs(request_body); 382 let other_refs_owned: Vec<(String, String, String)> = other_refs
360 debug!("Parsed {} refs from push request", pushed_refs.len()); 383 .into_iter()
361 for (old_oid, new_oid, ref_name) in &pushed_refs { 384 .map(|(a, b, c)| (a.clone(), b.clone(), c.clone()))
362 debug!(" {} {} -> {}", ref_name, old_oid, new_oid); 385 .collect();
363 }
364 386
365 // Validate refs against state 387 // Validate non-refs/nostr/ refs against state
366 if let Some(ref state) = auth_result.state { 388 if let Some(ref state) = auth_result.state {
367 debug!("Validating against state with {} branches", state.branches.len()); 389 debug!(
368 390 "Validating against state with {} branches",
391 state.branches.len()
392 );
393
369 // If we have a state event but couldn't parse any refs, reject the push. 394 // If we have a state event but couldn't parse any refs, reject the push.
370 // This protects against parsing failures allowing unauthorized pushes. 395 // This protects against parsing failures allowing unauthorized pushes.
371 if pushed_refs.is_empty() && !state.branches.is_empty() { 396 if other_refs_owned.is_empty() && !state.branches.is_empty() {
372 warn!("No refs parsed from push request but state event has branches - rejecting"); 397 warn!("No refs parsed from push request but state event has branches - rejecting");
373 return Ok(AuthorizationResult::denied( 398 return Ok(AuthorizationResult::denied(
374 "Failed to parse refs from push request - cannot validate against state" 399 "Failed to parse refs from push request - cannot validate against state",
375 )); 400 ));
376 } 401 }
377 402
378 if let Err(e) = validate_push_refs(state, &pushed_refs) { 403 if let Err(e) = validate_push_refs(state, &other_refs_owned) {
379 warn!("Ref validation failed: {}", e); 404 warn!("Ref validation failed: {}", e);
380 return Ok(AuthorizationResult::denied(format!( 405 return Ok(AuthorizationResult::denied(format!(
381 "Ref validation failed: {}", 406 "Ref validation failed: {}",
@@ -423,4 +448,4 @@ impl GitError {
423 _ => StatusCode::INTERNAL_SERVER_ERROR, 448 _ => StatusCode::INTERNAL_SERVER_ERROR,
424 } 449 }
425 } 450 }
426} \ No newline at end of file 451}
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 076e211..494f8b9 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -40,7 +40,7 @@ use tracing::{debug, info};
40pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { 40pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf {
41 // Remove .git suffix if present 41 // Remove .git suffix if present
42 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); 42 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier);
43 43
44 PathBuf::from(git_data_path) 44 PathBuf::from(git_data_path)
45 .join(npub) 45 .join(npub)
46 .join(format!("{}.git", identifier)) 46 .join(format!("{}.git", identifier))
@@ -89,7 +89,10 @@ pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool {
89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { 89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> {
90 // Validate the ref format 90 // Validate the ref format
91 if !head_ref.starts_with("refs/heads/") { 91 if !head_ref.starts_with("refs/heads/") {
92 return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref)); 92 return Err(format!(
93 "Invalid HEAD ref: {} (must start with refs/heads/)",
94 head_ref
95 ));
93 } 96 }
94 97
95 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); 98 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display());
@@ -130,7 +133,10 @@ pub fn try_set_head_if_available(
130) -> Result<bool, String> { 133) -> Result<bool, String> {
131 // Check if repository exists 134 // Check if repository exists
132 if !repo_path.exists() { 135 if !repo_path.exists() {
133 debug!("Repository not found at {}, cannot set HEAD", repo_path.display()); 136 debug!(
137 "Repository not found at {}, cannot set HEAD",
138 repo_path.display()
139 );
134 return Ok(false); 140 return Ok(false);
135 } 141 }
136 142
@@ -149,6 +155,115 @@ pub fn try_set_head_if_available(
149 Ok(true) 155 Ok(true)
150} 156}
151 157
158/// Get the commit hash that a ref points to
159///
160/// # Arguments
161/// * `repo_path` - Path to the bare git repository
162/// * `ref_name` - The ref name (e.g., "refs/nostr/<event-id>")
163///
164/// # Returns
165/// Some(commit_hash) if the ref exists, None otherwise
166pub fn get_ref_commit(repo_path: &Path, ref_name: &str) -> Option<String> {
167 let output = Command::new("git")
168 .args(["rev-parse", ref_name])
169 .current_dir(repo_path)
170 .output()
171 .ok()?;
172
173 if output.status.success() {
174 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
175 } else {
176 None
177 }
178}
179
180/// Delete a git ref from the repository
181///
182/// # Arguments
183/// * `repo_path` - Path to the bare git repository
184/// * `ref_name` - The ref name to delete (e.g., "refs/nostr/<event-id>")
185///
186/// # Returns
187/// Ok(()) if successful, Err with error message otherwise
188pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> {
189 debug!("Deleting ref {} from {}", ref_name, repo_path.display());
190
191 let output = Command::new("git")
192 .args(["update-ref", "-d", ref_name])
193 .current_dir(repo_path)
194 .output()
195 .map_err(|e| format!("Failed to execute git update-ref: {}", e))?;
196
197 if !output.status.success() {
198 let stderr = String::from_utf8_lossy(&output.stderr);
199 return Err(format!("git update-ref -d failed: {}", stderr));
200 }
201
202 info!("Deleted ref {} from {}", ref_name, repo_path.display());
203 Ok(())
204}
205
206/// Validate refs/nostr/<event-id> ref against expected commit
207///
208/// If the ref exists but points to a different commit than expected,
209/// the ref is deleted. This is called when a PR event is received to
210/// ensure refs/nostr refs are consistent with their corresponding events.
211///
212/// # Arguments
213/// * `repo_path` - Path to the bare git repository
214/// * `event_id` - The event ID (hex string)
215/// * `expected_commit` - The commit hash from the event's `c` tag
216///
217/// # Returns
218/// Ok(true) if ref was deleted (mismatch), Ok(false) if no action taken, Err on failure
219pub fn validate_nostr_ref(
220 repo_path: &Path,
221 event_id: &str,
222 expected_commit: &str,
223) -> Result<bool, String> {
224 let ref_name = format!("refs/nostr/{}", event_id);
225
226 // Check if repository exists
227 if !repo_path.exists() {
228 debug!(
229 "Repository not found at {}, skipping ref validation",
230 repo_path.display()
231 );
232 return Ok(false);
233 }
234
235 // Check if the ref exists
236 let current_commit = match get_ref_commit(repo_path, &ref_name) {
237 Some(commit) => commit,
238 None => {
239 debug!("Ref {} does not exist in {}", ref_name, repo_path.display());
240 return Ok(false);
241 }
242 };
243
244 // Compare commits
245 if current_commit == expected_commit {
246 debug!(
247 "Ref {} points to correct commit {} in {}",
248 ref_name,
249 expected_commit,
250 repo_path.display()
251 );
252 return Ok(false);
253 }
254
255 // Commit mismatch - delete the ref
256 info!(
257 "Deleting mismatched ref {} in {}: expected {}, found {}",
258 ref_name,
259 repo_path.display(),
260 expected_commit,
261 current_commit
262 );
263 delete_ref(repo_path, &ref_name)?;
264 Ok(true)
265}
266
152/// Get the current HEAD ref from a repository 267/// Get the current HEAD ref from a repository
153/// 268///
154/// # Arguments 269/// # Arguments
@@ -178,25 +293,25 @@ pub fn get_repository_head(repo_path: &Path) -> Option<String> {
178pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { 293pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> {
179 // Remove leading slash 294 // Remove leading slash
180 let path = path.strip_prefix('/').unwrap_or(path); 295 let path = path.strip_prefix('/').unwrap_or(path);
181 296
182 // Split into components 297 // Split into components
183 let parts: Vec<&str> = path.splitn(3, '/').collect(); 298 let parts: Vec<&str> = path.splitn(3, '/').collect();
184 299
185 if parts.len() < 3 { 300 if parts.len() < 3 {
186 return None; 301 return None;
187 } 302 }
188 303
189 let npub = parts[0]; 304 let npub = parts[0];
190 let repo_part = parts[1]; 305 let repo_part = parts[1];
191 let subpath = parts[2]; 306 let subpath = parts[2];
192 307
193 // Extract identifier (remove .git suffix if present for the middle part) 308 // Extract identifier (remove .git suffix if present for the middle part)
194 let identifier = if repo_part.ends_with(".git") { 309 let identifier = if repo_part.ends_with(".git") {
195 &repo_part[..repo_part.len() - 4] 310 &repo_part[..repo_part.len() - 4]
196 } else { 311 } else {
197 repo_part 312 repo_part
198 }; 313 };
199 314
200 Some((npub, identifier, subpath)) 315 Some((npub, identifier, subpath))
201} 316}
202 317
@@ -210,13 +325,13 @@ mod tests {
210 fn create_test_repo() -> (TempDir, PathBuf) { 325 fn create_test_repo() -> (TempDir, PathBuf) {
211 let temp_dir = TempDir::new().unwrap(); 326 let temp_dir = TempDir::new().unwrap();
212 let repo_path = temp_dir.path().join("test.git"); 327 let repo_path = temp_dir.path().join("test.git");
213 328
214 // Initialize bare repository 329 // Initialize bare repository
215 Command::new("git") 330 Command::new("git")
216 .args(["init", "--bare", repo_path.to_str().unwrap()]) 331 .args(["init", "--bare", repo_path.to_str().unwrap()])
217 .output() 332 .output()
218 .unwrap(); 333 .unwrap();
219 334
220 (temp_dir, repo_path) 335 (temp_dir, repo_path)
221 } 336 }
222 337
@@ -225,19 +340,23 @@ mod tests {
225 let temp_dir = TempDir::new().unwrap(); 340 let temp_dir = TempDir::new().unwrap();
226 let work_dir = temp_dir.path().join("work"); 341 let work_dir = temp_dir.path().join("work");
227 let bare_repo = temp_dir.path().join("test.git"); 342 let bare_repo = temp_dir.path().join("test.git");
228 343
229 // Initialize bare repository 344 // Initialize bare repository
230 Command::new("git") 345 Command::new("git")
231 .args(["init", "--bare", bare_repo.to_str().unwrap()]) 346 .args(["init", "--bare", "--initial-branch=main", bare_repo.to_str().unwrap()])
232 .output() 347 .output()
233 .unwrap(); 348 .unwrap();
234 349
235 // Clone to working directory 350 // Clone to working directory
236 Command::new("git") 351 Command::new("git")
237 .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()]) 352 .args([
353 "clone",
354 bare_repo.to_str().unwrap(),
355 work_dir.to_str().unwrap(),
356 ])
238 .output() 357 .output()
239 .unwrap(); 358 .unwrap();
240 359
241 // Configure git for commits 360 // Configure git for commits
242 Command::new("git") 361 Command::new("git")
243 .args(["config", "user.email", "test@test.com"]) 362 .args(["config", "user.email", "test@test.com"])
@@ -249,7 +368,7 @@ mod tests {
249 .current_dir(&work_dir) 368 .current_dir(&work_dir)
250 .output() 369 .output()
251 .unwrap(); 370 .unwrap();
252 371
253 // Create a file and commit 372 // Create a file and commit
254 fs::write(work_dir.join("README.md"), "# Test").unwrap(); 373 fs::write(work_dir.join("README.md"), "# Test").unwrap();
255 Command::new("git") 374 Command::new("git")
@@ -262,7 +381,7 @@ mod tests {
262 .current_dir(&work_dir) 381 .current_dir(&work_dir)
263 .output() 382 .output()
264 .unwrap(); 383 .unwrap();
265 384
266 // Get commit hash 385 // Get commit hash
267 let output = Command::new("git") 386 let output = Command::new("git")
268 .args(["rev-parse", "HEAD"]) 387 .args(["rev-parse", "HEAD"])
@@ -270,41 +389,27 @@ mod tests {
270 .output() 389 .output()
271 .unwrap(); 390 .unwrap();
272 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); 391 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
273 392
274 // Push to bare repo 393 // Push to bare repo
275 Command::new("git") 394 Command::new("git")
276 .args(["push", "origin", "master"]) 395 .args(["push", "origin", "main"])
277 .current_dir(&work_dir) 396 .current_dir(&work_dir)
278 .output() 397 .output()
279 .unwrap(); 398 .unwrap();
280 399
281 (temp_dir, bare_repo, commit_hash) 400 (temp_dir, bare_repo, commit_hash)
282 } 401 }
283 402
284 #[test] 403 #[test]
285 fn test_resolve_repo_path() { 404 fn test_resolve_repo_path() {
286 let path = resolve_repo_path( 405 let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo");
287 "/data/git", 406 assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git"));
288 "npub1abc123",
289 "my-repo"
290 );
291 assert_eq!(
292 path,
293 PathBuf::from("/data/git/npub1abc123/my-repo.git")
294 );
295 } 407 }
296 408
297 #[test] 409 #[test]
298 fn test_resolve_repo_path_with_git_suffix() { 410 fn test_resolve_repo_path_with_git_suffix() {
299 let path = resolve_repo_path( 411 let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git");
300 "/data/git", 412 assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git"));
301 "npub1abc123",
302 "my-repo.git"
303 );
304 assert_eq!(
305 path,
306 PathBuf::from("/data/git/npub1abc123/my-repo.git")
307 );
308 } 413 }
309 414
310 #[test] 415 #[test]
@@ -332,7 +437,10 @@ mod tests {
332 #[test] 437 #[test]
333 fn test_commit_exists_nonexistent() { 438 fn test_commit_exists_nonexistent() {
334 let (_temp_dir, repo_path) = create_test_repo(); 439 let (_temp_dir, repo_path) = create_test_repo();
335 assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef")); 440 assert!(!commit_exists(
441 &repo_path,
442 "deadbeef1234567890abcdef1234567890abcdef"
443 ));
336 } 444 }
337 445
338 #[test] 446 #[test]
@@ -344,11 +452,11 @@ mod tests {
344 #[test] 452 #[test]
345 fn test_set_repository_head() { 453 fn test_set_repository_head() {
346 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); 454 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit();
347 455
348 // Default HEAD might be refs/heads/master 456 // Default HEAD might be refs/heads/master
349 let result = set_repository_head(&repo_path, "refs/heads/main"); 457 let result = set_repository_head(&repo_path, "refs/heads/main");
350 assert!(result.is_ok()); 458 assert!(result.is_ok());
351 459
352 let head = get_repository_head(&repo_path); 460 let head = get_repository_head(&repo_path);
353 assert_eq!(head, Some("refs/heads/main".to_string())); 461 assert_eq!(head, Some("refs/heads/main".to_string()));
354 } 462 }
@@ -356,7 +464,7 @@ mod tests {
356 #[test] 464 #[test]
357 fn test_set_repository_head_invalid_ref() { 465 fn test_set_repository_head_invalid_ref() {
358 let (_temp_dir, repo_path) = create_test_repo(); 466 let (_temp_dir, repo_path) = create_test_repo();
359 467
360 // Invalid ref format should fail 468 // Invalid ref format should fail
361 let result = set_repository_head(&repo_path, "main"); 469 let result = set_repository_head(&repo_path, "main");
362 assert!(result.is_err()); 470 assert!(result.is_err());
@@ -366,13 +474,13 @@ mod tests {
366 #[test] 474 #[test]
367 fn test_try_set_head_if_available_commit_missing() { 475 fn test_try_set_head_if_available_commit_missing() {
368 let (_temp_dir, repo_path) = create_test_repo(); 476 let (_temp_dir, repo_path) = create_test_repo();
369 477
370 let result = try_set_head_if_available( 478 let result = try_set_head_if_available(
371 &repo_path, 479 &repo_path,
372 "refs/heads/main", 480 "refs/heads/main",
373 "deadbeef1234567890abcdef1234567890abcdef", 481 "deadbeef1234567890abcdef1234567890abcdef",
374 ); 482 );
375 483
376 // Should return Ok(false) - commit not found 484 // Should return Ok(false) - commit not found
377 assert!(result.is_ok()); 485 assert!(result.is_ok());
378 assert!(!result.unwrap()); 486 assert!(!result.unwrap());
@@ -381,19 +489,15 @@ mod tests {
381 #[test] 489 #[test]
382 fn test_try_set_head_if_available_success() { 490 fn test_try_set_head_if_available_success() {
383 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); 491 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit();
384 492
385 let result = try_set_head_if_available( 493 let result = try_set_head_if_available(&repo_path, "refs/heads/main", &commit_hash);
386 &repo_path, 494
387 "refs/heads/main",
388 &commit_hash,
389 );
390
391 // Should return Ok(true) - HEAD was set 495 // Should return Ok(true) - HEAD was set
392 assert!(result.is_ok()); 496 assert!(result.is_ok());
393 assert!(result.unwrap()); 497 assert!(result.unwrap());
394 498
395 // Verify HEAD was set 499 // Verify HEAD was set
396 let head = get_repository_head(&repo_path); 500 let head = get_repository_head(&repo_path);
397 assert_eq!(head, Some("refs/heads/main".to_string())); 501 assert_eq!(head, Some("refs/heads/main".to_string()));
398 } 502 }
399} \ No newline at end of file 503}
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 7aa2b97..8e9926a 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -14,8 +14,8 @@ use nostr_relay_builder::prelude::*;
14use crate::config::{Config, DatabaseBackend}; 14use crate::config::{Config, DatabaseBackend};
15use crate::git; 15use crate::git;
16use crate::nostr::events::{ 16use crate::nostr::events::{
17 validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, 17 validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR,
18 KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 18 KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
19}; 19};
20 20
21/// NIP-34 Write Policy with Full GRASP-01 Event Validation 21/// NIP-34 Write Policy with Full GRASP-01 Event Validation
@@ -36,7 +36,11 @@ pub struct Nip34WritePolicy {
36} 36}
37 37
38impl Nip34WritePolicy { 38impl Nip34WritePolicy {
39 pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>, git_data_path: impl Into<PathBuf>) -> Self { 39 pub fn new(
40 domain: impl Into<String>,
41 database: Arc<MemoryDatabase>,
42 git_data_path: impl Into<PathBuf>,
43 ) -> Self {
40 Self { 44 Self {
41 domain: domain.into(), 45 domain: domain.into(),
42 database, 46 database,
@@ -48,7 +52,7 @@ impl Nip34WritePolicy {
48 /// Path format: <git_data_path>/<npub>/<identifier>.git 52 /// Path format: <git_data_path>/<npub>/<identifier>.git
49 fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { 53 fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
50 let repo_path = self.git_data_path.join(&announcement.repo_path()); 54 let repo_path = self.git_data_path.join(&announcement.repo_path());
51 55
52 // Check if repository already exists 56 // Check if repository already exists
53 if repo_path.exists() { 57 if repo_path.exists() {
54 tracing::debug!("Repository already exists at {}", repo_path.display()); 58 tracing::debug!("Repository already exists at {}", repo_path.display());
@@ -56,13 +60,12 @@ impl Nip34WritePolicy {
56 } 60 }
57 61
58 // Create parent directory (npub directory) 62 // Create parent directory (npub directory)
59 let parent = repo_path.parent().ok_or_else(|| { 63 let parent = repo_path
60 format!("Invalid repository path: {}", repo_path.display()) 64 .parent()
61 })?; 65 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
62 66
63 std::fs::create_dir_all(parent).map_err(|e| { 67 std::fs::create_dir_all(parent)
64 format!("Failed to create directory {}: {}", parent.display(), e) 68 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
65 })?;
66 69
67 // Initialize bare repository using git command 70 // Initialize bare repository using git command
68 let output = std::process::Command::new("git") 71 let output = std::process::Command::new("git")
@@ -165,7 +168,11 @@ impl Nip34WritePolicy {
165 tracing::debug!( 168 tracing::debug!(
166 "Found authorized announcement for {}: owner={}, maintainer={}", 169 "Found authorized announcement for {}: owner={}, maintainer={}",
167 identifier, 170 identifier,
168 if is_owner { event.pubkey.to_hex() } else { "n/a".to_string() }, 171 if is_owner {
172 event.pubkey.to_hex()
173 } else {
174 "n/a".to_string()
175 },
169 is_maintainer 176 is_maintainer
170 ); 177 );
171 authorized.push(announcement); 178 authorized.push(announcement);
@@ -198,10 +205,7 @@ impl Nip34WritePolicy {
198 let head_ref = match &state.head { 205 let head_ref = match &state.head {
199 Some(h) => h, 206 Some(h) => h,
200 None => { 207 None => {
201 tracing::debug!( 208 tracing::debug!("State event for {} has no HEAD reference", state.identifier);
202 "State event for {} has no HEAD reference",
203 state.identifier
204 );
205 return Ok(0); 209 return Ok(0);
206 } 210 }
207 }; 211 };
@@ -232,11 +236,9 @@ impl Nip34WritePolicy {
232 }; 236 };
233 237
234 // Find all announcements where state author is authorized 238 // Find all announcements where state author is authorized
235 let announcements = Self::find_authorized_announcements( 239 let announcements =
236 database, 240 Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey)
237 &state.identifier, 241 .await?;
238 &state.event.pubkey,
239 ).await?;
240 242
241 if announcements.is_empty() { 243 if announcements.is_empty() {
242 tracing::debug!( 244 tracing::debug!(
@@ -271,7 +273,7 @@ impl Nip34WritePolicy {
271 } 273 }
272 274
273 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git 275 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
274 let repo_path = self.git_data_path.join(&announcement.repo_path()); 276 let repo_path = self.git_data_path.join(announcement.repo_path().clone());
275 277
276 match git::try_set_head_if_available(&repo_path, head_ref, head_commit) { 278 match git::try_set_head_if_available(&repo_path, head_ref, head_commit) {
277 Ok(true) => { 279 Ok(true) => {
@@ -291,11 +293,7 @@ impl Nip34WritePolicy {
291 ); 293 );
292 } 294 }
293 Err(e) => { 295 Err(e) => {
294 tracing::warn!( 296 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
295 "Failed to set HEAD in {}: {}",
296 repo_path.display(),
297 e
298 );
299 } 297 }
300 } 298 }
301 } 299 }
@@ -338,6 +336,191 @@ impl Nip34WritePolicy {
338 (addressable_refs, event_refs) 336 (addressable_refs, event_refs)
339 } 337 }
340 338
339 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
340 ///
341 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
342 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
343 /// repository and validates that it points to the correct commit (from the
344 /// `c` tag). If the ref exists but points to a different commit, the ref is
345 /// deleted.
346 ///
347 /// PR and PR Update events can have multiple `a` tags to update multiple
348 /// repositories simultaneously.
349 ///
350 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
351 /// with their corresponding events.
352 ///
353 /// # Arguments
354 /// * `database` - Database for looking up repository announcements
355 /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619)
356 ///
357 /// # Returns
358 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
359 async fn validate_pr_nostr_ref(
360 &self,
361 database: &Arc<MemoryDatabase>,
362 event: &Event,
363 ) -> Result<Option<usize>, String> {
364 let event_id = event.id.to_hex();
365
366 // Extract the `c` tag (commit hash) from the PR event
367 let expected_commit = event.tags.iter().find_map(|tag| {
368 let tag_vec = tag.clone().to_vec();
369 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
370 Some(tag_vec[1].clone())
371 } else {
372 None
373 }
374 });
375
376 let expected_commit = match expected_commit {
377 Some(c) => c,
378 None => {
379 tracing::debug!(
380 "PR event {} has no 'c' tag, skipping ref validation",
381 event_id
382 );
383 return Ok(None);
384 }
385 };
386
387 // Extract ALL `a` tags (repository references) from the PR event
388 // PR events can reference multiple repositories
389 // Format: 30617:<pubkey>:<identifier>
390 let repo_refs: Vec<String> = event
391 .tags
392 .iter()
393 .filter_map(|tag| {
394 let tag_vec = tag.clone().to_vec();
395 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
396 Some(tag_vec[1].clone())
397 } else {
398 None
399 }
400 })
401 .collect();
402
403 if repo_refs.is_empty() {
404 tracing::debug!(
405 "PR event {} has no repo 'a' tags, skipping ref validation",
406 event_id
407 );
408 return Ok(None);
409 }
410
411 let mut deleted_count = 0;
412
413 // Process each repository reference
414 for repo_ref in repo_refs {
415 // Parse the repo reference: 30617:<pubkey>:<identifier>
416 let parts: Vec<&str> = repo_ref.split(':').collect();
417 if parts.len() < 3 {
418 tracing::debug!(
419 "PR event {} has invalid 'a' tag format: {}",
420 event_id,
421 repo_ref
422 );
423 continue;
424 }
425
426 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
427 Ok(pk) => pk,
428 Err(_) => {
429 tracing::debug!(
430 "PR event {} has invalid pubkey in 'a' tag: {}",
431 event_id,
432 parts[1]
433 );
434 continue;
435 }
436 };
437 let identifier = parts[2];
438
439 // Look up repository announcement to get the npub for path
440 let filter = Filter::new()
441 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
442 .author(repo_pubkey)
443 .custom_tag(
444 SingleLetterTag::lowercase(Alphabet::D),
445 identifier.to_string(),
446 );
447
448 let announcements: Vec<Event> = match database.query(filter).await {
449 Ok(events) => events.into_iter().collect(),
450 Err(e) => {
451 tracing::warn!(
452 "Failed to query for repository announcement for PR {}: {}",
453 event_id,
454 e
455 );
456 continue;
457 }
458 };
459
460 if announcements.is_empty() {
461 tracing::debug!(
462 "No repository announcement found for PR event {} (repo {}:{})",
463 event_id,
464 repo_pubkey.to_hex(),
465 identifier
466 );
467 continue;
468 }
469
470 // Process each matching announcement (there could be multiple)
471 for announcement_event in announcements {
472 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
473 Ok(a) => a,
474 Err(e) => {
475 tracing::warn!(
476 "Failed to parse announcement for PR {} validation: {}",
477 event_id,
478 e
479 );
480 continue;
481 }
482 };
483
484 // Build repository path
485 let repo_path = self.git_data_path.join(&announcement.repo_path());
486
487 // Validate the ref
488 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
489 Ok(true) => {
490 tracing::info!(
491 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
492 event_id,
493 repo_path.display(),
494 expected_commit
495 );
496 deleted_count += 1;
497 }
498 Ok(false) => {
499 tracing::debug!(
500 "refs/nostr/{} in {} is valid or doesn't exist",
501 event_id,
502 repo_path.display()
503 );
504 }
505 Err(e) => {
506 tracing::warn!(
507 "Failed to validate refs/nostr/{} in {}: {}",
508 event_id,
509 repo_path.display(),
510 e
511 );
512 }
513 }
514 }
515 }
516
517 if deleted_count > 0 {
518 Ok(Some(deleted_count))
519 } else {
520 Ok(None)
521 }
522 }
523
341 /// Check if any addressable events (repositories) exist in database 524 /// Check if any addressable events (repositories) exist in database
342 /// Returns the first matching addressable reference found, or None if none match 525 /// Returns the first matching addressable reference found, or None if none match
343 async fn find_accepted_repository( 526 async fn find_accepted_repository(
@@ -377,16 +560,17 @@ impl Nip34WritePolicy {
377 use std::collections::HashMap; 560 use std::collections::HashMap;
378 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new(); 561 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
379 for (addr, kind, pubkey, identifier) in parsed_refs { 562 for (addr, kind, pubkey, identifier) in parsed_refs {
380 by_kind.entry(kind).or_default().push((addr, pubkey, identifier)); 563 by_kind
564 .entry(kind)
565 .or_default()
566 .push((addr, pubkey, identifier));
381 } 567 }
382 568
383 // Query each kind group 569 // Query each kind group
384 for (kind, refs) in by_kind { 570 for (kind, refs) in by_kind {
385 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); 571 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
386 572
387 let filter = Filter::new() 573 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
388 .kind(Kind::from(kind))
389 .authors(authors);
390 574
391 match database.query(filter).await { 575 match database.query(filter).await {
392 Ok(events) => { 576 Ok(events) => {
@@ -445,7 +629,7 @@ impl Nip34WritePolicy {
445 event: &Event, 629 event: &Event,
446 ) -> Result<bool, String> { 630 ) -> Result<bool, String> {
447 let kind_u16 = event.kind.as_u16(); 631 let kind_u16 = event.kind.as_u16();
448 632
449 // Check if this is any kind of replaceable event 633 // Check if this is any kind of replaceable event
450 let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000; 634 let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000;
451 let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000; 635 let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000;
@@ -454,7 +638,9 @@ impl Nip34WritePolicy {
454 // Build the appropriate address format based on event type 638 // Build the appropriate address format based on event type
455 let address = if is_parameterized_replaceable { 639 let address = if is_parameterized_replaceable {
456 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) 640 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
457 let identifier = event.tags.iter() 641 let identifier = event
642 .tags
643 .iter()
458 .find_map(|tag| { 644 .find_map(|tag| {
459 let tag_vec = tag.clone().to_vec(); 645 let tag_vec = tag.clone().to_vec();
460 if tag_vec.len() >= 2 && tag_vec[0] == "d" { 646 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
@@ -464,12 +650,17 @@ impl Nip34WritePolicy {
464 } 650 }
465 }) 651 })
466 .unwrap_or_default(); // Empty string if no 'd' tag 652 .unwrap_or_default(); // Empty string if no 'd' tag
467 format!("{}:{}:{}", event.kind.as_u16(), event.pubkey.to_hex(), identifier) 653 format!(
654 "{}:{}:{}",
655 event.kind.as_u16(),
656 event.pubkey.to_hex(),
657 identifier
658 )
468 } else { 659 } else {
469 // For regular replaceable: kind:pubkey format (1 colon) 660 // For regular replaceable: kind:pubkey format (1 colon)
470 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) 661 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
471 }; 662 };
472 663
473 // Check addressable reference tags: a, A, q (with address format) 664 // Check addressable reference tags: a, A, q (with address format)
474 let addressable_tags = [ 665 let addressable_tags = [
475 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference 666 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
@@ -479,7 +670,7 @@ impl Nip34WritePolicy {
479 670
480 for tag_type in &addressable_tags { 671 for tag_type in &addressable_tags {
481 let filter = Filter::new().custom_tag(tag_type.clone(), address.clone()); 672 let filter = Filter::new().custom_tag(tag_type.clone(), address.clone());
482 673
483 match database.query(filter).await { 674 match database.query(filter).await {
484 Ok(events) => { 675 Ok(events) => {
485 if !events.is_empty() { 676 if !events.is_empty() {
@@ -492,7 +683,7 @@ impl Nip34WritePolicy {
492 } else { 683 } else {
493 // For regular events, check event ID reference tags: e, E, q (with hex ID) 684 // For regular events, check event ID reference tags: e, E, q (with hex ID)
494 let event_id_hex = event.id.to_hex(); 685 let event_id_hex = event.id.to_hex();
495 686
496 let event_id_tags = [ 687 let event_id_tags = [
497 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference 688 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
498 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference 689 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
@@ -501,7 +692,7 @@ impl Nip34WritePolicy {
501 692
502 for tag_type in &event_id_tags { 693 for tag_type in &event_id_tags {
503 let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone()); 694 let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone());
504 695
505 match database.query(filter).await { 696 match database.query(filter).await {
506 Ok(events) => { 697 Ok(events) => {
507 if !events.is_empty() { 698 if !events.is_empty() {
@@ -545,7 +736,7 @@ impl WritePolicy for Nip34WritePolicy {
545 // Note: We still accept the event even if repo creation fails 736 // Note: We still accept the event even if repo creation fails
546 // The git operation failure shouldn't prevent event acceptance 737 // The git operation failure shouldn't prevent event acceptance
547 } 738 }
548 739
549 tracing::debug!( 740 tracing::debug!(
550 "Accepted repository announcement: {}", 741 "Accepted repository announcement: {}",
551 event_id_str 742 event_id_str
@@ -563,11 +754,7 @@ impl WritePolicy for Nip34WritePolicy {
563 } 754 }
564 } 755 }
565 Err(e) => { 756 Err(e) => {
566 tracing::warn!( 757 tracing::warn!("Rejected repository announcement {}: {}", event_id_str, e);
567 "Rejected repository announcement {}: {}",
568 event_id_str,
569 e
570 );
571 PolicyResult::Reject(e.to_string()) 758 PolicyResult::Reject(e.to_string())
572 } 759 }
573 }, 760 },
@@ -577,7 +764,10 @@ impl WritePolicy for Nip34WritePolicy {
577 match RepositoryState::from_event(event.clone()) { 764 match RepositoryState::from_event(event.clone()) {
578 Ok(state) => { 765 Ok(state) => {
579 // Try to set HEAD for all authorized repos if this is the latest state 766 // Try to set HEAD for all authorized repos if this is the latest state
580 match self.try_set_head_for_authorized_repos(&database, &state).await { 767 match self
768 .try_set_head_for_authorized_repos(&database, &state)
769 .await
770 {
581 Ok(count) if count > 0 => { 771 Ok(count) if count > 0 => {
582 tracing::info!( 772 tracing::info!(
583 "Set HEAD from state event {} for {} repo(s) with identifier {}", 773 "Set HEAD from state event {} for {} repo(s) with identifier {}",
@@ -600,11 +790,8 @@ impl WritePolicy for Nip34WritePolicy {
600 ); 790 );
601 } 791 }
602 } 792 }
603 793
604 tracing::debug!( 794 tracing::debug!("Accepted repository state: {}", event_id_str);
605 "Accepted repository state: {}",
606 event_id_str
607 );
608 PolicyResult::Accept 795 PolicyResult::Accept
609 } 796 }
610 Err(e) => { 797 Err(e) => {
@@ -620,14 +807,104 @@ impl WritePolicy for Nip34WritePolicy {
620 } 807 }
621 } 808 }
622 Err(e) => { 809 Err(e) => {
810 tracing::warn!("Rejected repository state {}: {}", event_id_str, e);
811 PolicyResult::Reject(e.to_string())
812 }
813 },
814 // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/<event-id> refs before acceptance
815 KIND_PR | KIND_PR_UPDATE => {
816 // Validate refs/nostr refs for this PR event
817 // This deletes any refs/nostr/<event-id> that points to wrong commit
818 if let Err(e) = self.validate_pr_nostr_ref(&database, event).await {
623 tracing::warn!( 819 tracing::warn!(
624 "Rejected repository state {}: {}", 820 "Failed to validate refs/nostr for PR event {}: {}",
625 event_id_str, 821 event_id_str,
626 e 822 e
627 ); 823 );
628 PolicyResult::Reject(e.to_string()) 824 // Don't reject - just log the error and proceed with normal validation
629 } 825 }
630 }, 826
827 // Continue with standard reference checking (same as default case)
828 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
829
830 // Check 1: Does this event reference an accepted repository?
831 match Self::find_accepted_repository(&database, &addressable_refs).await {
832 Ok(Some(addr_ref)) => {
833 tracing::debug!(
834 "Accepted PR event {}: references accepted repository {}",
835 event_id_str,
836 addr_ref
837 );
838 return PolicyResult::Accept;
839 }
840 Ok(None) => {
841 // No matching repositories, continue to next check
842 }
843 Err(e) => {
844 tracing::warn!(
845 "Database query failed for PR {}, rejecting (fail-secure): {}",
846 event_id_str,
847 e
848 );
849 return PolicyResult::Reject(format!("Database query failed: {}", e));
850 }
851 }
852
853 // Check 2: Does this event reference an accepted event?
854 match Self::find_accepted_event(&database, &event_refs).await {
855 Ok(Some(event_ref)) => {
856 tracing::debug!(
857 "Accepted PR event {}: references accepted event {}",
858 event_id_str,
859 event_ref
860 );
861 return PolicyResult::Accept;
862 }
863 Ok(None) => {
864 // No matching events, continue to next check
865 }
866 Err(e) => {
867 tracing::warn!(
868 "Database query failed for PR {}, rejecting (fail-secure): {}",
869 event_id_str,
870 e
871 );
872 return PolicyResult::Reject(format!("Database query failed: {}", e));
873 }
874 }
875
876 // Check 3: Is this event referenced by an accepted event?
877 match Self::is_referenced_by_accepted(&database, event).await {
878 Ok(true) => {
879 tracing::debug!(
880 "Accepted PR event {}: referenced by accepted event",
881 event_id_str
882 );
883 return PolicyResult::Accept;
884 }
885 Ok(false) => {
886 // No forward references found, continue to rejection
887 }
888 Err(e) => {
889 tracing::warn!(
890 "Database query failed for PR {}, rejecting (fail-secure): {}",
891 event_id_str,
892 e
893 );
894 return PolicyResult::Reject(format!("Database query failed: {}", e));
895 }
896 }
897
898 // No valid references found - reject as orphan event
899 tracing::info!(
900 "Rejected orphan PR event {}: no references to accepted repos or events",
901 event_id_str
902 );
903 PolicyResult::Reject(
904 "PR event must reference an accepted repository or accepted event"
905 .to_string(),
906 )
907 }
631 // GRASP-01: Check if event references accepted repositories or events 908 // GRASP-01: Check if event references accepted repositories or events
632 _ => { 909 _ => {
633 // Extract all reference tags from event 910 // Extract all reference tags from event
@@ -709,7 +986,7 @@ impl WritePolicy for Nip34WritePolicy {
709 event_refs.len() 986 event_refs.len()
710 ); 987 );
711 PolicyResult::Reject( 988 PolicyResult::Reject(
712 "Event must reference an accepted repository or accepted event".to_string() 989 "Event must reference an accepted repository or accepted event".to_string(),
713 ) 990 )
714 } 991 }
715 } 992 }
@@ -786,4 +1063,4 @@ pub fn create_relay(config: &Config) -> Result<RelayWithDatabase> {
786 relay: LocalRelay::new(builder), 1063 relay: LocalRelay::new(builder),
787 database, 1064 database,
788 }) 1065 })
789} \ No newline at end of file 1066}
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
index 97688b1..6a62ccd 100644
--- a/src/nostr/events.rs
+++ b/src/nostr/events.rs
@@ -15,6 +15,12 @@ pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
15/// NIP-34 Repository State Announcement (kind 30618) 15/// NIP-34 Repository State Announcement (kind 30618)
16pub const KIND_REPOSITORY_STATE: u16 = 30618; 16pub const KIND_REPOSITORY_STATE: u16 = 30618;
17 17
18/// NIP-34 Pull Request (kind 1618) - has `c` tag for commit
19pub const KIND_PR: u16 = 1618;
20
21/// NIP-34 Pull Request Update (kind 1619) - has `c` tag for commit
22pub const KIND_PR_UPDATE: u16 = 1619;
23
18/// Repository announcement details extracted from NIP-34 event 24/// Repository announcement details extracted from NIP-34 event
19#[derive(Debug, Clone)] 25#[derive(Debug, Clone)]
20pub struct RepositoryAnnouncement { 26pub struct RepositoryAnnouncement {