upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/config.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 21:06:39 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 21:21:52 +0000
commit82b56c37b26a2fac1a294873e539b19b9325dca6 (patch)
tree07800949230f13f91fec2eebbd94b8fbb00dd83f /src/config.rs
parenta12927181c571fc1641772ad44dd4c6a4ab209d9 (diff)
feat(config): add repository whitelist for curated GRASP-01 acceptance
Adds NGIT_REPOSITORY_WHITELIST option for curated relay operation that accepts only whitelisted repositories while maintaining GRASP-01 compliance (announcements must list the service). This differs from archive whitelist which enables GRASP-05 mode and doesn't require service listing. Key features: - Supports three whitelist formats: npub, npub/identifier, identifier - Enforces mutual exclusivity with archive read-only mode - Updates NIP-11 curation field when whitelist is enabled - Maintains GRASP-01 compliance (doesn't add GRASP-05 support) Configuration synced across all four sources: src/config.rs, docs/reference/configuration.md, nix/module.nix, and .env.example as required by AGENTS.md.
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs210
1 files changed, 170 insertions, 40 deletions
diff --git a/src/config.rs b/src/config.rs
index d9917a3..8a9eb4d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -5,21 +5,21 @@ use serde::{Deserialize, Serialize};
5use std::fs; 5use std::fs;
6use std::path::PathBuf; 6use std::path::PathBuf;
7 7
8/// GRASP-05 Archive whitelist entry 8/// Whitelist entry for repository/archive filtering
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "lowercase")] 10#[serde(rename_all = "lowercase")]
11pub enum ArchiveWhitelistEntry { 11pub enum WhitelistEntry {
12 /// Archive all repos from this pubkey: "npub1..." 12 /// All repos from this pubkey: "npub1..."
13 Pubkey(String), 13 Pubkey(String),
14 14
15 /// Archive specific repo: "npub1.../identifier" 15 /// Specific repo: "npub1.../identifier"
16 Repository { npub: String, identifier: String }, 16 Repository { npub: String, identifier: String },
17 17
18 /// Archive any repo with this identifier: "identifier" 18 /// Any repo with this identifier: "identifier"
19 Identifier(String), 19 Identifier(String),
20} 20}
21 21
22impl ArchiveWhitelistEntry { 22impl WhitelistEntry {
23 /// Parse a whitelist entry from string 23 /// Parse a whitelist entry from string
24 /// 24 ///
25 /// Formats: 25 /// Formats:
@@ -83,6 +83,20 @@ impl ArchiveWhitelistEntry {
83 Self::Identifier(i) => identifier == i, 83 Self::Identifier(i) => identifier == i,
84 } 84 }
85 } 85 }
86
87 /// Parse whitelist from comma-separated string
88 pub fn parse_whitelist(input: &str) -> Result<Vec<Self>> {
89 if input.trim().is_empty() {
90 return Ok(Vec::new());
91 }
92
93 input
94 .split(',')
95 .map(|s| s.trim())
96 .filter(|s| !s.is_empty())
97 .map(Self::parse)
98 .collect()
99 }
86} 100}
87 101
88/// GRASP-05 Archive mode configuration 102/// GRASP-05 Archive mode configuration
@@ -97,7 +111,7 @@ pub struct ArchiveConfig {
97 /// Whitelist entries for selective archiving 111 /// Whitelist entries for selective archiving
98 /// 112 ///
99 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). 113 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
100 pub whitelist: Vec<ArchiveWhitelistEntry>, 114 pub whitelist: Vec<WhitelistEntry>,
101 115
102 /// Read-only archive mode: relay is a read-only sync of archived repositories 116 /// Read-only archive mode: relay is a read-only sync of archived repositories
103 /// 117 ///
@@ -127,28 +141,47 @@ impl ArchiveConfig {
127 .iter() 141 .iter()
128 .any(|entry| entry.matches(npub, identifier)) 142 .any(|entry| entry.matches(npub, identifier))
129 } 143 }
144}
130 145
131 /// Parse archive whitelist from comma-separated string 146impl Default for ArchiveConfig {
132 pub fn parse_whitelist(input: &str) -> Result<Vec<ArchiveWhitelistEntry>> { 147 fn default() -> Self {
133 if input.trim().is_empty() { 148 Self {
134 return Ok(Vec::new()); 149 archive_all: false,
150 whitelist: Vec::new(),
151 read_only: false,
135 } 152 }
153 }
154}
136 155
137 input 156/// Repository whitelist configuration
138 .split(',') 157#[derive(Debug, Clone, Serialize, Deserialize)]
139 .map(|s| s.trim()) 158pub struct RepositoryConfig {
140 .filter(|s| !s.is_empty()) 159 /// Whitelist entries for selective repository acceptance
141 .map(ArchiveWhitelistEntry::parse) 160 ///
142 .collect() 161 /// If empty, all repositories listing the service are accepted (GRASP-01 mode).
162 pub whitelist: Vec<WhitelistEntry>,
163}
164
165impl RepositoryConfig {
166 /// Check if repository whitelist is enabled (non-empty whitelist)
167 pub fn enabled(&self) -> bool {
168 !self.whitelist.is_empty()
169 }
170
171 /// Check if an announcement matches the repository whitelist
172 ///
173 /// Returns true if announcement matches any whitelist entry
174 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
175 self.whitelist
176 .iter()
177 .any(|entry| entry.matches(npub, identifier))
143 } 178 }
144} 179}
145 180
146impl Default for ArchiveConfig { 181impl Default for RepositoryConfig {
147 fn default() -> Self { 182 fn default() -> Self {
148 Self { 183 Self {
149 archive_all: false,
150 whitelist: Vec::new(), 184 whitelist: Vec::new(),
151 read_only: false,
152 } 185 }
153 } 186 }
154} 187}
@@ -325,6 +358,12 @@ pub struct Config {
325 /// Throws error if set to true without archive_all or archive_whitelist 358 /// Throws error if set to true without archive_all or archive_whitelist
326 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] 359 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")]
327 pub archive_read_only: Option<bool>, 360 pub archive_read_only: Option<bool>,
361
362 /// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries
363 /// Formats: "npub1...", "npub1.../identifier", "identifier"
364 /// When set, only announcements matching the whitelist AND listing the service are accepted
365 #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")]
366 pub repository_whitelist: String,
328} 367}
329 368
330impl Config { 369impl Config {
@@ -430,7 +469,7 @@ impl Config {
430 /// Read-only mode defaults to true if archive mode is enabled, false otherwise. 469 /// 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. 470 /// Throws error if explicitly set to true without archive mode enabled.
432 pub fn archive_config(&self) -> Result<ArchiveConfig> { 471 pub fn archive_config(&self) -> Result<ArchiveConfig> {
433 let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; 472 let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist)?;
434 let archive_enabled = self.archive_all || !whitelist.is_empty(); 473 let archive_enabled = self.archive_all || !whitelist.is_empty();
435 474
436 let read_only = match self.archive_read_only { 475 let read_only = match self.archive_read_only {
@@ -456,6 +495,28 @@ impl Config {
456 }) 495 })
457 } 496 }
458 497
498 /// Get parsed repository whitelist configuration
499 ///
500 /// Throws error if repository_whitelist is set together with archive_read_only=true
501 pub fn repository_config(&self) -> Result<RepositoryConfig> {
502 let whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist)?;
503
504 // Validate incompatible configurations
505 if !whitelist.is_empty() {
506 let archive_config = self.archive_config()?;
507 if archive_config.read_only {
508 return Err(anyhow!(
509 "NGIT_REPOSITORY_WHITELIST cannot be used with NGIT_ARCHIVE_READ_ONLY=true. \
510 Archive read-only mode rejects announcements that don't match the archive whitelist, \
511 regardless of service listing. Either set NGIT_ARCHIVE_READ_ONLY=false or use \
512 NGIT_ARCHIVE_WHITELIST instead of NGIT_REPOSITORY_WHITELIST."
513 ));
514 }
515 }
516
517 Ok(RepositoryConfig { whitelist })
518 }
519
459 /// Create config for testing 520 /// Create config for testing
460 #[cfg(test)] 521 #[cfg(test)]
461 pub fn for_testing() -> Self { 522 pub fn for_testing() -> Self {
@@ -489,6 +550,7 @@ impl Config {
489 archive_all: false, 550 archive_all: false,
490 archive_whitelist: String::new(), 551 archive_whitelist: String::new(),
491 archive_read_only: None, 552 archive_read_only: None,
553 repository_whitelist: String::new(),
492 } 554 }
493 } 555 }
494} 556}
@@ -629,9 +691,9 @@ mod tests {
629 // Generate a valid test npub 691 // Generate a valid test npub
630 let keys = Keys::generate(); 692 let keys = Keys::generate();
631 let test_npub = keys.public_key().to_bech32().unwrap(); 693 let test_npub = keys.public_key().to_bech32().unwrap();
632 let entry = ArchiveWhitelistEntry::parse(&test_npub).unwrap(); 694 let entry = WhitelistEntry::parse(&test_npub).unwrap();
633 assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_))); 695 assert!(matches!(entry, WhitelistEntry::Pubkey(_)));
634 if let ArchiveWhitelistEntry::Pubkey(npub) = entry { 696 if let WhitelistEntry::Pubkey(npub) = entry {
635 assert_eq!(npub, test_npub); 697 assert_eq!(npub, test_npub);
636 } 698 }
637 } 699 }
@@ -640,9 +702,9 @@ mod tests {
640 fn test_parse_whitelist_entry_repository() { 702 fn test_parse_whitelist_entry_repository() {
641 let keys = Keys::generate(); 703 let keys = Keys::generate();
642 let test_npub = keys.public_key().to_bech32().unwrap(); 704 let test_npub = keys.public_key().to_bech32().unwrap();
643 let entry = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap(); 705 let entry = WhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
644 assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. })); 706 assert!(matches!(entry, WhitelistEntry::Repository { .. }));
645 if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry { 707 if let WhitelistEntry::Repository { npub, identifier } = entry {
646 assert_eq!(npub, test_npub); 708 assert_eq!(npub, test_npub);
647 assert_eq!(identifier, "linux"); 709 assert_eq!(identifier, "linux");
648 } 710 }
@@ -650,16 +712,16 @@ mod tests {
650 712
651 #[test] 713 #[test]
652 fn test_parse_whitelist_entry_identifier() { 714 fn test_parse_whitelist_entry_identifier() {
653 let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap(); 715 let entry = WhitelistEntry::parse("bitcoin-core").unwrap();
654 assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_))); 716 assert!(matches!(entry, WhitelistEntry::Identifier(_)));
655 if let ArchiveWhitelistEntry::Identifier(id) = entry { 717 if let WhitelistEntry::Identifier(id) = entry {
656 assert_eq!(id, "bitcoin-core"); 718 assert_eq!(id, "bitcoin-core");
657 } 719 }
658 } 720 }
659 721
660 #[test] 722 #[test]
661 fn test_parse_whitelist_entry_invalid_npub() { 723 fn test_parse_whitelist_entry_invalid_npub() {
662 let result = ArchiveWhitelistEntry::parse("npub1invalid"); 724 let result = WhitelistEntry::parse("npub1invalid");
663 assert!(result.is_err()); 725 assert!(result.is_err());
664 } 726 }
665 727
@@ -667,7 +729,7 @@ mod tests {
667 fn test_whitelist_entry_matches() { 729 fn test_whitelist_entry_matches() {
668 let keys = Keys::generate(); 730 let keys = Keys::generate();
669 let test_npub = keys.public_key().to_bech32().unwrap(); 731 let test_npub = keys.public_key().to_bech32().unwrap();
670 let entry = ArchiveWhitelistEntry::Pubkey(test_npub.clone()); 732 let entry = WhitelistEntry::Pubkey(test_npub.clone());
671 assert!(entry.matches(&test_npub, "any-identifier")); 733 assert!(entry.matches(&test_npub, "any-identifier"));
672 assert!(!entry.matches("npub1different", "any-identifier")); 734 assert!(!entry.matches("npub1different", "any-identifier"));
673 } 735 }
@@ -676,7 +738,7 @@ mod tests {
676 fn test_whitelist_entry_matches_repository() { 738 fn test_whitelist_entry_matches_repository() {
677 let keys = Keys::generate(); 739 let keys = Keys::generate();
678 let test_npub = keys.public_key().to_bech32().unwrap(); 740 let test_npub = keys.public_key().to_bech32().unwrap();
679 let entry = ArchiveWhitelistEntry::Repository { 741 let entry = WhitelistEntry::Repository {
680 npub: test_npub.clone(), 742 npub: test_npub.clone(),
681 identifier: "linux".to_string(), 743 identifier: "linux".to_string(),
682 }; 744 };
@@ -687,7 +749,7 @@ mod tests {
687 749
688 #[test] 750 #[test]
689 fn test_whitelist_entry_matches_identifier() { 751 fn test_whitelist_entry_matches_identifier() {
690 let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string()); 752 let entry = WhitelistEntry::Identifier("bitcoin-core".to_string());
691 assert!(entry.matches("npub1alice", "bitcoin-core")); 753 assert!(entry.matches("npub1alice", "bitcoin-core"));
692 assert!(entry.matches("npub1bob", "bitcoin-core")); 754 assert!(entry.matches("npub1bob", "bitcoin-core"));
693 assert!(!entry.matches("npub1alice", "other-repo")); 755 assert!(!entry.matches("npub1alice", "other-repo"));
@@ -707,7 +769,7 @@ mod tests {
707 769
708 let config = ArchiveConfig { 770 let config = ArchiveConfig {
709 archive_all: false, 771 archive_all: false,
710 whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], 772 whitelist: vec![WhitelistEntry::Identifier("test".into())],
711 read_only: true, 773 read_only: true,
712 }; 774 };
713 assert!(config.enabled()); 775 assert!(config.enabled());
@@ -720,8 +782,8 @@ mod tests {
720 let config = ArchiveConfig { 782 let config = ArchiveConfig {
721 archive_all: false, 783 archive_all: false,
722 whitelist: vec![ 784 whitelist: vec![
723 ArchiveWhitelistEntry::Pubkey(test_npub.clone()), 785 WhitelistEntry::Pubkey(test_npub.clone()),
724 ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), 786 WhitelistEntry::Identifier("bitcoin-core".into()),
725 ], 787 ],
726 read_only: false, 788 read_only: false,
727 }; 789 };
@@ -745,10 +807,10 @@ mod tests {
745 807
746 #[test] 808 #[test]
747 fn test_parse_whitelist_empty() { 809 fn test_parse_whitelist_empty() {
748 let whitelist = ArchiveConfig::parse_whitelist("").unwrap(); 810 let whitelist = WhitelistEntry::parse_whitelist("").unwrap();
749 assert!(whitelist.is_empty()); 811 assert!(whitelist.is_empty());
750 812
751 let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap(); 813 let whitelist = WhitelistEntry::parse_whitelist(" ").unwrap();
752 assert!(whitelist.is_empty()); 814 assert!(whitelist.is_empty());
753 } 815 }
754 816
@@ -758,7 +820,7 @@ mod tests {
758 let keys2 = Keys::generate(); 820 let keys2 = Keys::generate();
759 let test_npub1 = keys1.public_key().to_bech32().unwrap(); 821 let test_npub1 = keys1.public_key().to_bech32().unwrap();
760 let test_npub2 = keys2.public_key().to_bech32().unwrap(); 822 let test_npub2 = keys2.public_key().to_bech32().unwrap();
761 let whitelist = ArchiveConfig::parse_whitelist(&format!( 823 let whitelist = WhitelistEntry::parse_whitelist(&format!(
762 "{},bitcoin-core,{}/linux", 824 "{},bitcoin-core,{}/linux",
763 test_npub1, test_npub2 825 test_npub1, test_npub2
764 )) 826 ))
@@ -850,4 +912,72 @@ mod tests {
850 .to_string() 912 .to_string()
851 .contains("requires either")); 913 .contains("requires either"));
852 } 914 }
915
916 #[test]
917 fn test_repository_whitelist_parsing() {
918 let keys = Keys::generate();
919 let test_npub = keys.public_key().to_bech32().unwrap();
920 let config = Config {
921 repository_whitelist: format!("{},bitcoin-core", test_npub),
922 ..Config::for_testing()
923 };
924 let repo_config = config.repository_config().unwrap();
925 assert_eq!(repo_config.whitelist.len(), 2);
926 assert!(repo_config.enabled());
927 }
928
929 #[test]
930 fn test_repository_whitelist_empty() {
931 let config = Config::for_testing();
932 let repo_config = config.repository_config().unwrap();
933 assert!(repo_config.whitelist.is_empty());
934 assert!(!repo_config.enabled());
935 }
936
937 #[test]
938 fn test_repository_whitelist_incompatible_with_archive_read_only() {
939 let keys = Keys::generate();
940 let test_npub = keys.public_key().to_bech32().unwrap();
941 let config = Config {
942 archive_all: true,
943 archive_read_only: Some(true),
944 repository_whitelist: test_npub,
945 ..Config::for_testing()
946 };
947 let result = config.repository_config();
948 assert!(result.is_err());
949 let err = result.unwrap_err().to_string();
950 assert!(err.contains("cannot be used with"));
951 assert!(err.contains("NGIT_ARCHIVE_READ_ONLY=true"));
952 }
953
954 #[test]
955 fn test_repository_whitelist_compatible_with_archive_read_only_false() {
956 let keys = Keys::generate();
957 let test_npub = keys.public_key().to_bech32().unwrap();
958 let config = Config {
959 archive_all: true,
960 archive_read_only: Some(false),
961 repository_whitelist: test_npub,
962 ..Config::for_testing()
963 };
964 // Should not error
965 assert!(config.repository_config().is_ok());
966 }
967
968 #[test]
969 fn test_repository_config_matches() {
970 let keys = Keys::generate();
971 let test_npub = keys.public_key().to_bech32().unwrap();
972 let config = RepositoryConfig {
973 whitelist: vec![
974 WhitelistEntry::Pubkey(test_npub.clone()),
975 WhitelistEntry::Identifier("bitcoin-core".into()),
976 ],
977 };
978
979 assert!(config.matches(&test_npub, "any-repo"));
980 assert!(config.matches("npub1bob", "bitcoin-core"));
981 assert!(!config.matches("npub1bob", "other-repo"));
982 }
853} 983}