From 70c577f10bbe150b6b13bec545dc8720ad005a64 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 21:32:38 +0000 Subject: feat(config): add repository blacklist to block specific repos/npubs/identifiers Adds NGIT_REPOSITORY_BLACKLIST option for blocking repositories, taking precedence over all whitelists (archive and repository) to enable moderation without affecting curation policy. Key features: - Three blacklist formats: , /, - Blacklist checked first before any other validation - Overrides archive whitelist and repository whitelist - Specific rejection reasons based on match type (npub/identifier/both) - Not flagged in NIP-11 curation (operational, not policy) Implementation: - Add BlacklistConfig struct with check() method returning detailed reasons - Add NGIT_REPOSITORY_BLACKLIST config option and blacklist_config() method - Update validate_announcement() to check blacklist first with specific reasons - 12 new unit tests covering all blacklist behavior and precedence Configuration synced across all four sources: - src/config.rs: Core implementation with BlacklistConfig - .env.example: Comprehensive documentation with examples - docs/reference/configuration.md: Complete reference documentation - nix/module.nix: NixOS module option with environment mapping Testing: - 12 new tests for blacklist functionality (config + validation) - All 332 library tests passing - All 38 integration tests passing Use cases: - Block spam/malware repos by identifier - Block abusive users by npub - Block specific problematic repos by npub/identifier - Temporary blocks for investigation --- src/config.rs | 143 ++++++++++++++++++++++++++++++++++++++++ src/nostr/events.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) (limited to 'src') diff --git a/src/config.rs b/src/config.rs index 37b1c1e..5f8cbca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -195,6 +195,55 @@ impl Default for RepositoryConfig { } } +/// Repository blacklist configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlacklistConfig { + /// Blacklist entries for blocking specific repositories + /// + /// If empty, no repositories are blacklisted. + /// Blacklist takes precedence over both archive and repository whitelists. + pub blacklist: Vec, +} + +impl BlacklistConfig { + /// Check if repository blacklist is enabled (non-empty blacklist) + pub fn enabled(&self) -> bool { + !self.blacklist.is_empty() + } + + /// Check if an announcement matches the repository blacklist + /// + /// Returns Some(reason) if blacklisted, None if not blacklisted. + /// The reason indicates what type of match occurred (npub, npub/identifier, or identifier). + pub fn check(&self, npub: &str, identifier: &str) -> Option { + for entry in &self.blacklist { + if entry.matches(npub, identifier) { + let reason = match entry { + WhitelistEntry::Pubkey(_) => { + format!("Repository owner {} is blacklisted", npub) + } + WhitelistEntry::Repository { .. } => { + format!("Repository {}/{} is blacklisted", npub, identifier) + } + WhitelistEntry::Identifier(_) => { + format!("Repository identifier {} is blacklisted", identifier) + } + }; + return Some(reason); + } + } + None + } +} + +impl Default for BlacklistConfig { + fn default() -> Self { + Self { + blacklist: Vec::new(), + } + } +} + /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -373,6 +422,12 @@ pub struct Config { /// 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, + + /// Repository blacklist: comma-separated list of npub/identifier/npub/identifier entries to reject + /// Formats: "npub1...", "npub1.../identifier", "identifier" + /// Blacklist takes precedence over all whitelists (archive and repository) + #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] + pub repository_blacklist: String, } impl Config { @@ -549,6 +604,14 @@ impl Config { RepositoryConfig { whitelist } } + /// Get parsed repository blacklist configuration + /// + /// This method assumes config has been validated - call Config::validate() first! + pub fn blacklist_config(&self) -> BlacklistConfig { + let blacklist = WhitelistEntry::parse_whitelist(&self.repository_blacklist); + BlacklistConfig { blacklist } + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -583,6 +646,7 @@ impl Config { archive_whitelist: String::new(), archive_read_only: None, repository_whitelist: String::new(), + repository_blacklist: String::new(), } } } @@ -1105,4 +1169,83 @@ mod tests { .to_string() .contains("relay_owner_nsec not set")); } + + #[test] + fn test_blacklist_config_parsing() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + repository_blacklist: format!("{},bitcoin-core", test_npub), + ..Config::for_testing() + }; + let blacklist_config = config.blacklist_config(); + assert_eq!(blacklist_config.blacklist.len(), 2); + assert!(blacklist_config.enabled()); + } + + #[test] + fn test_blacklist_config_empty() { + let config = Config::for_testing(); + let blacklist_config = config.blacklist_config(); + assert!(blacklist_config.blacklist.is_empty()); + assert!(!blacklist_config.enabled()); + } + + #[test] + fn test_blacklist_check_npub() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Pubkey(test_npub.clone())], + }; + + let result = config.check(&test_npub, "any-repo"); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains("owner")); + assert!(reason.contains(&test_npub)); + } + + #[test] + fn test_blacklist_check_identifier() { + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], + }; + + let result = config.check("npub1alice", "banned-repo"); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains("identifier")); + assert!(reason.contains("banned-repo")); + } + + #[test] + fn test_blacklist_check_repository() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Repository { + npub: test_npub.clone(), + identifier: "specific-repo".to_string(), + }], + }; + + let result = config.check(&test_npub, "specific-repo"); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains(&test_npub)); + assert!(reason.contains("specific-repo")); + } + + #[test] + fn test_blacklist_check_not_blacklisted() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], + }; + + let result = config.check(&test_npub, "allowed-repo"); + assert!(result.is_none()); + } } diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 3b4ef25..39014da 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -366,6 +366,9 @@ impl RepositoryState { /// - AcceptArchive: Announcement matches archive config (GRASP-05) /// - Reject: Validation failed /// +/// Blacklist takes precedence over all whitelists: +/// - If blacklisted, always reject with specific reason (npub/identifier/npub+identifier) +/// /// When archive_read_only is true: /// - ONLY accept announcements matching archive whitelist/all /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) @@ -403,10 +406,16 @@ pub fn validate_announcement( // Get validated configs (config.validate() must be called at startup) let archive_config = config.archive_config(); let repository_config = config.repository_config(); + let blacklist_config = config.blacklist_config(); let npub = announcement.owner_npub(); let lists_service = announcement.lists_service(&config.domain); + // Check blacklist FIRST - it overrides everything + if let Some(reason) = blacklist_config.check(&npub, &announcement.identifier) { + return AnnouncementResult::Reject(reason); + } + // 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 @@ -1309,4 +1318,182 @@ mod tests { let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } + + #[test] + fn test_blacklist_rejects_npub() { + 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 blacklist for this npub + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: npub.clone(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("owner")); + assert!(reason.contains(&npub)); + } else { + panic!("Expected Reject, got {:?}", result); + } + } + + #[test] + fn test_blacklist_rejects_identifier() { + 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, + "banned-repo", + vec!["https://gitnostr.com/alice/banned-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for this identifier + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: "banned-repo".to_string(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("identifier")); + assert!(reason.contains("banned-repo")); + } else { + panic!("Expected Reject, got {:?}", result); + } + } + + #[test] + fn test_blacklist_rejects_specific_repository() { + 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, + "specific-repo", + vec!["https://gitnostr.com/alice/specific-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for this specific repo + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: format!("{}/specific-repo", npub), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains(&npub)); + assert!(reason.contains("specific-repo")); + } else { + panic!("Expected Reject, got {:?}", result); + } + } + + #[test] + fn test_blacklist_overrides_repository_whitelist() { + 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 both whitelist and blacklist - blacklist should win + let config = Config { + domain: "gitnostr.com".to_string(), + repository_whitelist: npub.clone(), + repository_blacklist: npub.clone(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_blacklist_overrides_archive_whitelist() { + 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 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"], + ); + + // Config with archive whitelist and blacklist - blacklist should win + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: npub.clone(), + archive_read_only: Some(false), + repository_blacklist: npub.clone(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_blacklist_allows_non_blacklisted() { + 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, + "allowed-repo", + vec!["https://gitnostr.com/alice/allowed-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for different identifier + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: "banned-repo".to_string(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Accept)); + } } -- cgit v1.2.3