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/config.rs | |
| 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/config.rs')
| -rw-r--r-- | src/config.rs | 107 |
1 files changed, 106 insertions, 1 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 | } |