upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-21 13:28:37 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-21 13:38:11 +0000
commit46fbcc0a4c8a8dbf6cd345d6eaa6fe33a82100bb (patch)
tree6ab52486732077dbab80907d974c195b1e2f7216 /src
parent780d09b0c1eb823f02fc61de6dbf99b2d5cefaca (diff)
feat: add archive-grasp-services configuration option
Enables relay operators to backup/archive specific GRASP servers by domain. Includes configuration, validation, documentation, and integration tests.
Diffstat (limited to 'src')
-rw-r--r--src/config.rs265
-rw-r--r--src/nostr/events.rs11
2 files changed, 269 insertions, 7 deletions
diff --git a/src/config.rs b/src/config.rs
index 0a867e3..4b1e8d9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -122,6 +122,11 @@ pub struct ArchiveConfig {
122 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). 122 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
123 pub whitelist: Vec<WhitelistEntry>, 123 pub whitelist: Vec<WhitelistEntry>,
124 124
125 /// GRASP server domains to archive (archive all repositories from these domains)
126 ///
127 /// If non-empty, archives all repositories from the specified GRASP server domains.
128 pub grasp_services: Vec<String>,
129
125 /// Read-only archive mode: relay is a read-only sync of archived repositories 130 /// Read-only archive mode: relay is a read-only sync of archived repositories
126 /// 131 ///
127 /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. 132 /// When true, the relay ONLY accepts announcements matching the archive whitelist/all.
@@ -131,9 +136,9 @@ pub struct ArchiveConfig {
131} 136}
132 137
133impl ArchiveConfig { 138impl ArchiveConfig {
134 /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist) 139 /// Check if GRASP-05 is enabled (either archive_all, non-empty whitelist, or non-empty grasp_services)
135 pub fn enabled(&self) -> bool { 140 pub fn enabled(&self) -> bool {
136 self.archive_all || !self.whitelist.is_empty() 141 self.archive_all || !self.whitelist.is_empty() || !self.grasp_services.is_empty()
137 } 142 }
138 143
139 /// Check if an announcement matches the archive configuration 144 /// Check if an announcement matches the archive configuration
@@ -141,6 +146,7 @@ impl ArchiveConfig {
141 /// Returns true if: 146 /// Returns true if:
142 /// - archive_all is true, OR 147 /// - archive_all is true, OR
143 /// - announcement matches any whitelist entry 148 /// - announcement matches any whitelist entry
149 /// Note: grasp_services matching is handled via matches_grasp_services()
144 pub fn matches(&self, npub: &str, identifier: &str) -> bool { 150 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
145 if self.archive_all { 151 if self.archive_all {
146 return true; 152 return true;
@@ -150,6 +156,19 @@ impl ArchiveConfig {
150 .iter() 156 .iter()
151 .any(|entry| entry.matches(npub, identifier)) 157 .any(|entry| entry.matches(npub, identifier))
152 } 158 }
159
160 /// Check if any of the given domains match the configured grasp_services
161 ///
162 /// Returns true if any domain in the list matches any configured grasp_services entry.
163 pub fn matches_grasp_services(&self, domains: &[String]) -> bool {
164 if self.grasp_services.is_empty() {
165 return false;
166 }
167
168 domains
169 .iter()
170 .any(|domain| self.grasp_services.iter().any(|service| service == domain))
171 }
153} 172}
154 173
155impl Default for ArchiveConfig { 174impl Default for ArchiveConfig {
@@ -157,6 +176,7 @@ impl Default for ArchiveConfig {
157 Self { 176 Self {
158 archive_all: false, 177 archive_all: false,
159 whitelist: Vec::new(), 178 whitelist: Vec::new(),
179 grasp_services: Vec::new(),
160 read_only: false, 180 read_only: false,
161 } 181 }
162 } 182 }
@@ -447,9 +467,15 @@ pub struct Config {
447 #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] 467 #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
448 pub archive_whitelist: String, 468 pub archive_whitelist: String,
449 469
470 /// GRASP-05 archive GRASP services: comma-separated list of GRASP server domains to archive
471 /// When set, archives all repositories from the specified GRASP server domains
472 /// Mutually exclusive with archive_all and archive_whitelist
473 #[arg(long, env = "NGIT_ARCHIVE_GRASP_SERVICES", default_value = "")]
474 pub archive_grasp_services: String,
475
450 /// Archive read-only mode: relay is a read-only sync of archived repositories 476 /// Archive read-only mode: relay is a read-only sync of archived repositories
451 /// Defaults to true if archive_all or archive_whitelist is set, false otherwise 477 /// Defaults to true if archive_all, archive_whitelist, or archive_grasp_services is set, false otherwise
452 /// Throws error if set to true without archive_all or archive_whitelist 478 /// Throws error if set to true without archive_all, archive_whitelist, or archive_grasp_services
453 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] 479 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")]
454 pub archive_read_only: Option<bool>, 480 pub archive_read_only: Option<bool>,
455 481
@@ -589,13 +615,32 @@ impl Config {
589 615
590 // Validate archive configuration 616 // Validate archive configuration
591 let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); 617 let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist);
592 let archive_enabled = self.archive_all || !archive_whitelist.is_empty(); 618 let archive_grasp_services = self.parse_archive_grasp_services();
619 let archive_enabled =
620 self.archive_all || !archive_whitelist.is_empty() || !archive_grasp_services.is_empty();
621
622 // Fatal error: archive_grasp_services cannot be used with archive_all or archive_whitelist
623 if !archive_grasp_services.is_empty() {
624 if self.archive_all {
625 return Err(anyhow!(
626 "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_ALL=true. \
627 These options are mutually exclusive."
628 ));
629 }
630 if !archive_whitelist.is_empty() {
631 return Err(anyhow!(
632 "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_WHITELIST. \
633 These options are mutually exclusive."
634 ));
635 }
636 }
593 637
594 // Fatal error: archive_read_only=true without archive mode enabled 638 // Fatal error: archive_read_only=true without archive mode enabled
595 if let Some(true) = self.archive_read_only { 639 if let Some(true) = self.archive_read_only {
596 if !archive_enabled { 640 if !archive_enabled {
597 return Err(anyhow!( 641 return Err(anyhow!(
598 "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" 642 "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true, \
643 NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set"
599 )); 644 ));
600 } 645 }
601 } 646 }
@@ -619,13 +664,32 @@ impl Config {
619 Ok(()) 664 Ok(())
620 } 665 }
621 666
667 /// Parse archive GRASP services from comma-separated string
668 ///
669 /// Returns a list of domain names (GRASP server domains to archive).
670 /// Whitespace is trimmed and empty entries are ignored.
671 pub fn parse_archive_grasp_services(&self) -> Vec<String> {
672 if self.archive_grasp_services.trim().is_empty() {
673 return Vec::new();
674 }
675
676 self.archive_grasp_services
677 .split(',')
678 .map(|s| s.trim())
679 .filter(|s| !s.is_empty())
680 .map(|s| s.to_string())
681 .collect()
682 }
683
622 /// Get parsed archive configuration with computed read-only mode 684 /// Get parsed archive configuration with computed read-only mode
623 /// 685 ///
624 /// Read-only mode defaults to true if archive mode is enabled, false otherwise. 686 /// Read-only mode defaults to true if archive mode is enabled, false otherwise.
625 /// This method assumes config has been validated - call Config::validate() first! 687 /// This method assumes config has been validated - call Config::validate() first!
626 pub fn archive_config(&self) -> ArchiveConfig { 688 pub fn archive_config(&self) -> ArchiveConfig {
627 let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); 689 let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist);
628 let archive_enabled = self.archive_all || !whitelist.is_empty(); 690 let archive_grasp_services = self.parse_archive_grasp_services();
691 let archive_enabled =
692 self.archive_all || !whitelist.is_empty() || !archive_grasp_services.is_empty();
629 693
630 let read_only = match self.archive_read_only { 694 let read_only = match self.archive_read_only {
631 Some(true) => true, // Already validated in validate() 695 Some(true) => true, // Already validated in validate()
@@ -639,6 +703,7 @@ impl Config {
639 ArchiveConfig { 703 ArchiveConfig {
640 archive_all: self.archive_all, 704 archive_all: self.archive_all,
641 whitelist, 705 whitelist,
706 grasp_services: archive_grasp_services,
642 read_only, 707 read_only,
643 } 708 }
644 } 709 }
@@ -705,6 +770,7 @@ impl Config {
705 naughty_list_expiration_hours: 12, 770 naughty_list_expiration_hours: 12,
706 archive_all: false, 771 archive_all: false,
707 archive_whitelist: String::new(), 772 archive_whitelist: String::new(),
773 archive_grasp_services: String::new(),
708 archive_read_only: None, 774 archive_read_only: None,
709 repository_whitelist: String::new(), 775 repository_whitelist: String::new(),
710 repository_blacklist: String::new(), 776 repository_blacklist: String::new(),
@@ -936,6 +1002,7 @@ mod tests {
936 let config = ArchiveConfig { 1002 let config = ArchiveConfig {
937 archive_all: true, 1003 archive_all: true,
938 whitelist: Vec::new(), 1004 whitelist: Vec::new(),
1005 grasp_services: Vec::new(),
939 read_only: true, 1006 read_only: true,
940 }; 1007 };
941 assert!(config.enabled()); 1008 assert!(config.enabled());
@@ -943,6 +1010,7 @@ mod tests {
943 let config = ArchiveConfig { 1010 let config = ArchiveConfig {
944 archive_all: false, 1011 archive_all: false,
945 whitelist: vec![WhitelistEntry::Identifier("test".into())], 1012 whitelist: vec![WhitelistEntry::Identifier("test".into())],
1013 grasp_services: Vec::new(),
946 read_only: true, 1014 read_only: true,
947 }; 1015 };
948 assert!(config.enabled()); 1016 assert!(config.enabled());
@@ -958,6 +1026,7 @@ mod tests {
958 WhitelistEntry::Pubkey(test_npub.clone()), 1026 WhitelistEntry::Pubkey(test_npub.clone()),
959 WhitelistEntry::Identifier("bitcoin-core".into()), 1027 WhitelistEntry::Identifier("bitcoin-core".into()),
960 ], 1028 ],
1029 grasp_services: Vec::new(),
961 read_only: false, 1030 read_only: false,
962 }; 1031 };
963 1032
@@ -971,6 +1040,7 @@ mod tests {
971 let config = ArchiveConfig { 1040 let config = ArchiveConfig {
972 archive_all: true, 1041 archive_all: true,
973 whitelist: Vec::new(), 1042 whitelist: Vec::new(),
1043 grasp_services: Vec::new(),
974 read_only: true, 1044 read_only: true,
975 }; 1045 };
976 1046
@@ -1379,4 +1449,185 @@ mod tests {
1379 let result = config.check(&allowed_npub); 1449 let result = config.check(&allowed_npub);
1380 assert!(result.is_none()); 1450 assert!(result.is_none());
1381 } 1451 }
1452
1453 #[test]
1454 fn test_parse_archive_grasp_services_empty() {
1455 let config = Config::for_testing();
1456 let services = config.parse_archive_grasp_services();
1457 assert!(services.is_empty());
1458
1459 let config = Config {
1460 archive_grasp_services: " ".to_string(),
1461 ..Config::for_testing()
1462 };
1463 let services = config.parse_archive_grasp_services();
1464 assert!(services.is_empty());
1465 }
1466
1467 #[test]
1468 fn test_parse_archive_grasp_services_single() {
1469 let config = Config {
1470 archive_grasp_services: "git.example.com".to_string(),
1471 ..Config::for_testing()
1472 };
1473 let services = config.parse_archive_grasp_services();
1474 assert_eq!(services.len(), 1);
1475 assert_eq!(services[0], "git.example.com");
1476 }
1477
1478 #[test]
1479 fn test_parse_archive_grasp_services_multiple() {
1480 let config = Config {
1481 archive_grasp_services: "git.example.com,git.nostr.dev,relay.gitnostr.com".to_string(),
1482 ..Config::for_testing()
1483 };
1484 let services = config.parse_archive_grasp_services();
1485 assert_eq!(services.len(), 3);
1486 assert_eq!(services[0], "git.example.com");
1487 assert_eq!(services[1], "git.nostr.dev");
1488 assert_eq!(services[2], "relay.gitnostr.com");
1489 }
1490
1491 #[test]
1492 fn test_parse_archive_grasp_services_with_whitespace() {
1493 let config = Config {
1494 archive_grasp_services: " git.example.com , git.nostr.dev , relay.gitnostr.com "
1495 .to_string(),
1496 ..Config::for_testing()
1497 };
1498 let services = config.parse_archive_grasp_services();
1499 assert_eq!(services.len(), 3);
1500 assert_eq!(services[0], "git.example.com");
1501 assert_eq!(services[1], "git.nostr.dev");
1502 assert_eq!(services[2], "relay.gitnostr.com");
1503 }
1504
1505 #[test]
1506 fn test_archive_grasp_services_validation_error_with_archive_all() {
1507 let config = Config {
1508 archive_all: true,
1509 archive_grasp_services: "git.example.com".to_string(),
1510 ..Config::for_testing()
1511 };
1512 let result = config.validate();
1513 assert!(result.is_err());
1514 let err = result.unwrap_err().to_string();
1515 assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES"));
1516 assert!(err.contains("NGIT_ARCHIVE_ALL"));
1517 assert!(err.contains("mutually exclusive"));
1518 }
1519
1520 #[test]
1521 fn test_archive_grasp_services_validation_error_with_archive_whitelist() {
1522 let keys = Keys::generate();
1523 let test_npub = keys.public_key().to_bech32().unwrap();
1524 let config = Config {
1525 archive_whitelist: test_npub,
1526 archive_grasp_services: "git.example.com".to_string(),
1527 ..Config::for_testing()
1528 };
1529 let result = config.validate();
1530 assert!(result.is_err());
1531 let err = result.unwrap_err().to_string();
1532 assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES"));
1533 assert!(err.contains("NGIT_ARCHIVE_WHITELIST"));
1534 assert!(err.contains("mutually exclusive"));
1535 }
1536
1537 #[test]
1538 fn test_archive_grasp_services_enables_archive_mode() {
1539 let config = Config {
1540 archive_grasp_services: "git.example.com".to_string(),
1541 ..Config::for_testing()
1542 };
1543 let archive_config = config.archive_config();
1544 assert!(archive_config.enabled());
1545 assert_eq!(archive_config.read_only, true); // Default to true
1546 }
1547
1548 #[test]
1549 fn test_archive_grasp_services_read_only_default() {
1550 // Default: true when archive_grasp_services is set
1551 let config = Config {
1552 archive_grasp_services: "git.example.com".to_string(),
1553 ..Config::for_testing()
1554 };
1555 assert_eq!(config.archive_config().read_only, true);
1556 }
1557
1558 #[test]
1559 fn test_archive_grasp_services_read_only_explicit_false() {
1560 // Explicit false should be respected
1561 let config = Config {
1562 archive_grasp_services: "git.example.com".to_string(),
1563 archive_read_only: Some(false),
1564 ..Config::for_testing()
1565 };
1566 assert_eq!(config.archive_config().read_only, false);
1567 }
1568
1569 #[test]
1570 fn test_archive_read_only_validation_with_grasp_services() {
1571 // Should succeed with archive_grasp_services set
1572 let config = Config {
1573 archive_grasp_services: "git.example.com".to_string(),
1574 archive_read_only: Some(true),
1575 ..Config::for_testing()
1576 };
1577 assert!(config.validate().is_ok());
1578 }
1579
1580 #[test]
1581 fn test_archive_config_matches_grasp_services() {
1582 let config = ArchiveConfig {
1583 archive_all: false,
1584 whitelist: Vec::new(),
1585 grasp_services: vec!["git.example.com".to_string(), "gitlab.org".to_string()],
1586 read_only: true,
1587 };
1588
1589 // Should match configured services
1590 assert!(config.matches_grasp_services(&["git.example.com".to_string()]));
1591 assert!(config.matches_grasp_services(&["gitlab.org".to_string()]));
1592
1593 // Should not match unconfigured services
1594 assert!(!config.matches_grasp_services(&["github.com".to_string()]));
1595 assert!(!config.matches_grasp_services(&["other.com".to_string()]));
1596 }
1597
1598 #[test]
1599 fn test_archive_config_matches_grasp_services_empty() {
1600 let config = ArchiveConfig {
1601 archive_all: false,
1602 whitelist: Vec::new(),
1603 grasp_services: Vec::new(),
1604 read_only: true,
1605 };
1606
1607 // Should not match anything when grasp_services is empty
1608 assert!(!config.matches_grasp_services(&["git.example.com".to_string()]));
1609 assert!(!config.matches_grasp_services(&[]));
1610 }
1611
1612 #[test]
1613 fn test_archive_config_matches_grasp_services_multiple_domains() {
1614 let config = ArchiveConfig {
1615 archive_all: false,
1616 whitelist: Vec::new(),
1617 grasp_services: vec!["git.example.com".to_string()],
1618 read_only: true,
1619 };
1620
1621 // Should match if any domain matches
1622 assert!(config.matches_grasp_services(&[
1623 "github.com".to_string(),
1624 "git.example.com".to_string(),
1625 "gitlab.org".to_string(),
1626 ]));
1627
1628 // Should not match if no domain matches
1629 assert!(
1630 !config.matches_grasp_services(&["github.com".to_string(), "gitlab.org".to_string(),])
1631 );
1632 }
1382} 1633}
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
index 1d5a50f..718633e 100644
--- a/src/nostr/events.rs
+++ b/src/nostr/events.rs
@@ -436,6 +436,17 @@ pub fn validate_announcement(
436 return AnnouncementResult::AcceptArchive; 436 return AnnouncementResult::AcceptArchive;
437 } 437 }
438 438
439 // GRASP-05: Archive mode - accept if announcement lists any configured GRASP service in clone URLs
440 // Only check clone URLs (not relays) since we're archiving from OTHER services
441 // Check if announcement matches any configured GRASP service domains
442 if archive_config
443 .grasp_services
444 .iter()
445 .any(|service| announcement.has_clone_url(service))
446 {
447 return AnnouncementResult::AcceptArchive;
448 }
449
439 // Reject with appropriate error message 450 // Reject with appropriate error message
440 if archive_config.read_only { 451 if archive_config.read_only {
441 AnnouncementResult::Reject(format!( 452 AnnouncementResult::Reject(format!(