diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 20:30:13 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 20:30:13 +0000 |
| commit | a12927181c571fc1641772ad44dd4c6a4ab209d9 (patch) | |
| tree | d7cb99fa87606e9fb13d91305cda8a0f919e6528 /src | |
| parent | c29191b1e1239e931c575a926ec9480e594476d6 (diff) | |
feat(grasp-05): add read-only mode with auto-enable for archive configs
Implements NGIT_ARCHIVE_READ_ONLY configuration option that defaults to true
when archive mode is enabled, allowing relays to operate as read-only syncs
of archived repositories.
Key changes:
- Add NGIT_ARCHIVE_READ_ONLY config option (defaults to true if archive enabled)
- NIP-11 advertises GRASP-05 support and includes curation field when read-only
- Validation logic rejects non-whitelisted repos in read-only mode
- Comprehensive tests for read-only behavior and defaults
- Full documentation in config reference, .env.example, and NixOS module
Read-only mode enables passive mirroring without being listed in announcements,
useful for backup/archive operations while preventing accidental write acceptance.
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 107 | ||||
| -rw-r--r-- | src/http/nip11.rs | 87 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 5 | ||||
| -rw-r--r-- | src/nostr/events.rs | 123 |
4 files changed, 306 insertions, 16 deletions
diff --git a/src/config.rs b/src/config.rs index b1ab43e..d9917a3 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -98,6 +98,13 @@ pub struct ArchiveConfig { | |||
| 98 | /// | 98 | /// |
| 99 | /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). | 99 | /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). |
| 100 | pub whitelist: Vec<ArchiveWhitelistEntry>, | 100 | pub whitelist: Vec<ArchiveWhitelistEntry>, |
| 101 | |||
| 102 | /// Read-only archive mode: relay is a read-only sync of archived repositories | ||
| 103 | /// | ||
| 104 | /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. | ||
| 105 | /// Announcements listing the relay but not in the whitelist are rejected. | ||
| 106 | /// When false, the relay operates in GRASP-01 mode for unwhitelisted repos. | ||
| 107 | pub read_only: bool, | ||
| 101 | } | 108 | } |
| 102 | 109 | ||
| 103 | impl ArchiveConfig { | 110 | impl ArchiveConfig { |
| @@ -141,6 +148,7 @@ impl Default for ArchiveConfig { | |||
| 141 | Self { | 148 | Self { |
| 142 | archive_all: false, | 149 | archive_all: false, |
| 143 | whitelist: Vec::new(), | 150 | whitelist: Vec::new(), |
| 151 | read_only: false, | ||
| 144 | } | 152 | } |
| 145 | } | 153 | } |
| 146 | } | 154 | } |
| @@ -311,6 +319,12 @@ pub struct Config { | |||
| 311 | /// Formats: "npub1...", "npub1.../identifier", "identifier" | 319 | /// Formats: "npub1...", "npub1.../identifier", "identifier" |
| 312 | #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] | 320 | #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] |
| 313 | pub archive_whitelist: String, | 321 | pub archive_whitelist: String, |
| 322 | |||
| 323 | /// Archive read-only mode: relay is a read-only sync of archived repositories | ||
| 324 | /// Defaults to true if archive_all or archive_whitelist is set, false otherwise | ||
| 325 | /// Throws error if set to true without archive_all or archive_whitelist | ||
| 326 | #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] | ||
| 327 | pub archive_read_only: Option<bool>, | ||
| 314 | } | 328 | } |
| 315 | 329 | ||
| 316 | impl Config { | 330 | impl Config { |
| @@ -411,12 +425,34 @@ impl Config { | |||
| 411 | } | 425 | } |
| 412 | } | 426 | } |
| 413 | 427 | ||
| 414 | /// Get parsed archive configuration | 428 | /// Get parsed archive configuration with computed read-only mode |
| 429 | /// | ||
| 430 | /// Read-only mode defaults to true if archive mode is enabled, false otherwise. | ||
| 431 | /// Throws error if explicitly set to true without archive mode enabled. | ||
| 415 | pub fn archive_config(&self) -> Result<ArchiveConfig> { | 432 | pub fn archive_config(&self) -> Result<ArchiveConfig> { |
| 416 | let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; | 433 | let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; |
| 434 | let archive_enabled = self.archive_all || !whitelist.is_empty(); | ||
| 435 | |||
| 436 | let read_only = match self.archive_read_only { | ||
| 437 | Some(true) => { | ||
| 438 | if !archive_enabled { | ||
| 439 | return Err(anyhow!( | ||
| 440 | "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" | ||
| 441 | )); | ||
| 442 | } | ||
| 443 | true | ||
| 444 | } | ||
| 445 | Some(false) => false, | ||
| 446 | None => { | ||
| 447 | // Default: true if archive mode enabled, false otherwise | ||
| 448 | archive_enabled | ||
| 449 | } | ||
| 450 | }; | ||
| 451 | |||
| 417 | Ok(ArchiveConfig { | 452 | Ok(ArchiveConfig { |
| 418 | archive_all: self.archive_all, | 453 | archive_all: self.archive_all, |
| 419 | whitelist, | 454 | whitelist, |
| 455 | read_only, | ||
| 420 | }) | 456 | }) |
| 421 | } | 457 | } |
| 422 | 458 | ||
| @@ -452,6 +488,7 @@ impl Config { | |||
| 452 | naughty_list_expiration_hours: 12, | 488 | naughty_list_expiration_hours: 12, |
| 453 | archive_all: false, | 489 | archive_all: false, |
| 454 | archive_whitelist: String::new(), | 490 | archive_whitelist: String::new(), |
| 491 | archive_read_only: None, | ||
| 455 | } | 492 | } |
| 456 | } | 493 | } |
| 457 | } | 494 | } |
| @@ -664,12 +701,14 @@ mod tests { | |||
| 664 | let config = ArchiveConfig { | 701 | let config = ArchiveConfig { |
| 665 | archive_all: true, | 702 | archive_all: true, |
| 666 | whitelist: Vec::new(), | 703 | whitelist: Vec::new(), |
| 704 | read_only: true, | ||
| 667 | }; | 705 | }; |
| 668 | assert!(config.enabled()); | 706 | assert!(config.enabled()); |
| 669 | 707 | ||
| 670 | let config = ArchiveConfig { | 708 | let config = ArchiveConfig { |
| 671 | archive_all: false, | 709 | archive_all: false, |
| 672 | whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], | 710 | whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], |
| 711 | read_only: true, | ||
| 673 | }; | 712 | }; |
| 674 | assert!(config.enabled()); | 713 | assert!(config.enabled()); |
| 675 | } | 714 | } |
| @@ -684,6 +723,7 @@ mod tests { | |||
| 684 | ArchiveWhitelistEntry::Pubkey(test_npub.clone()), | 723 | ArchiveWhitelistEntry::Pubkey(test_npub.clone()), |
| 685 | ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), | 724 | ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), |
| 686 | ], | 725 | ], |
| 726 | read_only: false, | ||
| 687 | }; | 727 | }; |
| 688 | 728 | ||
| 689 | assert!(config.matches(&test_npub, "any-repo")); | 729 | assert!(config.matches(&test_npub, "any-repo")); |
| @@ -696,6 +736,7 @@ mod tests { | |||
| 696 | let config = ArchiveConfig { | 736 | let config = ArchiveConfig { |
| 697 | archive_all: true, | 737 | archive_all: true, |
| 698 | whitelist: Vec::new(), | 738 | whitelist: Vec::new(), |
| 739 | read_only: true, | ||
| 699 | }; | 740 | }; |
| 700 | 741 | ||
| 701 | assert!(config.matches("npub1alice", "any-repo")); | 742 | assert!(config.matches("npub1alice", "any-repo")); |
| @@ -745,4 +786,68 @@ mod tests { | |||
| 745 | }; | 786 | }; |
| 746 | assert!(config.archive_config().is_err()); | 787 | assert!(config.archive_config().is_err()); |
| 747 | } | 788 | } |
| 789 | |||
| 790 | #[test] | ||
| 791 | fn test_archive_read_only_defaults() { | ||
| 792 | // Default: false when no archive mode | ||
| 793 | let config = Config::for_testing(); | ||
| 794 | assert_eq!(config.archive_config().unwrap().read_only, false); | ||
| 795 | |||
| 796 | // Default: true when archive_all is set | ||
| 797 | let config = Config { | ||
| 798 | archive_all: true, | ||
| 799 | ..Config::for_testing() | ||
| 800 | }; | ||
| 801 | assert_eq!(config.archive_config().unwrap().read_only, true); | ||
| 802 | |||
| 803 | // Default: true when archive_whitelist is set | ||
| 804 | let keys = Keys::generate(); | ||
| 805 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 806 | let config = Config { | ||
| 807 | archive_whitelist: test_npub, | ||
| 808 | ..Config::for_testing() | ||
| 809 | }; | ||
| 810 | assert_eq!(config.archive_config().unwrap().read_only, true); | ||
| 811 | } | ||
| 812 | |||
| 813 | #[test] | ||
| 814 | fn test_archive_read_only_explicit() { | ||
| 815 | // Explicit true with archive_all | ||
| 816 | let config = Config { | ||
| 817 | archive_all: true, | ||
| 818 | archive_read_only: Some(true), | ||
| 819 | ..Config::for_testing() | ||
| 820 | }; | ||
| 821 | assert_eq!(config.archive_config().unwrap().read_only, true); | ||
| 822 | |||
| 823 | // Explicit false with archive_all (unusual but allowed) | ||
| 824 | let config = Config { | ||
| 825 | archive_all: true, | ||
| 826 | archive_read_only: Some(false), | ||
| 827 | ..Config::for_testing() | ||
| 828 | }; | ||
| 829 | assert_eq!(config.archive_config().unwrap().read_only, false); | ||
| 830 | |||
| 831 | // Explicit false without archive mode | ||
| 832 | let config = Config { | ||
| 833 | archive_read_only: Some(false), | ||
| 834 | ..Config::for_testing() | ||
| 835 | }; | ||
| 836 | assert_eq!(config.archive_config().unwrap().read_only, false); | ||
| 837 | } | ||
| 838 | |||
| 839 | #[test] | ||
| 840 | fn test_archive_read_only_error() { | ||
| 841 | // Error: true without archive mode | ||
| 842 | let config = Config { | ||
| 843 | archive_read_only: Some(true), | ||
| 844 | ..Config::for_testing() | ||
| 845 | }; | ||
| 846 | assert!(config.archive_config().is_err()); | ||
| 847 | assert!(config | ||
| 848 | .archive_config() | ||
| 849 | .unwrap_err() | ||
| 850 | .to_string() | ||
| 851 | .contains("requires either")); | ||
| 852 | } | ||
| 748 | } | 853 | } |
diff --git a/src/http/nip11.rs b/src/http/nip11.rs index b756d9c..71cadb1 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs | |||
| @@ -56,6 +56,41 @@ pub struct RelayInformationDocument { | |||
| 56 | impl RelayInformationDocument { | 56 | impl RelayInformationDocument { |
| 57 | /// Create NIP-11 relay information document from configuration | 57 | /// Create NIP-11 relay information document from configuration |
| 58 | pub fn from_config(config: &Config) -> Self { | 58 | pub fn from_config(config: &Config) -> Self { |
| 59 | // Determine if archive mode is enabled | ||
| 60 | let archive_config = config.archive_config().ok(); | ||
| 61 | let archive_enabled = archive_config | ||
| 62 | .as_ref() | ||
| 63 | .map(|ac| ac.enabled()) | ||
| 64 | .unwrap_or(false); | ||
| 65 | let archive_read_only = archive_config | ||
| 66 | .as_ref() | ||
| 67 | .map(|ac| ac.read_only) | ||
| 68 | .unwrap_or(false); | ||
| 69 | |||
| 70 | // Build supported_grasps list | ||
| 71 | let mut supported_grasps = vec!["GRASP-01".to_string()]; | ||
| 72 | if archive_enabled { | ||
| 73 | supported_grasps.push("GRASP-05".to_string()); | ||
| 74 | } | ||
| 75 | supported_grasps.push("GRASP-02".to_string()); | ||
| 76 | |||
| 77 | // Build curation field for archive read-only mode | ||
| 78 | let curation = if archive_read_only { | ||
| 79 | if let Some(ref ac) = archive_config { | ||
| 80 | if ac.archive_all { | ||
| 81 | Some("Read-only sync of all repositories found on network".to_string()) | ||
| 82 | } else if !ac.whitelist.is_empty() { | ||
| 83 | Some("Read-only sync of whitelisted repositories and maintainers".to_string()) | ||
| 84 | } else { | ||
| 85 | None | ||
| 86 | } | ||
| 87 | } else { | ||
| 88 | None | ||
| 89 | } | ||
| 90 | } else { | ||
| 91 | None | ||
| 92 | }; | ||
| 93 | |||
| 59 | Self { | 94 | Self { |
| 60 | name: config.relay_name(), | 95 | name: config.relay_name(), |
| 61 | description: config.relay_description.clone(), | 96 | description: config.relay_description.clone(), |
| @@ -75,9 +110,9 @@ impl RelayInformationDocument { | |||
| 75 | icon: Some(format!("https://{}/icon.png", config.domain)), | 110 | icon: Some(format!("https://{}/icon.png", config.domain)), |
| 76 | 111 | ||
| 77 | // GRASP Extensions | 112 | // GRASP Extensions |
| 78 | supported_grasps: vec!["GRASP-01".to_string(), "GRASP-02".to_string()], | 113 | supported_grasps, |
| 79 | repo_acceptance_criteria: "None".to_string(), | 114 | repo_acceptance_criteria: "None".to_string(), |
| 80 | curation: None, // Not a curated relay - only SPAM prevention via GRASP-01 policy | 115 | curation, |
| 81 | } | 116 | } |
| 82 | } | 117 | } |
| 83 | 118 | ||
| @@ -90,6 +125,7 @@ impl RelayInformationDocument { | |||
| 90 | #[cfg(test)] | 125 | #[cfg(test)] |
| 91 | mod tests { | 126 | mod tests { |
| 92 | use super::*; | 127 | use super::*; |
| 128 | use nostr_sdk::nips::nip19::ToBech32; | ||
| 93 | 129 | ||
| 94 | #[test] | 130 | #[test] |
| 95 | fn test_relay_information_document_structure() { | 131 | fn test_relay_information_document_structure() { |
| @@ -112,6 +148,7 @@ mod tests { | |||
| 112 | assert!(doc.supported_nips.contains(&11)); | 148 | assert!(doc.supported_nips.contains(&11)); |
| 113 | assert!(doc.supported_nips.contains(&34)); | 149 | assert!(doc.supported_nips.contains(&34)); |
| 114 | assert!(doc.supported_nips.contains(&77)); | 150 | assert!(doc.supported_nips.contains(&77)); |
| 151 | // Without archive mode, only GRASP-01 and GRASP-02 | ||
| 115 | assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]); | 152 | assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]); |
| 116 | assert!(doc.repo_acceptance_criteria.contains("None")); | 153 | assert!(doc.repo_acceptance_criteria.contains("None")); |
| 117 | assert!(doc.curation.is_none()); | 154 | assert!(doc.curation.is_none()); |
| @@ -147,4 +184,50 @@ mod tests { | |||
| 147 | assert_eq!(parsed["supported_grasps"][1], "GRASP-02"); | 184 | assert_eq!(parsed["supported_grasps"][1], "GRASP-02"); |
| 148 | assert_eq!(parsed["icon"], "https://relay.example.com/icon.png"); | 185 | assert_eq!(parsed["icon"], "https://relay.example.com/icon.png"); |
| 149 | } | 186 | } |
| 187 | |||
| 188 | #[test] | ||
| 189 | fn test_nip11_with_archive_mode() { | ||
| 190 | let mut config = Config::for_testing(); | ||
| 191 | config.domain = "relay.example.com".to_string(); | ||
| 192 | config.relay_name_override = Some("Archive Relay".to_string()); | ||
| 193 | config.archive_all = true; | ||
| 194 | config.archive_read_only = Some(true); | ||
| 195 | |||
| 196 | let doc = RelayInformationDocument::from_config(&config); | ||
| 197 | |||
| 198 | // Archive mode enabled: should include GRASP-05 | ||
| 199 | assert_eq!( | ||
| 200 | doc.supported_grasps, | ||
| 201 | vec!["GRASP-01", "GRASP-05", "GRASP-02"] | ||
| 202 | ); | ||
| 203 | // Archive read-only: should have curation field | ||
| 204 | assert!(doc.curation.is_some()); | ||
| 205 | assert!(doc | ||
| 206 | .curation | ||
| 207 | .unwrap() | ||
| 208 | .contains("Read-only sync of all repositories")); | ||
| 209 | } | ||
| 210 | |||
| 211 | #[test] | ||
| 212 | fn test_nip11_with_whitelist_archive() { | ||
| 213 | let keys = nostr_sdk::Keys::generate(); | ||
| 214 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 215 | let mut config = Config::for_testing(); | ||
| 216 | config.domain = "relay.example.com".to_string(); | ||
| 217 | config.archive_whitelist = format!("{},bitcoin-core", test_npub); | ||
| 218 | |||
| 219 | let doc = RelayInformationDocument::from_config(&config); | ||
| 220 | |||
| 221 | // Archive whitelist enabled: should include GRASP-05 | ||
| 222 | assert_eq!( | ||
| 223 | doc.supported_grasps, | ||
| 224 | vec!["GRASP-01", "GRASP-05", "GRASP-02"] | ||
| 225 | ); | ||
| 226 | // Archive read-only defaults to true: should have curation field | ||
| 227 | assert!(doc.curation.is_some()); | ||
| 228 | assert!(doc | ||
| 229 | .curation | ||
| 230 | .unwrap() | ||
| 231 | .contains("Read-only sync of whitelisted")); | ||
| 232 | } | ||
| 150 | } | 233 | } |
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index deee641..33f2fe5 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -575,9 +575,10 @@ pub async fn create_relay( | |||
| 575 | 575 | ||
| 576 | if archive_config.enabled() { | 576 | if archive_config.enabled() { |
| 577 | tracing::info!( | 577 | tracing::info!( |
| 578 | "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}", | 578 | "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}", |
| 579 | archive_config.archive_all, | 579 | archive_config.archive_all, |
| 580 | archive_config.whitelist.len() | 580 | archive_config.whitelist.len(), |
| 581 | archive_config.read_only | ||
| 581 | ); | 582 | ); |
| 582 | } | 583 | } |
| 583 | 584 | ||
diff --git a/src/nostr/events.rs b/src/nostr/events.rs index dabe5fe..f83e00c 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs | |||
| @@ -362,10 +362,14 @@ impl RepositoryState { | |||
| 362 | /// Validate a repository announcement according to GRASP-01 and GRASP-05 | 362 | /// Validate a repository announcement according to GRASP-01 and GRASP-05 |
| 363 | /// | 363 | /// |
| 364 | /// Returns: | 364 | /// Returns: |
| 365 | /// - Accept: Announcement lists our service (GRASP-01) | 365 | /// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode |
| 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 | /// When archive_read_only is true: | ||
| 370 | /// - ONLY accept announcements matching archive whitelist/all | ||
| 371 | /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) | ||
| 372 | /// | ||
| 369 | /// Note: AcceptMaintainer is NOT returned here (requires database access) | 373 | /// Note: AcceptMaintainer is NOT returned here (requires database access) |
| 370 | pub fn validate_announcement( | 374 | pub fn validate_announcement( |
| 371 | event: &Event, | 375 | event: &Event, |
| @@ -394,23 +398,32 @@ pub fn validate_announcement( | |||
| 394 | Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), | 398 | Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), |
| 395 | }; | 399 | }; |
| 396 | 400 | ||
| 397 | // GRASP-01: Check if announcement lists our service | 401 | // GRASP-01: Normal mode - accept if announcement lists our service |
| 398 | if announcement.lists_service(domain) { | 402 | if announcement.lists_service(domain) && !archive_config.read_only { |
| 399 | return AnnouncementResult::Accept; | 403 | return AnnouncementResult::Accept; |
| 400 | } | 404 | } |
| 401 | 405 | ||
| 402 | // GRASP-05: Check if announcement matches archive configuration | ||
| 403 | let npub = announcement.owner_npub(); | 406 | let npub = announcement.owner_npub(); |
| 407 | |||
| 408 | // GRASP-05: Archive mode - accept if announcement matches whitelist | ||
| 404 | if archive_config.matches(&npub, &announcement.identifier) { | 409 | if archive_config.matches(&npub, &announcement.identifier) { |
| 405 | return AnnouncementResult::AcceptArchive; | 410 | return AnnouncementResult::AcceptArchive; |
| 406 | } | 411 | } |
| 407 | 412 | ||
| 408 | // Reject: Doesn't list us and not whitelisted | 413 | // Reject with appropriate error message |
| 409 | AnnouncementResult::Reject(format!( | 414 | if archive_config.read_only { |
| 410 | "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ | 415 | AnnouncementResult::Reject(format!( |
| 411 | Found clone URLs: {:?}, relays: {:?}", | 416 | "Archive read-only mode: announcement must match archive whitelist. \ |
| 412 | announcement.clone_urls, announcement.relays | 417 | Repository {}/{} not in whitelist", |
| 413 | )) | 418 | npub, announcement.identifier |
| 419 | )) | ||
| 420 | } else { | ||
| 421 | AnnouncementResult::Reject(format!( | ||
| 422 | "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ | ||
| 423 | Found clone URLs: {:?}, relays: {:?}", | ||
| 424 | announcement.clone_urls, announcement.relays | ||
| 425 | )) | ||
| 426 | } | ||
| 414 | } | 427 | } |
| 415 | 428 | ||
| 416 | /// Validate a repository state announcement according to GRASP-01 | 429 | /// Validate a repository state announcement according to GRASP-01 |
| @@ -969,6 +982,7 @@ mod tests { | |||
| 969 | let archive_config = ArchiveConfig { | 982 | let archive_config = ArchiveConfig { |
| 970 | archive_all: false, | 983 | archive_all: false, |
| 971 | whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], | 984 | whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], |
| 985 | read_only: false, | ||
| 972 | }; | 986 | }; |
| 973 | 987 | ||
| 974 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 988 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -994,6 +1008,7 @@ mod tests { | |||
| 994 | let archive_config = ArchiveConfig { | 1008 | let archive_config = ArchiveConfig { |
| 995 | archive_all: false, | 1009 | archive_all: false, |
| 996 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], | 1010 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], |
| 1011 | read_only: false, | ||
| 997 | }; | 1012 | }; |
| 998 | 1013 | ||
| 999 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1014 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1023,6 +1038,7 @@ mod tests { | |||
| 1023 | npub, | 1038 | npub, |
| 1024 | identifier: "linux".into(), | 1039 | identifier: "linux".into(), |
| 1025 | }], | 1040 | }], |
| 1041 | read_only: false, | ||
| 1026 | }; | 1042 | }; |
| 1027 | 1043 | ||
| 1028 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1044 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1048,6 +1064,7 @@ mod tests { | |||
| 1048 | let archive_config = ArchiveConfig { | 1064 | let archive_config = ArchiveConfig { |
| 1049 | archive_all: true, | 1065 | archive_all: true, |
| 1050 | whitelist: Vec::new(), | 1066 | whitelist: Vec::new(), |
| 1067 | read_only: false, | ||
| 1051 | }; | 1068 | }; |
| 1052 | 1069 | ||
| 1053 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1070 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1073,6 +1090,7 @@ mod tests { | |||
| 1073 | let archive_config = ArchiveConfig { | 1090 | let archive_config = ArchiveConfig { |
| 1074 | archive_all: false, | 1091 | archive_all: false, |
| 1075 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], | 1092 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], |
| 1093 | read_only: false, | ||
| 1076 | }; | 1094 | }; |
| 1077 | 1095 | ||
| 1078 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1096 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1094,13 +1112,96 @@ mod tests { | |||
| 1094 | vec!["wss://gitnostr.com"], | 1112 | vec!["wss://gitnostr.com"], |
| 1095 | ); | 1113 | ); |
| 1096 | 1114 | ||
| 1097 | // Even with archive config, GRASP-01 Accept takes precedence | 1115 | // With archive_read_only=false, GRASP-01 Accept takes precedence |
| 1098 | let archive_config = ArchiveConfig { | 1116 | let archive_config = ArchiveConfig { |
| 1099 | archive_all: true, | 1117 | archive_all: true, |
| 1100 | whitelist: Vec::new(), | 1118 | whitelist: Vec::new(), |
| 1119 | read_only: false, | ||
| 1101 | }; | 1120 | }; |
| 1102 | 1121 | ||
| 1103 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1122 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| 1104 | assert!(matches!(result, AnnouncementResult::Accept)); | 1123 | assert!(matches!(result, AnnouncementResult::Accept)); |
| 1105 | } | 1124 | } |
| 1125 | |||
| 1126 | #[test] | ||
| 1127 | fn test_archive_read_only_rejects_non_whitelisted() { | ||
| 1128 | use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; | ||
| 1129 | use crate::nostr::policy::AnnouncementResult; | ||
| 1130 | |||
| 1131 | let keys = create_test_keys(); | ||
| 1132 | |||
| 1133 | // Create announcement that DOES list our service | ||
| 1134 | let event = create_announcement_event( | ||
| 1135 | &keys, | ||
| 1136 | "test-repo", | ||
| 1137 | vec!["https://gitnostr.com/alice/test-repo.git"], | ||
| 1138 | vec!["wss://gitnostr.com"], | ||
| 1139 | ); | ||
| 1140 | |||
| 1141 | // With archive_read_only=true and whitelist that doesn't include this repo, | ||
| 1142 | // should reject even though it lists our service | ||
| 1143 | let archive_config = ArchiveConfig { | ||
| 1144 | archive_all: false, | ||
| 1145 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], | ||
| 1146 | read_only: true, | ||
| 1147 | }; | ||
| 1148 | |||
| 1149 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | ||
| 1150 | assert!(matches!(result, AnnouncementResult::Reject(_))); | ||
| 1151 | } | ||
| 1152 | |||
| 1153 | #[test] | ||
| 1154 | fn test_archive_read_only_accepts_whitelisted() { | ||
| 1155 | use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; | ||
| 1156 | use crate::nostr::policy::AnnouncementResult; | ||
| 1157 | |||
| 1158 | let keys = create_test_keys(); | ||
| 1159 | let npub = keys.public_key().to_bech32().unwrap(); | ||
| 1160 | |||
| 1161 | // Create announcement that lists our service | ||
| 1162 | let event = create_announcement_event( | ||
| 1163 | &keys, | ||
| 1164 | "test-repo", | ||
| 1165 | vec!["https://gitnostr.com/alice/test-repo.git"], | ||
| 1166 | vec!["wss://gitnostr.com"], | ||
| 1167 | ); | ||
| 1168 | |||
| 1169 | // With archive_read_only=true and whitelist that DOES include this repo, | ||
| 1170 | // should accept as AcceptArchive | ||
| 1171 | let archive_config = ArchiveConfig { | ||
| 1172 | archive_all: false, | ||
| 1173 | whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], | ||
| 1174 | read_only: true, | ||
| 1175 | }; | ||
| 1176 | |||
| 1177 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | ||
| 1178 | assert!(matches!(result, AnnouncementResult::AcceptArchive)); | ||
| 1179 | } | ||
| 1180 | |||
| 1181 | #[test] | ||
| 1182 | fn test_archive_read_only_with_archive_all() { | ||
| 1183 | use crate::config::ArchiveConfig; | ||
| 1184 | use crate::nostr::policy::AnnouncementResult; | ||
| 1185 | |||
| 1186 | let keys = create_test_keys(); | ||
| 1187 | |||
| 1188 | // Create announcement that lists our service | ||
| 1189 | let event = create_announcement_event( | ||
| 1190 | &keys, | ||
| 1191 | "any-repo", | ||
| 1192 | vec!["https://gitnostr.com/alice/any-repo.git"], | ||
| 1193 | vec!["wss://gitnostr.com"], | ||
| 1194 | ); | ||
| 1195 | |||
| 1196 | // With archive_read_only=true and archive_all=true, | ||
| 1197 | // should accept as AcceptArchive | ||
| 1198 | let archive_config = ArchiveConfig { | ||
| 1199 | archive_all: true, | ||
| 1200 | whitelist: Vec::new(), | ||
| 1201 | read_only: true, | ||
| 1202 | }; | ||
| 1203 | |||
| 1204 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | ||
| 1205 | assert!(matches!(result, AnnouncementResult::AcceptArchive)); | ||
| 1206 | } | ||
| 1106 | } | 1207 | } |