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 | |
| 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
| -rw-r--r-- | .env.example | 29 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/reference/configuration.md | 89 | ||||
| -rw-r--r-- | nix/module.nix | 14 | ||||
| -rw-r--r-- | src/config.rs | 143 | ||||
| -rw-r--r-- | src/nostr/events.rs | 187 |
6 files changed, 462 insertions, 2 deletions
diff --git a/.env.example b/.env.example index 0789b28..993399a 100644 --- a/.env.example +++ b/.env.example | |||
| @@ -227,4 +227,31 @@ | |||
| 227 | # NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust | 227 | # NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust |
| 228 | # Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive) | 228 | # Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive) |
| 229 | # Note: When set, NIP-11 curation field will indicate curated repository acceptance | 229 | # Note: When set, NIP-11 curation field will indicate curated repository acceptance |
| 230 | # NGIT_REPOSITORY_WHITELIST= \ No newline at end of file | 230 | # NGIT_REPOSITORY_WHITELIST= |
| 231 | |||
| 232 | # ============================================================================ | ||
| 233 | # REPOSITORY BLACKLIST | ||
| 234 | # ============================================================================ | ||
| 235 | |||
| 236 | # Blacklist specific repos/pubkeys/identifiers to reject | ||
| 237 | # Comma-separated list supporting three formats (same as whitelist formats): | ||
| 238 | # <npub> - Block all repos from this pubkey | ||
| 239 | # <npub>/<identifier> - Block specific repo | ||
| 240 | # <identifier> - Block repos with this identifier (any pubkey) | ||
| 241 | # | ||
| 242 | # Blacklist takes precedence over ALL whitelists: | ||
| 243 | # - Blacklisted repos are rejected even if they match archive or repository whitelists | ||
| 244 | # - Blacklisted repos are rejected even if they list our service | ||
| 245 | # | ||
| 246 | # Rejection reasons indicate the match type: | ||
| 247 | # - "Repository owner <npub> is blacklisted" (npub format) | ||
| 248 | # - "Repository <npub>/<identifier> is blacklisted" (npub/identifier format) | ||
| 249 | # - "Repository identifier <identifier> is blacklisted" (identifier format) | ||
| 250 | # | ||
| 251 | # CLI: --repository-blacklist <list> | ||
| 252 | # Default: (empty - no repositories are blacklisted) | ||
| 253 | # Examples: | ||
| 254 | # NGIT_REPOSITORY_BLACKLIST=npub1spam... | ||
| 255 | # NGIT_REPOSITORY_BLACKLIST=npub1alice.../bad-repo | ||
| 256 | # NGIT_REPOSITORY_BLACKLIST=malware-repo,spam-repo | ||
| 257 | # NGIT_REPOSITORY_BLACKLIST= \ No newline at end of file | ||
| @@ -36,7 +36,7 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15 | |||
| 36 | - **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself | 36 | - **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself |
| 37 | - **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation | 37 | - **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation |
| 38 | - **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization | 38 | - **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization |
| 39 | - **Repository Whitelist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) | 39 | - **Repository Whitelist/Blacklist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) and blacklist (overrides all whitelists) |
| 40 | - **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization | 40 | - **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization |
| 41 | - **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE | 41 | - **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE |
| 42 | - **Live & Historic Sync**: Real-time event streaming plus catch-up for past events | 42 | - **Live & Historic Sync**: Real-time event streaming plus catch-up for past events |
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1c62911..b90686e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md | |||
| @@ -744,6 +744,95 @@ NGIT_REPOSITORY_WHITELIST=bitcoin-core,npub1alice... | |||
| 744 | 744 | ||
| 745 | --- | 745 | --- |
| 746 | 746 | ||
| 747 | ### Repository Blacklist | ||
| 748 | |||
| 749 | #### `NGIT_REPOSITORY_BLACKLIST` | ||
| 750 | |||
| 751 | **Description:** Blacklist specific repositories/pubkeys/identifiers to reject | ||
| 752 | **Type:** Comma-separated list | ||
| 753 | **Default:** Empty (no repositories are blacklisted) | ||
| 754 | **Required:** No | ||
| 755 | |||
| 756 | **Format:** Same as whitelist formats: | ||
| 757 | - `npub1...` - Block all repos from this pubkey | ||
| 758 | - `npub1.../identifier` - Block specific repo | ||
| 759 | - `identifier` - Block repos with this identifier (any pubkey) | ||
| 760 | |||
| 761 | **Precedence:** Blacklist takes precedence over **ALL** whitelists: | ||
| 762 | - Blacklisted repos are rejected even if they match archive or repository whitelists | ||
| 763 | - Blacklisted repos are rejected even if they list our service | ||
| 764 | - Blacklist is checked **first** before any other validation | ||
| 765 | |||
| 766 | **Examples:** | ||
| 767 | |||
| 768 | ```bash | ||
| 769 | # Block all repos from specific pubkey | ||
| 770 | NGIT_REPOSITORY_BLACKLIST=npub1spam... | ||
| 771 | |||
| 772 | # Block specific repo | ||
| 773 | NGIT_REPOSITORY_BLACKLIST=npub1alice.../malware-repo | ||
| 774 | |||
| 775 | # Block repos with specific identifiers | ||
| 776 | NGIT_REPOSITORY_BLACKLIST=malware,spam,phishing | ||
| 777 | |||
| 778 | # Combined blacklist | ||
| 779 | NGIT_REPOSITORY_BLACKLIST=npub1spam...,npub1alice.../bad-repo,malware | ||
| 780 | ``` | ||
| 781 | |||
| 782 | **Rejection Reasons:** | ||
| 783 | |||
| 784 | The blacklist provides specific rejection reasons based on the match type: | ||
| 785 | |||
| 786 | - **Npub format:** `"Repository owner <npub> is blacklisted"` | ||
| 787 | - **Npub/identifier format:** `"Repository <npub>/<identifier> is blacklisted"` | ||
| 788 | - **Identifier format:** `"Repository identifier <identifier> is blacklisted"` | ||
| 789 | |||
| 790 | These reasons help operators understand why a repository was rejected without needing to flag it in curation metadata. | ||
| 791 | |||
| 792 | **Behavior:** | ||
| 793 | |||
| 794 | Blacklist is checked **before** all other validation: | ||
| 795 | 1. Check blacklist → Reject if matched | ||
| 796 | 2. Check if lists service → Accept if matches repository whitelist (if enabled) | ||
| 797 | 3. Check archive config → Accept if matches archive whitelist (if enabled) | ||
| 798 | 4. Reject otherwise | ||
| 799 | |||
| 800 | **Use Cases:** | ||
| 801 | |||
| 802 | ```bash | ||
| 803 | # Block spam/malware repos | ||
| 804 | NGIT_REPOSITORY_BLACKLIST=malware,spam,phishing | ||
| 805 | |||
| 806 | # Block abusive users | ||
| 807 | NGIT_REPOSITORY_BLACKLIST=npub1spammer...,npub1abuser... | ||
| 808 | |||
| 809 | # Block specific problematic repos | ||
| 810 | NGIT_REPOSITORY_BLACKLIST=npub1alice.../copyright-violation,npub1bob.../illegal-content | ||
| 811 | |||
| 812 | # Temporary block for investigation | ||
| 813 | NGIT_REPOSITORY_BLACKLIST=npub1suspicious.../repo-under-review | ||
| 814 | ``` | ||
| 815 | |||
| 816 | **Comparison with Whitelists:** | ||
| 817 | |||
| 818 | | Configuration | Blacklisted? | Matches Whitelist? | Lists Service? | Result | | ||
| 819 | |---------------|--------------|-------------------|----------------|---------| | ||
| 820 | | Blacklist only | Yes | N/A | N/A | ❌ Reject (blacklisted) | | ||
| 821 | | Blacklist only | No | N/A | Yes | ✅ Accept (GRASP-01) | | ||
| 822 | | Blacklist + Repository whitelist | Yes | Yes | Yes | ❌ Reject (blacklist wins) | | ||
| 823 | | Blacklist + Archive whitelist | Yes | Yes | No | ❌ Reject (blacklist wins) | | ||
| 824 | | Blacklist + Both whitelists | Yes | Yes | Yes | ❌ Reject (blacklist wins) | | ||
| 825 | | Blacklist only | No | N/A | No | ❌ Reject (no whitelist match) | | ||
| 826 | |||
| 827 | **NIP-11 Impact:** | ||
| 828 | |||
| 829 | Blacklist does **not** affect NIP-11 metadata: | ||
| 830 | - No `curation` field changes (blacklist is operational, not curation policy) | ||
| 831 | - Blacklist is transparent to clients (rejected with specific reason) | ||
| 832 | - Operators can use blacklist without advertising curation | ||
| 833 | |||
| 834 | --- | ||
| 835 | |||
| 747 | ### Logging Configuration | 836 | ### Logging Configuration |
| 748 | 837 | ||
| 749 | #### `RUST_LOG` | 838 | #### `RUST_LOG` |
diff --git a/nix/module.nix b/nix/module.nix index d5dfd88..cfac0fc 100644 --- a/nix/module.nix +++ b/nix/module.nix | |||
| @@ -224,6 +224,19 @@ let | |||
| 224 | ''; | 224 | ''; |
| 225 | }; | 225 | }; |
| 226 | 226 | ||
| 227 | repositoryBlacklist = mkOption { | ||
| 228 | type = types.listOf types.str; | ||
| 229 | default = [ ]; | ||
| 230 | example = [ "npub1spam..." "npub1alice.../bad-repo" "malware" ]; | ||
| 231 | description = '' | ||
| 232 | Repository blacklist for blocking specific repositories/pubkeys/identifiers. | ||
| 233 | Blacklist takes precedence over ALL whitelists (archive and repository). | ||
| 234 | Formats: <npub>, <npub>/<identifier>, <identifier> | ||
| 235 | Blacklisted repos are rejected with specific reasons (npub/identifier/both). | ||
| 236 | Does not affect NIP-11 curation field (operational, not curation policy). | ||
| 237 | ''; | ||
| 238 | }; | ||
| 239 | |||
| 227 | user = mkOption { | 240 | user = mkOption { |
| 228 | type = types.str; | 241 | type = types.str; |
| 229 | default = "ngit-grasp-${name}"; | 242 | default = "ngit-grasp-${name}"; |
| @@ -267,6 +280,7 @@ let | |||
| 267 | NGIT_ARCHIVE_ALL = toString cfg.archiveAll; | 280 | NGIT_ARCHIVE_ALL = toString cfg.archiveAll; |
| 268 | NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; | 281 | NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; |
| 269 | NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; | 282 | NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; |
| 283 | NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; | ||
| 270 | RUST_LOG = cfg.logLevel; | 284 | RUST_LOG = cfg.logLevel; |
| 271 | } // optionalAttrs (cfg.relayName != null) { | 285 | } // optionalAttrs (cfg.relayName != null) { |
| 272 | NGIT_RELAY_NAME = cfg.relayName; | 286 | NGIT_RELAY_NAME = cfg.relayName; |
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 | } |