From 46fbcc0a4c8a8dbf6cd345d6eaa6fe33a82100bb Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 21 Jan 2026 13:28:37 +0000 Subject: 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. --- .env.example | 14 +- docs/reference/configuration.md | 80 ++++++++- nix/module.nix | 17 +- src/config.rs | 265 +++++++++++++++++++++++++++- src/nostr/events.rs | 11 ++ tests/archive_grasp_services.rs | 378 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 753 insertions(+), 12 deletions(-) create mode 100644 tests/archive_grasp_services.rs diff --git a/.env.example b/.env.example index a19a07d..e152b89 100644 --- a/.env.example +++ b/.env.example @@ -191,6 +191,18 @@ # NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust # NGIT_ARCHIVE_WHITELIST= +# Archive GRASP services: comma-separated list of GRASP server domains to archive +# Archives all repositories from the specified GRASP server domains +# Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com) +# Mutually exclusive with NGIT_ARCHIVE_ALL and NGIT_ARCHIVE_WHITELIST +# Automatically sets NGIT_ARCHIVE_READ_ONLY to true by default +# CLI: --archive-grasp-services +# Default: (empty) +# Examples: +# NGIT_ARCHIVE_GRASP_SERVICES=git.example.com +# NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev,relay.gitnostr.com +# NGIT_ARCHIVE_GRASP_SERVICES= + # Archive read-only mode (relay is read-only sync of archived repositories) # When true: # - NIP-11 includes GRASP-05 in supported_grasps @@ -200,7 +212,7 @@ # - Archive mode disabled (standard GRASP-01 operation) # # CLI: --archive-read-only -# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise +# Default: true if NGIT_ARCHIVE_ALL, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES is set, false otherwise # Note: Setting to true without archive config causes startup error # Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive) # 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 --- +#### `NGIT_ARCHIVE_GRASP_SERVICES` + +**Description:** Comma-separated list of GRASP server domains to archive +**Type:** String (comma-separated domain names) +**Default:** (empty) +**Required:** No + +**Format:** +- `` - Archive all repositories from this GRASP server domain +- **Must be bare domains only** (e.g., `git.example.com`, NOT `wss://git.example.com`) +- Matching extracts domains from announcement clone URLs and compares them exactly (case-sensitive) + +**Examples:** + +```bash +# Archive all repos from a single GRASP server +NGIT_ARCHIVE_GRASP_SERVICES=git.example.com + +# Archive repos from multiple GRASP servers +NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev,relay.gitnostr.com + +# Archive from localhost (testing) +NGIT_ARCHIVE_GRASP_SERVICES=localhost:7334 +``` + +**Validation:** + +- Domain entries must be bare domains without scheme prefixes (ws://, wss://, https://, etc.) +- Whitespace is trimmed +- Empty entries are ignored +- **Mutually exclusive** with `NGIT_ARCHIVE_ALL` and `NGIT_ARCHIVE_WHITELIST` + +**Security Notes:** + +- Archives ALL repositories from the specified GRASP server domains +- Use with caution - ensure you trust the GRASP servers you're archiving from +- Storage requirements depend on the size of repositories on the archived servers +- Automatically sets `NGIT_ARCHIVE_READ_ONLY=true` by default + +**Error Conditions:** + +```bash +# ERROR: Cannot use with NGIT_ARCHIVE_ALL +NGIT_ARCHIVE_GRASP_SERVICES=git.example.com +NGIT_ARCHIVE_ALL=true +# → Server fails to start: "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with +# NGIT_ARCHIVE_ALL=true. These options are mutually exclusive." + +# ERROR: Cannot use with NGIT_ARCHIVE_WHITELIST +NGIT_ARCHIVE_GRASP_SERVICES=git.example.com +NGIT_ARCHIVE_WHITELIST=npub1alice... +# → Server fails to start: "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with +# NGIT_ARCHIVE_WHITELIST. These options are mutually exclusive." +``` + +**Use Cases:** + +```bash +# Backup/mirror a specific GRASP server +NGIT_ARCHIVE_GRASP_SERVICES=git.example.com +NGIT_ARCHIVE_READ_ONLY=true # Default + +# Archive multiple trusted GRASP servers +NGIT_ARCHIVE_GRASP_SERVICES=git.nostr.dev,relay.gitnostr.com +``` + +--- + #### `NGIT_ARCHIVE_READ_ONLY` **Description:** Configure relay as read-only sync of archived repositories **Type:** Boolean -**Default:** `true` if `NGIT_ARCHIVE_ALL` or `NGIT_ARCHIVE_WHITELIST` is set, `false` otherwise +**Default:** `true` if `NGIT_ARCHIVE_ALL`, `NGIT_ARCHIVE_WHITELIST`, or `NGIT_ARCHIVE_GRASP_SERVICES` is set, `false` otherwise **Required:** No **Examples:** @@ -591,7 +659,7 @@ NGIT_ARCHIVE_READ_ONLY=true NGIT_ARCHIVE_READ_ONLY=false # Automatic (default behavior) -# - If NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set → true +# - If NGIT_ARCHIVE_ALL, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES is set → true # - Otherwise → false # NGIT_ARCHIVE_READ_ONLY= ``` @@ -615,8 +683,9 @@ NGIT_ARCHIVE_READ_ONLY=false NGIT_ARCHIVE_READ_ONLY=true NGIT_ARCHIVE_ALL=false NGIT_ARCHIVE_WHITELIST= +NGIT_ARCHIVE_GRASP_SERVICES= # → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either -# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" +# NGIT_ARCHIVE_ALL=true, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set" # ERROR: Cannot use repository whitelist with archive read-only NGIT_ARCHIVE_READ_ONLY=true @@ -633,6 +702,7 @@ When `NGIT_ARCHIVE_READ_ONLY=true`: - `curation`: Set to one of: - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`) - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set) + - `"Read-only sync of repositories from specified GRASP servers"` (if `NGIT_ARCHIVE_GRASP_SERVICES` set) **Use Cases:** @@ -648,6 +718,10 @@ NGIT_ARCHIVE_READ_ONLY=true # Default # Writable mirror (advanced, not typical) NGIT_ARCHIVE_WHITELIST=npub1alice... NGIT_ARCHIVE_READ_ONLY=false + +# Archive specific GRASP servers +NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev +NGIT_ARCHIVE_READ_ONLY=true # Default ``` --- 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 ''; }; + archiveGraspServices = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "git.example.com" "git.nostr.dev" ]; + description = '' + GRASP-05 archive GRASP services: list of GRASP server domains to archive. + Archives all repositories from the specified GRASP server domains. + Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com). + Mutually exclusive with archiveAll and archiveWhitelist. + Automatically sets archiveReadOnly to true by default. + ''; + }; + archiveReadOnly = mkOption { type = types.nullOr types.bool; default = null; @@ -205,7 +218,7 @@ let - NIP-11 includes GRASP-05 in supported_grasps - NIP-11 curation field describes archive scope - Repository announcements not listing this service are accepted per whitelist/archive-all - Default: true if archiveAll or archiveWhitelist is set, false otherwise + Default: true if archiveAll, archiveWhitelist, or archiveGraspServices is set, false otherwise Note: Setting to true without archive config causes startup error Note: Cannot be used with repositoryWhitelist (mutually exclusive) ''; @@ -298,6 +311,8 @@ let toString cfg.naughtyListExpirationHours; NGIT_ARCHIVE_ALL = if cfg.archiveAll then "true" else "false"; NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; + NGIT_ARCHIVE_GRASP_SERVICES = + concatStringsSep "," cfg.archiveGraspServices; NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; 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 { /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). pub whitelist: Vec, + /// GRASP server domains to archive (archive all repositories from these domains) + /// + /// If non-empty, archives all repositories from the specified GRASP server domains. + pub grasp_services: Vec, + /// Read-only archive mode: relay is a read-only sync of archived repositories /// /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. @@ -131,9 +136,9 @@ pub struct ArchiveConfig { } impl ArchiveConfig { - /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist) + /// Check if GRASP-05 is enabled (either archive_all, non-empty whitelist, or non-empty grasp_services) pub fn enabled(&self) -> bool { - self.archive_all || !self.whitelist.is_empty() + self.archive_all || !self.whitelist.is_empty() || !self.grasp_services.is_empty() } /// Check if an announcement matches the archive configuration @@ -141,6 +146,7 @@ impl ArchiveConfig { /// Returns true if: /// - archive_all is true, OR /// - announcement matches any whitelist entry + /// Note: grasp_services matching is handled via matches_grasp_services() pub fn matches(&self, npub: &str, identifier: &str) -> bool { if self.archive_all { return true; @@ -150,6 +156,19 @@ impl ArchiveConfig { .iter() .any(|entry| entry.matches(npub, identifier)) } + + /// Check if any of the given domains match the configured grasp_services + /// + /// Returns true if any domain in the list matches any configured grasp_services entry. + pub fn matches_grasp_services(&self, domains: &[String]) -> bool { + if self.grasp_services.is_empty() { + return false; + } + + domains + .iter() + .any(|domain| self.grasp_services.iter().any(|service| service == domain)) + } } impl Default for ArchiveConfig { @@ -157,6 +176,7 @@ impl Default for ArchiveConfig { Self { archive_all: false, whitelist: Vec::new(), + grasp_services: Vec::new(), read_only: false, } } @@ -447,9 +467,15 @@ pub struct Config { #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] pub archive_whitelist: String, + /// GRASP-05 archive GRASP services: comma-separated list of GRASP server domains to archive + /// When set, archives all repositories from the specified GRASP server domains + /// Mutually exclusive with archive_all and archive_whitelist + #[arg(long, env = "NGIT_ARCHIVE_GRASP_SERVICES", default_value = "")] + pub archive_grasp_services: String, + /// Archive read-only mode: relay is a read-only sync of archived repositories - /// Defaults to true if archive_all or archive_whitelist is set, false otherwise - /// Throws error if set to true without archive_all or archive_whitelist + /// Defaults to true if archive_all, archive_whitelist, or archive_grasp_services is set, false otherwise + /// Throws error if set to true without archive_all, archive_whitelist, or archive_grasp_services #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] pub archive_read_only: Option, @@ -589,13 +615,32 @@ impl Config { // Validate archive configuration let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); - let archive_enabled = self.archive_all || !archive_whitelist.is_empty(); + let archive_grasp_services = self.parse_archive_grasp_services(); + let archive_enabled = + self.archive_all || !archive_whitelist.is_empty() || !archive_grasp_services.is_empty(); + + // Fatal error: archive_grasp_services cannot be used with archive_all or archive_whitelist + if !archive_grasp_services.is_empty() { + if self.archive_all { + return Err(anyhow!( + "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_ALL=true. \ + These options are mutually exclusive." + )); + } + if !archive_whitelist.is_empty() { + return Err(anyhow!( + "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_WHITELIST. \ + These options are mutually exclusive." + )); + } + } // Fatal error: archive_read_only=true without archive mode enabled if let Some(true) = self.archive_read_only { if !archive_enabled { return Err(anyhow!( - "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" + "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true, \ + NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set" )); } } @@ -619,13 +664,32 @@ impl Config { Ok(()) } + /// Parse archive GRASP services from comma-separated string + /// + /// Returns a list of domain names (GRASP server domains to archive). + /// Whitespace is trimmed and empty entries are ignored. + pub fn parse_archive_grasp_services(&self) -> Vec { + if self.archive_grasp_services.trim().is_empty() { + return Vec::new(); + } + + self.archive_grasp_services + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() + } + /// Get parsed archive configuration with computed read-only mode /// /// Read-only mode defaults to true if archive mode is enabled, false otherwise. /// This method assumes config has been validated - call Config::validate() first! pub fn archive_config(&self) -> ArchiveConfig { let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); - let archive_enabled = self.archive_all || !whitelist.is_empty(); + let archive_grasp_services = self.parse_archive_grasp_services(); + let archive_enabled = + self.archive_all || !whitelist.is_empty() || !archive_grasp_services.is_empty(); let read_only = match self.archive_read_only { Some(true) => true, // Already validated in validate() @@ -639,6 +703,7 @@ impl Config { ArchiveConfig { archive_all: self.archive_all, whitelist, + grasp_services: archive_grasp_services, read_only, } } @@ -705,6 +770,7 @@ impl Config { naughty_list_expiration_hours: 12, archive_all: false, archive_whitelist: String::new(), + archive_grasp_services: String::new(), archive_read_only: None, repository_whitelist: String::new(), repository_blacklist: String::new(), @@ -936,6 +1002,7 @@ mod tests { let config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), + grasp_services: Vec::new(), read_only: true, }; assert!(config.enabled()); @@ -943,6 +1010,7 @@ mod tests { let config = ArchiveConfig { archive_all: false, whitelist: vec![WhitelistEntry::Identifier("test".into())], + grasp_services: Vec::new(), read_only: true, }; assert!(config.enabled()); @@ -958,6 +1026,7 @@ mod tests { WhitelistEntry::Pubkey(test_npub.clone()), WhitelistEntry::Identifier("bitcoin-core".into()), ], + grasp_services: Vec::new(), read_only: false, }; @@ -971,6 +1040,7 @@ mod tests { let config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), + grasp_services: Vec::new(), read_only: true, }; @@ -1379,4 +1449,185 @@ mod tests { let result = config.check(&allowed_npub); assert!(result.is_none()); } + + #[test] + fn test_parse_archive_grasp_services_empty() { + let config = Config::for_testing(); + let services = config.parse_archive_grasp_services(); + assert!(services.is_empty()); + + let config = Config { + archive_grasp_services: " ".to_string(), + ..Config::for_testing() + }; + let services = config.parse_archive_grasp_services(); + assert!(services.is_empty()); + } + + #[test] + fn test_parse_archive_grasp_services_single() { + let config = Config { + archive_grasp_services: "git.example.com".to_string(), + ..Config::for_testing() + }; + let services = config.parse_archive_grasp_services(); + assert_eq!(services.len(), 1); + assert_eq!(services[0], "git.example.com"); + } + + #[test] + fn test_parse_archive_grasp_services_multiple() { + let config = Config { + archive_grasp_services: "git.example.com,git.nostr.dev,relay.gitnostr.com".to_string(), + ..Config::for_testing() + }; + let services = config.parse_archive_grasp_services(); + assert_eq!(services.len(), 3); + assert_eq!(services[0], "git.example.com"); + assert_eq!(services[1], "git.nostr.dev"); + assert_eq!(services[2], "relay.gitnostr.com"); + } + + #[test] + fn test_parse_archive_grasp_services_with_whitespace() { + let config = Config { + archive_grasp_services: " git.example.com , git.nostr.dev , relay.gitnostr.com " + .to_string(), + ..Config::for_testing() + }; + let services = config.parse_archive_grasp_services(); + assert_eq!(services.len(), 3); + assert_eq!(services[0], "git.example.com"); + assert_eq!(services[1], "git.nostr.dev"); + assert_eq!(services[2], "relay.gitnostr.com"); + } + + #[test] + fn test_archive_grasp_services_validation_error_with_archive_all() { + let config = Config { + archive_all: true, + archive_grasp_services: "git.example.com".to_string(), + ..Config::for_testing() + }; + let result = config.validate(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES")); + assert!(err.contains("NGIT_ARCHIVE_ALL")); + assert!(err.contains("mutually exclusive")); + } + + #[test] + fn test_archive_grasp_services_validation_error_with_archive_whitelist() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + archive_whitelist: test_npub, + archive_grasp_services: "git.example.com".to_string(), + ..Config::for_testing() + }; + let result = config.validate(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES")); + assert!(err.contains("NGIT_ARCHIVE_WHITELIST")); + assert!(err.contains("mutually exclusive")); + } + + #[test] + fn test_archive_grasp_services_enables_archive_mode() { + let config = Config { + archive_grasp_services: "git.example.com".to_string(), + ..Config::for_testing() + }; + let archive_config = config.archive_config(); + assert!(archive_config.enabled()); + assert_eq!(archive_config.read_only, true); // Default to true + } + + #[test] + fn test_archive_grasp_services_read_only_default() { + // Default: true when archive_grasp_services is set + let config = Config { + archive_grasp_services: "git.example.com".to_string(), + ..Config::for_testing() + }; + assert_eq!(config.archive_config().read_only, true); + } + + #[test] + fn test_archive_grasp_services_read_only_explicit_false() { + // Explicit false should be respected + let config = Config { + archive_grasp_services: "git.example.com".to_string(), + archive_read_only: Some(false), + ..Config::for_testing() + }; + assert_eq!(config.archive_config().read_only, false); + } + + #[test] + fn test_archive_read_only_validation_with_grasp_services() { + // Should succeed with archive_grasp_services set + let config = Config { + archive_grasp_services: "git.example.com".to_string(), + archive_read_only: Some(true), + ..Config::for_testing() + }; + assert!(config.validate().is_ok()); + } + + #[test] + fn test_archive_config_matches_grasp_services() { + let config = ArchiveConfig { + archive_all: false, + whitelist: Vec::new(), + grasp_services: vec!["git.example.com".to_string(), "gitlab.org".to_string()], + read_only: true, + }; + + // Should match configured services + assert!(config.matches_grasp_services(&["git.example.com".to_string()])); + assert!(config.matches_grasp_services(&["gitlab.org".to_string()])); + + // Should not match unconfigured services + assert!(!config.matches_grasp_services(&["github.com".to_string()])); + assert!(!config.matches_grasp_services(&["other.com".to_string()])); + } + + #[test] + fn test_archive_config_matches_grasp_services_empty() { + let config = ArchiveConfig { + archive_all: false, + whitelist: Vec::new(), + grasp_services: Vec::new(), + read_only: true, + }; + + // Should not match anything when grasp_services is empty + assert!(!config.matches_grasp_services(&["git.example.com".to_string()])); + assert!(!config.matches_grasp_services(&[])); + } + + #[test] + fn test_archive_config_matches_grasp_services_multiple_domains() { + let config = ArchiveConfig { + archive_all: false, + whitelist: Vec::new(), + grasp_services: vec!["git.example.com".to_string()], + read_only: true, + }; + + // Should match if any domain matches + assert!(config.matches_grasp_services(&[ + "github.com".to_string(), + "git.example.com".to_string(), + "gitlab.org".to_string(), + ])); + + // Should not match if no domain matches + assert!( + !config.matches_grasp_services(&["github.com".to_string(), "gitlab.org".to_string(),]) + ); + } } 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( return AnnouncementResult::AcceptArchive; } + // GRASP-05: Archive mode - accept if announcement lists any configured GRASP service in clone URLs + // Only check clone URLs (not relays) since we're archiving from OTHER services + // Check if announcement matches any configured GRASP service domains + if archive_config + .grasp_services + .iter() + .any(|service| announcement.has_clone_url(service)) + { + return AnnouncementResult::AcceptArchive; + } + // Reject with appropriate error message if archive_config.read_only { 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 @@ +//! Archive GRASP Services Integration Tests +//! +//! Tests that verify archive_grasp_services filtering behavior: +//! - Announcements with matching GRASP service domains are accepted +//! - Announcements with non-matching GRASP service domains are rejected +//! - Multiple configured services work correctly +//! - Case-insensitive domain matching +//! +//! # Test Strategy +//! +//! These tests verify the GRASP-05 archive mode with grasp_services filtering: +//! 1. Configure relay with specific GRASP service domains +//! 2. Send announcements with various clone URLs +//! 3. Verify announcements are accepted/rejected based on domain matching +//! 4. Verify repositories are created only for accepted announcements +//! +//! # Running Tests +//! +//! ```bash +//! # Run all archive grasp services tests +//! cargo test --test archive_grasp_services +//! +//! # Run specific test +//! cargo test --test archive_grasp_services test_archive_accepts_matching_grasp_service +//! +//! # With output for debugging +//! cargo test --test archive_grasp_services -- --nocapture +//! ``` + +mod common; + +use common::TestRelay; +use nostr_sdk::prelude::*; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +/// Helper to start a relay with archive_grasp_services configuration +/// +/// This is a specialized version of TestRelay::start_with_archive_and_sync +/// that adds the NGIT_ARCHIVE_GRASP_SERVICES environment variable. +async fn start_relay_with_grasp_services(services: &str) -> (Child, String, PathBuf) { + let port = TestRelay::find_free_port(); + let bind_address = format!("127.0.0.1:{}", port); + let url = format!("ws://127.0.0.1:{}", port); + + // Create temporary directory for git repositories + let git_data_dir = tempfile::tempdir().expect("Failed to create temporary git data directory"); + + // Use the built binary directly + let binary_path = std::env::current_exe() + .expect("Failed to get current exe") + .parent() + .expect("Failed to get parent dir") + .parent() + .expect("Failed to get grandparent dir") + .join("ngit-grasp"); + + // Generate a test owner npub + let test_keys = nostr_sdk::Keys::generate(); + let test_npub = test_keys + .public_key() + .to_bech32() + .expect("Failed to generate test npub"); + + // Start the relay process with archive_grasp_services + let mut cmd = Command::new(&binary_path); + cmd.env("NGIT_BIND_ADDRESS", &bind_address) + .env("NGIT_DOMAIN", &bind_address) + .env("NGIT_GIT_DATA_PATH", git_data_dir.path()) + .env("NGIT_DATABASE_BACKEND", "memory") + .env("NGIT_OWNER_NPUB", &test_npub) + .env("NGIT_ARCHIVE_GRASP_SERVICES", services) + .env( + "RUST_LOG", + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), + ) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let process = cmd.spawn().expect("Failed to start relay process"); + + // Store git data path for test assertions + let git_data_path = git_data_dir.path().to_path_buf(); + + // Wait for relay to be ready + wait_for_relay_ready(port).await; + + (process, url, git_data_path) +} + +/// Wait for the relay to be ready to accept connections +async fn wait_for_relay_ready(port: u16) { + let max_attempts = 50; // 5 seconds total + let delay = Duration::from_millis(100); + + for attempt in 0..max_attempts { + // Try to connect to the relay + match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { + Ok(_) => { + // Connection successful, relay is ready + // Give it a tiny bit more time to fully initialize + tokio::time::sleep(Duration::from_millis(100)).await; + return; + } + Err(_) => { + if attempt == max_attempts - 1 { + panic!("Relay failed to start after {} attempts", max_attempts); + } + tokio::time::sleep(delay).await; + } + } + } +} + +/// Test that announcements with matching GRASP service domains are accepted. +/// +/// Scenario: +/// 1. Start relay with archive_grasp_services="git.example.com" +/// 2. Send announcement with clone URL from git.example.com +/// 3. Verify announcement is accepted (repository is created) +#[tokio::test] +async fn test_archive_accepts_matching_grasp_service() { + let (mut process, url, git_data_path) = + start_relay_with_grasp_services("git.example.com").await; + let keys = Keys::generate(); + let identifier = "test-repo"; + + // Create announcement with clone URL from git.example.com + let npub = keys.public_key().to_bech32().expect("Failed to get npub"); + let tags = vec![ + Tag::identifier(identifier), + Tag::custom( + TagKind::custom("clone"), + vec![format!("https://git.example.com/user/{}.git", identifier)], + ), + Tag::custom( + TagKind::custom("relays"), + vec!["wss://relay.example.com".to_string()], + ), + ]; + + let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") + .tags(tags) + .sign_with_keys(&keys) + .expect("Failed to sign announcement"); + + // Send announcement to relay + let client = Client::new(keys.clone()); + client.add_relay(&url).await.expect("Failed to add relay"); + client.connect().await; + + tokio::time::sleep(Duration::from_millis(500)).await; + + client + .send_event(&announcement) + .await + .expect("Failed to send announcement"); + + tokio::time::sleep(Duration::from_millis(500)).await; + + // Verify repository was created (announcement was accepted) + let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier)); + + assert!( + repo_path.exists(), + "Repository should be created for announcement with matching GRASP service domain" + ); + + // Cleanup + client.disconnect().await; + let _ = process.kill(); + let _ = process.wait(); +} + +/// Test that announcements with non-matching GRASP service domains are rejected. +/// +/// Scenario: +/// 1. Start relay with archive_grasp_services="git.example.com" +/// 2. Send announcement with clone URL from github.com (not in services list) +/// 3. Verify announcement is rejected (repository is NOT created) +#[tokio::test] +async fn test_archive_rejects_non_matching_grasp_service() { + let (mut process, url, git_data_path) = + start_relay_with_grasp_services("git.example.com").await; + let keys = Keys::generate(); + let identifier = "test-repo"; + + // Create announcement with clone URL from github.com (NOT in services list) + let npub = keys.public_key().to_bech32().expect("Failed to get npub"); + let tags = vec![ + Tag::identifier(identifier), + Tag::custom( + TagKind::custom("clone"), + vec![format!("https://github.com/user/{}.git", identifier)], + ), + Tag::custom( + TagKind::custom("relays"), + vec!["wss://relay.example.com".to_string()], + ), + ]; + + let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") + .tags(tags) + .sign_with_keys(&keys) + .expect("Failed to sign announcement"); + + // Send announcement to relay + let client = Client::new(keys.clone()); + client.add_relay(&url).await.expect("Failed to add relay"); + client.connect().await; + + tokio::time::sleep(Duration::from_millis(500)).await; + + client + .send_event(&announcement) + .await + .expect("Failed to send announcement"); + + tokio::time::sleep(Duration::from_millis(500)).await; + + // Verify repository was NOT created (announcement was rejected) + let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier)); + + assert!( + !repo_path.exists(), + "Repository should NOT be created for announcement with non-matching GRASP service domain" + ); + + // Cleanup + client.disconnect().await; + let _ = process.kill(); + let _ = process.wait(); +} + +/// Test that multiple configured GRASP services work correctly. +/// +/// Scenario: +/// 1. Start relay with archive_grasp_services="git.example.com,gitlab.example.org" +/// 2. Send announcements with clone URLs from both services +/// 3. Verify both announcements are accepted +/// 4. Send announcement from non-listed service +/// 5. Verify it is rejected +#[tokio::test] +async fn test_archive_multiple_grasp_services() { + let (mut process, url, git_data_path) = + start_relay_with_grasp_services("git.example.com,gitlab.example.org").await; + + // Test first service (git.example.com) + let keys1 = Keys::generate(); + let identifier1 = "test-repo-1"; + let npub1 = keys1.public_key().to_bech32().expect("Failed to get npub"); + + let tags1 = vec![ + Tag::identifier(identifier1), + Tag::custom( + TagKind::custom("clone"), + vec![format!("https://git.example.com/user/{}.git", identifier1)], + ), + Tag::custom( + TagKind::custom("relays"), + vec!["wss://relay.example.com".to_string()], + ), + ]; + + let announcement1 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") + .tags(tags1) + .sign_with_keys(&keys1) + .expect("Failed to sign announcement"); + + let client1 = Client::new(keys1.clone()); + client1.add_relay(&url).await.expect("Failed to add relay"); + client1.connect().await; + tokio::time::sleep(Duration::from_millis(500)).await; + + client1 + .send_event(&announcement1) + .await + .expect("Failed to send announcement"); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Test second service (gitlab.example.org) + let keys2 = Keys::generate(); + let identifier2 = "test-repo-2"; + let npub2 = keys2.public_key().to_bech32().expect("Failed to get npub"); + + let tags2 = vec![ + Tag::identifier(identifier2), + Tag::custom( + TagKind::custom("clone"), + vec![format!( + "https://gitlab.example.org/user/{}.git", + identifier2 + )], + ), + Tag::custom( + TagKind::custom("relays"), + vec!["wss://relay.example.com".to_string()], + ), + ]; + + let announcement2 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") + .tags(tags2) + .sign_with_keys(&keys2) + .expect("Failed to sign announcement"); + + let client2 = Client::new(keys2.clone()); + client2.add_relay(&url).await.expect("Failed to add relay"); + client2.connect().await; + tokio::time::sleep(Duration::from_millis(500)).await; + + client2 + .send_event(&announcement2) + .await + .expect("Failed to send announcement"); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Test non-listed service (github.com) + let keys3 = Keys::generate(); + let identifier3 = "test-repo-3"; + let npub3 = keys3.public_key().to_bech32().expect("Failed to get npub"); + + let tags3 = vec![ + Tag::identifier(identifier3), + Tag::custom( + TagKind::custom("clone"), + vec![format!("https://github.com/user/{}.git", identifier3)], + ), + Tag::custom( + TagKind::custom("relays"), + vec!["wss://relay.example.com".to_string()], + ), + ]; + + let announcement3 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") + .tags(tags3) + .sign_with_keys(&keys3) + .expect("Failed to sign announcement"); + + let client3 = Client::new(keys3.clone()); + client3.add_relay(&url).await.expect("Failed to add relay"); + client3.connect().await; + tokio::time::sleep(Duration::from_millis(500)).await; + + client3 + .send_event(&announcement3) + .await + .expect("Failed to send announcement"); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Verify first service announcement was accepted + let repo_path1 = git_data_path.join(format!("{}/{}.git", npub1, identifier1)); + assert!( + repo_path1.exists(), + "Repository should be created for first GRASP service (git.example.com)" + ); + + // Verify second service announcement was accepted + let repo_path2 = git_data_path.join(format!("{}/{}.git", npub2, identifier2)); + assert!( + repo_path2.exists(), + "Repository should be created for second GRASP service (gitlab.example.org)" + ); + + // Verify non-listed service announcement was rejected + let repo_path3 = git_data_path.join(format!("{}/{}.git", npub3, identifier3)); + assert!( + !repo_path3.exists(), + "Repository should NOT be created for non-listed service (github.com)" + ); + + // Cleanup + client1.disconnect().await; + client2.disconnect().await; + client3.disconnect().await; + let _ = process.kill(); + let _ = process.wait(); +} -- cgit v1.2.3