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 17:40:25 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 17:40:25 +0000
commitc29191b1e1239e931c575a926ec9480e594476d6 (patch)
tree6fcb776ba34b6fab766ceb613997b07b18e780df /src/config.rs
parent2b8992631b9dedcfd4ea44e8565b14ac8a5ed8ea (diff)
feat(grasp-05): implement archive mode for backup/mirror operation
Implements GRASP-05 specification for accepting repository announcements that don't list this relay, enabling archive, mirror, and backup use cases. Core Features: - Three whitelist formats: <npub>, <npub>/<identifier>, <identifier> - Archive-all mode for complete ecosystem mirrors - Fail-fast npub validation at startup - Read-only enforcement (archived repos reject pushes) - Full GRASP-02 sync (git data + Nostr events) - Dynamic archive status (no flags/metadata) Implementation: - Add ArchiveWhitelistEntry enum with Pubkey/Repository/Identifier variants - Add ArchiveConfig with validation and matching logic - Update AnnouncementResult to include AcceptArchive variant - Refactor validate_announcement() to return AnnouncementResult with archive check - Update AnnouncementPolicy with catch-all pattern for cleaner code - Wire archive config through builder and policy layers Configuration: - NGIT_ARCHIVE_ALL: Accept all announcements (⚠️ storage risk) - NGIT_ARCHIVE_WHITELIST: Comma-separated whitelist entries - Updated docs, .env.example, and nix/module.nix Testing: - 28 unit tests for config parsing and whitelist matching - 7 integration tests for archive mode validation - All 296 tests passing Validation Priority: 1. Lists our service → Accept (GRASP-01, read/write) 2. Is maintainer → AcceptMaintainer (multi-maintainer, read/write) 3. Matches archive config → AcceptArchive (GRASP-05, read-only) 4. None of above → Reject Security Considerations: - Archive-all mode has storage/bandwidth DoS risk - Identifier-only format matches any pubkey (use npub/identifier for high-value) - Invalid npubs cause startup failure (fail-fast) Documentation: - Concise explanation focused on rationale - Reference docs updated with all config options - README updated to reflect completed feature - Removed from roadmap, added to compliance section See docs/explanation/grasp-05-archive.md for details.
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs321
1 files changed, 320 insertions, 1 deletions
diff --git a/src/config.rs b/src/config.rs
index 1812fe2..b1ab43e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,10 +1,150 @@
1use anyhow::{Context, Result}; 1use anyhow::{anyhow, Context, Result};
2use clap::{Parser, ValueEnum}; 2use clap::{Parser, ValueEnum};
3use nostr_sdk::prelude::*; 3use nostr_sdk::prelude::*;
4use serde::{Deserialize, Serialize}; 4use 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
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "lowercase")]
11pub enum ArchiveWhitelistEntry {
12 /// Archive all repos from this pubkey: "npub1..."
13 Pubkey(String),
14
15 /// Archive specific repo: "npub1.../identifier"
16 Repository { npub: String, identifier: String },
17
18 /// Archive any repo with this identifier: "identifier"
19 Identifier(String),
20}
21
22impl ArchiveWhitelistEntry {
23 /// Parse a whitelist entry from string
24 ///
25 /// Formats:
26 /// - "npub1..." -> Pubkey
27 /// - "npub1.../identifier" -> Repository
28 /// - "identifier" -> Identifier
29 ///
30 /// Validates npub format at parse time (fail fast)
31 pub fn parse(s: &str) -> Result<Self> {
32 let trimmed = s.trim();
33
34 if trimmed.contains('/') {
35 // Format: npub1.../identifier
36 let parts: Vec<&str> = trimmed.split('/').collect();
37 if parts.len() != 2 {
38 return Err(anyhow!(
39 "Invalid whitelist entry format '{}'. Expected 'npub/identifier'",
40 s
41 ));
42 }
43
44 let npub = parts[0];
45 let identifier = parts[1];
46
47 // Validate npub format (fail fast)
48 if !npub.starts_with("npub1") {
49 return Err(anyhow!(
50 "Invalid whitelist entry '{}'. First part must be npub",
51 s
52 ));
53 }
54
55 PublicKey::from_bech32(npub)
56 .context(format!("Invalid npub in whitelist entry '{}'", s))?;
57
58 Ok(Self::Repository {
59 npub: npub.to_string(),
60 identifier: identifier.to_string(),
61 })
62 } else if trimmed.starts_with("npub1") {
63 // Format: npub1...
64 // Validate npub format (fail fast)
65 PublicKey::from_bech32(trimmed)
66 .context(format!("Invalid npub in whitelist entry '{}'", s))?;
67
68 Ok(Self::Pubkey(trimmed.to_string()))
69 } else {
70 // Format: identifier
71 Ok(Self::Identifier(trimmed.to_string()))
72 }
73 }
74
75 /// Check if this entry matches the given npub and identifier
76 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
77 match self {
78 Self::Pubkey(p) => npub == p,
79 Self::Repository {
80 npub: p,
81 identifier: i,
82 } => npub == p && identifier == i,
83 Self::Identifier(i) => identifier == i,
84 }
85 }
86}
87
88/// GRASP-05 Archive mode configuration
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ArchiveConfig {
91 /// Accept all repository announcements (no filtering)
92 ///
93 /// WARNING: Setting this to true allows anyone to mirror any repository
94 /// to this relay, potentially causing storage/bandwidth exhaustion.
95 pub archive_all: bool,
96
97 /// Whitelist entries for selective archiving
98 ///
99 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
100 pub whitelist: Vec<ArchiveWhitelistEntry>,
101}
102
103impl ArchiveConfig {
104 /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist)
105 pub fn enabled(&self) -> bool {
106 self.archive_all || !self.whitelist.is_empty()
107 }
108
109 /// Check if an announcement matches the archive configuration
110 ///
111 /// Returns true if:
112 /// - archive_all is true, OR
113 /// - announcement matches any whitelist entry
114 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
115 if self.archive_all {
116 return true;
117 }
118
119 self.whitelist
120 .iter()
121 .any(|entry| entry.matches(npub, identifier))
122 }
123
124 /// Parse archive whitelist from comma-separated string
125 pub fn parse_whitelist(input: &str) -> Result<Vec<ArchiveWhitelistEntry>> {
126 if input.trim().is_empty() {
127 return Ok(Vec::new());
128 }
129
130 input
131 .split(',')
132 .map(|s| s.trim())
133 .filter(|s| !s.is_empty())
134 .map(ArchiveWhitelistEntry::parse)
135 .collect()
136 }
137}
138
139impl Default for ArchiveConfig {
140 fn default() -> Self {
141 Self {
142 archive_all: false,
143 whitelist: Vec::new(),
144 }
145 }
146}
147
8/// Database backend type for the relay 148/// Database backend type for the relay
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] 149#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
10#[serde(rename_all = "lowercase")] 150#[serde(rename_all = "lowercase")]
@@ -162,6 +302,15 @@ pub struct Config {
162 /// tracked separately and retried after this expiration period. 302 /// tracked separately and retried after this expiration period.
163 #[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)] 303 #[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)]
164 pub naughty_list_expiration_hours: u64, 304 pub naughty_list_expiration_hours: u64,
305
306 /// Enable GRASP-05 archive mode: accept all announcements regardless of listing (WARNING: storage risk)
307 #[arg(long, env = "NGIT_ARCHIVE_ALL", default_value_t = false)]
308 pub archive_all: bool,
309
310 /// GRASP-05 archive whitelist: comma-separated list of npub/identifier/npub/identifier entries
311 /// Formats: "npub1...", "npub1.../identifier", "identifier"
312 #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
313 pub archive_whitelist: String,
165} 314}
166 315
167impl Config { 316impl Config {
@@ -262,6 +411,15 @@ impl Config {
262 } 411 }
263 } 412 }
264 413
414 /// Get parsed archive configuration
415 pub fn archive_config(&self) -> Result<ArchiveConfig> {
416 let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
417 Ok(ArchiveConfig {
418 archive_all: self.archive_all,
419 whitelist,
420 })
421 }
422
265 /// Create config for testing 423 /// Create config for testing
266 #[cfg(test)] 424 #[cfg(test)]
267 pub fn for_testing() -> Self { 425 pub fn for_testing() -> Self {
@@ -292,6 +450,8 @@ impl Config {
292 rejected_hot_cache_duration_secs: 120, 450 rejected_hot_cache_duration_secs: 120,
293 rejected_cold_index_expiry_secs: 604800, 451 rejected_cold_index_expiry_secs: 604800,
294 naughty_list_expiration_hours: 12, 452 naughty_list_expiration_hours: 12,
453 archive_all: false,
454 archive_whitelist: String::new(),
295 } 455 }
296 } 456 }
297} 457}
@@ -426,4 +586,163 @@ mod tests {
426 assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50); 586 assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50);
427 assert_eq!(config.metrics_top_n_repos, 25); 587 assert_eq!(config.metrics_top_n_repos, 25);
428 } 588 }
589
590 #[test]
591 fn test_parse_whitelist_entry_pubkey() {
592 // Generate a valid test npub
593 let keys = Keys::generate();
594 let test_npub = keys.public_key().to_bech32().unwrap();
595 let entry = ArchiveWhitelistEntry::parse(&test_npub).unwrap();
596 assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_)));
597 if let ArchiveWhitelistEntry::Pubkey(npub) = entry {
598 assert_eq!(npub, test_npub);
599 }
600 }
601
602 #[test]
603 fn test_parse_whitelist_entry_repository() {
604 let keys = Keys::generate();
605 let test_npub = keys.public_key().to_bech32().unwrap();
606 let entry = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
607 assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. }));
608 if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry {
609 assert_eq!(npub, test_npub);
610 assert_eq!(identifier, "linux");
611 }
612 }
613
614 #[test]
615 fn test_parse_whitelist_entry_identifier() {
616 let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap();
617 assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_)));
618 if let ArchiveWhitelistEntry::Identifier(id) = entry {
619 assert_eq!(id, "bitcoin-core");
620 }
621 }
622
623 #[test]
624 fn test_parse_whitelist_entry_invalid_npub() {
625 let result = ArchiveWhitelistEntry::parse("npub1invalid");
626 assert!(result.is_err());
627 }
628
629 #[test]
630 fn test_whitelist_entry_matches() {
631 let keys = Keys::generate();
632 let test_npub = keys.public_key().to_bech32().unwrap();
633 let entry = ArchiveWhitelistEntry::Pubkey(test_npub.clone());
634 assert!(entry.matches(&test_npub, "any-identifier"));
635 assert!(!entry.matches("npub1different", "any-identifier"));
636 }
637
638 #[test]
639 fn test_whitelist_entry_matches_repository() {
640 let keys = Keys::generate();
641 let test_npub = keys.public_key().to_bech32().unwrap();
642 let entry = ArchiveWhitelistEntry::Repository {
643 npub: test_npub.clone(),
644 identifier: "linux".to_string(),
645 };
646 assert!(entry.matches(&test_npub, "linux"));
647 assert!(!entry.matches(&test_npub, "bitcoin"));
648 assert!(!entry.matches("npub1different", "linux"));
649 }
650
651 #[test]
652 fn test_whitelist_entry_matches_identifier() {
653 let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string());
654 assert!(entry.matches("npub1alice", "bitcoin-core"));
655 assert!(entry.matches("npub1bob", "bitcoin-core"));
656 assert!(!entry.matches("npub1alice", "other-repo"));
657 }
658
659 #[test]
660 fn test_archive_config_enabled() {
661 let config = ArchiveConfig::default();
662 assert!(!config.enabled());
663
664 let config = ArchiveConfig {
665 archive_all: true,
666 whitelist: Vec::new(),
667 };
668 assert!(config.enabled());
669
670 let config = ArchiveConfig {
671 archive_all: false,
672 whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
673 };
674 assert!(config.enabled());
675 }
676
677 #[test]
678 fn test_archive_config_matches() {
679 let keys = Keys::generate();
680 let test_npub = keys.public_key().to_bech32().unwrap();
681 let config = ArchiveConfig {
682 archive_all: false,
683 whitelist: vec![
684 ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
685 ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
686 ],
687 };
688
689 assert!(config.matches(&test_npub, "any-repo"));
690 assert!(config.matches("npub1bob", "bitcoin-core"));
691 assert!(!config.matches("npub1bob", "other-repo"));
692 }
693
694 #[test]
695 fn test_archive_config_matches_archive_all() {
696 let config = ArchiveConfig {
697 archive_all: true,
698 whitelist: Vec::new(),
699 };
700
701 assert!(config.matches("npub1alice", "any-repo"));
702 assert!(config.matches("npub1bob", "other-repo"));
703 }
704
705 #[test]
706 fn test_parse_whitelist_empty() {
707 let whitelist = ArchiveConfig::parse_whitelist("").unwrap();
708 assert!(whitelist.is_empty());
709
710 let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap();
711 assert!(whitelist.is_empty());
712 }
713
714 #[test]
715 fn test_parse_whitelist_multiple() {
716 let keys1 = Keys::generate();
717 let keys2 = Keys::generate();
718 let test_npub1 = keys1.public_key().to_bech32().unwrap();
719 let test_npub2 = keys2.public_key().to_bech32().unwrap();
720 let whitelist = ArchiveConfig::parse_whitelist(&format!(
721 "{},bitcoin-core,{}/linux",
722 test_npub1, test_npub2
723 ))
724 .unwrap();
725 assert_eq!(whitelist.len(), 3);
726 }
727
728 #[test]
729 fn test_archive_config_parsing() {
730 let keys = Keys::generate();
731 let test_npub = keys.public_key().to_bech32().unwrap();
732 let config = Config {
733 archive_whitelist: format!("{},bitcoin-core", test_npub),
734 ..Config::for_testing()
735 };
736 let archive_config = config.archive_config().unwrap();
737 assert_eq!(archive_config.whitelist.len(), 2);
738 }
739
740 #[test]
741 fn test_archive_config_invalid_npub() {
742 let config = Config {
743 archive_whitelist: "npub1invalid".to_string(),
744 ..Config::for_testing()
745 };
746 assert!(config.archive_config().is_err());
747 }
429} 748}