diff options
| -rw-r--r-- | grasp-audit/src/specs/grasp01/purgatory.rs | 329 | ||||
| -rw-r--r-- | src/nostr/policy/deletion.rs | 138 | ||||
| -rw-r--r-- | tests/purgatory.rs | 4 |
3 files changed, 307 insertions, 164 deletions
diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs index 9d97d3b..29eabad 100644 --- a/grasp-audit/src/specs/grasp01/purgatory.rs +++ b/grasp-audit/src/specs/grasp01/purgatory.rs | |||
| @@ -27,9 +27,11 @@ | |||
| 27 | //! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds | 27 | //! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds |
| 28 | //! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data | 28 | //! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data |
| 29 | 29 | ||
| 30 | use crate::fixtures::{clone_repo, create_commit, try_push}; | ||
| 30 | use crate::specs::grasp01::SpecRef; | 31 | use crate::specs::grasp01::SpecRef; |
| 31 | use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; | 32 | use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; |
| 32 | use nostr_sdk::prelude::*; | 33 | use nostr_sdk::prelude::*; |
| 34 | use std::fs; | ||
| 33 | use std::time::Duration; | 35 | use std::time::Duration; |
| 34 | 36 | ||
| 35 | /// Test suite for GRASP-01 purgatory behavior | 37 | /// Test suite for GRASP-01 purgatory behavior |
| @@ -47,9 +49,9 @@ impl PurgatoryTests { | |||
| 47 | results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); | 49 | results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); |
| 48 | 50 | ||
| 49 | // Deletion event tests (NIP-09) | 51 | // Deletion event tests (NIP-09) |
| 50 | results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await); | 52 | results.add(Self::test_deletion_by_event_id_removes_purgatory_state_event(client).await); |
| 51 | results.add( | 53 | results.add( |
| 52 | Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await, | 54 | Self::test_deletion_by_coordinate_removes_purgatory_state_event(client).await, |
| 53 | ); | 55 | ); |
| 54 | 56 | ||
| 55 | // State event purgatory tests (already implemented) | 57 | // State event purgatory tests (already implemented) |
| @@ -656,192 +658,293 @@ impl PurgatoryTests { | |||
| 656 | // Deletion Event Tests (NIP-09) | 658 | // Deletion Event Tests (NIP-09) |
| 657 | // ============================================================ | 659 | // ============================================================ |
| 658 | 660 | ||
| 659 | /// Test: Kind 5 deletion event by event ID removes purgatory announcement | 661 | /// Test: Kind 5 deletion event by event ID removes a purgatory state event |
| 660 | /// | 662 | /// |
| 661 | /// Spec: NIP-09 | 663 | /// Spec: NIP-09 |
| 662 | /// "A special event with kind 5... having a list of one or more `e` or `a` tags, | 664 | /// "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." | 665 | /// each referencing an event the author is requesting to be deleted." |
| 664 | /// | 666 | /// |
| 665 | /// This test verifies: | 667 | /// This test verifies: |
| 666 | /// 1. Send a valid repository announcement (enters purgatory) | 668 | /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible |
| 667 | /// 2. Send a kind 5 deletion event referencing the announcement by event ID | 669 | /// 2. Clone the repo and create a unique commit (not yet pushed) |
| 668 | /// 3. The announcement is no longer in purgatory (git push would fail) | 670 | /// 3. Submit a state event pointing to that unique commit (enters purgatory) |
| 669 | /// 4. The deletion event itself is accepted by the relay | 671 | /// 4. Send a kind 5 deletion event referencing the state event by event ID |
| 670 | pub async fn test_deletion_by_event_id_removes_purgatory_announcement( | 672 | /// 5. Attempt to push the unique commit — MUST be rejected (no authorized state event) |
| 673 | pub async fn test_deletion_by_event_id_removes_purgatory_state_event( | ||
| 671 | client: &AuditClient, | 674 | client: &AuditClient, |
| 672 | ) -> TestResult { | 675 | ) -> TestResult { |
| 673 | TestResult::new( | 676 | TestResult::new( |
| 674 | "deletion_by_event_id_removes_purgatory_announcement", | 677 | "deletion_by_event_id_removes_purgatory_state_event", |
| 675 | SpecRef::PurgatoryAcceptUntilGitData, | 678 | SpecRef::PurgatoryAcceptUntilGitData, |
| 676 | "Kind 5 deletion by event ID SHOULD remove a purgatory announcement", | 679 | "Kind 5 deletion by event ID SHOULD remove a purgatory state event, causing push rejection", |
| 677 | ) | 680 | ) |
| 678 | .run(|| async { | 681 | .run(|| async { |
| 679 | let ctx = TestContext::new(client); | 682 | let ctx = TestContext::new(client); |
| 680 | 683 | ||
| 681 | // Send announcement to purgatory | 684 | // Stage 1: get a promoted repo with git data already on the relay |
| 682 | let repo = ctx | 685 | let existing_state = ctx |
| 683 | .get_fixture(FixtureKind::ValidRepoSent) | 686 | .get_fixture(FixtureKind::OwnerStateDataPushed) |
| 684 | .await | 687 | .await |
| 685 | .map_err(|e| format!("Failed to create repo announcement: {}", e))?; | 688 | .map_err(|e| format!("Failed to get promoted repo: {}", e))?; |
| 686 | 689 | ||
| 687 | let repo_id = repo | 690 | let repo_id = existing_state |
| 688 | .tags | 691 | .tags |
| 689 | .iter() | 692 | .iter() |
| 690 | .find(|t| t.kind() == TagKind::d()) | 693 | .find(|t| t.kind() == TagKind::d()) |
| 691 | .and_then(|t| t.content()) | 694 | .and_then(|t| t.content()) |
| 692 | .ok_or("Missing d tag in repo announcement")? | 695 | .ok_or("Missing d tag in state event")? |
| 693 | .to_string(); | 696 | .to_string(); |
| 694 | 697 | ||
| 695 | // Verify it's in purgatory (not served) | 698 | let relay_domain = client |
| 696 | tokio::time::sleep(Duration::from_millis(300)).await; | 699 | .relay_url() |
| 697 | if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { | 700 | .await |
| 698 | return Err( | 701 | .map_err(|e| e.to_string())? |
| 699 | "Announcement was served immediately - purgatory not working".to_string(), | 702 | .trim_start_matches("ws://") |
| 700 | ); | 703 | .trim_start_matches("wss://") |
| 704 | .to_string(); | ||
| 705 | |||
| 706 | let npub = client | ||
| 707 | .public_key() | ||
| 708 | .to_bech32() | ||
| 709 | .map_err(|e| e.to_string())?; | ||
| 710 | |||
| 711 | // Stage 2: clone the repo and create a unique commit (not pushed yet) | ||
| 712 | let clone_path = clone_repo(&relay_domain, &npub, &repo_id) | ||
| 713 | .map_err(|e| format!("Failed to clone repo: {}", e))?; | ||
| 714 | |||
| 715 | let cleanup = || { let _ = fs::remove_dir_all(&clone_path); }; | ||
| 716 | |||
| 717 | let unique_commit = match create_commit(&clone_path, "deletion test unique commit") { | ||
| 718 | Ok(h) => h, | ||
| 719 | Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); } | ||
| 720 | }; | ||
| 721 | |||
| 722 | // Stage 3: submit a state event pointing to the unique commit (enters purgatory) | ||
| 723 | let state_event = client | ||
| 724 | .event_builder(Kind::RepoState, "") | ||
| 725 | .tag(Tag::identifier(&repo_id)) | ||
| 726 | .tag(Tag::custom( | ||
| 727 | TagKind::custom("refs/heads/main"), | ||
| 728 | vec![unique_commit.clone()], | ||
| 729 | )) | ||
| 730 | .tag(Tag::custom( | ||
| 731 | TagKind::custom("HEAD"), | ||
| 732 | vec!["ref: refs/heads/main".to_string()], | ||
| 733 | )) | ||
| 734 | .build(client.keys()) | ||
| 735 | .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?; | ||
| 736 | |||
| 737 | let (_, in_purgatory) = client | ||
| 738 | .send_event_and_note_purgatory(state_event.clone()) | ||
| 739 | .await | ||
| 740 | .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?; | ||
| 741 | |||
| 742 | if !in_purgatory { | ||
| 743 | cleanup(); | ||
| 744 | return Err(format!( | ||
| 745 | "State event was served immediately (not in purgatory). \ | ||
| 746 | Commit {} may already exist on relay.", | ||
| 747 | unique_commit | ||
| 748 | )); | ||
| 701 | } | 749 | } |
| 702 | 750 | ||
| 703 | // Build and send kind 5 deletion event referencing the announcement by event ID | 751 | // Stage 4: send kind 5 deletion event referencing the state event by event ID |
| 704 | let deletion = client | 752 | let deletion = client |
| 705 | .event_builder(Kind::EventDeletion, "") | 753 | .event_builder(Kind::EventDeletion, "") |
| 706 | .tag(Tag::event(repo.id)) | 754 | .tag(Tag::event(state_event.id)) |
| 707 | .tag(Tag::custom( | 755 | .tag(Tag::custom(TagKind::custom("k"), vec!["30618"])) |
| 708 | TagKind::custom("k"), | ||
| 709 | vec!["30617"], | ||
| 710 | )) | ||
| 711 | .build(client.keys()) | 756 | .build(client.keys()) |
| 712 | .map_err(|e| format!("Failed to build deletion event: {}", e))?; | 757 | .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?; |
| 713 | 758 | ||
| 714 | client | 759 | client |
| 715 | .send_event(deletion) | 760 | .send_event(deletion) |
| 716 | .await | 761 | .await |
| 717 | .map_err(|e| format!("Relay rejected deletion event: {}", e))?; | 762 | .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?; |
| 718 | 763 | ||
| 719 | tokio::time::sleep(Duration::from_millis(300)).await; | 764 | tokio::time::sleep(Duration::from_millis(300)).await; |
| 720 | 765 | ||
| 721 | // Verify the announcement can no longer be promoted by attempting a git push. | 766 | // Stage 5: attempt to push the unique commit — must be rejected |
| 722 | // We check this indirectly: if the purgatory entry was removed, a subsequent | 767 | let push_result = try_push(&clone_path); |
| 723 | // git push to the repo path should fail (no bare repo). | 768 | cleanup(); |
| 724 | // For the integration test we verify the announcement is still not served | 769 | |
| 725 | // (it was never promoted) and that the deletion event was accepted. | 770 | match push_result { |
| 726 | // The bare-repo deletion is verified by attempting a git clone. | 771 | Ok(false) => Ok(()), // push rejected as expected |
| 727 | let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) | 772 | Ok(true) => Err(format!( |
| 728 | .map_err(|e| e.to_string())?; | 773 | "Push was accepted but should have been rejected. \ |
| 729 | let clone_url = format!( | 774 | The state event (id={}) was deleted, so commit {} \ |
| 730 | "{}/{}/{}.git", | 775 | should not be authorized.", |
| 731 | http_url, | 776 | state_event.id, unique_commit |
| 732 | client.public_key().to_bech32().map_err(|e| e.to_string())?, | 777 | )), |
| 733 | repo_id | 778 | Err(e) => Err(format!("Git push error: {}", e)), |
| 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 | } | 779 | } |
| 749 | |||
| 750 | Ok(()) | ||
| 751 | }) | 780 | }) |
| 752 | .await | 781 | .await |
| 753 | } | 782 | } |
| 754 | 783 | ||
| 755 | /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement | 784 | /// Test: Kind 5 deletion event by `a` tag coordinate removes a purgatory state event |
| 756 | /// | 785 | /// |
| 757 | /// Spec: NIP-09 | 786 | /// Spec: NIP-09 |
| 758 | /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable | 787 | /// "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." | 788 | /// event up to the `created_at` timestamp of the deletion request event." |
| 760 | /// | 789 | /// |
| 761 | /// This test verifies: | 790 | /// This test verifies: |
| 762 | /// 1. Send a valid repository announcement (enters purgatory) | 791 | /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible |
| 763 | /// 2. Send a kind 5 deletion event referencing the announcement by coordinate | 792 | /// 2. Generate a fresh keypair for a new maintainer |
| 764 | /// (`30617:<pubkey>:<identifier>`) | 793 | /// 3. Send a replacement owner announcement adding the new maintainer (goes to DB) |
| 765 | /// 3. The announcement is no longer in purgatory | 794 | /// 4. Send a state event signed by the new maintainer pointing to a unique commit |
| 766 | pub async fn test_deletion_by_coordinate_removes_purgatory_announcement( | 795 | /// (enters purgatory — maintainer is authorized but commit doesn't exist yet) |
| 796 | /// 5. Delete by coordinate `30618:<new_maintainer_pubkey>:<identifier>` | ||
| 797 | /// 6. Clone repo, create that unique commit, attempt to push — MUST be rejected | ||
| 798 | /// (the state event was deleted, so the commit is no longer authorized) | ||
| 799 | pub async fn test_deletion_by_coordinate_removes_purgatory_state_event( | ||
| 767 | client: &AuditClient, | 800 | client: &AuditClient, |
| 768 | ) -> TestResult { | 801 | ) -> TestResult { |
| 769 | TestResult::new( | 802 | TestResult::new( |
| 770 | "deletion_by_coordinate_removes_purgatory_announcement", | 803 | "deletion_by_coordinate_removes_purgatory_state_event", |
| 771 | SpecRef::PurgatoryAcceptUntilGitData, | 804 | SpecRef::PurgatoryAcceptUntilGitData, |
| 772 | "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement", | 805 | "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory state event, causing push rejection", |
| 773 | ) | 806 | ) |
| 774 | .run(|| async { | 807 | .run(|| async { |
| 775 | let ctx = TestContext::new(client); | 808 | let ctx = TestContext::new(client); |
| 776 | 809 | ||
| 777 | // Send announcement to purgatory | 810 | // Stage 1: get a promoted repo with git data already on the relay |
| 778 | let repo = ctx | 811 | let existing_state = ctx |
| 779 | .get_fixture(FixtureKind::ValidRepoSent) | 812 | .get_fixture(FixtureKind::OwnerStateDataPushed) |
| 780 | .await | 813 | .await |
| 781 | .map_err(|e| format!("Failed to create repo announcement: {}", e))?; | 814 | .map_err(|e| format!("Failed to get promoted repo: {}", e))?; |
| 782 | 815 | ||
| 783 | let repo_id = repo | 816 | let repo_id = existing_state |
| 784 | .tags | 817 | .tags |
| 785 | .iter() | 818 | .iter() |
| 786 | .find(|t| t.kind() == TagKind::d()) | 819 | .find(|t| t.kind() == TagKind::d()) |
| 787 | .and_then(|t| t.content()) | 820 | .and_then(|t| t.content()) |
| 788 | .ok_or("Missing d tag in repo announcement")? | 821 | .ok_or("Missing d tag in state event")? |
| 789 | .to_string(); | 822 | .to_string(); |
| 790 | 823 | ||
| 791 | // Verify it's in purgatory (not served) | 824 | // Stage 2: generate a fresh keypair for a new maintainer |
| 792 | tokio::time::sleep(Duration::from_millis(300)).await; | 825 | let new_maintainer_keys = Keys::generate(); |
| 793 | if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { | 826 | let new_maintainer_hex = new_maintainer_keys.public_key().to_hex(); |
| 794 | return Err( | ||
| 795 | "Announcement was served immediately - purgatory not working".to_string(), | ||
| 796 | ); | ||
| 797 | } | ||
| 798 | 827 | ||
| 799 | // Build coordinate: `30617:<pubkey_hex>:<identifier>` | 828 | // Stage 3: send a replacement owner announcement that adds the new maintainer. |
| 800 | let coord = format!( | 829 | // This is a replacement (same pubkey + identifier already in DB) so it goes |
| 801 | "30617:{}:{}", | 830 | // straight to the database without entering purgatory. |
| 802 | client.public_key().to_hex(), | 831 | let relay_url = client |
| 803 | repo_id | 832 | .relay_url() |
| 804 | ); | 833 | .await |
| 834 | .map_err(|e| e.to_string())?; | ||
| 835 | let http_url = relay_url | ||
| 836 | .replace("ws://", "http://") | ||
| 837 | .replace("wss://", "https://"); | ||
| 838 | let npub = client | ||
| 839 | .public_key() | ||
| 840 | .to_bech32() | ||
| 841 | .map_err(|e| e.to_string())?; | ||
| 805 | 842 | ||
| 806 | // Build and send kind 5 deletion event referencing by coordinate | 843 | let replacement_announcement = client |
| 807 | let deletion = client | 844 | .event_builder(Kind::GitRepoAnnouncement, "") |
| 808 | .event_builder(Kind::EventDeletion, "") | 845 | .tag(Tag::identifier(&repo_id)) |
| 809 | .tag(Tag::custom(TagKind::custom("a"), vec![coord])) | 846 | .tag(Tag::custom( |
| 810 | .tag(Tag::custom(TagKind::custom("k"), vec!["30617"])) | 847 | TagKind::custom("clone"), |
| 848 | vec![format!("{}/{}/{}.git", http_url, npub, repo_id)], | ||
| 849 | )) | ||
| 850 | .tag(Tag::custom( | ||
| 851 | TagKind::custom("relays"), | ||
| 852 | vec![relay_url.clone()], | ||
| 853 | )) | ||
| 854 | .tag(Tag::custom( | ||
| 855 | TagKind::custom("maintainers"), | ||
| 856 | vec![new_maintainer_hex.clone()], | ||
| 857 | )) | ||
| 811 | .build(client.keys()) | 858 | .build(client.keys()) |
| 812 | .map_err(|e| format!("Failed to build deletion event: {}", e))?; | 859 | .map_err(|e| format!("Failed to build replacement announcement: {}", e))?; |
| 813 | 860 | ||
| 814 | client | 861 | client |
| 815 | .send_event(deletion) | 862 | .send_event(replacement_announcement) |
| 816 | .await | 863 | .await |
| 817 | .map_err(|e| format!("Relay rejected deletion event: {}", e))?; | 864 | .map_err(|e| format!("Relay rejected replacement announcement: {}", e))?; |
| 818 | 865 | ||
| 819 | tokio::time::sleep(Duration::from_millis(300)).await; | 866 | tokio::time::sleep(Duration::from_millis(200)).await; |
| 820 | 867 | ||
| 821 | // Verify bare repo was deleted | 868 | // Stage 4: clone the repo and create a unique commit (not pushed yet) |
| 822 | let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) | 869 | let relay_domain = relay_url |
| 823 | .map_err(|e| e.to_string())?; | 870 | .trim_start_matches("ws://") |
| 824 | let clone_url = format!( | 871 | .trim_start_matches("wss://") |
| 825 | "{}/{}/{}.git", | 872 | .to_string(); |
| 826 | http_url, | 873 | |
| 827 | client.public_key().to_bech32().map_err(|e| e.to_string())?, | 874 | let clone_path = clone_repo(&relay_domain, &npub, &repo_id) |
| 828 | repo_id | 875 | .map_err(|e| format!("Failed to clone repo: {}", e))?; |
| 829 | ); | 876 | |
| 877 | let cleanup = || { let _ = fs::remove_dir_all(&clone_path); }; | ||
| 830 | 878 | ||
| 831 | let output = std::process::Command::new("git") | 879 | let unique_commit = match create_commit(&clone_path, "deletion coordinate test unique commit") { |
| 832 | .args(["ls-remote", &clone_url]) | 880 | Ok(h) => h, |
| 833 | .output() | 881 | Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); } |
| 834 | .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; | 882 | }; |
| 835 | 883 | ||
| 836 | if output.status.success() { | 884 | // Stage 5: submit a state event signed by the new maintainer pointing to the |
| 885 | // unique commit. The new maintainer is now authorized (listed in the replacement | ||
| 886 | // announcement), so the state event should enter purgatory (commit doesn't exist). | ||
| 887 | let state_event = client | ||
| 888 | .event_builder(Kind::RepoState, "") | ||
| 889 | .tag(Tag::identifier(&repo_id)) | ||
| 890 | .tag(Tag::custom( | ||
| 891 | TagKind::custom("refs/heads/main"), | ||
| 892 | vec![unique_commit.clone()], | ||
| 893 | )) | ||
| 894 | .tag(Tag::custom( | ||
| 895 | TagKind::custom("HEAD"), | ||
| 896 | vec!["ref: refs/heads/main".to_string()], | ||
| 897 | )) | ||
| 898 | .build(&new_maintainer_keys) | ||
| 899 | .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?; | ||
| 900 | |||
| 901 | let (_, in_purgatory) = client | ||
| 902 | .send_event_and_note_purgatory(state_event.clone()) | ||
| 903 | .await | ||
| 904 | .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?; | ||
| 905 | |||
| 906 | if !in_purgatory { | ||
| 907 | cleanup(); | ||
| 837 | return Err(format!( | 908 | return Err(format!( |
| 838 | "Bare repo still exists after deletion event. \ | 909 | "State event was served immediately (not in purgatory). \ |
| 839 | Expected git ls-remote to fail for {}", | 910 | Commit {} may already exist on relay.", |
| 840 | clone_url | 911 | unique_commit |
| 841 | )); | 912 | )); |
| 842 | } | 913 | } |
| 843 | 914 | ||
| 844 | Ok(()) | 915 | // Stage 6: send kind 5 deletion event signed by the new maintainer, |
| 916 | // referencing their state event by coordinate `30618:<pubkey>:<identifier>` | ||
| 917 | let coord = format!("30618:{}:{}", new_maintainer_hex, repo_id); | ||
| 918 | |||
| 919 | let deletion = client | ||
| 920 | .event_builder(Kind::EventDeletion, "") | ||
| 921 | .tag(Tag::custom(TagKind::custom("a"), vec![coord])) | ||
| 922 | .tag(Tag::custom(TagKind::custom("k"), vec!["30618"])) | ||
| 923 | .build(&new_maintainer_keys) | ||
| 924 | .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?; | ||
| 925 | |||
| 926 | client | ||
| 927 | .send_event(deletion) | ||
| 928 | .await | ||
| 929 | .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?; | ||
| 930 | |||
| 931 | tokio::time::sleep(Duration::from_millis(300)).await; | ||
| 932 | |||
| 933 | // Stage 7: attempt to push the unique commit — must be rejected because | ||
| 934 | // the new maintainer's state event was deleted from purgatory | ||
| 935 | let push_result = try_push(&clone_path); | ||
| 936 | cleanup(); | ||
| 937 | |||
| 938 | match push_result { | ||
| 939 | Ok(false) => Ok(()), // push rejected as expected | ||
| 940 | Ok(true) => Err(format!( | ||
| 941 | "Push was accepted but should have been rejected. \ | ||
| 942 | The new maintainer's state event (id={}) was deleted by coordinate, \ | ||
| 943 | so commit {} should not be authorized.", | ||
| 944 | state_event.id, unique_commit | ||
| 945 | )), | ||
| 946 | Err(e) => Err(format!("Git push error: {}", e)), | ||
| 947 | } | ||
| 845 | }) | 948 | }) |
| 846 | .await | 949 | .await |
| 847 | } | 950 | } |
diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs index 69a5758..01241c9 100644 --- a/src/nostr/policy/deletion.rs +++ b/src/nostr/policy/deletion.rs | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | /// Deletion Policy - NIP-09 event deletion request handling | 1 | /// Deletion Policy - NIP-09 event deletion request handling |
| 2 | /// | 2 | /// |
| 3 | /// Handles kind 5 (EventDeletion) events that request removal of repository | 3 | /// Handles kind 5 (EventDeletion) events that request removal of purgatory entries |
| 4 | /// announcements (kind 30617) from purgatory. | 4 | /// for repository announcements (kind 30617) and state events (kind 30618). |
| 5 | /// | 5 | /// |
| 6 | /// ## NIP-09 Rules Enforced | 6 | /// ## NIP-09 Rules Enforced |
| 7 | /// | 7 | /// |
| @@ -13,9 +13,9 @@ | |||
| 13 | /// | 13 | /// |
| 14 | /// ## Purgatory Interaction | 14 | /// ## Purgatory Interaction |
| 15 | /// | 15 | /// |
| 16 | /// When a valid deletion request targets a kind 30617 announcement that is currently | 16 | /// - Kind 30617 (announcement) in purgatory: entry removed, bare repo deleted from disk |
| 17 | /// in purgatory (not yet promoted to the database), the purgatory entry is removed | 17 | /// - Kind 30618 (state event) in purgatory: matching state event(s) removed by event ID |
| 18 | /// and the bare repository is deleted from disk. | 18 | /// or by (author, identifier) coordinate |
| 19 | use nostr_relay_builder::prelude::{Event, WritePolicyResult}; | 19 | use nostr_relay_builder::prelude::{Event, WritePolicyResult}; |
| 20 | 20 | ||
| 21 | use super::PolicyContext; | 21 | use super::PolicyContext; |
| @@ -48,13 +48,13 @@ impl DeletionPolicy { | |||
| 48 | WritePolicyResult::Accept | 48 | WritePolicyResult::Accept |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | /// Remove any purgatory announcements targeted by this deletion event. | 51 | /// Remove any purgatory entries targeted by this deletion event. |
| 52 | /// | 52 | /// |
| 53 | /// Handles both reference styles from NIP-09: | 53 | /// Handles both reference styles from NIP-09: |
| 54 | /// - `e` tags: event ID references — match against purgatory entry event IDs | 54 | /// - `e` tags: event ID references — match against announcement or state event IDs |
| 55 | /// - `a` tags: addressable coordinate references — `30617:<pubkey>:<identifier>` | 55 | /// - `a` tags: addressable coordinate references — `30617:…` or `30618:…` |
| 56 | /// | 56 | /// |
| 57 | /// Only removes entries where the purgatory entry's owner matches the deletion | 57 | /// Only removes entries where the purgatory entry's author matches the deletion |
| 58 | /// event's pubkey (enforces author-only deletion). | 58 | /// event's pubkey (enforces author-only deletion). |
| 59 | fn remove_purgatory_targets(&self, event: &Event) { | 59 | fn remove_purgatory_targets(&self, event: &Event) { |
| 60 | let author = &event.pubkey; | 60 | let author = &event.pubkey; |
| @@ -81,17 +81,19 @@ impl DeletionPolicy { | |||
| 81 | } | 81 | } |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | /// Remove a purgatory announcement matched by event ID. | 84 | /// Remove a purgatory entry (announcement or state event) matched by event ID. |
| 85 | /// | 85 | /// |
| 86 | /// Scans all purgatory announcements owned by `author` and removes the one | 86 | /// Checks announcements first (kind 30617), then state events (kind 30618). |
| 87 | /// whose event ID hex matches `target_id_hex`. | 87 | /// Only removes entries whose author matches `author`. |
| 88 | fn remove_by_event_id(&self, author: &nostr_relay_builder::prelude::PublicKey, target_id_hex: &str, _deletion_created_at: u64) { | 88 | fn remove_by_event_id( |
| 89 | // Scan announcements owned by this author for a matching event ID | 89 | &self, |
| 90 | // We use get_announcements_by_identifier would require knowing the identifier, | 90 | author: &nostr_relay_builder::prelude::PublicKey, |
| 91 | // so instead we iterate via find_announcement after collecting all entries. | 91 | target_id_hex: &str, |
| 92 | _deletion_created_at: u64, | ||
| 93 | ) { | ||
| 94 | // --- Check announcements (kind 30617) --- | ||
| 92 | // The DashMap doesn't expose a direct "find by event ID" method, so we use | 95 | // The DashMap doesn't expose a direct "find by event ID" method, so we use |
| 93 | // the announcements_for_sync snapshot to get all (repo_id, _) pairs and then | 96 | // the announcements_for_sync snapshot to enumerate all (repo_id, _) pairs. |
| 94 | // look up each one. | ||
| 95 | let all = self.ctx.purgatory.announcements_for_sync(); | 97 | let all = self.ctx.purgatory.announcements_for_sync(); |
| 96 | for (repo_id, _) in all { | 98 | for (repo_id, _) in all { |
| 97 | // repo_id format: "30617:{pubkey_hex}:{identifier}" | 99 | // repo_id format: "30617:{pubkey_hex}:{identifier}" |
| @@ -102,7 +104,6 @@ impl DeletionPolicy { | |||
| 102 | let entry_pubkey_hex = parts[1]; | 104 | let entry_pubkey_hex = parts[1]; |
| 103 | let identifier = parts[2]; | 105 | let identifier = parts[2]; |
| 104 | 106 | ||
| 105 | // Only check entries owned by the deletion event author | ||
| 106 | if entry_pubkey_hex != author.to_hex() { | 107 | if entry_pubkey_hex != author.to_hex() { |
| 107 | continue; | 108 | continue; |
| 108 | } | 109 | } |
| @@ -116,18 +117,37 @@ impl DeletionPolicy { | |||
| 116 | "Deletion request: removing purgatory announcement by event ID" | 117 | "Deletion request: removing purgatory announcement by event ID" |
| 117 | ); | 118 | ); |
| 118 | self.evict_purgatory_entry(author, identifier); | 119 | self.evict_purgatory_entry(author, identifier); |
| 119 | return; // event IDs are unique, no need to continue | 120 | return; // event IDs are unique |
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | // --- Check state events (kind 30618) --- | ||
| 126 | // State events are keyed by identifier; scan all identifiers for a match. | ||
| 127 | let state_identifiers = self.ctx.purgatory.get_all_identifiers(); | ||
| 128 | for identifier in state_identifiers { | ||
| 129 | let entries = self.ctx.purgatory.find_state(&identifier); | ||
| 130 | for entry in entries { | ||
| 131 | if entry.author == *author && entry.event.id.to_hex() == target_id_hex { | ||
| 132 | tracing::info!( | ||
| 133 | event_id = %target_id_hex, | ||
| 134 | identifier = %identifier, | ||
| 135 | author = %author.to_hex(), | ||
| 136 | "Deletion request: removing purgatory state event by event ID" | ||
| 137 | ); | ||
| 138 | self.ctx.purgatory.remove_state_event(&identifier, &entry.event.id); | ||
| 139 | return; // event IDs are unique | ||
| 120 | } | 140 | } |
| 121 | } | 141 | } |
| 122 | } | 142 | } |
| 123 | } | 143 | } |
| 124 | 144 | ||
| 125 | /// Remove a purgatory announcement matched by addressable coordinate. | 145 | /// Remove a purgatory entry matched by addressable coordinate. |
| 146 | /// | ||
| 147 | /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`. | ||
| 148 | /// Handles kind 30617 (announcements) and kind 30618 (state events). | ||
| 126 | /// | 149 | /// |
| 127 | /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`. Only kind 30617 | 150 | /// Per NIP-09, all versions up to `deletion_created_at` are considered deleted. |
| 128 | /// coordinates are relevant here. Per NIP-09, all versions up to `deletion_created_at` | ||
| 129 | /// are considered deleted — since purgatory entries are always a single event per | ||
| 130 | /// (owner, identifier), we delete if the entry's `created_at` ≤ `deletion_created_at`. | ||
| 131 | fn remove_by_coordinate( | 151 | fn remove_by_coordinate( |
| 132 | &self, | 152 | &self, |
| 133 | author: &nostr_relay_builder::prelude::PublicKey, | 153 | author: &nostr_relay_builder::prelude::PublicKey, |
| @@ -144,11 +164,6 @@ impl DeletionPolicy { | |||
| 144 | let coord_pubkey_hex = parts[1]; | 164 | let coord_pubkey_hex = parts[1]; |
| 145 | let identifier = parts[2]; | 165 | let identifier = parts[2]; |
| 146 | 166 | ||
| 147 | // Only handle kind 30617 (GitRepoAnnouncement) | ||
| 148 | if kind_str != "30617" { | ||
| 149 | return; | ||
| 150 | } | ||
| 151 | |||
| 152 | // The coordinate pubkey must match the deletion event author | 167 | // The coordinate pubkey must match the deletion event author |
| 153 | if coord_pubkey_hex != author.to_hex() { | 168 | if coord_pubkey_hex != author.to_hex() { |
| 154 | tracing::debug!( | 169 | tracing::debug!( |
| @@ -159,25 +174,50 @@ impl DeletionPolicy { | |||
| 159 | return; | 174 | return; |
| 160 | } | 175 | } |
| 161 | 176 | ||
| 162 | if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { | 177 | match kind_str { |
| 163 | // Per NIP-09: delete all versions up to deletion_created_at | 178 | "30617" => { |
| 164 | if entry.event.created_at.as_secs() <= deletion_created_at { | 179 | // Announcement purgatory entry |
| 165 | tracing::info!( | 180 | if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { |
| 166 | identifier = %identifier, | 181 | if entry.event.created_at.as_secs() <= deletion_created_at { |
| 167 | author = %author.to_hex(), | 182 | tracing::info!( |
| 168 | entry_created_at = entry.event.created_at.as_secs(), | 183 | identifier = %identifier, |
| 169 | deletion_created_at = %deletion_created_at, | 184 | author = %author.to_hex(), |
| 170 | "Deletion request: removing purgatory announcement by coordinate" | 185 | "Deletion request: removing purgatory announcement by coordinate" |
| 171 | ); | 186 | ); |
| 172 | self.evict_purgatory_entry(author, identifier); | 187 | self.evict_purgatory_entry(author, identifier); |
| 173 | } else { | 188 | } else { |
| 174 | tracing::debug!( | 189 | tracing::debug!( |
| 175 | identifier = %identifier, | 190 | identifier = %identifier, |
| 176 | author = %author.to_hex(), | 191 | author = %author.to_hex(), |
| 177 | entry_created_at = entry.event.created_at.as_secs(), | 192 | "Ignoring deletion: purgatory announcement is newer than deletion request" |
| 178 | deletion_created_at = %deletion_created_at, | 193 | ); |
| 179 | "Ignoring deletion: purgatory entry is newer than deletion request" | 194 | } |
| 180 | ); | 195 | } |
| 196 | } | ||
| 197 | "30618" => { | ||
| 198 | // State event purgatory entries for this (author, identifier). | ||
| 199 | // Remove all entries authored by `author` with created_at ≤ deletion_created_at. | ||
| 200 | let entries = self.ctx.purgatory.find_state(identifier); | ||
| 201 | let mut removed = 0usize; | ||
| 202 | for entry in entries { | ||
| 203 | if entry.author == *author | ||
| 204 | && entry.event.created_at.as_secs() <= deletion_created_at | ||
| 205 | { | ||
| 206 | self.ctx.purgatory.remove_state_event(identifier, &entry.event.id); | ||
| 207 | removed += 1; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | if removed > 0 { | ||
| 211 | tracing::info!( | ||
| 212 | identifier = %identifier, | ||
| 213 | author = %author.to_hex(), | ||
| 214 | removed = %removed, | ||
| 215 | "Deletion request: removed purgatory state event(s) by coordinate" | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | } | ||
| 219 | _ => { | ||
| 220 | // Other kinds not handled | ||
| 181 | } | 221 | } |
| 182 | } | 222 | } |
| 183 | } | 223 | } |
diff --git a/tests/purgatory.rs b/tests/purgatory.rs index 553271f..73f85ca 100644 --- a/tests/purgatory.rs +++ b/tests/purgatory.rs | |||
| @@ -70,8 +70,8 @@ isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement); | |||
| 70 | // Deletion Event Tests (NIP-09) | 70 | // Deletion Event Tests (NIP-09) |
| 71 | // ============================================================ | 71 | // ============================================================ |
| 72 | 72 | ||
| 73 | isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_announcement); | 73 | isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_state_event); |
| 74 | isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_announcement); | 74 | isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_state_event); |
| 75 | 75 | ||
| 76 | // ============================================================ | 76 | // ============================================================ |
| 77 | // State Event Purgatory Tests (already implemented) | 77 | // State Event Purgatory Tests (already implemented) |