From c29191b1e1239e931c575a926ec9480e594476d6 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 17:40:25 +0000 Subject: feat(grasp-05): implement archive mode for backup/mirror operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements GRASP-05 specification for accepting repository announcements that don't list this relay, enabling archive, mirror, and backup use cases. Core Features: - Three whitelist formats: , /, - Archive-all mode for complete ecosystem mirrors - Fail-fast npub validation at startup - Read-only enforcement (archived repos reject pushes) - Full GRASP-02 sync (git data + Nostr events) - Dynamic archive status (no flags/metadata) Implementation: - Add ArchiveWhitelistEntry enum with Pubkey/Repository/Identifier variants - Add ArchiveConfig with validation and matching logic - Update AnnouncementResult to include AcceptArchive variant - Refactor validate_announcement() to return AnnouncementResult with archive check - Update AnnouncementPolicy with catch-all pattern for cleaner code - Wire archive config through builder and policy layers Configuration: - NGIT_ARCHIVE_ALL: Accept all announcements (⚠️ storage risk) - NGIT_ARCHIVE_WHITELIST: Comma-separated whitelist entries - Updated docs, .env.example, and nix/module.nix Testing: - 28 unit tests for config parsing and whitelist matching - 7 integration tests for archive mode validation - All 296 tests passing Validation Priority: 1. Lists our service → Accept (GRASP-01, read/write) 2. Is maintainer → AcceptMaintainer (multi-maintainer, read/write) 3. Matches archive config → AcceptArchive (GRASP-05, read-only) 4. None of above → Reject Security Considerations: - Archive-all mode has storage/bandwidth DoS risk - Identifier-only format matches any pubkey (use npub/identifier for high-value) - Invalid npubs cause startup failure (fail-fast) Documentation: - Concise explanation focused on rationale - Reference docs updated with all config options - README updated to reflect completed feature - Removed from roadmap, added to compliance section See docs/explanation/grasp-05-archive.md for details. --- src/config.rs | 321 ++++++++++++++++++++++++++++++++++++++- src/nostr/builder.rs | 53 ++++++- src/nostr/events.rs | 277 ++++++++++++++++++++++++++++----- src/nostr/policy/announcement.rs | 42 ++--- 4 files changed, 637 insertions(+), 56 deletions(-) (limited to 'src') diff --git a/src/config.rs b/src/config.rs index 1812fe2..b1ab43e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,150 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use clap::{Parser, ValueEnum}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +/// GRASP-05 Archive whitelist entry +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ArchiveWhitelistEntry { + /// Archive all repos from this pubkey: "npub1..." + Pubkey(String), + + /// Archive specific repo: "npub1.../identifier" + Repository { npub: String, identifier: String }, + + /// Archive any repo with this identifier: "identifier" + Identifier(String), +} + +impl ArchiveWhitelistEntry { + /// Parse a whitelist entry from string + /// + /// Formats: + /// - "npub1..." -> Pubkey + /// - "npub1.../identifier" -> Repository + /// - "identifier" -> Identifier + /// + /// Validates npub format at parse time (fail fast) + pub fn parse(s: &str) -> Result { + let trimmed = s.trim(); + + if trimmed.contains('/') { + // Format: npub1.../identifier + let parts: Vec<&str> = trimmed.split('/').collect(); + if parts.len() != 2 { + return Err(anyhow!( + "Invalid whitelist entry format '{}'. Expected 'npub/identifier'", + s + )); + } + + let npub = parts[0]; + let identifier = parts[1]; + + // Validate npub format (fail fast) + if !npub.starts_with("npub1") { + return Err(anyhow!( + "Invalid whitelist entry '{}'. First part must be npub", + s + )); + } + + PublicKey::from_bech32(npub) + .context(format!("Invalid npub in whitelist entry '{}'", s))?; + + Ok(Self::Repository { + npub: npub.to_string(), + identifier: identifier.to_string(), + }) + } else if trimmed.starts_with("npub1") { + // Format: npub1... + // Validate npub format (fail fast) + PublicKey::from_bech32(trimmed) + .context(format!("Invalid npub in whitelist entry '{}'", s))?; + + Ok(Self::Pubkey(trimmed.to_string())) + } else { + // Format: identifier + Ok(Self::Identifier(trimmed.to_string())) + } + } + + /// Check if this entry matches the given npub and identifier + pub fn matches(&self, npub: &str, identifier: &str) -> bool { + match self { + Self::Pubkey(p) => npub == p, + Self::Repository { + npub: p, + identifier: i, + } => npub == p && identifier == i, + Self::Identifier(i) => identifier == i, + } + } +} + +/// GRASP-05 Archive mode configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArchiveConfig { + /// Accept all repository announcements (no filtering) + /// + /// WARNING: Setting this to true allows anyone to mirror any repository + /// to this relay, potentially causing storage/bandwidth exhaustion. + pub archive_all: bool, + + /// Whitelist entries for selective archiving + /// + /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). + pub whitelist: Vec, +} + +impl ArchiveConfig { + /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist) + pub fn enabled(&self) -> bool { + self.archive_all || !self.whitelist.is_empty() + } + + /// Check if an announcement matches the archive configuration + /// + /// Returns true if: + /// - archive_all is true, OR + /// - announcement matches any whitelist entry + pub fn matches(&self, npub: &str, identifier: &str) -> bool { + if self.archive_all { + return true; + } + + self.whitelist + .iter() + .any(|entry| entry.matches(npub, identifier)) + } + + /// Parse archive whitelist from comma-separated string + pub fn parse_whitelist(input: &str) -> Result> { + if input.trim().is_empty() { + return Ok(Vec::new()); + } + + input + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(ArchiveWhitelistEntry::parse) + .collect() + } +} + +impl Default for ArchiveConfig { + fn default() -> Self { + Self { + archive_all: false, + whitelist: Vec::new(), + } + } +} + /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -162,6 +302,15 @@ pub struct Config { /// tracked separately and retried after this expiration period. #[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)] pub naughty_list_expiration_hours: u64, + + /// Enable GRASP-05 archive mode: accept all announcements regardless of listing (WARNING: storage risk) + #[arg(long, env = "NGIT_ARCHIVE_ALL", default_value_t = false)] + pub archive_all: bool, + + /// GRASP-05 archive whitelist: comma-separated list of npub/identifier/npub/identifier entries + /// Formats: "npub1...", "npub1.../identifier", "identifier" + #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] + pub archive_whitelist: String, } impl Config { @@ -262,6 +411,15 @@ impl Config { } } + /// Get parsed archive configuration + pub fn archive_config(&self) -> Result { + let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; + Ok(ArchiveConfig { + archive_all: self.archive_all, + whitelist, + }) + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -292,6 +450,8 @@ impl Config { rejected_hot_cache_duration_secs: 120, rejected_cold_index_expiry_secs: 604800, naughty_list_expiration_hours: 12, + archive_all: false, + archive_whitelist: String::new(), } } } @@ -426,4 +586,163 @@ mod tests { assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50); assert_eq!(config.metrics_top_n_repos, 25); } + + #[test] + fn test_parse_whitelist_entry_pubkey() { + // Generate a valid test npub + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let entry = ArchiveWhitelistEntry::parse(&test_npub).unwrap(); + assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_))); + if let ArchiveWhitelistEntry::Pubkey(npub) = entry { + assert_eq!(npub, test_npub); + } + } + + #[test] + fn test_parse_whitelist_entry_repository() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let entry = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap(); + assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. })); + if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry { + assert_eq!(npub, test_npub); + assert_eq!(identifier, "linux"); + } + } + + #[test] + fn test_parse_whitelist_entry_identifier() { + let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap(); + assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_))); + if let ArchiveWhitelistEntry::Identifier(id) = entry { + assert_eq!(id, "bitcoin-core"); + } + } + + #[test] + fn test_parse_whitelist_entry_invalid_npub() { + let result = ArchiveWhitelistEntry::parse("npub1invalid"); + assert!(result.is_err()); + } + + #[test] + fn test_whitelist_entry_matches() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let entry = ArchiveWhitelistEntry::Pubkey(test_npub.clone()); + assert!(entry.matches(&test_npub, "any-identifier")); + assert!(!entry.matches("npub1different", "any-identifier")); + } + + #[test] + fn test_whitelist_entry_matches_repository() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let entry = ArchiveWhitelistEntry::Repository { + npub: test_npub.clone(), + identifier: "linux".to_string(), + }; + assert!(entry.matches(&test_npub, "linux")); + assert!(!entry.matches(&test_npub, "bitcoin")); + assert!(!entry.matches("npub1different", "linux")); + } + + #[test] + fn test_whitelist_entry_matches_identifier() { + let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string()); + assert!(entry.matches("npub1alice", "bitcoin-core")); + assert!(entry.matches("npub1bob", "bitcoin-core")); + assert!(!entry.matches("npub1alice", "other-repo")); + } + + #[test] + fn test_archive_config_enabled() { + let config = ArchiveConfig::default(); + assert!(!config.enabled()); + + let config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + assert!(config.enabled()); + + let config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], + }; + assert!(config.enabled()); + } + + #[test] + fn test_archive_config_matches() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = ArchiveConfig { + archive_all: false, + whitelist: vec![ + ArchiveWhitelistEntry::Pubkey(test_npub.clone()), + ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), + ], + }; + + assert!(config.matches(&test_npub, "any-repo")); + assert!(config.matches("npub1bob", "bitcoin-core")); + assert!(!config.matches("npub1bob", "other-repo")); + } + + #[test] + fn test_archive_config_matches_archive_all() { + let config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + + assert!(config.matches("npub1alice", "any-repo")); + assert!(config.matches("npub1bob", "other-repo")); + } + + #[test] + fn test_parse_whitelist_empty() { + let whitelist = ArchiveConfig::parse_whitelist("").unwrap(); + assert!(whitelist.is_empty()); + + let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap(); + assert!(whitelist.is_empty()); + } + + #[test] + fn test_parse_whitelist_multiple() { + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + let test_npub1 = keys1.public_key().to_bech32().unwrap(); + let test_npub2 = keys2.public_key().to_bech32().unwrap(); + let whitelist = ArchiveConfig::parse_whitelist(&format!( + "{},bitcoin-core,{}/linux", + test_npub1, test_npub2 + )) + .unwrap(); + assert_eq!(whitelist.len(), 3); + } + + #[test] + fn test_archive_config_parsing() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + archive_whitelist: format!("{},bitcoin-core", test_npub), + ..Config::for_testing() + }; + let archive_config = config.archive_config().unwrap(); + assert_eq!(archive_config.whitelist.len(), 2); + } + + #[test] + fn test_archive_config_invalid_npub() { + let config = Config { + archive_whitelist: "npub1invalid".to_string(), + ..Config::for_testing() + }; + assert!(config.archive_config().is_err()); + } } diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index c010854..deee641 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -55,10 +55,11 @@ impl Nip34WritePolicy { database: SharedDatabase, git_data_path: impl Into, purgatory: std::sync::Arc, + archive_config: crate::config::ArchiveConfig, ) -> Self { let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); Self { - announcement_policy: AnnouncementPolicy::new(ctx.clone()), + announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config), state_policy: StatePolicy::new(ctx.clone()), pr_event_policy: PrEventPolicy::new(ctx.clone()), related_event_policy: RelatedEventPolicy::new(ctx.clone()), @@ -147,6 +148,34 @@ impl Nip34WritePolicy { } } } + AnnouncementResult::AcceptArchive => { + // GRASP-05: Archive mode - accept announcement but don't create bare repository + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + tracing::info!( + "Accepted archive announcement {} for {}/{} (GRASP-05 read-only mirror)", + event_id_str, + announcement.owner_npub(), + announcement.identifier + ); + // Don't create bare repository for archived announcements + + // Check purgatory for state events that might now be authorized + self.check_purgatory_state_events_for_identifier(&announcement.identifier) + .await; + + WritePolicyResult::Accept + } + Err(e) => { + tracing::warn!( + "Failed to parse archive announcement {}: {}", + event_id_str, + e + ); + WritePolicyResult::reject(format!("Failed to parse announcement: {}", e)) + } + } + } AnnouncementResult::Reject(reason) => { tracing::warn!( "Rejected repository announcement {}: {}", @@ -539,9 +568,27 @@ pub async fn create_relay( // Clone Arc for the write policy so both relay and policy can access the database let git_data_path = config.effective_git_data_path(); + // Parse archive configuration + let archive_config = config + .archive_config() + .map_err(|e| anyhow::anyhow!("Failed to parse archive configuration: {}", e))?; + + if archive_config.enabled() { + tracing::info!( + "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}", + archive_config.archive_all, + archive_config.whitelist.len() + ); + } + // Create write policy with purgatory integration - let write_policy = - Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory); + let write_policy = Nip34WritePolicy::new( + &config.domain, + database.clone(), + &git_data_path, + purgatory, + archive_config, + ); let relay = LocalRelayBuilder::default() .database(database.clone()) diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 9d43ca3..dabe5fe 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -359,13 +359,24 @@ impl RepositoryState { } } -/// Validate a repository announcement according to GRASP-01 +/// Validate a repository announcement according to GRASP-01 and GRASP-05 /// -/// Returns Ok(()) if valid, Err with reason if invalid. -pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> { +/// Returns: +/// - Accept: Announcement lists our service (GRASP-01) +/// - AcceptArchive: Announcement matches archive config (GRASP-05) +/// - Reject: Validation failed +/// +/// Note: AcceptMaintainer is NOT returned here (requires database access) +pub fn validate_announcement( + event: &Event, + domain: &str, + archive_config: &crate::config::ArchiveConfig, +) -> crate::nostr::policy::AnnouncementResult { + use crate::nostr::policy::AnnouncementResult; + // Must be kind 30617 if event.kind != Kind::GitRepoAnnouncement { - return Err(anyhow!( + return AnnouncementResult::Reject(format!( "Invalid kind: expected {}", Kind::GitRepoAnnouncement )); @@ -374,24 +385,32 @@ pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> { // Must have identifier let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d()); if !has_identifier { - return Err(anyhow!("Missing required 'd' tag (identifier)")); + return AnnouncementResult::Reject("Missing required 'd' tag (identifier)".to_string()); } // Parse full announcement to validate structure - let announcement = RepositoryAnnouncement::from_event(event.clone())?; - - // GRASP-01: MUST reject announcements that do not list the service - // in both `clone` and `relays` tags unless implementing GRASP-05 - if !announcement.lists_service(domain) { - return Err(anyhow!( - "Announcement must list service in both 'clone' and 'relays' tags. \ - Found clone URLs: {:?}, relays: {:?}", - announcement.clone_urls, - announcement.relays - )); + let announcement = match RepositoryAnnouncement::from_event(event.clone()) { + Ok(a) => a, + Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), + }; + + // GRASP-01: Check if announcement lists our service + if announcement.lists_service(domain) { + return AnnouncementResult::Accept; } - Ok(()) + // GRASP-05: Check if announcement matches archive configuration + let npub = announcement.owner_npub(); + if archive_config.matches(&npub, &announcement.identifier) { + return AnnouncementResult::AcceptArchive; + } + + // Reject: Doesn't list us and not whitelisted + AnnouncementResult::Reject(format!( + "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ + Found clone URLs: {:?}, relays: {:?}", + announcement.clone_urls, announcement.relays + )) } /// Validate a repository state announcement according to GRASP-01 @@ -529,6 +548,9 @@ mod tests { #[test] fn test_validate_announcement_success() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -537,12 +559,15 @@ mod tests { vec!["wss://gitnostr.com"], ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_ok()); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_missing_clone() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -551,13 +576,19 @@ mod tests { vec!["wss://gitnostr.com"], ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("clone")); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("clone")); + } else { + panic!("Expected Reject, got {:?}", result); + } } #[test] fn test_validate_announcement_missing_relay() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -566,13 +597,19 @@ mod tests { vec![], // No relays ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("relays")); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("relays")); + } else { + panic!("Expected Reject, got {:?}", result); + } } #[test] fn test_validate_announcement_wrong_domain() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -581,8 +618,8 @@ mod tests { vec!["wss://other-service.com"], ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_err()); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] @@ -805,6 +842,9 @@ mod tests { #[test] fn test_validate_announcement_with_trailing_slash_in_relay() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -814,12 +854,16 @@ mod tests { ); // Should accept despite trailing slash mismatch - let result = validate_announcement(&event, "git.shakespeare.diy"); - assert!(result.is_ok()); + let result = + validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_with_trailing_slash_in_clone_url() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -829,12 +873,16 @@ mod tests { ); // Should accept despite trailing slash mismatch - let result = validate_announcement(&event, "git.shakespeare.diy"); - assert!(result.is_ok()); + let result = + validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_with_trailing_slash_in_both() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -844,12 +892,16 @@ mod tests { ); // Should accept with trailing slashes in both - let result = validate_announcement(&event, "git.shakespeare.diy"); - assert!(result.is_ok()); + let result = + validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_domain_with_trailing_slash() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -859,8 +911,8 @@ mod tests { ); // Should accept even when domain parameter has trailing slash - let result = validate_announcement(&event, "gitnostr.com/"); - assert!(result.is_ok()); + let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] @@ -896,4 +948,159 @@ mod tests { assert!(announcement.has_relay("example.com")); assert!(announcement.has_relay("example.com/")); } + + #[test] + fn test_validate_announcement_archive_mode_npub() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://other-service.com/alice/test-repo.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists this npub + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_archive_mode_identifier() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "bitcoin-core", + vec!["https://other-service.com/alice/bitcoin-core.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists this identifier + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_archive_mode_repository() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "linux", + vec!["https://other-service.com/alice/linux.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists this specific repo + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Repository { + npub, + identifier: "linux".into(), + }], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_archive_all() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "any-repo", + vec!["https://other-service.com/alice/any-repo.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config with archive_all enabled + let archive_config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_reject_not_in_whitelist() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "other-repo", + vec!["https://other-service.com/alice/other-repo.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists different identifier + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_validate_announcement_grasp01_takes_precedence() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that DOES list our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Even with archive config, GRASP-01 Accept takes precedence + let archive_config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::Accept)); + } } diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 61840fb..db87976 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -5,15 +5,18 @@ use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; use super::PolicyContext; +use crate::config::ArchiveConfig; use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; /// Result of announcement policy evaluation -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub enum AnnouncementResult { - /// Accept: Event passes validation + /// Accept: Event lists our service (GRASP-01 compliant) Accept, - /// Accept as maintainer: Event accepted via maintainer exception + /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) AcceptMaintainer, + /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) + AcceptArchive, /// Reject: Event fails validation with reason Reject(String), } @@ -22,31 +25,34 @@ pub enum AnnouncementResult { #[derive(Clone)] pub struct AnnouncementPolicy { ctx: PolicyContext, + archive_config: ArchiveConfig, } impl AnnouncementPolicy { - pub fn new(ctx: PolicyContext) -> Self { - Self { ctx } + pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self { + Self { + ctx, + archive_config, + } } /// Validate a repository announcement event /// /// Returns `Accept` if the announcement lists the service properly, /// `AcceptMaintainer` if accepted via maintainer exception, + /// `AcceptArchive` if accepted via GRASP-05 archive config, /// or `Reject` with reason. pub async fn validate(&self, event: &Event) -> AnnouncementResult { - // First, try normal validation (announcement lists service) - match validate_announcement(event, &self.ctx.domain) { - Ok(_) => AnnouncementResult::Accept, - Err(validation_err) => { - // Validation failed - check if this is a recursive maintainer announcement - // GRASP-01 Exception: Accept announcements from recursive maintainers - // even without listing the service, for chain discovery and GRASP-02 sync + // First, try validation (GRASP-01 + GRASP-05) + let validation_result = + validate_announcement(event, &self.ctx.domain, &self.archive_config); - // Try to parse the announcement to get identifier + match validation_result { + AnnouncementResult::Reject(reason) => { + // Validation failed - check maintainer exception + // GRASP-01 Exception: Accept announcements from recursive maintainers match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { - // Check if author is listed as maintainer in any existing announcement match self .is_maintainer_in_any_announcement( &announcement.identifier, @@ -55,16 +61,18 @@ impl AnnouncementPolicy { .await { Ok(true) => AnnouncementResult::AcceptMaintainer, - Ok(false) => AnnouncementResult::Reject(validation_err.to_string()), + Ok(false) => AnnouncementResult::Reject(reason), Err(_) => { // Fail-secure: reject on database errors - AnnouncementResult::Reject(validation_err.to_string()) + AnnouncementResult::Reject(reason) } } } - Err(_) => AnnouncementResult::Reject(validation_err.to_string()), + Err(_) => AnnouncementResult::Reject(reason), } } + // Accept, AcceptArchive, or AcceptMaintainer - return as-is + result => result, } } -- cgit v1.2.3