diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/nostr/builder.rs | 8 | ||||
| -rw-r--r-- | src/nostr/policy/deletion.rs | 438 | ||||
| -rw-r--r-- | src/nostr/policy/mod.rs | 2 |
3 files changed, 446 insertions, 2 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index c2d4939..d056e46 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -14,8 +14,8 @@ use nostr_relay_builder::prelude::*; | |||
| 14 | use crate::config::{Config, DatabaseBackend}; | 14 | use crate::config::{Config, DatabaseBackend}; |
| 15 | use crate::nostr::events::RepositoryAnnouncement; | 15 | use crate::nostr::events::RepositoryAnnouncement; |
| 16 | use crate::nostr::policy::{ | 16 | use crate::nostr::policy::{ |
| 17 | AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult, | 17 | AnnouncementPolicy, AnnouncementResult, DeletionPolicy, PolicyContext, PrEventPolicy, |
| 18 | RelatedEventPolicy, StatePolicy, StateResult, | 18 | ReferenceResult, RelatedEventPolicy, StatePolicy, StateResult, |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| 21 | 21 | ||
| @@ -29,6 +29,7 @@ pub type SharedDatabase = Arc<dyn NostrDatabase>; | |||
| 29 | /// - `StatePolicy` - State event validation + ref alignment | 29 | /// - `StatePolicy` - State event validation + ref alignment |
| 30 | /// - `PrEventPolicy` - PR/PR Update validation | 30 | /// - `PrEventPolicy` - PR/PR Update validation |
| 31 | /// - `RelatedEventPolicy` - Forward/backward reference checking | 31 | /// - `RelatedEventPolicy` - Forward/backward reference checking |
| 32 | /// - `DeletionPolicy` - NIP-09 event deletion request handling | ||
| 32 | /// | 33 | /// |
| 33 | /// Uses stateful database queries to check event relationships. | 34 | /// Uses stateful database queries to check event relationships. |
| 34 | #[derive(Clone)] | 35 | #[derive(Clone)] |
| @@ -38,6 +39,7 @@ pub struct Nip34WritePolicy { | |||
| 38 | state_policy: StatePolicy, | 39 | state_policy: StatePolicy, |
| 39 | pr_event_policy: PrEventPolicy, | 40 | pr_event_policy: PrEventPolicy, |
| 40 | related_event_policy: RelatedEventPolicy, | 41 | related_event_policy: RelatedEventPolicy, |
| 42 | deletion_policy: DeletionPolicy, | ||
| 41 | } | 43 | } |
| 42 | 44 | ||
| 43 | impl std::fmt::Debug for Nip34WritePolicy { | 45 | impl std::fmt::Debug for Nip34WritePolicy { |
| @@ -69,6 +71,7 @@ impl Nip34WritePolicy { | |||
| 69 | state_policy: StatePolicy::new(ctx.clone()), | 71 | state_policy: StatePolicy::new(ctx.clone()), |
| 70 | pr_event_policy: PrEventPolicy::new(ctx.clone()), | 72 | pr_event_policy: PrEventPolicy::new(ctx.clone()), |
| 71 | related_event_policy: RelatedEventPolicy::new(ctx.clone()), | 73 | related_event_policy: RelatedEventPolicy::new(ctx.clone()), |
| 74 | deletion_policy: DeletionPolicy::new(ctx.clone()), | ||
| 72 | ctx, | 75 | ctx, |
| 73 | } | 76 | } |
| 74 | } | 77 | } |
| @@ -521,6 +524,7 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 521 | ); | 524 | ); |
| 522 | WritePolicyResult::Accept | 525 | WritePolicyResult::Accept |
| 523 | } | 526 | } |
| 527 | Kind::EventDeletion => self.deletion_policy.handle(event).await, | ||
| 524 | _ => self.handle_related_event(event, "Event").await, | 528 | _ => self.handle_related_event(event, "Event").await, |
| 525 | } | 529 | } |
| 526 | }) | 530 | }) |
diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs new file mode 100644 index 0000000..69a5758 --- /dev/null +++ b/src/nostr/policy/deletion.rs | |||
| @@ -0,0 +1,438 @@ | |||
| 1 | /// Deletion Policy - NIP-09 event deletion request handling | ||
| 2 | /// | ||
| 3 | /// Handles kind 5 (EventDeletion) events that request removal of repository | ||
| 4 | /// announcements (kind 30617) from purgatory. | ||
| 5 | /// | ||
| 6 | /// ## NIP-09 Rules Enforced | ||
| 7 | /// | ||
| 8 | /// - Only the event author can delete their own events (pubkey must match) | ||
| 9 | /// - `e` tags reference specific event IDs to delete | ||
| 10 | /// - `a` tags reference addressable events by coordinate (`<kind>:<pubkey>:<d-identifier>`) | ||
| 11 | /// - When an `a` tag is used, all versions up to `created_at` of the deletion request | ||
| 12 | /// are considered deleted | ||
| 13 | /// | ||
| 14 | /// ## Purgatory Interaction | ||
| 15 | /// | ||
| 16 | /// When a valid deletion request targets a kind 30617 announcement that is currently | ||
| 17 | /// in purgatory (not yet promoted to the database), the purgatory entry is removed | ||
| 18 | /// and the bare repository is deleted from disk. | ||
| 19 | use nostr_relay_builder::prelude::{Event, WritePolicyResult}; | ||
| 20 | |||
| 21 | use super::PolicyContext; | ||
| 22 | |||
| 23 | /// Policy for handling NIP-09 event deletion requests | ||
| 24 | #[derive(Clone)] | ||
| 25 | pub struct DeletionPolicy { | ||
| 26 | ctx: PolicyContext, | ||
| 27 | } | ||
| 28 | |||
| 29 | impl DeletionPolicy { | ||
| 30 | pub fn new(ctx: PolicyContext) -> Self { | ||
| 31 | Self { ctx } | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Process a kind 5 (EventDeletion) event. | ||
| 35 | /// | ||
| 36 | /// Checks whether the deletion request targets any purgatory announcements | ||
| 37 | /// and removes them if so. The deletion event itself is always accepted | ||
| 38 | /// (relays should store deletion requests per NIP-09). | ||
| 39 | /// | ||
| 40 | /// Only the event author can delete their own events — this is enforced by | ||
| 41 | /// checking that the purgatory entry's owner matches `event.pubkey`. | ||
| 42 | pub async fn handle(&self, event: &Event) -> WritePolicyResult { | ||
| 43 | // Process purgatory removals synchronously (no async needed) | ||
| 44 | self.remove_purgatory_targets(event); | ||
| 45 | |||
| 46 | // Always accept the deletion event itself so it is stored and | ||
| 47 | // can prevent re-acceptance of the deleted event in the future. | ||
| 48 | WritePolicyResult::Accept | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Remove any purgatory announcements targeted by this deletion event. | ||
| 52 | /// | ||
| 53 | /// Handles both reference styles from NIP-09: | ||
| 54 | /// - `e` tags: event ID references — match against purgatory entry event IDs | ||
| 55 | /// - `a` tags: addressable coordinate references — `30617:<pubkey>:<identifier>` | ||
| 56 | /// | ||
| 57 | /// Only removes entries where the purgatory entry's owner matches the deletion | ||
| 58 | /// event's pubkey (enforces author-only deletion). | ||
| 59 | fn remove_purgatory_targets(&self, event: &Event) { | ||
| 60 | let author = &event.pubkey; | ||
| 61 | |||
| 62 | for tag in event.tags.iter() { | ||
| 63 | let tag_vec = tag.as_slice(); | ||
| 64 | if tag_vec.len() < 2 { | ||
| 65 | continue; | ||
| 66 | } | ||
| 67 | |||
| 68 | match tag_vec[0].as_str() { | ||
| 69 | "e" => { | ||
| 70 | // Event ID reference: find purgatory announcement with this event ID | ||
| 71 | let target_id = &tag_vec[1]; | ||
| 72 | self.remove_by_event_id(author, target_id, event.created_at.as_secs()); | ||
| 73 | } | ||
| 74 | "a" => { | ||
| 75 | // Addressable coordinate reference: `<kind>:<pubkey>:<d-identifier>` | ||
| 76 | let coord = &tag_vec[1]; | ||
| 77 | self.remove_by_coordinate(author, coord, event.created_at.as_secs()); | ||
| 78 | } | ||
| 79 | _ => {} | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Remove a purgatory announcement matched by event ID. | ||
| 85 | /// | ||
| 86 | /// Scans all purgatory announcements owned by `author` and removes the one | ||
| 87 | /// whose event ID hex matches `target_id_hex`. | ||
| 88 | fn remove_by_event_id(&self, author: &nostr_relay_builder::prelude::PublicKey, target_id_hex: &str, _deletion_created_at: u64) { | ||
| 89 | // Scan announcements owned by this author for a matching event ID | ||
| 90 | // We use get_announcements_by_identifier would require knowing the identifier, | ||
| 91 | // so instead we iterate via find_announcement after collecting all entries. | ||
| 92 | // 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 | ||
| 94 | // look up each one. | ||
| 95 | let all = self.ctx.purgatory.announcements_for_sync(); | ||
| 96 | for (repo_id, _) in all { | ||
| 97 | // repo_id format: "30617:{pubkey_hex}:{identifier}" | ||
| 98 | let parts: Vec<&str> = repo_id.splitn(3, ':').collect(); | ||
| 99 | if parts.len() != 3 { | ||
| 100 | continue; | ||
| 101 | } | ||
| 102 | let entry_pubkey_hex = parts[1]; | ||
| 103 | let identifier = parts[2]; | ||
| 104 | |||
| 105 | // Only check entries owned by the deletion event author | ||
| 106 | if entry_pubkey_hex != author.to_hex() { | ||
| 107 | continue; | ||
| 108 | } | ||
| 109 | |||
| 110 | if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { | ||
| 111 | if entry.event.id.to_hex() == target_id_hex { | ||
| 112 | tracing::info!( | ||
| 113 | event_id = %target_id_hex, | ||
| 114 | identifier = %identifier, | ||
| 115 | author = %author.to_hex(), | ||
| 116 | "Deletion request: removing purgatory announcement by event ID" | ||
| 117 | ); | ||
| 118 | self.evict_purgatory_entry(author, identifier); | ||
| 119 | return; // event IDs are unique, no need to continue | ||
| 120 | } | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Remove a purgatory announcement matched by addressable coordinate. | ||
| 126 | /// | ||
| 127 | /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`. Only kind 30617 | ||
| 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( | ||
| 132 | &self, | ||
| 133 | author: &nostr_relay_builder::prelude::PublicKey, | ||
| 134 | coordinate: &str, | ||
| 135 | deletion_created_at: u64, | ||
| 136 | ) { | ||
| 137 | // Parse coordinate: `<kind>:<pubkey>:<d-identifier>` | ||
| 138 | let parts: Vec<&str> = coordinate.splitn(3, ':').collect(); | ||
| 139 | if parts.len() != 3 { | ||
| 140 | return; | ||
| 141 | } | ||
| 142 | |||
| 143 | let kind_str = parts[0]; | ||
| 144 | let coord_pubkey_hex = parts[1]; | ||
| 145 | let identifier = parts[2]; | ||
| 146 | |||
| 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 | ||
| 153 | if coord_pubkey_hex != author.to_hex() { | ||
| 154 | tracing::debug!( | ||
| 155 | coord_pubkey = %coord_pubkey_hex, | ||
| 156 | deletion_author = %author.to_hex(), | ||
| 157 | "Ignoring deletion: coordinate pubkey does not match deletion author" | ||
| 158 | ); | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | |||
| 162 | if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { | ||
| 163 | // Per NIP-09: delete all versions up to deletion_created_at | ||
| 164 | if entry.event.created_at.as_secs() <= deletion_created_at { | ||
| 165 | tracing::info!( | ||
| 166 | identifier = %identifier, | ||
| 167 | author = %author.to_hex(), | ||
| 168 | entry_created_at = entry.event.created_at.as_secs(), | ||
| 169 | deletion_created_at = %deletion_created_at, | ||
| 170 | "Deletion request: removing purgatory announcement by coordinate" | ||
| 171 | ); | ||
| 172 | self.evict_purgatory_entry(author, identifier); | ||
| 173 | } else { | ||
| 174 | tracing::debug!( | ||
| 175 | identifier = %identifier, | ||
| 176 | author = %author.to_hex(), | ||
| 177 | entry_created_at = entry.event.created_at.as_secs(), | ||
| 178 | deletion_created_at = %deletion_created_at, | ||
| 179 | "Ignoring deletion: purgatory entry is newer than deletion request" | ||
| 180 | ); | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | /// Remove a purgatory announcement and delete its bare repository from disk. | ||
| 186 | fn evict_purgatory_entry( | ||
| 187 | &self, | ||
| 188 | author: &nostr_relay_builder::prelude::PublicKey, | ||
| 189 | identifier: &str, | ||
| 190 | ) { | ||
| 191 | // Get repo path before removing | ||
| 192 | if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { | ||
| 193 | if entry.repo_path.exists() { | ||
| 194 | if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) { | ||
| 195 | tracing::warn!( | ||
| 196 | path = %entry.repo_path.display(), | ||
| 197 | error = %e, | ||
| 198 | "Failed to delete bare repository during deletion request processing" | ||
| 199 | ); | ||
| 200 | } else { | ||
| 201 | tracing::info!( | ||
| 202 | path = %entry.repo_path.display(), | ||
| 203 | "Deleted bare repository for deletion-requested purgatory announcement" | ||
| 204 | ); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | self.ctx.purgatory.remove_announcement(author, identifier); | ||
| 210 | |||
| 211 | // Remove state events for this identifier only if no other owner's | ||
| 212 | // announcement remains in purgatory (state events are keyed by identifier alone) | ||
| 213 | let other_owners_remain = !self | ||
| 214 | .ctx | ||
| 215 | .purgatory | ||
| 216 | .get_announcements_by_identifier(identifier) | ||
| 217 | .is_empty(); | ||
| 218 | |||
| 219 | if !other_owners_remain { | ||
| 220 | self.ctx.purgatory.remove_state(identifier); | ||
| 221 | } | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | #[cfg(test)] | ||
| 226 | mod tests { | ||
| 227 | use super::*; | ||
| 228 | use crate::nostr::policy::PolicyContext; | ||
| 229 | use crate::purgatory::Purgatory; | ||
| 230 | use nostr_relay_builder::prelude::*; | ||
| 231 | use std::collections::HashSet; | ||
| 232 | use std::path::PathBuf; | ||
| 233 | use std::sync::Arc; | ||
| 234 | |||
| 235 | fn make_context() -> PolicyContext { | ||
| 236 | let db = Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions { | ||
| 237 | events: true, | ||
| 238 | max_events: None, | ||
| 239 | })); | ||
| 240 | let purgatory = Arc::new(Purgatory::new(PathBuf::new())); | ||
| 241 | let config = crate::config::Config::for_testing(); | ||
| 242 | PolicyContext::new("test.example.com", db, PathBuf::new(), purgatory, config) | ||
| 243 | } | ||
| 244 | |||
| 245 | fn make_announcement_event(keys: &Keys, identifier: &str) -> Event { | ||
| 246 | EventBuilder::new(Kind::GitRepoAnnouncement, "") | ||
| 247 | .tags(vec![ | ||
| 248 | Tag::identifier(identifier), | ||
| 249 | Tag::custom(TagKind::custom("clone"), vec!["https://example.com/repo.git"]), | ||
| 250 | ]) | ||
| 251 | .sign_with_keys(keys) | ||
| 252 | .unwrap() | ||
| 253 | } | ||
| 254 | |||
| 255 | fn add_to_purgatory(ctx: &PolicyContext, event: &Event, identifier: &str) { | ||
| 256 | ctx.purgatory.add_announcement( | ||
| 257 | event.clone(), | ||
| 258 | identifier.to_string(), | ||
| 259 | event.pubkey, | ||
| 260 | PathBuf::new(), | ||
| 261 | HashSet::new(), | ||
| 262 | ); | ||
| 263 | } | ||
| 264 | |||
| 265 | #[tokio::test] | ||
| 266 | async fn test_deletion_by_event_id_removes_purgatory_entry() { | ||
| 267 | let ctx = make_context(); | ||
| 268 | let keys = Keys::generate(); | ||
| 269 | let identifier = "my-repo"; | ||
| 270 | |||
| 271 | let announcement = make_announcement_event(&keys, identifier); | ||
| 272 | add_to_purgatory(&ctx, &announcement, identifier); | ||
| 273 | |||
| 274 | assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier)); | ||
| 275 | |||
| 276 | // Build kind 5 deletion event referencing the announcement by event ID | ||
| 277 | let deletion = EventBuilder::new(Kind::EventDeletion, "") | ||
| 278 | .tags(vec![ | ||
| 279 | Tag::event(announcement.id), | ||
| 280 | Tag::custom(TagKind::custom("k"), vec!["30617"]), | ||
| 281 | ]) | ||
| 282 | .sign_with_keys(&keys) | ||
| 283 | .unwrap(); | ||
| 284 | |||
| 285 | let policy = DeletionPolicy::new(ctx.clone()); | ||
| 286 | let result = policy.handle(&deletion).await; | ||
| 287 | |||
| 288 | assert!(matches!(result, WritePolicyResult::Accept)); | ||
| 289 | assert!( | ||
| 290 | !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier), | ||
| 291 | "Purgatory entry should have been removed" | ||
| 292 | ); | ||
| 293 | } | ||
| 294 | |||
| 295 | #[tokio::test] | ||
| 296 | async fn test_deletion_by_coordinate_removes_purgatory_entry() { | ||
| 297 | let ctx = make_context(); | ||
| 298 | let keys = Keys::generate(); | ||
| 299 | let identifier = "my-repo"; | ||
| 300 | |||
| 301 | let announcement = make_announcement_event(&keys, identifier); | ||
| 302 | add_to_purgatory(&ctx, &announcement, identifier); | ||
| 303 | |||
| 304 | assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier)); | ||
| 305 | |||
| 306 | // Build kind 5 deletion event referencing the announcement by coordinate | ||
| 307 | let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier); | ||
| 308 | let deletion = EventBuilder::new(Kind::EventDeletion, "") | ||
| 309 | .tags(vec![ | ||
| 310 | Tag::custom(TagKind::custom("a"), vec![coord]), | ||
| 311 | Tag::custom(TagKind::custom("k"), vec!["30617"]), | ||
| 312 | ]) | ||
| 313 | .sign_with_keys(&keys) | ||
| 314 | .unwrap(); | ||
| 315 | |||
| 316 | let policy = DeletionPolicy::new(ctx.clone()); | ||
| 317 | let result = policy.handle(&deletion).await; | ||
| 318 | |||
| 319 | assert!(matches!(result, WritePolicyResult::Accept)); | ||
| 320 | assert!( | ||
| 321 | !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier), | ||
| 322 | "Purgatory entry should have been removed" | ||
| 323 | ); | ||
| 324 | } | ||
| 325 | |||
| 326 | #[tokio::test] | ||
| 327 | async fn test_deletion_by_wrong_author_does_not_remove() { | ||
| 328 | let ctx = make_context(); | ||
| 329 | let owner_keys = Keys::generate(); | ||
| 330 | let attacker_keys = Keys::generate(); | ||
| 331 | let identifier = "my-repo"; | ||
| 332 | |||
| 333 | let announcement = make_announcement_event(&owner_keys, identifier); | ||
| 334 | add_to_purgatory(&ctx, &announcement, identifier); | ||
| 335 | |||
| 336 | // Attacker tries to delete by event ID | ||
| 337 | let deletion = EventBuilder::new(Kind::EventDeletion, "") | ||
| 338 | .tags(vec![ | ||
| 339 | Tag::event(announcement.id), | ||
| 340 | Tag::custom(TagKind::custom("k"), vec!["30617"]), | ||
| 341 | ]) | ||
| 342 | .sign_with_keys(&attacker_keys) | ||
| 343 | .unwrap(); | ||
| 344 | |||
| 345 | let policy = DeletionPolicy::new(ctx.clone()); | ||
| 346 | let result = policy.handle(&deletion).await; | ||
| 347 | |||
| 348 | assert!(matches!(result, WritePolicyResult::Accept)); | ||
| 349 | assert!( | ||
| 350 | ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier), | ||
| 351 | "Purgatory entry should NOT have been removed by wrong author" | ||
| 352 | ); | ||
| 353 | } | ||
| 354 | |||
| 355 | #[tokio::test] | ||
| 356 | async fn test_deletion_by_coordinate_wrong_author_does_not_remove() { | ||
| 357 | let ctx = make_context(); | ||
| 358 | let owner_keys = Keys::generate(); | ||
| 359 | let attacker_keys = Keys::generate(); | ||
| 360 | let identifier = "my-repo"; | ||
| 361 | |||
| 362 | let announcement = make_announcement_event(&owner_keys, identifier); | ||
| 363 | add_to_purgatory(&ctx, &announcement, identifier); | ||
| 364 | |||
| 365 | // Attacker tries to delete by coordinate using owner's pubkey in coord | ||
| 366 | // but signs with their own key — coord pubkey != deletion author | ||
| 367 | let coord = format!("30617:{}:{}", owner_keys.public_key().to_hex(), identifier); | ||
| 368 | let deletion = EventBuilder::new(Kind::EventDeletion, "") | ||
| 369 | .tags(vec![ | ||
| 370 | Tag::custom(TagKind::custom("a"), vec![coord]), | ||
| 371 | Tag::custom(TagKind::custom("k"), vec!["30617"]), | ||
| 372 | ]) | ||
| 373 | .sign_with_keys(&attacker_keys) | ||
| 374 | .unwrap(); | ||
| 375 | |||
| 376 | let policy = DeletionPolicy::new(ctx.clone()); | ||
| 377 | let result = policy.handle(&deletion).await; | ||
| 378 | |||
| 379 | assert!(matches!(result, WritePolicyResult::Accept)); | ||
| 380 | assert!( | ||
| 381 | ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier), | ||
| 382 | "Purgatory entry should NOT have been removed by wrong author" | ||
| 383 | ); | ||
| 384 | } | ||
| 385 | |||
| 386 | #[tokio::test] | ||
| 387 | async fn test_deletion_of_nonexistent_entry_is_accepted() { | ||
| 388 | let ctx = make_context(); | ||
| 389 | let keys = Keys::generate(); | ||
| 390 | |||
| 391 | // No purgatory entry exists — deletion should still be accepted | ||
| 392 | let deletion = EventBuilder::new(Kind::EventDeletion, "") | ||
| 393 | .tags(vec![ | ||
| 394 | Tag::custom(TagKind::custom("a"), vec![ | ||
| 395 | format!("30617:{}:nonexistent", keys.public_key().to_hex()) | ||
| 396 | ]), | ||
| 397 | ]) | ||
| 398 | .sign_with_keys(&keys) | ||
| 399 | .unwrap(); | ||
| 400 | |||
| 401 | let policy = DeletionPolicy::new(ctx.clone()); | ||
| 402 | let result = policy.handle(&deletion).await; | ||
| 403 | |||
| 404 | assert!(matches!(result, WritePolicyResult::Accept)); | ||
| 405 | } | ||
| 406 | |||
| 407 | #[tokio::test] | ||
| 408 | async fn test_deletion_by_coordinate_respects_created_at() { | ||
| 409 | let ctx = make_context(); | ||
| 410 | let keys = Keys::generate(); | ||
| 411 | let identifier = "my-repo"; | ||
| 412 | |||
| 413 | // Create announcement with a future timestamp | ||
| 414 | let future_ts = Timestamp::now().as_secs() + 3600; // 1 hour in the future | ||
| 415 | let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "") | ||
| 416 | .tags(vec![Tag::identifier(identifier)]) | ||
| 417 | .custom_created_at(Timestamp::from(future_ts)) | ||
| 418 | .sign_with_keys(&keys) | ||
| 419 | .unwrap(); | ||
| 420 | add_to_purgatory(&ctx, &announcement, identifier); | ||
| 421 | |||
| 422 | // Deletion event with current timestamp (older than announcement) | ||
| 423 | let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier); | ||
| 424 | let deletion = EventBuilder::new(Kind::EventDeletion, "") | ||
| 425 | .tags(vec![Tag::custom(TagKind::custom("a"), vec![coord])]) | ||
| 426 | .sign_with_keys(&keys) | ||
| 427 | .unwrap(); | ||
| 428 | |||
| 429 | let policy = DeletionPolicy::new(ctx.clone()); | ||
| 430 | let result = policy.handle(&deletion).await; | ||
| 431 | |||
| 432 | assert!(matches!(result, WritePolicyResult::Accept)); | ||
| 433 | assert!( | ||
| 434 | ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier), | ||
| 435 | "Purgatory entry should NOT be removed: entry is newer than deletion request" | ||
| 436 | ); | ||
| 437 | } | ||
| 438 | } | ||
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index 1566b6c..f5b981a 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs | |||
| @@ -6,11 +6,13 @@ | |||
| 6 | /// - `PrEventPolicy` - PR/PR Update validation | 6 | /// - `PrEventPolicy` - PR/PR Update validation |
| 7 | /// - `RelatedEventPolicy` - Forward/backward reference checking | 7 | /// - `RelatedEventPolicy` - Forward/backward reference checking |
| 8 | mod announcement; | 8 | mod announcement; |
| 9 | mod deletion; | ||
| 9 | mod pr_event; | 10 | mod pr_event; |
| 10 | mod related; | 11 | mod related; |
| 11 | mod state; | 12 | mod state; |
| 12 | 13 | ||
| 13 | pub use announcement::{AnnouncementPolicy, AnnouncementResult}; | 14 | pub use announcement::{AnnouncementPolicy, AnnouncementResult}; |
| 15 | pub use deletion::DeletionPolicy; | ||
| 14 | pub use pr_event::PrEventPolicy; | 16 | pub use pr_event::PrEventPolicy; |
| 15 | pub use related::{ReferenceResult, RelatedEventPolicy}; | 17 | pub use related::{ReferenceResult, RelatedEventPolicy}; |
| 16 | pub use state::{StatePolicy, StateResult}; | 18 | pub use state::{StatePolicy, StateResult}; |