From 82b56c37b26a2fac1a294873e539b19b9325dca6 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 21:06:39 +0000 Subject: feat(config): add repository whitelist for curated GRASP-01 acceptance Adds NGIT_REPOSITORY_WHITELIST option for curated relay operation that accepts only whitelisted repositories while maintaining GRASP-01 compliance (announcements must list the service). This differs from archive whitelist which enables GRASP-05 mode and doesn't require service listing. Key features: - Supports three whitelist formats: npub, npub/identifier, identifier - Enforces mutual exclusivity with archive read-only mode - Updates NIP-11 curation field when whitelist is enabled - Maintains GRASP-01 compliance (doesn't add GRASP-05 support) Configuration synced across all four sources: src/config.rs, docs/reference/configuration.md, nix/module.nix, and .env.example as required by AGENTS.md. --- src/config.rs | 210 +++++++++++++++++++++++------ src/http/nip11.rs | 65 ++++++++- src/nostr/builder.rs | 48 +++---- src/nostr/events.rs | 281 +++++++++++++++++++++++++++------------ src/nostr/policy/announcement.rs | 14 +- 5 files changed, 460 insertions(+), 158 deletions(-) (limited to 'src') diff --git a/src/config.rs b/src/config.rs index d9917a3..8a9eb4d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,21 +5,21 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -/// GRASP-05 Archive whitelist entry +/// Whitelist entry for repository/archive filtering #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] -pub enum ArchiveWhitelistEntry { - /// Archive all repos from this pubkey: "npub1..." +pub enum WhitelistEntry { + /// All repos from this pubkey: "npub1..." Pubkey(String), - /// Archive specific repo: "npub1.../identifier" + /// Specific repo: "npub1.../identifier" Repository { npub: String, identifier: String }, - /// Archive any repo with this identifier: "identifier" + /// Any repo with this identifier: "identifier" Identifier(String), } -impl ArchiveWhitelistEntry { +impl WhitelistEntry { /// Parse a whitelist entry from string /// /// Formats: @@ -83,6 +83,20 @@ impl ArchiveWhitelistEntry { Self::Identifier(i) => identifier == i, } } + + /// Parse 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(Self::parse) + .collect() + } } /// GRASP-05 Archive mode configuration @@ -97,7 +111,7 @@ pub struct ArchiveConfig { /// Whitelist entries for selective archiving /// /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). - pub whitelist: Vec, + pub whitelist: Vec, /// Read-only archive mode: relay is a read-only sync of archived repositories /// @@ -127,28 +141,47 @@ impl ArchiveConfig { .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()); +impl Default for ArchiveConfig { + fn default() -> Self { + Self { + archive_all: false, + whitelist: Vec::new(), + read_only: false, } + } +} - input - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(ArchiveWhitelistEntry::parse) - .collect() +/// Repository whitelist configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryConfig { + /// Whitelist entries for selective repository acceptance + /// + /// If empty, all repositories listing the service are accepted (GRASP-01 mode). + pub whitelist: Vec, +} + +impl RepositoryConfig { + /// Check if repository whitelist is enabled (non-empty whitelist) + pub fn enabled(&self) -> bool { + !self.whitelist.is_empty() + } + + /// Check if an announcement matches the repository whitelist + /// + /// Returns true if announcement matches any whitelist entry + pub fn matches(&self, npub: &str, identifier: &str) -> bool { + self.whitelist + .iter() + .any(|entry| entry.matches(npub, identifier)) } } -impl Default for ArchiveConfig { +impl Default for RepositoryConfig { fn default() -> Self { Self { - archive_all: false, whitelist: Vec::new(), - read_only: false, } } } @@ -325,6 +358,12 @@ pub struct Config { /// Throws error if set to true without archive_all or archive_whitelist #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] pub archive_read_only: Option, + + /// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries + /// Formats: "npub1...", "npub1.../identifier", "identifier" + /// When set, only announcements matching the whitelist AND listing the service are accepted + #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")] + pub repository_whitelist: String, } impl Config { @@ -430,7 +469,7 @@ impl Config { /// Read-only mode defaults to true if archive mode is enabled, false otherwise. /// Throws error if explicitly set to true without archive mode enabled. pub fn archive_config(&self) -> Result { - let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; + let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist)?; let archive_enabled = self.archive_all || !whitelist.is_empty(); let read_only = match self.archive_read_only { @@ -456,6 +495,28 @@ impl Config { }) } + /// Get parsed repository whitelist configuration + /// + /// Throws error if repository_whitelist is set together with archive_read_only=true + pub fn repository_config(&self) -> Result { + let whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist)?; + + // Validate incompatible configurations + if !whitelist.is_empty() { + let archive_config = self.archive_config()?; + if archive_config.read_only { + return Err(anyhow!( + "NGIT_REPOSITORY_WHITELIST cannot be used with NGIT_ARCHIVE_READ_ONLY=true. \ + Archive read-only mode rejects announcements that don't match the archive whitelist, \ + regardless of service listing. Either set NGIT_ARCHIVE_READ_ONLY=false or use \ + NGIT_ARCHIVE_WHITELIST instead of NGIT_REPOSITORY_WHITELIST." + )); + } + } + + Ok(RepositoryConfig { whitelist }) + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -489,6 +550,7 @@ impl Config { archive_all: false, archive_whitelist: String::new(), archive_read_only: None, + repository_whitelist: String::new(), } } } @@ -629,9 +691,9 @@ mod tests { // 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 { + let entry = WhitelistEntry::parse(&test_npub).unwrap(); + assert!(matches!(entry, WhitelistEntry::Pubkey(_))); + if let WhitelistEntry::Pubkey(npub) = entry { assert_eq!(npub, test_npub); } } @@ -640,9 +702,9 @@ mod tests { 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 { + let entry = WhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap(); + assert!(matches!(entry, WhitelistEntry::Repository { .. })); + if let WhitelistEntry::Repository { npub, identifier } = entry { assert_eq!(npub, test_npub); assert_eq!(identifier, "linux"); } @@ -650,16 +712,16 @@ mod tests { #[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 { + let entry = WhitelistEntry::parse("bitcoin-core").unwrap(); + assert!(matches!(entry, WhitelistEntry::Identifier(_))); + if let WhitelistEntry::Identifier(id) = entry { assert_eq!(id, "bitcoin-core"); } } #[test] fn test_parse_whitelist_entry_invalid_npub() { - let result = ArchiveWhitelistEntry::parse("npub1invalid"); + let result = WhitelistEntry::parse("npub1invalid"); assert!(result.is_err()); } @@ -667,7 +729,7 @@ mod tests { 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()); + let entry = WhitelistEntry::Pubkey(test_npub.clone()); assert!(entry.matches(&test_npub, "any-identifier")); assert!(!entry.matches("npub1different", "any-identifier")); } @@ -676,7 +738,7 @@ mod tests { fn test_whitelist_entry_matches_repository() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); - let entry = ArchiveWhitelistEntry::Repository { + let entry = WhitelistEntry::Repository { npub: test_npub.clone(), identifier: "linux".to_string(), }; @@ -687,7 +749,7 @@ mod tests { #[test] fn test_whitelist_entry_matches_identifier() { - let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string()); + let entry = WhitelistEntry::Identifier("bitcoin-core".to_string()); assert!(entry.matches("npub1alice", "bitcoin-core")); assert!(entry.matches("npub1bob", "bitcoin-core")); assert!(!entry.matches("npub1alice", "other-repo")); @@ -707,7 +769,7 @@ mod tests { let config = ArchiveConfig { archive_all: false, - whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], + whitelist: vec![WhitelistEntry::Identifier("test".into())], read_only: true, }; assert!(config.enabled()); @@ -720,8 +782,8 @@ mod tests { let config = ArchiveConfig { archive_all: false, whitelist: vec![ - ArchiveWhitelistEntry::Pubkey(test_npub.clone()), - ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), + WhitelistEntry::Pubkey(test_npub.clone()), + WhitelistEntry::Identifier("bitcoin-core".into()), ], read_only: false, }; @@ -745,10 +807,10 @@ mod tests { #[test] fn test_parse_whitelist_empty() { - let whitelist = ArchiveConfig::parse_whitelist("").unwrap(); + let whitelist = WhitelistEntry::parse_whitelist("").unwrap(); assert!(whitelist.is_empty()); - let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap(); + let whitelist = WhitelistEntry::parse_whitelist(" ").unwrap(); assert!(whitelist.is_empty()); } @@ -758,7 +820,7 @@ mod tests { 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!( + let whitelist = WhitelistEntry::parse_whitelist(&format!( "{},bitcoin-core,{}/linux", test_npub1, test_npub2 )) @@ -850,4 +912,72 @@ mod tests { .to_string() .contains("requires either")); } + + #[test] + fn test_repository_whitelist_parsing() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + repository_whitelist: format!("{},bitcoin-core", test_npub), + ..Config::for_testing() + }; + let repo_config = config.repository_config().unwrap(); + assert_eq!(repo_config.whitelist.len(), 2); + assert!(repo_config.enabled()); + } + + #[test] + fn test_repository_whitelist_empty() { + let config = Config::for_testing(); + let repo_config = config.repository_config().unwrap(); + assert!(repo_config.whitelist.is_empty()); + assert!(!repo_config.enabled()); + } + + #[test] + fn test_repository_whitelist_incompatible_with_archive_read_only() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + archive_all: true, + archive_read_only: Some(true), + repository_whitelist: test_npub, + ..Config::for_testing() + }; + let result = config.repository_config(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("cannot be used with")); + assert!(err.contains("NGIT_ARCHIVE_READ_ONLY=true")); + } + + #[test] + fn test_repository_whitelist_compatible_with_archive_read_only_false() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + archive_all: true, + archive_read_only: Some(false), + repository_whitelist: test_npub, + ..Config::for_testing() + }; + // Should not error + assert!(config.repository_config().is_ok()); + } + + #[test] + fn test_repository_config_matches() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = RepositoryConfig { + whitelist: vec![ + WhitelistEntry::Pubkey(test_npub.clone()), + WhitelistEntry::Identifier("bitcoin-core".into()), + ], + }; + + assert!(config.matches(&test_npub, "any-repo")); + assert!(config.matches("npub1bob", "bitcoin-core")); + assert!(!config.matches("npub1bob", "other-repo")); + } } diff --git a/src/http/nip11.rs b/src/http/nip11.rs index 71cadb1..ff7b8df 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -74,8 +74,15 @@ impl RelayInformationDocument { } supported_grasps.push("GRASP-02".to_string()); - // Build curation field for archive read-only mode + // Build curation field for archive read-only mode or repository whitelist + let repository_config = config.repository_config().ok(); + let repository_whitelist_enabled = repository_config + .as_ref() + .map(|rc| rc.enabled()) + .unwrap_or(false); + let curation = if archive_read_only { + // Archive read-only mode (GRASP-05 only) if let Some(ref ac) = archive_config { if ac.archive_all { Some("Read-only sync of all repositories found on network".to_string()) @@ -87,6 +94,18 @@ impl RelayInformationDocument { } else { None } + } else if archive_enabled && repository_whitelist_enabled { + // Both archive (non-read-only) AND repository whitelist enabled + Some( + "Accepts whitelisted repositories (with or without service listing) and whitelisted repositories that list this service" + .to_string(), + ) + } else if repository_whitelist_enabled { + // Repository whitelist only + Some( + "Accepts only whitelisted repositories and maintainers that list this service" + .to_string(), + ) } else { None }; @@ -230,4 +249,48 @@ mod tests { .unwrap() .contains("Read-only sync of whitelisted")); } + + #[test] + fn test_nip11_with_repository_whitelist() { + let keys = nostr_sdk::Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let mut config = Config::for_testing(); + config.domain = "relay.example.com".to_string(); + config.repository_whitelist = format!("{},bitcoin-core", test_npub); + + let doc = RelayInformationDocument::from_config(&config); + + // Repository whitelist doesn't enable GRASP-05 + assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]); + // Should have curation field for repository whitelist + assert!(doc.curation.is_some()); + assert!(doc + .curation + .unwrap() + .contains("Accepts only whitelisted repositories")); + } + + #[test] + fn test_nip11_with_archive_and_repository_whitelist() { + let keys = nostr_sdk::Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let mut config = Config::for_testing(); + config.domain = "relay.example.com".to_string(); + config.archive_whitelist = "bitcoin-core".to_string(); + config.archive_read_only = Some(false); // Non-read-only archive mode + config.repository_whitelist = test_npub; + + let doc = RelayInformationDocument::from_config(&config); + + // Should have GRASP-05 enabled due to archive whitelist + assert_eq!( + doc.supported_grasps, + vec!["GRASP-01", "GRASP-05", "GRASP-02"] + ); + // Should have curation field reflecting BOTH archive and repository whitelist + assert!(doc.curation.is_some()); + let curation = doc.curation.unwrap(); + assert!(curation.contains("whitelisted repositories")); + assert!(curation.contains("with or without service listing")); + } } diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 33f2fe5..10f7648 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -51,15 +51,14 @@ impl std::fmt::Debug for Nip34WritePolicy { impl Nip34WritePolicy { pub fn new( - domain: impl Into, database: SharedDatabase, git_data_path: impl Into, purgatory: std::sync::Arc, - archive_config: crate::config::ArchiveConfig, + config: crate::config::Config, ) -> Self { - let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); + let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory); Self { - announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config), + announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()), state_policy: StatePolicy::new(ctx.clone()), pr_event_policy: PrEventPolicy::new(ctx.clone()), related_event_policy: RelatedEventPolicy::new(ctx.clone()), @@ -568,28 +567,31 @@ 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={}, read_only={}", - archive_config.archive_all, - archive_config.whitelist.len(), - archive_config.read_only - ); + // Parse and log archive configuration + if let Ok(archive_config) = config.archive_config() { + if archive_config.enabled() { + tracing::info!( + "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}", + archive_config.archive_all, + archive_config.whitelist.len(), + archive_config.read_only + ); + } + } + + // Parse and log repository configuration + if let Ok(repository_config) = config.repository_config() { + if repository_config.enabled() { + tracing::info!( + "Repository whitelist enabled: whitelist_entries={}", + repository_config.whitelist.len() + ); + } } // Create write policy with purgatory integration - let write_policy = Nip34WritePolicy::new( - &config.domain, - database.clone(), - &git_data_path, - purgatory, - archive_config, - ); + let write_policy = + Nip34WritePolicy::new(database.clone(), &git_data_path, purgatory, config.clone()); let relay = LocalRelayBuilder::default() .database(database.clone()) diff --git a/src/nostr/events.rs b/src/nostr/events.rs index f83e00c..3ec075d 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -362,7 +362,7 @@ impl RepositoryState { /// Validate a repository announcement according to GRASP-01 and GRASP-05 /// /// Returns: -/// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode +/// - Accept: Announcement lists our service (GRASP-01) AND matches repository whitelist (if enabled) /// - AcceptArchive: Announcement matches archive config (GRASP-05) /// - Reject: Validation failed /// @@ -370,11 +370,13 @@ impl RepositoryState { /// - ONLY accept announcements matching archive whitelist/all /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) /// +/// When repository_whitelist is set: +/// - Announcements must BOTH list our service AND match the repository whitelist +/// /// Note: AcceptMaintainer is NOT returned here (requires database access) pub fn validate_announcement( event: &Event, - domain: &str, - archive_config: &crate::config::ArchiveConfig, + config: &crate::config::Config, ) -> crate::nostr::policy::AnnouncementResult { use crate::nostr::policy::AnnouncementResult; @@ -398,12 +400,33 @@ pub fn validate_announcement( Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), }; - // GRASP-01: Normal mode - accept if announcement lists our service - if announcement.lists_service(domain) && !archive_config.read_only { - return AnnouncementResult::Accept; - } + // Get archive and repository configs (fail-secure: reject on config errors) + let archive_config = match config.archive_config() { + Ok(c) => c, + Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)), + }; + let repository_config = match config.repository_config() { + Ok(c) => c, + Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)), + }; let npub = announcement.owner_npub(); + let lists_service = announcement.lists_service(&config.domain); + + // GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled) + if lists_service && !archive_config.read_only { + // Check repository whitelist if enabled + if repository_config.enabled() { + if !repository_config.matches(&npub, &announcement.identifier) { + return AnnouncementResult::Reject(format!( + "Announcement lists service but does not match repository whitelist. \ + Repository {}/{} not in whitelist", + npub, announcement.identifier + )); + } + } + return AnnouncementResult::Accept; + } // GRASP-05: Archive mode - accept if announcement matches whitelist if archive_config.matches(&npub, &announcement.identifier) { @@ -561,7 +584,7 @@ mod tests { #[test] fn test_validate_announcement_success() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -572,13 +595,17 @@ mod tests { vec!["wss://gitnostr.com"], ); - let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + let config = Config { + domain: "gitnostr.com".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_missing_clone() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -589,7 +616,11 @@ mod tests { vec!["wss://gitnostr.com"], ); - let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + let config = Config { + domain: "gitnostr.com".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); if let AnnouncementResult::Reject(reason) = result { assert!(reason.contains("clone")); } else { @@ -599,7 +630,7 @@ mod tests { #[test] fn test_validate_announcement_missing_relay() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -610,7 +641,11 @@ mod tests { vec![], // No relays ); - let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + let config = Config { + domain: "gitnostr.com".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); if let AnnouncementResult::Reject(reason) = result { assert!(reason.contains("relays")); } else { @@ -620,7 +655,7 @@ mod tests { #[test] fn test_validate_announcement_wrong_domain() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -631,7 +666,11 @@ mod tests { vec!["wss://other-service.com"], ); - let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + let config = Config { + domain: "gitnostr.com".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } @@ -855,7 +894,7 @@ mod tests { #[test] fn test_validate_announcement_with_trailing_slash_in_relay() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -867,14 +906,17 @@ mod tests { ); // Should accept despite trailing slash mismatch - let result = - validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + let config = Config { + domain: "git.shakespeare.diy".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_with_trailing_slash_in_clone_url() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -886,14 +928,17 @@ mod tests { ); // Should accept despite trailing slash mismatch - let result = - validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + let config = Config { + domain: "git.shakespeare.diy".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_with_trailing_slash_in_both() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -905,14 +950,17 @@ mod tests { ); // Should accept with trailing slashes in both - let result = - validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + let config = Config { + domain: "git.shakespeare.diy".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_domain_with_trailing_slash() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -924,7 +972,11 @@ mod tests { ); // Should accept even when domain parameter has trailing slash - let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default()); + let config = Config { + domain: "gitnostr.com/".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } @@ -964,7 +1016,7 @@ mod tests { #[test] fn test_validate_announcement_archive_mode_npub() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -978,20 +1030,21 @@ mod tests { vec!["wss://other-service.com"], ); - // Create archive config that whitelists this npub - let archive_config = ArchiveConfig { - archive_all: false, - whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], - read_only: false, + // Create config that whitelists this npub + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: npub, + archive_read_only: Some(false), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_archive_mode_identifier() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1004,20 +1057,21 @@ mod tests { 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())], - read_only: false, + // Create config that whitelists this identifier + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: "bitcoin-core".to_string(), + archive_read_only: Some(false), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_archive_mode_repository() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1031,23 +1085,21 @@ mod tests { 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(), - }], - read_only: false, + // Create config that whitelists this specific repo + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: format!("{}/linux", npub), + archive_read_only: Some(false), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_archive_all() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1060,20 +1112,21 @@ mod tests { vec!["wss://other-service.com"], ); - // Create archive config with archive_all enabled - let archive_config = ArchiveConfig { + // Config with archive_all enabled + let config = Config { + domain: "gitnostr.com".to_string(), archive_all: true, - whitelist: Vec::new(), - read_only: false, + archive_read_only: Some(false), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_reject_not_in_whitelist() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1086,20 +1139,21 @@ mod tests { 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())], - read_only: false, + // Config that whitelists different identifier + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: "bitcoin-core".to_string(), + archive_read_only: Some(false), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_validate_announcement_grasp01_takes_precedence() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1113,19 +1167,20 @@ mod tests { ); // With archive_read_only=false, GRASP-01 Accept takes precedence - let archive_config = ArchiveConfig { + let config = Config { + domain: "gitnostr.com".to_string(), archive_all: true, - whitelist: Vec::new(), - read_only: false, + archive_read_only: Some(false), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_archive_read_only_rejects_non_whitelisted() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1140,19 +1195,20 @@ mod tests { // With archive_read_only=true and whitelist that doesn't include this repo, // should reject even though it lists our service - let archive_config = ArchiveConfig { - archive_all: false, - whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], - read_only: true, + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: "bitcoin-core".to_string(), + archive_read_only: Some(true), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_archive_read_only_accepts_whitelisted() { - use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1168,19 +1224,20 @@ mod tests { // With archive_read_only=true and whitelist that DOES include this repo, // should accept as AcceptArchive - let archive_config = ArchiveConfig { - archive_all: false, - whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], - read_only: true, + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: npub, + archive_read_only: Some(true), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_archive_read_only_with_archive_all() { - use crate::config::ArchiveConfig; + use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); @@ -1195,13 +1252,67 @@ mod tests { // With archive_read_only=true and archive_all=true, // should accept as AcceptArchive - let archive_config = ArchiveConfig { + let config = Config { + domain: "gitnostr.com".to_string(), archive_all: true, - whitelist: Vec::new(), - read_only: true, + archive_read_only: Some(true), + ..Config::for_testing() }; - let result = validate_announcement(&event, "gitnostr.com", &archive_config); + let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } + + #[test] + fn test_repository_whitelist_accepts_matching() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with repository whitelist that includes this repo + let config = Config { + domain: "gitnostr.com".to_string(), + repository_whitelist: npub, + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Accept)); + } + + #[test] + fn test_repository_whitelist_rejects_non_matching() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with repository whitelist that does NOT include this repo + let config = Config { + domain: "gitnostr.com".to_string(), + repository_whitelist: "bitcoin-core".to_string(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } } diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index db87976..15a6e58 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -5,7 +5,7 @@ use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; use super::PolicyContext; -use crate::config::ArchiveConfig; +use crate::config::Config; use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; /// Result of announcement policy evaluation @@ -25,15 +25,12 @@ pub enum AnnouncementResult { #[derive(Clone)] pub struct AnnouncementPolicy { ctx: PolicyContext, - archive_config: ArchiveConfig, + config: Config, } impl AnnouncementPolicy { - pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self { - Self { - ctx, - archive_config, - } + pub fn new(ctx: PolicyContext, config: Config) -> Self { + Self { ctx, config } } /// Validate a repository announcement event @@ -44,8 +41,7 @@ impl AnnouncementPolicy { /// or `Reject` with reason. pub async fn validate(&self, event: &Event) -> AnnouncementResult { // First, try validation (GRASP-01 + GRASP-05) - let validation_result = - validate_announcement(event, &self.ctx.domain, &self.archive_config); + let validation_result = validate_announcement(event, &self.config); match validation_result { AnnouncementResult::Reject(reason) => { -- cgit v1.2.3