diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 21:32:38 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 21:33:15 +0000 |
| commit | 70c577f10bbe150b6b13bec545dc8720ad005a64 (patch) | |
| tree | 4f390cd523248db007ecb4335a61598b930ccad9 /src | |
| parent | 1948312d40f34fca868d1ef6d6d94e165c09738c (diff) | |
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: <npub>, <npub>/<identifier>, <identifier>
- 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
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 143 | ||||
| -rw-r--r-- | src/nostr/events.rs | 187 |
2 files changed, 330 insertions, 0 deletions
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 { | |||
| 195 | } | 195 | } |
| 196 | } | 196 | } |
| 197 | 197 | ||
| 198 | /// Repository blacklist configuration | ||
| 199 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 200 | pub struct BlacklistConfig { | ||
| 201 | /// Blacklist entries for blocking specific repositories | ||
| 202 | /// | ||
| 203 | /// If empty, no repositories are blacklisted. | ||
| 204 | /// Blacklist takes precedence over both archive and repository whitelists. | ||
| 205 | pub blacklist: Vec<WhitelistEntry>, | ||
| 206 | } | ||
| 207 | |||
| 208 | impl BlacklistConfig { | ||
| 209 | /// Check if repository blacklist is enabled (non-empty blacklist) | ||
| 210 | pub fn enabled(&self) -> bool { | ||
| 211 | !self.blacklist.is_empty() | ||
| 212 | } | ||
| 213 | |||
| 214 | /// Check if an announcement matches the repository blacklist | ||
| 215 | /// | ||
| 216 | /// Returns Some(reason) if blacklisted, None if not blacklisted. | ||
| 217 | /// The reason indicates what type of match occurred (npub, npub/identifier, or identifier). | ||
| 218 | pub fn check(&self, npub: &str, identifier: &str) -> Option<String> { | ||
| 219 | for entry in &self.blacklist { | ||
| 220 | if entry.matches(npub, identifier) { | ||
| 221 | let reason = match entry { | ||
| 222 | WhitelistEntry::Pubkey(_) => { | ||
| 223 | format!("Repository owner {} is blacklisted", npub) | ||
| 224 | } | ||
| 225 | WhitelistEntry::Repository { .. } => { | ||
| 226 | format!("Repository {}/{} is blacklisted", npub, identifier) | ||
| 227 | } | ||
| 228 | WhitelistEntry::Identifier(_) => { | ||
| 229 | format!("Repository identifier {} is blacklisted", identifier) | ||
| 230 | } | ||
| 231 | }; | ||
| 232 | return Some(reason); | ||
| 233 | } | ||
| 234 | } | ||
| 235 | None | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | impl Default for BlacklistConfig { | ||
| 240 | fn default() -> Self { | ||
| 241 | Self { | ||
| 242 | blacklist: Vec::new(), | ||
| 243 | } | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 198 | /// Database backend type for the relay | 247 | /// Database backend type for the relay |
| 199 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] | 248 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] |
| 200 | #[serde(rename_all = "lowercase")] | 249 | #[serde(rename_all = "lowercase")] |
| @@ -373,6 +422,12 @@ pub struct Config { | |||
| 373 | /// When set, only announcements matching the whitelist AND listing the service are accepted | 422 | /// When set, only announcements matching the whitelist AND listing the service are accepted |
| 374 | #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")] | 423 | #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")] |
| 375 | pub repository_whitelist: String, | 424 | pub repository_whitelist: String, |
| 425 | |||
| 426 | /// Repository blacklist: comma-separated list of npub/identifier/npub/identifier entries to reject | ||
| 427 | /// Formats: "npub1...", "npub1.../identifier", "identifier" | ||
| 428 | /// Blacklist takes precedence over all whitelists (archive and repository) | ||
| 429 | #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] | ||
| 430 | pub repository_blacklist: String, | ||
| 376 | } | 431 | } |
| 377 | 432 | ||
| 378 | impl Config { | 433 | impl Config { |
| @@ -549,6 +604,14 @@ impl Config { | |||
| 549 | RepositoryConfig { whitelist } | 604 | RepositoryConfig { whitelist } |
| 550 | } | 605 | } |
| 551 | 606 | ||
| 607 | /// Get parsed repository blacklist configuration | ||
| 608 | /// | ||
| 609 | /// This method assumes config has been validated - call Config::validate() first! | ||
| 610 | pub fn blacklist_config(&self) -> BlacklistConfig { | ||
| 611 | let blacklist = WhitelistEntry::parse_whitelist(&self.repository_blacklist); | ||
| 612 | BlacklistConfig { blacklist } | ||
| 613 | } | ||
| 614 | |||
| 552 | /// Create config for testing | 615 | /// Create config for testing |
| 553 | #[cfg(test)] | 616 | #[cfg(test)] |
| 554 | pub fn for_testing() -> Self { | 617 | pub fn for_testing() -> Self { |
| @@ -583,6 +646,7 @@ impl Config { | |||
| 583 | archive_whitelist: String::new(), | 646 | archive_whitelist: String::new(), |
| 584 | archive_read_only: None, | 647 | archive_read_only: None, |
| 585 | repository_whitelist: String::new(), | 648 | repository_whitelist: String::new(), |
| 649 | repository_blacklist: String::new(), | ||
| 586 | } | 650 | } |
| 587 | } | 651 | } |
| 588 | } | 652 | } |
| @@ -1105,4 +1169,83 @@ mod tests { | |||
| 1105 | .to_string() | 1169 | .to_string() |
| 1106 | .contains("relay_owner_nsec not set")); | 1170 | .contains("relay_owner_nsec not set")); |
| 1107 | } | 1171 | } |
| 1172 | |||
| 1173 | #[test] | ||
| 1174 | fn test_blacklist_config_parsing() { | ||
| 1175 | let keys = Keys::generate(); | ||
| 1176 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1177 | let config = Config { | ||
| 1178 | repository_blacklist: format!("{},bitcoin-core", test_npub), | ||
| 1179 | ..Config::for_testing() | ||
| 1180 | }; | ||
| 1181 | let blacklist_config = config.blacklist_config(); | ||
| 1182 | assert_eq!(blacklist_config.blacklist.len(), 2); | ||
| 1183 | assert!(blacklist_config.enabled()); | ||
| 1184 | } | ||
| 1185 | |||
| 1186 | #[test] | ||
| 1187 | fn test_blacklist_config_empty() { | ||
| 1188 | let config = Config::for_testing(); | ||
| 1189 | let blacklist_config = config.blacklist_config(); | ||
| 1190 | assert!(blacklist_config.blacklist.is_empty()); | ||
| 1191 | assert!(!blacklist_config.enabled()); | ||
| 1192 | } | ||
| 1193 | |||
| 1194 | #[test] | ||
| 1195 | fn test_blacklist_check_npub() { | ||
| 1196 | let keys = Keys::generate(); | ||
| 1197 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1198 | let config = BlacklistConfig { | ||
| 1199 | blacklist: vec![WhitelistEntry::Pubkey(test_npub.clone())], | ||
| 1200 | }; | ||
| 1201 | |||
| 1202 | let result = config.check(&test_npub, "any-repo"); | ||
| 1203 | assert!(result.is_some()); | ||
| 1204 | let reason = result.unwrap(); | ||
| 1205 | assert!(reason.contains("owner")); | ||
| 1206 | assert!(reason.contains(&test_npub)); | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | #[test] | ||
| 1210 | fn test_blacklist_check_identifier() { | ||
| 1211 | let config = BlacklistConfig { | ||
| 1212 | blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], | ||
| 1213 | }; | ||
| 1214 | |||
| 1215 | let result = config.check("npub1alice", "banned-repo"); | ||
| 1216 | assert!(result.is_some()); | ||
| 1217 | let reason = result.unwrap(); | ||
| 1218 | assert!(reason.contains("identifier")); | ||
| 1219 | assert!(reason.contains("banned-repo")); | ||
| 1220 | } | ||
| 1221 | |||
| 1222 | #[test] | ||
| 1223 | fn test_blacklist_check_repository() { | ||
| 1224 | let keys = Keys::generate(); | ||
| 1225 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1226 | let config = BlacklistConfig { | ||
| 1227 | blacklist: vec![WhitelistEntry::Repository { | ||
| 1228 | npub: test_npub.clone(), | ||
| 1229 | identifier: "specific-repo".to_string(), | ||
| 1230 | }], | ||
| 1231 | }; | ||
| 1232 | |||
| 1233 | let result = config.check(&test_npub, "specific-repo"); | ||
| 1234 | assert!(result.is_some()); | ||
| 1235 | let reason = result.unwrap(); | ||
| 1236 | assert!(reason.contains(&test_npub)); | ||
| 1237 | assert!(reason.contains("specific-repo")); | ||
| 1238 | } | ||
| 1239 | |||
| 1240 | #[test] | ||
| 1241 | fn test_blacklist_check_not_blacklisted() { | ||
| 1242 | let keys = Keys::generate(); | ||
| 1243 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1244 | let config = BlacklistConfig { | ||
| 1245 | blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], | ||
| 1246 | }; | ||
| 1247 | |||
| 1248 | let result = config.check(&test_npub, "allowed-repo"); | ||
| 1249 | assert!(result.is_none()); | ||
| 1250 | } | ||
| 1108 | } | 1251 | } |
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 { | |||
| 366 | /// - AcceptArchive: Announcement matches archive config (GRASP-05) | 366 | /// - AcceptArchive: Announcement matches archive config (GRASP-05) |
| 367 | /// - Reject: Validation failed | 367 | /// - Reject: Validation failed |
| 368 | /// | 368 | /// |
| 369 | /// Blacklist takes precedence over all whitelists: | ||
| 370 | /// - If blacklisted, always reject with specific reason (npub/identifier/npub+identifier) | ||
| 371 | /// | ||
| 369 | /// When archive_read_only is true: | 372 | /// When archive_read_only is true: |
| 370 | /// - ONLY accept announcements matching archive whitelist/all | 373 | /// - ONLY accept announcements matching archive whitelist/all |
| 371 | /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) | 374 | /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) |
| @@ -403,10 +406,16 @@ pub fn validate_announcement( | |||
| 403 | // Get validated configs (config.validate() must be called at startup) | 406 | // Get validated configs (config.validate() must be called at startup) |
| 404 | let archive_config = config.archive_config(); | 407 | let archive_config = config.archive_config(); |
| 405 | let repository_config = config.repository_config(); | 408 | let repository_config = config.repository_config(); |
| 409 | let blacklist_config = config.blacklist_config(); | ||
| 406 | 410 | ||
| 407 | let npub = announcement.owner_npub(); | 411 | let npub = announcement.owner_npub(); |
| 408 | let lists_service = announcement.lists_service(&config.domain); | 412 | let lists_service = announcement.lists_service(&config.domain); |
| 409 | 413 | ||
| 414 | // Check blacklist FIRST - it overrides everything | ||
| 415 | if let Some(reason) = blacklist_config.check(&npub, &announcement.identifier) { | ||
| 416 | return AnnouncementResult::Reject(reason); | ||
| 417 | } | ||
| 418 | |||
| 410 | // GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled) | 419 | // GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled) |
| 411 | if lists_service && !archive_config.read_only { | 420 | if lists_service && !archive_config.read_only { |
| 412 | // Check repository whitelist if enabled | 421 | // Check repository whitelist if enabled |
| @@ -1309,4 +1318,182 @@ mod tests { | |||
| 1309 | let result = validate_announcement(&event, &config); | 1318 | let result = validate_announcement(&event, &config); |
| 1310 | assert!(matches!(result, AnnouncementResult::Reject(_))); | 1319 | assert!(matches!(result, AnnouncementResult::Reject(_))); |
| 1311 | } | 1320 | } |
| 1321 | |||
| 1322 | #[test] | ||
| 1323 | fn test_blacklist_rejects_npub() { | ||
| 1324 | use crate::config::Config; | ||
| 1325 | use crate::nostr::policy::AnnouncementResult; | ||
| 1326 | |||
| 1327 | let keys = create_test_keys(); | ||
| 1328 | let npub = keys.public_key().to_bech32().unwrap(); | ||
| 1329 | |||
| 1330 | // Create announcement that lists our service | ||
| 1331 | let event = create_announcement_event( | ||
| 1332 | &keys, | ||
| 1333 | "test-repo", | ||
| 1334 | vec!["https://gitnostr.com/alice/test-repo.git"], | ||
| 1335 | vec!["wss://gitnostr.com"], | ||
| 1336 | ); | ||
| 1337 | |||
| 1338 | // Config with blacklist for this npub | ||
| 1339 | let config = Config { | ||
| 1340 | domain: "gitnostr.com".to_string(), | ||
| 1341 | repository_blacklist: npub.clone(), | ||
| 1342 | ..Config::for_testing() | ||
| 1343 | }; | ||
| 1344 | |||
| 1345 | let result = validate_announcement(&event, &config); | ||
| 1346 | if let AnnouncementResult::Reject(reason) = result { | ||
| 1347 | assert!(reason.contains("owner")); | ||
| 1348 | assert!(reason.contains(&npub)); | ||
| 1349 | } else { | ||
| 1350 | panic!("Expected Reject, got {:?}", result); | ||
| 1351 | } | ||
| 1352 | } | ||
| 1353 | |||
| 1354 | #[test] | ||
| 1355 | fn test_blacklist_rejects_identifier() { | ||
| 1356 | use crate::config::Config; | ||
| 1357 | use crate::nostr::policy::AnnouncementResult; | ||
| 1358 | |||
| 1359 | let keys = create_test_keys(); | ||
| 1360 | |||
| 1361 | // Create announcement that lists our service | ||
| 1362 | let event = create_announcement_event( | ||
| 1363 | &keys, | ||
| 1364 | "banned-repo", | ||
| 1365 | vec!["https://gitnostr.com/alice/banned-repo.git"], | ||
| 1366 | vec!["wss://gitnostr.com"], | ||
| 1367 | ); | ||
| 1368 | |||
| 1369 | // Config with blacklist for this identifier | ||
| 1370 | let config = Config { | ||
| 1371 | domain: "gitnostr.com".to_string(), | ||
| 1372 | repository_blacklist: "banned-repo".to_string(), | ||
| 1373 | ..Config::for_testing() | ||
| 1374 | }; | ||
| 1375 | |||
| 1376 | let result = validate_announcement(&event, &config); | ||
| 1377 | if let AnnouncementResult::Reject(reason) = result { | ||
| 1378 | assert!(reason.contains("identifier")); | ||
| 1379 | assert!(reason.contains("banned-repo")); | ||
| 1380 | } else { | ||
| 1381 | panic!("Expected Reject, got {:?}", result); | ||
| 1382 | } | ||
| 1383 | } | ||
| 1384 | |||
| 1385 | #[test] | ||
| 1386 | fn test_blacklist_rejects_specific_repository() { | ||
| 1387 | use crate::config::Config; | ||
| 1388 | use crate::nostr::policy::AnnouncementResult; | ||
| 1389 | |||
| 1390 | let keys = create_test_keys(); | ||
| 1391 | let npub = keys.public_key().to_bech32().unwrap(); | ||
| 1392 | |||
| 1393 | // Create announcement that lists our service | ||
| 1394 | let event = create_announcement_event( | ||
| 1395 | &keys, | ||
| 1396 | "specific-repo", | ||
| 1397 | vec!["https://gitnostr.com/alice/specific-repo.git"], | ||
| 1398 | vec!["wss://gitnostr.com"], | ||
| 1399 | ); | ||
| 1400 | |||
| 1401 | // Config with blacklist for this specific repo | ||
| 1402 | let config = Config { | ||
| 1403 | domain: "gitnostr.com".to_string(), | ||
| 1404 | repository_blacklist: format!("{}/specific-repo", npub), | ||
| 1405 | ..Config::for_testing() | ||
| 1406 | }; | ||
| 1407 | |||
| 1408 | let result = validate_announcement(&event, &config); | ||
| 1409 | if let AnnouncementResult::Reject(reason) = result { | ||
| 1410 | assert!(reason.contains(&npub)); | ||
| 1411 | assert!(reason.contains("specific-repo")); | ||
| 1412 | } else { | ||
| 1413 | panic!("Expected Reject, got {:?}", result); | ||
| 1414 | } | ||
| 1415 | } | ||
| 1416 | |||
| 1417 | #[test] | ||
| 1418 | fn test_blacklist_overrides_repository_whitelist() { | ||
| 1419 | use crate::config::Config; | ||
| 1420 | use crate::nostr::policy::AnnouncementResult; | ||
| 1421 | |||
| 1422 | let keys = create_test_keys(); | ||
| 1423 | let npub = keys.public_key().to_bech32().unwrap(); | ||
| 1424 | |||
| 1425 | // Create announcement that lists our service | ||
| 1426 | let event = create_announcement_event( | ||
| 1427 | &keys, | ||
| 1428 | "test-repo", | ||
| 1429 | vec!["https://gitnostr.com/alice/test-repo.git"], | ||
| 1430 | vec!["wss://gitnostr.com"], | ||
| 1431 | ); | ||
| 1432 | |||
| 1433 | // Config with both whitelist and blacklist - blacklist should win | ||
| 1434 | let config = Config { | ||
| 1435 | domain: "gitnostr.com".to_string(), | ||
| 1436 | repository_whitelist: npub.clone(), | ||
| 1437 | repository_blacklist: npub.clone(), | ||
| 1438 | ..Config::for_testing() | ||
| 1439 | }; | ||
| 1440 | |||
| 1441 | let result = validate_announcement(&event, &config); | ||
| 1442 | assert!(matches!(result, AnnouncementResult::Reject(_))); | ||
| 1443 | } | ||
| 1444 | |||
| 1445 | #[test] | ||
| 1446 | fn test_blacklist_overrides_archive_whitelist() { | ||
| 1447 | use crate::config::Config; | ||
| 1448 | use crate::nostr::policy::AnnouncementResult; | ||
| 1449 | |||
| 1450 | let keys = create_test_keys(); | ||
| 1451 | let npub = keys.public_key().to_bech32().unwrap(); | ||
| 1452 | |||
| 1453 | // Create announcement that does NOT list our service | ||
| 1454 | let event = create_announcement_event( | ||
| 1455 | &keys, | ||
| 1456 | "test-repo", | ||
| 1457 | vec!["https://other-service.com/alice/test-repo.git"], | ||
| 1458 | vec!["wss://other-service.com"], | ||
| 1459 | ); | ||
| 1460 | |||
| 1461 | // Config with archive whitelist and blacklist - blacklist should win | ||
| 1462 | let config = Config { | ||
| 1463 | domain: "gitnostr.com".to_string(), | ||
| 1464 | archive_whitelist: npub.clone(), | ||
| 1465 | archive_read_only: Some(false), | ||
| 1466 | repository_blacklist: npub.clone(), | ||
| 1467 | ..Config::for_testing() | ||
| 1468 | }; | ||
| 1469 | |||
| 1470 | let result = validate_announcement(&event, &config); | ||
| 1471 | assert!(matches!(result, AnnouncementResult::Reject(_))); | ||
| 1472 | } | ||
| 1473 | |||
| 1474 | #[test] | ||
| 1475 | fn test_blacklist_allows_non_blacklisted() { | ||
| 1476 | use crate::config::Config; | ||
| 1477 | use crate::nostr::policy::AnnouncementResult; | ||
| 1478 | |||
| 1479 | let keys = create_test_keys(); | ||
| 1480 | |||
| 1481 | // Create announcement that lists our service | ||
| 1482 | let event = create_announcement_event( | ||
| 1483 | &keys, | ||
| 1484 | "allowed-repo", | ||
| 1485 | vec!["https://gitnostr.com/alice/allowed-repo.git"], | ||
| 1486 | vec!["wss://gitnostr.com"], | ||
| 1487 | ); | ||
| 1488 | |||
| 1489 | // Config with blacklist for different identifier | ||
| 1490 | let config = Config { | ||
| 1491 | domain: "gitnostr.com".to_string(), | ||
| 1492 | repository_blacklist: "banned-repo".to_string(), | ||
| 1493 | ..Config::for_testing() | ||
| 1494 | }; | ||
| 1495 | |||
| 1496 | let result = validate_announcement(&event, &config); | ||
| 1497 | assert!(matches!(result, AnnouncementResult::Accept)); | ||
| 1498 | } | ||
| 1312 | } | 1499 | } |