upleb.uk

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

summaryrefslogtreecommitdiff
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
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.
-rw-r--r--.env.example14
-rw-r--r--docs/reference/configuration.md80
-rw-r--r--nix/module.nix17
-rw-r--r--src/config.rs265
-rw-r--r--src/nostr/events.rs11
-rw-r--r--tests/archive_grasp_services.rs378
6 files changed, 753 insertions, 12 deletions
diff --git a/.env.example b/.env.example
index a19a07d..e152b89 100644
--- a/.env.example
+++ b/.env.example
@@ -191,6 +191,18 @@
191# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust 191# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
192# NGIT_ARCHIVE_WHITELIST= 192# NGIT_ARCHIVE_WHITELIST=
193 193
194# Archive GRASP services: comma-separated list of GRASP server domains to archive
195# Archives all repositories from the specified GRASP server domains
196# Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com)
197# Mutually exclusive with NGIT_ARCHIVE_ALL and NGIT_ARCHIVE_WHITELIST
198# Automatically sets NGIT_ARCHIVE_READ_ONLY to true by default
199# CLI: --archive-grasp-services <list>
200# Default: (empty)
201# Examples:
202# NGIT_ARCHIVE_GRASP_SERVICES=git.example.com
203# NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev,relay.gitnostr.com
204# NGIT_ARCHIVE_GRASP_SERVICES=
205
194# Archive read-only mode (relay is read-only sync of archived repositories) 206# Archive read-only mode (relay is read-only sync of archived repositories)
195# When true: 207# When true:
196# - NIP-11 includes GRASP-05 in supported_grasps 208# - NIP-11 includes GRASP-05 in supported_grasps
@@ -200,7 +212,7 @@
200# - Archive mode disabled (standard GRASP-01 operation) 212# - Archive mode disabled (standard GRASP-01 operation)
201# 213#
202# CLI: --archive-read-only 214# CLI: --archive-read-only
203# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise 215# Default: true if NGIT_ARCHIVE_ALL, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES is set, false otherwise
204# Note: Setting to true without archive config causes startup error 216# Note: Setting to true without archive config causes startup error
205# Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive) 217# Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive)
206# NGIT_ARCHIVE_READ_ONLY= 218# NGIT_ARCHIVE_READ_ONLY=
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index c1cb712..b24b498 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -574,11 +574,79 @@ NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
574 574
575--- 575---
576 576
577#### `NGIT_ARCHIVE_GRASP_SERVICES`
578
579**Description:** Comma-separated list of GRASP server domains to archive
580**Type:** String (comma-separated domain names)
581**Default:** (empty)
582**Required:** No
583
584**Format:**
585- `<domain>` - Archive all repositories from this GRASP server domain
586- **Must be bare domains only** (e.g., `git.example.com`, NOT `wss://git.example.com`)
587- Matching extracts domains from announcement clone URLs and compares them exactly (case-sensitive)
588
589**Examples:**
590
591```bash
592# Archive all repos from a single GRASP server
593NGIT_ARCHIVE_GRASP_SERVICES=git.example.com
594
595# Archive repos from multiple GRASP servers
596NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev,relay.gitnostr.com
597
598# Archive from localhost (testing)
599NGIT_ARCHIVE_GRASP_SERVICES=localhost:7334
600```
601
602**Validation:**
603
604- Domain entries must be bare domains without scheme prefixes (ws://, wss://, https://, etc.)
605- Whitespace is trimmed
606- Empty entries are ignored
607- **Mutually exclusive** with `NGIT_ARCHIVE_ALL` and `NGIT_ARCHIVE_WHITELIST`
608
609**Security Notes:**
610
611- Archives ALL repositories from the specified GRASP server domains
612- Use with caution - ensure you trust the GRASP servers you're archiving from
613- Storage requirements depend on the size of repositories on the archived servers
614- Automatically sets `NGIT_ARCHIVE_READ_ONLY=true` by default
615
616**Error Conditions:**
617
618```bash
619# ERROR: Cannot use with NGIT_ARCHIVE_ALL
620NGIT_ARCHIVE_GRASP_SERVICES=git.example.com
621NGIT_ARCHIVE_ALL=true
622# → Server fails to start: "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with
623# NGIT_ARCHIVE_ALL=true. These options are mutually exclusive."
624
625# ERROR: Cannot use with NGIT_ARCHIVE_WHITELIST
626NGIT_ARCHIVE_GRASP_SERVICES=git.example.com
627NGIT_ARCHIVE_WHITELIST=npub1alice...
628# → Server fails to start: "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with
629# NGIT_ARCHIVE_WHITELIST. These options are mutually exclusive."
630```
631
632**Use Cases:**
633
634```bash
635# Backup/mirror a specific GRASP server
636NGIT_ARCHIVE_GRASP_SERVICES=git.example.com
637NGIT_ARCHIVE_READ_ONLY=true # Default
638
639# Archive multiple trusted GRASP servers
640NGIT_ARCHIVE_GRASP_SERVICES=git.nostr.dev,relay.gitnostr.com
641```
642
643---
644
577#### `NGIT_ARCHIVE_READ_ONLY` 645#### `NGIT_ARCHIVE_READ_ONLY`
578 646
579**Description:** Configure relay as read-only sync of archived repositories 647**Description:** Configure relay as read-only sync of archived repositories
580**Type:** Boolean 648**Type:** Boolean
581**Default:** `true` if `NGIT_ARCHIVE_ALL` or `NGIT_ARCHIVE_WHITELIST` is set, `false` otherwise 649**Default:** `true` if `NGIT_ARCHIVE_ALL`, `NGIT_ARCHIVE_WHITELIST`, or `NGIT_ARCHIVE_GRASP_SERVICES` is set, `false` otherwise
582**Required:** No 650**Required:** No
583 651
584**Examples:** 652**Examples:**
@@ -591,7 +659,7 @@ NGIT_ARCHIVE_READ_ONLY=true
591NGIT_ARCHIVE_READ_ONLY=false 659NGIT_ARCHIVE_READ_ONLY=false
592 660
593# Automatic (default behavior) 661# Automatic (default behavior)
594# - If NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set → true 662# - If NGIT_ARCHIVE_ALL, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES is set → true
595# - Otherwise → false 663# - Otherwise → false
596# NGIT_ARCHIVE_READ_ONLY= 664# NGIT_ARCHIVE_READ_ONLY=
597``` 665```
@@ -615,8 +683,9 @@ NGIT_ARCHIVE_READ_ONLY=false
615NGIT_ARCHIVE_READ_ONLY=true 683NGIT_ARCHIVE_READ_ONLY=true
616NGIT_ARCHIVE_ALL=false 684NGIT_ARCHIVE_ALL=false
617NGIT_ARCHIVE_WHITELIST= 685NGIT_ARCHIVE_WHITELIST=
686NGIT_ARCHIVE_GRASP_SERVICES=
618# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either 687# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either
619# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" 688# NGIT_ARCHIVE_ALL=true, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set"
620 689
621# ERROR: Cannot use repository whitelist with archive read-only 690# ERROR: Cannot use repository whitelist with archive read-only
622NGIT_ARCHIVE_READ_ONLY=true 691NGIT_ARCHIVE_READ_ONLY=true
@@ -633,6 +702,7 @@ When `NGIT_ARCHIVE_READ_ONLY=true`:
633- `curation`: Set to one of: 702- `curation`: Set to one of:
634 - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`) 703 - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`)
635 - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set) 704 - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set)
705 - `"Read-only sync of repositories from specified GRASP servers"` (if `NGIT_ARCHIVE_GRASP_SERVICES` set)
636 706
637**Use Cases:** 707**Use Cases:**
638 708
@@ -648,6 +718,10 @@ NGIT_ARCHIVE_READ_ONLY=true # Default
648# Writable mirror (advanced, not typical) 718# Writable mirror (advanced, not typical)
649NGIT_ARCHIVE_WHITELIST=npub1alice... 719NGIT_ARCHIVE_WHITELIST=npub1alice...
650NGIT_ARCHIVE_READ_ONLY=false 720NGIT_ARCHIVE_READ_ONLY=false
721
722# Archive specific GRASP servers
723NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev
724NGIT_ARCHIVE_READ_ONLY=true # Default
651``` 725```
652 726
653--- 727---
diff --git a/nix/module.nix b/nix/module.nix
index 40bc868..564259e 100644
--- a/nix/module.nix
+++ b/nix/module.nix
@@ -196,6 +196,19 @@ let
196 ''; 196 '';
197 }; 197 };
198 198
199 archiveGraspServices = mkOption {
200 type = types.listOf types.str;
201 default = [ ];
202 example = [ "git.example.com" "git.nostr.dev" ];
203 description = ''
204 GRASP-05 archive GRASP services: list of GRASP server domains to archive.
205 Archives all repositories from the specified GRASP server domains.
206 Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com).
207 Mutually exclusive with archiveAll and archiveWhitelist.
208 Automatically sets archiveReadOnly to true by default.
209 '';
210 };
211
199 archiveReadOnly = mkOption { 212 archiveReadOnly = mkOption {
200 type = types.nullOr types.bool; 213 type = types.nullOr types.bool;
201 default = null; 214 default = null;
@@ -205,7 +218,7 @@ let
205 - NIP-11 includes GRASP-05 in supported_grasps 218 - NIP-11 includes GRASP-05 in supported_grasps
206 - NIP-11 curation field describes archive scope 219 - NIP-11 curation field describes archive scope
207 - Repository announcements not listing this service are accepted per whitelist/archive-all 220 - Repository announcements not listing this service are accepted per whitelist/archive-all
208 Default: true if archiveAll or archiveWhitelist is set, false otherwise 221 Default: true if archiveAll, archiveWhitelist, or archiveGraspServices is set, false otherwise
209 Note: Setting to true without archive config causes startup error 222 Note: Setting to true without archive config causes startup error
210 Note: Cannot be used with repositoryWhitelist (mutually exclusive) 223 Note: Cannot be used with repositoryWhitelist (mutually exclusive)
211 ''; 224 '';
@@ -298,6 +311,8 @@ let
298 toString cfg.naughtyListExpirationHours; 311 toString cfg.naughtyListExpirationHours;
299 NGIT_ARCHIVE_ALL = if cfg.archiveAll then "true" else "false"; 312 NGIT_ARCHIVE_ALL = if cfg.archiveAll then "true" else "false";
300 NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; 313 NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
314 NGIT_ARCHIVE_GRASP_SERVICES =
315 concatStringsSep "," cfg.archiveGraspServices;
301 NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; 316 NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
302 NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; 317 NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist;
303 NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist; 318 NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist;
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!(
diff --git a/tests/archive_grasp_services.rs b/tests/archive_grasp_services.rs
new file mode 100644
index 0000000..a47fc55
--- /dev/null
+++ b/tests/archive_grasp_services.rs
@@ -0,0 +1,378 @@
1//! Archive GRASP Services Integration Tests
2//!
3//! Tests that verify archive_grasp_services filtering behavior:
4//! - Announcements with matching GRASP service domains are accepted
5//! - Announcements with non-matching GRASP service domains are rejected
6//! - Multiple configured services work correctly
7//! - Case-insensitive domain matching
8//!
9//! # Test Strategy
10//!
11//! These tests verify the GRASP-05 archive mode with grasp_services filtering:
12//! 1. Configure relay with specific GRASP service domains
13//! 2. Send announcements with various clone URLs
14//! 3. Verify announcements are accepted/rejected based on domain matching
15//! 4. Verify repositories are created only for accepted announcements
16//!
17//! # Running Tests
18//!
19//! ```bash
20//! # Run all archive grasp services tests
21//! cargo test --test archive_grasp_services
22//!
23//! # Run specific test
24//! cargo test --test archive_grasp_services test_archive_accepts_matching_grasp_service
25//!
26//! # With output for debugging
27//! cargo test --test archive_grasp_services -- --nocapture
28//! ```
29
30mod common;
31
32use common::TestRelay;
33use nostr_sdk::prelude::*;
34use std::path::PathBuf;
35use std::process::{Child, Command, Stdio};
36use std::time::Duration;
37
38/// Helper to start a relay with archive_grasp_services configuration
39///
40/// This is a specialized version of TestRelay::start_with_archive_and_sync
41/// that adds the NGIT_ARCHIVE_GRASP_SERVICES environment variable.
42async fn start_relay_with_grasp_services(services: &str) -> (Child, String, PathBuf) {
43 let port = TestRelay::find_free_port();
44 let bind_address = format!("127.0.0.1:{}", port);
45 let url = format!("ws://127.0.0.1:{}", port);
46
47 // Create temporary directory for git repositories
48 let git_data_dir = tempfile::tempdir().expect("Failed to create temporary git data directory");
49
50 // Use the built binary directly
51 let binary_path = std::env::current_exe()
52 .expect("Failed to get current exe")
53 .parent()
54 .expect("Failed to get parent dir")
55 .parent()
56 .expect("Failed to get grandparent dir")
57 .join("ngit-grasp");
58
59 // Generate a test owner npub
60 let test_keys = nostr_sdk::Keys::generate();
61 let test_npub = test_keys
62 .public_key()
63 .to_bech32()
64 .expect("Failed to generate test npub");
65
66 // Start the relay process with archive_grasp_services
67 let mut cmd = Command::new(&binary_path);
68 cmd.env("NGIT_BIND_ADDRESS", &bind_address)
69 .env("NGIT_DOMAIN", &bind_address)
70 .env("NGIT_GIT_DATA_PATH", git_data_dir.path())
71 .env("NGIT_DATABASE_BACKEND", "memory")
72 .env("NGIT_OWNER_NPUB", &test_npub)
73 .env("NGIT_ARCHIVE_GRASP_SERVICES", services)
74 .env(
75 "RUST_LOG",
76 std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
77 )
78 .stdout(Stdio::null())
79 .stderr(Stdio::null());
80
81 let process = cmd.spawn().expect("Failed to start relay process");
82
83 // Store git data path for test assertions
84 let git_data_path = git_data_dir.path().to_path_buf();
85
86 // Wait for relay to be ready
87 wait_for_relay_ready(port).await;
88
89 (process, url, git_data_path)
90}
91
92/// Wait for the relay to be ready to accept connections
93async fn wait_for_relay_ready(port: u16) {
94 let max_attempts = 50; // 5 seconds total
95 let delay = Duration::from_millis(100);
96
97 for attempt in 0..max_attempts {
98 // Try to connect to the relay
99 match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await {
100 Ok(_) => {
101 // Connection successful, relay is ready
102 // Give it a tiny bit more time to fully initialize
103 tokio::time::sleep(Duration::from_millis(100)).await;
104 return;
105 }
106 Err(_) => {
107 if attempt == max_attempts - 1 {
108 panic!("Relay failed to start after {} attempts", max_attempts);
109 }
110 tokio::time::sleep(delay).await;
111 }
112 }
113 }
114}
115
116/// Test that announcements with matching GRASP service domains are accepted.
117///
118/// Scenario:
119/// 1. Start relay with archive_grasp_services="git.example.com"
120/// 2. Send announcement with clone URL from git.example.com
121/// 3. Verify announcement is accepted (repository is created)
122#[tokio::test]
123async fn test_archive_accepts_matching_grasp_service() {
124 let (mut process, url, git_data_path) =
125 start_relay_with_grasp_services("git.example.com").await;
126 let keys = Keys::generate();
127 let identifier = "test-repo";
128
129 // Create announcement with clone URL from git.example.com
130 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
131 let tags = vec![
132 Tag::identifier(identifier),
133 Tag::custom(
134 TagKind::custom("clone"),
135 vec![format!("https://git.example.com/user/{}.git", identifier)],
136 ),
137 Tag::custom(
138 TagKind::custom("relays"),
139 vec!["wss://relay.example.com".to_string()],
140 ),
141 ];
142
143 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
144 .tags(tags)
145 .sign_with_keys(&keys)
146 .expect("Failed to sign announcement");
147
148 // Send announcement to relay
149 let client = Client::new(keys.clone());
150 client.add_relay(&url).await.expect("Failed to add relay");
151 client.connect().await;
152
153 tokio::time::sleep(Duration::from_millis(500)).await;
154
155 client
156 .send_event(&announcement)
157 .await
158 .expect("Failed to send announcement");
159
160 tokio::time::sleep(Duration::from_millis(500)).await;
161
162 // Verify repository was created (announcement was accepted)
163 let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier));
164
165 assert!(
166 repo_path.exists(),
167 "Repository should be created for announcement with matching GRASP service domain"
168 );
169
170 // Cleanup
171 client.disconnect().await;
172 let _ = process.kill();
173 let _ = process.wait();
174}
175
176/// Test that announcements with non-matching GRASP service domains are rejected.
177///
178/// Scenario:
179/// 1. Start relay with archive_grasp_services="git.example.com"
180/// 2. Send announcement with clone URL from github.com (not in services list)
181/// 3. Verify announcement is rejected (repository is NOT created)
182#[tokio::test]
183async fn test_archive_rejects_non_matching_grasp_service() {
184 let (mut process, url, git_data_path) =
185 start_relay_with_grasp_services("git.example.com").await;
186 let keys = Keys::generate();
187 let identifier = "test-repo";
188
189 // Create announcement with clone URL from github.com (NOT in services list)
190 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
191 let tags = vec![
192 Tag::identifier(identifier),
193 Tag::custom(
194 TagKind::custom("clone"),
195 vec![format!("https://github.com/user/{}.git", identifier)],
196 ),
197 Tag::custom(
198 TagKind::custom("relays"),
199 vec!["wss://relay.example.com".to_string()],
200 ),
201 ];
202
203 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
204 .tags(tags)
205 .sign_with_keys(&keys)
206 .expect("Failed to sign announcement");
207
208 // Send announcement to relay
209 let client = Client::new(keys.clone());
210 client.add_relay(&url).await.expect("Failed to add relay");
211 client.connect().await;
212
213 tokio::time::sleep(Duration::from_millis(500)).await;
214
215 client
216 .send_event(&announcement)
217 .await
218 .expect("Failed to send announcement");
219
220 tokio::time::sleep(Duration::from_millis(500)).await;
221
222 // Verify repository was NOT created (announcement was rejected)
223 let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier));
224
225 assert!(
226 !repo_path.exists(),
227 "Repository should NOT be created for announcement with non-matching GRASP service domain"
228 );
229
230 // Cleanup
231 client.disconnect().await;
232 let _ = process.kill();
233 let _ = process.wait();
234}
235
236/// Test that multiple configured GRASP services work correctly.
237///
238/// Scenario:
239/// 1. Start relay with archive_grasp_services="git.example.com,gitlab.example.org"
240/// 2. Send announcements with clone URLs from both services
241/// 3. Verify both announcements are accepted
242/// 4. Send announcement from non-listed service
243/// 5. Verify it is rejected
244#[tokio::test]
245async fn test_archive_multiple_grasp_services() {
246 let (mut process, url, git_data_path) =
247 start_relay_with_grasp_services("git.example.com,gitlab.example.org").await;
248
249 // Test first service (git.example.com)
250 let keys1 = Keys::generate();
251 let identifier1 = "test-repo-1";
252 let npub1 = keys1.public_key().to_bech32().expect("Failed to get npub");
253
254 let tags1 = vec![
255 Tag::identifier(identifier1),
256 Tag::custom(
257 TagKind::custom("clone"),
258 vec![format!("https://git.example.com/user/{}.git", identifier1)],
259 ),
260 Tag::custom(
261 TagKind::custom("relays"),
262 vec!["wss://relay.example.com".to_string()],
263 ),
264 ];
265
266 let announcement1 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
267 .tags(tags1)
268 .sign_with_keys(&keys1)
269 .expect("Failed to sign announcement");
270
271 let client1 = Client::new(keys1.clone());
272 client1.add_relay(&url).await.expect("Failed to add relay");
273 client1.connect().await;
274 tokio::time::sleep(Duration::from_millis(500)).await;
275
276 client1
277 .send_event(&announcement1)
278 .await
279 .expect("Failed to send announcement");
280 tokio::time::sleep(Duration::from_millis(500)).await;
281
282 // Test second service (gitlab.example.org)
283 let keys2 = Keys::generate();
284 let identifier2 = "test-repo-2";
285 let npub2 = keys2.public_key().to_bech32().expect("Failed to get npub");
286
287 let tags2 = vec![
288 Tag::identifier(identifier2),
289 Tag::custom(
290 TagKind::custom("clone"),
291 vec![format!(
292 "https://gitlab.example.org/user/{}.git",
293 identifier2
294 )],
295 ),
296 Tag::custom(
297 TagKind::custom("relays"),
298 vec!["wss://relay.example.com".to_string()],
299 ),
300 ];
301
302 let announcement2 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
303 .tags(tags2)
304 .sign_with_keys(&keys2)
305 .expect("Failed to sign announcement");
306
307 let client2 = Client::new(keys2.clone());
308 client2.add_relay(&url).await.expect("Failed to add relay");
309 client2.connect().await;
310 tokio::time::sleep(Duration::from_millis(500)).await;
311
312 client2
313 .send_event(&announcement2)
314 .await
315 .expect("Failed to send announcement");
316 tokio::time::sleep(Duration::from_millis(500)).await;
317
318 // Test non-listed service (github.com)
319 let keys3 = Keys::generate();
320 let identifier3 = "test-repo-3";
321 let npub3 = keys3.public_key().to_bech32().expect("Failed to get npub");
322
323 let tags3 = vec![
324 Tag::identifier(identifier3),
325 Tag::custom(
326 TagKind::custom("clone"),
327 vec![format!("https://github.com/user/{}.git", identifier3)],
328 ),
329 Tag::custom(
330 TagKind::custom("relays"),
331 vec!["wss://relay.example.com".to_string()],
332 ),
333 ];
334
335 let announcement3 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
336 .tags(tags3)
337 .sign_with_keys(&keys3)
338 .expect("Failed to sign announcement");
339
340 let client3 = Client::new(keys3.clone());
341 client3.add_relay(&url).await.expect("Failed to add relay");
342 client3.connect().await;
343 tokio::time::sleep(Duration::from_millis(500)).await;
344
345 client3
346 .send_event(&announcement3)
347 .await
348 .expect("Failed to send announcement");
349 tokio::time::sleep(Duration::from_millis(500)).await;
350
351 // Verify first service announcement was accepted
352 let repo_path1 = git_data_path.join(format!("{}/{}.git", npub1, identifier1));
353 assert!(
354 repo_path1.exists(),
355 "Repository should be created for first GRASP service (git.example.com)"
356 );
357
358 // Verify second service announcement was accepted
359 let repo_path2 = git_data_path.join(format!("{}/{}.git", npub2, identifier2));
360 assert!(
361 repo_path2.exists(),
362 "Repository should be created for second GRASP service (gitlab.example.org)"
363 );
364
365 // Verify non-listed service announcement was rejected
366 let repo_path3 = git_data_path.join(format!("{}/{}.git", npub3, identifier3));
367 assert!(
368 !repo_path3.exists(),
369 "Repository should NOT be created for non-listed service (github.com)"
370 );
371
372 // Cleanup
373 client1.disconnect().await;
374 client2.disconnect().await;
375 client3.disconnect().await;
376 let _ = process.kill();
377 let _ = process.wait();
378}