upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit/src/specs/grasp01/purgatory.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 13:29:47 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 13:29:47 +0000
commit65ac6ef83205c41653e6ffe2acd664f968926fb2 (patch)
treec31301c599dfaffd75e61af3f6004d1b95373a72 /grasp-audit/src/specs/grasp01/purgatory.rs
parentc368f9132a16d45a17ad55943e4b68ba85a6835b (diff)
feat: remove purgatory announcements on NIP-09 deletion events
Kind 5 deletion events signed by the announcement author now evict the corresponding purgatory entry and delete the bare repository from disk. Both NIP-09 reference styles are supported: - e tag (event ID): matches the purgatory entry whose event ID equals the tag value - a tag (coordinate 30617:<pubkey>:<identifier>): matches by coordinate, only removes entries with created_at <= deletion event created_at per NIP-09 spec Author-only enforcement: coordinate pubkey and e-tag owner must match the deletion event pubkey; third-party deletion attempts are silently ignored. Includes 6 unit tests and 2 integration tests (event ID and coordinate paths).
Diffstat (limited to 'grasp-audit/src/specs/grasp01/purgatory.rs')
-rw-r--r--grasp-audit/src/specs/grasp01/purgatory.rs199
1 files changed, 199 insertions, 0 deletions
diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs
index 9c4b401..9d97d3b 100644
--- a/grasp-audit/src/specs/grasp01/purgatory.rs
+++ b/grasp-audit/src/specs/grasp01/purgatory.rs
@@ -46,6 +46,12 @@ impl PurgatoryTests {
46 results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await); 46 results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await);
47 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); 47 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
48 48
49 // Deletion event tests (NIP-09)
50 results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await);
51 results.add(
52 Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await,
53 );
54
49 // State event purgatory tests (already implemented) 55 // State event purgatory tests (already implemented)
50 results.add(Self::test_state_event_not_served_before_git_data(client).await); 56 results.add(Self::test_state_event_not_served_before_git_data(client).await);
51 results.add(Self::test_state_event_served_after_git_push(client).await); 57 results.add(Self::test_state_event_served_after_git_push(client).await);
@@ -646,6 +652,199 @@ impl PurgatoryTests {
646 }) 652 })
647 .await 653 .await
648 } 654 }
655 // ============================================================
656 // Deletion Event Tests (NIP-09)
657 // ============================================================
658
659 /// Test: Kind 5 deletion event by event ID removes purgatory announcement
660 ///
661 /// Spec: NIP-09
662 /// "A special event with kind 5... having a list of one or more `e` or `a` tags,
663 /// each referencing an event the author is requesting to be deleted."
664 ///
665 /// This test verifies:
666 /// 1. Send a valid repository announcement (enters purgatory)
667 /// 2. Send a kind 5 deletion event referencing the announcement by event ID
668 /// 3. The announcement is no longer in purgatory (git push would fail)
669 /// 4. The deletion event itself is accepted by the relay
670 pub async fn test_deletion_by_event_id_removes_purgatory_announcement(
671 client: &AuditClient,
672 ) -> TestResult {
673 TestResult::new(
674 "deletion_by_event_id_removes_purgatory_announcement",
675 SpecRef::PurgatoryAcceptUntilGitData,
676 "Kind 5 deletion by event ID SHOULD remove a purgatory announcement",
677 )
678 .run(|| async {
679 let ctx = TestContext::new(client);
680
681 // Send announcement to purgatory
682 let repo = ctx
683 .get_fixture(FixtureKind::ValidRepoSent)
684 .await
685 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
686
687 let repo_id = repo
688 .tags
689 .iter()
690 .find(|t| t.kind() == TagKind::d())
691 .and_then(|t| t.content())
692 .ok_or("Missing d tag in repo announcement")?
693 .to_string();
694
695 // Verify it's in purgatory (not served)
696 tokio::time::sleep(Duration::from_millis(300)).await;
697 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? {
698 return Err(
699 "Announcement was served immediately - purgatory not working".to_string(),
700 );
701 }
702
703 // Build and send kind 5 deletion event referencing the announcement by event ID
704 let deletion = client
705 .event_builder(Kind::EventDeletion, "")
706 .tag(Tag::event(repo.id))
707 .tag(Tag::custom(
708 TagKind::custom("k"),
709 vec!["30617"],
710 ))
711 .build(client.keys())
712 .map_err(|e| format!("Failed to build deletion event: {}", e))?;
713
714 client
715 .send_event(deletion)
716 .await
717 .map_err(|e| format!("Relay rejected deletion event: {}", e))?;
718
719 tokio::time::sleep(Duration::from_millis(300)).await;
720
721 // Verify the announcement can no longer be promoted by attempting a git push.
722 // We check this indirectly: if the purgatory entry was removed, a subsequent
723 // git push to the repo path should fail (no bare repo).
724 // For the integration test we verify the announcement is still not served
725 // (it was never promoted) and that the deletion event was accepted.
726 // The bare-repo deletion is verified by attempting a git clone.
727 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?)
728 .map_err(|e| e.to_string())?;
729 let clone_url = format!(
730 "{}/{}/{}.git",
731 http_url,
732 client.public_key().to_bech32().map_err(|e| e.to_string())?,
733 repo_id
734 );
735
736 // git ls-remote should fail (bare repo deleted)
737 let output = std::process::Command::new("git")
738 .args(["ls-remote", &clone_url])
739 .output()
740 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
741
742 if output.status.success() {
743 return Err(format!(
744 "Bare repo still exists after deletion event. \
745 Expected git ls-remote to fail for {}",
746 clone_url
747 ));
748 }
749
750 Ok(())
751 })
752 .await
753 }
754
755 /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement
756 ///
757 /// Spec: NIP-09
758 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable
759 /// event up to the `created_at` timestamp of the deletion request event."
760 ///
761 /// This test verifies:
762 /// 1. Send a valid repository announcement (enters purgatory)
763 /// 2. Send a kind 5 deletion event referencing the announcement by coordinate
764 /// (`30617:<pubkey>:<identifier>`)
765 /// 3. The announcement is no longer in purgatory
766 pub async fn test_deletion_by_coordinate_removes_purgatory_announcement(
767 client: &AuditClient,
768 ) -> TestResult {
769 TestResult::new(
770 "deletion_by_coordinate_removes_purgatory_announcement",
771 SpecRef::PurgatoryAcceptUntilGitData,
772 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement",
773 )
774 .run(|| async {
775 let ctx = TestContext::new(client);
776
777 // Send announcement to purgatory
778 let repo = ctx
779 .get_fixture(FixtureKind::ValidRepoSent)
780 .await
781 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
782
783 let repo_id = repo
784 .tags
785 .iter()
786 .find(|t| t.kind() == TagKind::d())
787 .and_then(|t| t.content())
788 .ok_or("Missing d tag in repo announcement")?
789 .to_string();
790
791 // Verify it's in purgatory (not served)
792 tokio::time::sleep(Duration::from_millis(300)).await;
793 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? {
794 return Err(
795 "Announcement was served immediately - purgatory not working".to_string(),
796 );
797 }
798
799 // Build coordinate: `30617:<pubkey_hex>:<identifier>`
800 let coord = format!(
801 "30617:{}:{}",
802 client.public_key().to_hex(),
803 repo_id
804 );
805
806 // Build and send kind 5 deletion event referencing by coordinate
807 let deletion = client
808 .event_builder(Kind::EventDeletion, "")
809 .tag(Tag::custom(TagKind::custom("a"), vec![coord]))
810 .tag(Tag::custom(TagKind::custom("k"), vec!["30617"]))
811 .build(client.keys())
812 .map_err(|e| format!("Failed to build deletion event: {}", e))?;
813
814 client
815 .send_event(deletion)
816 .await
817 .map_err(|e| format!("Relay rejected deletion event: {}", e))?;
818
819 tokio::time::sleep(Duration::from_millis(300)).await;
820
821 // Verify bare repo was deleted
822 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?)
823 .map_err(|e| e.to_string())?;
824 let clone_url = format!(
825 "{}/{}/{}.git",
826 http_url,
827 client.public_key().to_bech32().map_err(|e| e.to_string())?,
828 repo_id
829 );
830
831 let output = std::process::Command::new("git")
832 .args(["ls-remote", &clone_url])
833 .output()
834 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
835
836 if output.status.success() {
837 return Err(format!(
838 "Bare repo still exists after deletion event. \
839 Expected git ls-remote to fail for {}",
840 clone_url
841 ));
842 }
843
844 Ok(())
845 })
846 .await
847 }
649} 848}
650 849
651#[cfg(test)] 850#[cfg(test)]