diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 13:29:47 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 13:29:47 +0000 |
| commit | 65ac6ef83205c41653e6ffe2acd664f968926fb2 (patch) | |
| tree | c31301c599dfaffd75e61af3f6004d1b95373a72 /grasp-audit | |
| parent | c368f9132a16d45a17ad55943e4b68ba85a6835b (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')
| -rw-r--r-- | grasp-audit/src/specs/grasp01/purgatory.rs | 199 |
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)] |