use anyhow::{anyhow, Context, Result}; use clap::{Parser, ValueEnum}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; /// Whitelist entry for repository/archive filtering #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum WhitelistEntry { /// All repos from this pubkey: "npub1..." Pubkey(String), /// Specific repo: "npub1.../identifier" Repository { npub: String, identifier: String }, /// Any repo with this identifier: "identifier" Identifier(String), } impl WhitelistEntry { /// Parse a whitelist entry from string /// /// Formats: /// - "npub1..." -> Pubkey /// - "npub1.../identifier" -> Repository /// - "identifier" -> Identifier /// /// Validates npub format at parse time (fail fast) pub fn parse(s: &str) -> Result { let trimmed = s.trim(); if trimmed.contains('/') { // Format: npub1.../identifier let parts: Vec<&str> = trimmed.split('/').collect(); if parts.len() != 2 { return Err(anyhow!( "Invalid whitelist entry format '{}'. Expected 'npub/identifier'", s )); } let npub = parts[0]; let identifier = parts[1]; // Validate npub format (fail fast) if !npub.starts_with("npub1") { return Err(anyhow!( "Invalid whitelist entry '{}'. First part must be npub", s )); } PublicKey::from_bech32(npub) .context(format!("Invalid npub in whitelist entry '{}'", s))?; Ok(Self::Repository { npub: npub.to_string(), identifier: identifier.to_string(), }) } else if trimmed.starts_with("npub1") { // Format: npub1... // Validate npub format (fail fast) PublicKey::from_bech32(trimmed) .context(format!("Invalid npub in whitelist entry '{}'", s))?; Ok(Self::Pubkey(trimmed.to_string())) } else { // Format: identifier Ok(Self::Identifier(trimmed.to_string())) } } /// Check if this entry matches the given npub and identifier pub fn matches(&self, npub: &str, identifier: &str) -> bool { match self { Self::Pubkey(p) => npub == p, Self::Repository { npub: p, identifier: i, } => npub == p && identifier == i, Self::Identifier(i) => identifier == i, } } /// Parse whitelist from comma-separated string /// /// Skips invalid entries with warnings instead of failing. /// This allows the config to load even if some whitelist entries are malformed. pub fn parse_whitelist(input: &str) -> Vec { if input.trim().is_empty() { return Vec::new(); } input .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .filter_map(|s| match Self::parse(s) { Ok(entry) => Some(entry), Err(e) => { tracing::warn!("Skipping invalid whitelist entry '{}': {}", s, e); None } }) .collect() } } /// GRASP-05 Archive mode configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ArchiveConfig { /// Accept all repository announcements (no filtering) /// /// WARNING: Setting this to true allows anyone to mirror any repository /// to this relay, potentially causing storage/bandwidth exhaustion. #[serde(default)] pub archive_all: bool, /// Whitelist entries for selective archiving /// /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). #[serde(default)] 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. #[serde(default)] 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. /// Announcements listing the relay but not in the whitelist are rejected. /// When false, the relay operates in GRASP-01 mode for unwhitelisted repos. #[serde(default)] pub read_only: bool, } impl ArchiveConfig { /// 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.grasp_services.is_empty() } /// Check if an announcement matches the archive configuration /// /// 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; } self.whitelist .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)) } } /// Repository whitelist configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RepositoryConfig { /// Whitelist entries for selective repository acceptance /// /// If empty, all repositories listing the service are accepted (GRASP-01 mode). #[serde(default)] pub whitelist: Vec, } impl RepositoryConfig { /// Check if repository whitelist is enabled (non-empty whitelist) pub fn enabled(&self) -> bool { !self.whitelist.is_empty() } /// Check if an announcement matches the repository whitelist /// /// Returns true if announcement matches any whitelist entry pub fn matches(&self, npub: &str, identifier: &str) -> bool { self.whitelist .iter() .any(|entry| entry.matches(npub, identifier)) } } /// Repository blacklist configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct BlacklistConfig { /// Blacklist entries for blocking specific repositories /// /// If empty, no repositories are blacklisted. /// Blacklist takes precedence over both archive and repository whitelists. #[serde(default)] pub blacklist: Vec, } impl BlacklistConfig { /// Check if repository blacklist is enabled (non-empty blacklist) pub fn enabled(&self) -> bool { !self.blacklist.is_empty() } /// Check if an announcement matches the repository blacklist /// /// Returns Some(reason) if blacklisted, None if not blacklisted. /// The reason indicates what type of match occurred (npub, npub/identifier, or identifier). pub fn check(&self, npub: &str, identifier: &str) -> Option { for entry in &self.blacklist { if entry.matches(npub, identifier) { let reason = match entry { WhitelistEntry::Pubkey(_) => { format!("Repository owner {} is blacklisted", npub) } WhitelistEntry::Repository { .. } => { format!("Repository {}/{} is blacklisted", npub, identifier) } WhitelistEntry::Identifier(_) => { format!("Repository identifier {} is blacklisted", identifier) } }; return Some(reason); } } None } } /// Event blacklist configuration for blocking events by author npub #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EventBlacklistConfig { /// Blacklisted npubs - events from these authors are rejected /// /// If empty, no events are blacklisted by author. /// Applies to ALL event types, preventing events from reaching both the relay and purgatory. #[serde(default)] pub blacklisted_npubs: Vec, } impl EventBlacklistConfig { /// Check if event blacklist is enabled (non-empty blacklist) pub fn enabled(&self) -> bool { !self.blacklisted_npubs.is_empty() } /// Check if an event author is blacklisted /// /// Returns Some(reason) if blacklisted, None if not blacklisted. pub fn check(&self, npub: &str) -> Option { if self.blacklisted_npubs.contains(&npub.to_string()) { Some(format!("Event author {} is blacklisted", npub)) } else { None } } } /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] pub enum DatabaseBackend { /// LMDB backend (persistent, general purpose) #[default] Lmdb, /// NostrDB backend (persistent, optimized for Nostr) NostrDb, /// In-memory database (fastest, no persistence - uses temp directory for git data) Memory, } impl std::fmt::Display for DatabaseBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Memory => write!(f, "memory"), Self::NostrDb => write!(f, "nostrdb"), Self::Lmdb => write!(f, "lmdb"), } } } /// ngit-grasp - A GRASP (Git Relays Authorized via Signed-Nostr Proofs) implementation /// /// Configuration is loaded with the following priority (highest to lowest): /// 1. CLI flags (e.g., --domain example.com) /// 2. Environment variables (e.g., NGIT_DOMAIN=example.com) /// 3. .env file (loaded automatically if present) /// 4. Built-in defaults #[derive(Debug, Clone, Serialize, Deserialize, Parser)] #[command(author, version, about, long_about = None)] #[command(propagate_version = true)] pub struct Config { /// Domain where this instance is hosted (required, used in GRASP validation) #[arg(long, env = "NGIT_DOMAIN")] pub domain: String, /// Relay operator's nsec (private key) for signing and authentication /// /// Used for: /// - NIP-11 relay information document (pubkey field derived from this nsec) /// - NIP-42 authentication when syncing from other relays /// - Future: signing events, WoT-based rate limiting of syncing relays /// /// If not provided via CLI/env, will be loaded from/saved to `.relay-owner.nsec` file /// in the current directory. If the file doesn't exist, a new key will be generated /// and saved automatically. #[arg(long, env = "NGIT_RELAY_OWNER_NSEC")] pub relay_owner_nsec: Option, /// Relay name for NIP-11 information document (defaults to "${domain} grasp relay") #[arg(long = "relay-name", env = "NGIT_RELAY_NAME")] pub relay_name_override: Option, /// Relay description for NIP-11 information document #[arg( long, env = "NGIT_RELAY_DESCRIPTION", default_value = "Git Nostr Relay - a grasp implementation" )] pub relay_description: String, /// Path to store Git repositories #[arg(long, env = "NGIT_GIT_DATA_PATH", default_value = "./data/git")] pub git_data_path: String, /// Path to store Nostr relay data #[arg(long, env = "NGIT_RELAY_DATA_PATH", default_value = "./data/relay")] pub relay_data_path: String, /// Server bind address (IP:PORT) #[arg(long, env = "NGIT_BIND_ADDRESS", default_value = "127.0.0.1:7334")] pub bind_address: String, /// Database backend type #[arg(long, env = "NGIT_DATABASE_BACKEND", value_enum, default_value_t = DatabaseBackend::Lmdb)] pub database_backend: DatabaseBackend, /// Enable Prometheus metrics endpoint #[arg(long, env = "NGIT_METRICS_ENABLED", default_value_t = true)] pub metrics_enabled: bool, /// Connections per IP before flagging as potential abuse in metrics (display only, no rate limiting) #[arg( long = "metrics-connection-per-ip-abuse-threshold", env = "NGIT_METRICS_CONNECTION_PER_IP_ABUSE_THRESHOLD", default_value_t = 10 )] pub metrics_connection_per_ip_abuse_threshold: u32, /// Number of top bandwidth repos to track in metrics #[arg( long = "metrics-top-n-repos", env = "NGIT_METRICS_TOP_N_REPOS", default_value_t = 10 )] pub metrics_top_n_repos: usize, /// URL of bootstrap relay to sync from on startup (optional) /// Sync discovers additional relays from repository announcements that list our service /// If no scheme is provided (wss:// or ws://), wss:// is assumed /// Examples: "relay.example.com" -> "wss://relay.example.com", "wss://relay.example.com" -> unchanged #[arg(long, env = "NGIT_SYNC_BOOTSTRAP_RELAY_URL")] pub sync_bootstrap_relay_url: Option, /// Maximum backoff time in seconds for sync relay reconnection (default: 3600 = 1 hour) #[arg(long, env = "NGIT_SYNC_MAX_BACKOFF_SECS", default_value_t = 3600)] pub sync_max_backoff_secs: u64, /// Interval in seconds for checking disconnected relays and attempting reconnection (default: 60) /// Set to lower value for faster reconnection testing #[arg( long, env = "NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", default_value_t = 60 )] pub sync_disconnect_check_interval_secs: u64, /// Base backoff time in seconds for relay reconnection (default: 5) /// Used for exponential backoff: base * 2^(failures-1) /// Set to 1 for faster test cycles /// Note: The connection timeout is capped at this value #[arg(long, env = "NGIT_SYNC_BASE_BACKOFF_SECS", default_value_t = 5)] pub sync_base_backoff_secs: u64, /// Disable NIP-77 negentropy sync (default: false) /// When enabled, sync will use REQ+EOSE instead of negentropy for history sync. /// Primarily useful for testing that sync works without negentropy support. #[arg(long, env = "NGIT_SYNC_DISABLE_NEGENTROPY", default_value_t = false)] pub sync_disable_negentropy: bool, /// Hot cache duration in seconds for rejected announcements (default: 120 = 2 minutes) /// Stores full event objects for immediate re-processing when dependencies resolve. /// Too short (<30s): Miss events from slow relays /// Too long (>5min): Waste memory #[arg( long, env = "NGIT_REJECTED_HOT_CACHE_DURATION_SECS", default_value_t = 120 )] pub rejected_hot_cache_duration_secs: u64, /// Cold index expiry in seconds for rejected announcements (default: 604800 = 7 days) /// Stores metadata only to prevent repeated downloads of rejected events. #[arg( long, env = "NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS", default_value_t = 604800 )] pub rejected_cold_index_expiry_secs: u64, /// Hours before removing relay from naughty list (default: 12) /// Relays with persistent infrastructure issues (DNS, TLS, protocol errors) are /// tracked separately and retried after this expiration period. #[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)] pub naughty_list_expiration_hours: u64, /// Enable GRASP-05 archive mode: accept all announcements regardless of listing (WARNING: storage risk) #[arg(long, env = "NGIT_ARCHIVE_ALL", default_value_t = false)] pub archive_all: bool, /// GRASP-05 archive whitelist: comma-separated list of npub/identifier/npub/identifier entries /// Formats: "npub1...", "npub1.../identifier", "identifier" #[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, 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, /// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries /// Formats: "npub1...", "npub1.../identifier", "identifier" /// When set, only announcements matching the whitelist AND listing the service are accepted #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")] pub repository_whitelist: String, /// Repository blacklist: comma-separated list of npub/identifier/npub/identifier entries to reject /// Formats: "npub1...", "npub1.../identifier", "identifier" /// Blacklist takes precedence over all whitelists (archive and repository) #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] pub repository_blacklist: String, /// Event blacklist: comma-separated list of npubs whose events are rejected /// All events from these authors are blocked from both relay storage and purgatory #[arg(long, env = "NGIT_EVENT_BLACKLIST", default_value = "")] pub event_blacklist: String, /// Maximum total connections to the relay (default: 4096) /// Prevents connection exhaustion DoS attacks #[arg(long, env = "NGIT_MAX_CONNECTIONS", default_value_t = 4096)] pub max_connections: usize, /// Log level for application logging #[arg(long, env = "NGIT_LOG_LEVEL", default_value = "info")] pub log_level: String, } impl Config { /// Path to the relay owner key file const RELAY_OWNER_KEY_FILE: &'static str = ".relay-owner.nsec"; /// Load configuration from CLI args, environment variables, and defaults. /// /// Priority (highest to lowest): /// 1. CLI flags /// 2. Environment variables /// 3. .env file /// 4. Built-in defaults pub fn load() -> Result { // Load .env file if present (before clap parses, so env vars are available) dotenvy::dotenv().ok(); // Parse CLI args (clap automatically handles env var fallback) let mut config = Self::parse(); // If relay_owner_nsec not provided, load from file or generate if config.relay_owner_nsec.is_none() { config.relay_owner_nsec = Some(Self::load_or_generate_relay_owner_key()?); } else { // If provided via CLI/env, trim any whitespace (newlines, spaces, etc.) // This handles cases where the value is read from a file with trailing newline config.relay_owner_nsec = config.relay_owner_nsec.map(|s| s.trim().to_string()); } Ok(config) } /// Load relay owner key from file, or generate and save a new one fn load_or_generate_relay_owner_key() -> Result { let key_path = PathBuf::from(Self::RELAY_OWNER_KEY_FILE); // Try to load existing key if key_path.exists() { let nsec = fs::read_to_string(&key_path) .context("Failed to read relay owner key file")? .trim() .to_string(); // Validate it's a valid nsec Keys::parse(&nsec).context("Invalid nsec in relay owner key file")?; tracing::info!("Loaded relay owner key from {}", key_path.display()); return Ok(nsec); } // Generate new key let keys = Keys::generate(); let nsec = keys.secret_key().to_bech32()?; // Save to file fs::write(&key_path, &nsec).context("Failed to write relay owner key file")?; tracing::info!( "Generated new relay owner key and saved to {}", key_path.display() ); Ok(nsec) } /// Get the relay owner's Keys object pub fn relay_owner_keys(&self) -> Result { let nsec = self .relay_owner_nsec .as_ref() .context("relay_owner_nsec not set (should be set by Config::load())")?; Keys::parse(nsec).context("Invalid relay_owner_nsec") } /// Get the relay owner's public key (hex format) for NIP-11 pub fn relay_owner_pubkey_hex(&self) -> Result { let keys = self.relay_owner_keys()?; Ok(keys.public_key().to_hex()) } /// Get the relay owner's public key (npub format) pub fn relay_owner_npub(&self) -> Result { let keys = self.relay_owner_keys()?; Ok(keys.public_key().to_bech32()?) } /// Get relay name (defaults to "${domain} grasp relay" if not set) pub fn relay_name(&self) -> String { self.relay_name_override .clone() .unwrap_or_else(|| format!("{} grasp relay", self.domain)) } /// Get effective git data path /// Returns a temp directory when using memory backend with default path, otherwise the configured path pub fn effective_git_data_path(&self) -> String { if self.database_backend == DatabaseBackend::Memory && self.git_data_path == "./data/git" { // Only use default temp directory if git_data_path is still the default value std::env::temp_dir() .join("ngit-grasp-git") .to_string_lossy() .into_owned() } else { self.git_data_path.clone() } } /// Validate configuration and return fatal errors /// /// This should be called immediately after Config::load() to fail fast on config errors. /// Recoverable issues (e.g., malformed whitelist entries) are logged as warnings and skipped. pub fn validate(&self) -> Result<()> { // Validate relay owner nsec (should always be set by Config::load()) let nsec = self .relay_owner_nsec .as_ref() .context("relay_owner_nsec not set (should be set by Config::load())")?; Keys::parse(nsec).context("Invalid relay_owner_nsec format")?; // Validate archive configuration let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); 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, \ NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set" )); } } // Validate repository whitelist configuration let repository_whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist); // Fatal error: repository_whitelist with archive_read_only=true (incompatible) if !repository_whitelist.is_empty() { let read_only = self.archive_read_only.unwrap_or(archive_enabled); if read_only { return Err(anyhow!( "NGIT_REPOSITORY_WHITELIST cannot be used with NGIT_ARCHIVE_READ_ONLY=true. \ Archive read-only mode rejects announcements that don't match the archive whitelist, \ regardless of service listing. Either set NGIT_ARCHIVE_READ_ONLY=false or use \ NGIT_ARCHIVE_WHITELIST instead of NGIT_REPOSITORY_WHITELIST." )); } } 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_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() Some(false) => false, None => { // Default: true if archive mode enabled, false otherwise archive_enabled } }; ArchiveConfig { archive_all: self.archive_all, whitelist, grasp_services: archive_grasp_services, read_only, } } /// Get parsed repository whitelist configuration /// /// This method assumes config has been validated - call Config::validate() first! pub fn repository_config(&self) -> RepositoryConfig { let whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist); RepositoryConfig { whitelist } } /// Get parsed repository blacklist configuration /// /// This method assumes config has been validated - call Config::validate() first! pub fn blacklist_config(&self) -> BlacklistConfig { let blacklist = WhitelistEntry::parse_whitelist(&self.repository_blacklist); BlacklistConfig { blacklist } } /// Get parsed event blacklist configuration /// /// This method assumes config has been validated - call Config::validate() first! pub fn event_blacklist_config(&self) -> EventBlacklistConfig { let blacklisted_npubs: Vec = self .event_blacklist .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect(); EventBlacklistConfig { blacklisted_npubs } } /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { // Generate a test key deterministically for consistent tests let keys = Keys::generate(); let nsec = keys .secret_key() .to_bech32() .expect("Failed to generate test nsec"); Self { domain: "localhost:7334".to_string(), relay_owner_nsec: Some(nsec), relay_name_override: Some("test relay".to_string()), relay_description: "test description".to_string(), git_data_path: "./test_data/git".to_string(), relay_data_path: "./test_data/relay".to_string(), bind_address: "127.0.0.1:7334".to_string(), database_backend: DatabaseBackend::Memory, metrics_enabled: true, metrics_connection_per_ip_abuse_threshold: 10, metrics_top_n_repos: 10, sync_bootstrap_relay_url: None, sync_max_backoff_secs: 3600, sync_disconnect_check_interval_secs: 60, sync_base_backoff_secs: 5, sync_disable_negentropy: false, rejected_hot_cache_duration_secs: 120, rejected_cold_index_expiry_secs: 604800, 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(), event_blacklist: String::new(), max_connections: 500, log_level: "debug".to_string(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_values() { let config = Config::for_testing(); assert_eq!(config.domain, "localhost:7334"); assert_eq!(config.bind_address, "127.0.0.1:7334"); // for_testing() uses Memory, but the actual default is Lmdb assert_eq!(config.database_backend, DatabaseBackend::Memory); } #[test] fn test_lmdb_is_default() { // Verify the actual default via the enum's Default trait assert_eq!(DatabaseBackend::default(), DatabaseBackend::Lmdb); } #[test] fn test_memory_backend_uses_temp_dir_with_default_path() { // When git_data_path is the default value, memory backend uses temp dir let config = Config { database_backend: DatabaseBackend::Memory, git_data_path: "./data/git".to_string(), // Default value ..Config::for_testing() }; let git_path = config.effective_git_data_path(); assert!(git_path.contains("ngit-grasp-git")); } #[test] fn test_memory_backend_respects_custom_path() { // When git_data_path is explicitly set, memory backend respects it let config = Config { database_backend: DatabaseBackend::Memory, git_data_path: "./custom/git/path".to_string(), ..Config::for_testing() }; let git_path = config.effective_git_data_path(); assert_eq!(git_path, "./custom/git/path"); } #[test] fn test_lmdb_backend_uses_configured_path() { let config = Config { database_backend: DatabaseBackend::Lmdb, git_data_path: "./my/git/path".to_string(), relay_data_path: "./my/relay/path".to_string(), ..Config::for_testing() }; assert_eq!(config.effective_git_data_path(), "./my/git/path"); } #[test] fn test_database_backend_display() { assert_eq!(DatabaseBackend::Memory.to_string(), "memory"); assert_eq!(DatabaseBackend::NostrDb.to_string(), "nostrdb"); assert_eq!(DatabaseBackend::Lmdb.to_string(), "lmdb"); } #[test] fn test_relay_name_default() { let config = Config { domain: "example.com".to_string(), relay_name_override: None, ..Config::for_testing() }; assert_eq!(config.relay_name(), "example.com grasp relay"); } #[test] fn test_relay_name_override() { let config = Config { domain: "example.com".to_string(), relay_name_override: Some("My Custom Relay".to_string()), ..Config::for_testing() }; assert_eq!(config.relay_name(), "My Custom Relay"); } #[test] fn test_relay_owner_keys() { let config = Config::for_testing(); let keys = config.relay_owner_keys().expect("Should have valid keys"); let npub = config.relay_owner_npub().expect("Should derive npub"); // Verify the npub matches the keys assert_eq!(npub, keys.public_key().to_bech32().unwrap()); assert!(npub.starts_with("npub1")); } #[test] fn test_relay_owner_nsec_trims_whitespace() { // Test that Config::load() trims whitespace from provided nsec // This simulates what happens when nsec is read from a file with trailing newline let nsec_clean = "nsec1rt5f3gfnktvd77fdarg00ff94l8j833y778ym3xlzkx89g9v5zvq4y2qee"; let nsec_with_newline = format!("{}\n", nsec_clean); let nsec_with_spaces = format!(" {} ", nsec_clean); // Test that trimming happens by directly creating config with whitespace let mut config1 = Config::for_testing(); config1.relay_owner_nsec = Some(nsec_with_newline.clone()); // Simulate what Config::load() does config1.relay_owner_nsec = config1.relay_owner_nsec.map(|s| s.trim().to_string()); let mut config2 = Config::for_testing(); config2.relay_owner_nsec = Some(nsec_with_spaces.clone()); config2.relay_owner_nsec = config2.relay_owner_nsec.map(|s| s.trim().to_string()); // Both should parse successfully after trimming assert!(config1.relay_owner_keys().is_ok()); assert!(config2.relay_owner_keys().is_ok()); // Both should produce the same public key let keys1 = config1.relay_owner_keys().unwrap(); let keys2 = config2.relay_owner_keys().unwrap(); assert_eq!(keys1.public_key(), keys2.public_key()); // Verify trimmed nsec equals clean nsec assert_eq!(config1.relay_owner_nsec.unwrap(), nsec_clean); assert_eq!(config2.relay_owner_nsec.unwrap(), nsec_clean); } #[test] fn test_metrics_config_defaults() { let config = Config::for_testing(); assert!(config.metrics_enabled); assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 10); assert_eq!(config.metrics_top_n_repos, 10); } #[test] fn test_metrics_config_custom_values() { let config = Config { metrics_enabled: false, metrics_connection_per_ip_abuse_threshold: 50, metrics_top_n_repos: 25, ..Config::for_testing() }; assert!(!config.metrics_enabled); assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50); assert_eq!(config.metrics_top_n_repos, 25); } #[test] fn test_parse_whitelist_entry_pubkey() { // Generate a valid test npub let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let entry = WhitelistEntry::parse(&test_npub).unwrap(); assert!(matches!(entry, WhitelistEntry::Pubkey(_))); if let WhitelistEntry::Pubkey(npub) = entry { assert_eq!(npub, test_npub); } } #[test] fn test_parse_whitelist_entry_repository() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let entry = WhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap(); assert!(matches!(entry, WhitelistEntry::Repository { .. })); if let WhitelistEntry::Repository { npub, identifier } = entry { assert_eq!(npub, test_npub); assert_eq!(identifier, "linux"); } } #[test] fn test_parse_whitelist_entry_identifier() { let entry = WhitelistEntry::parse("bitcoin-core").unwrap(); assert!(matches!(entry, WhitelistEntry::Identifier(_))); if let WhitelistEntry::Identifier(id) = entry { assert_eq!(id, "bitcoin-core"); } } #[test] fn test_parse_whitelist_entry_invalid_npub() { let result = WhitelistEntry::parse("npub1invalid"); assert!(result.is_err()); } #[test] fn test_whitelist_entry_matches() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let entry = WhitelistEntry::Pubkey(test_npub.clone()); assert!(entry.matches(&test_npub, "any-identifier")); assert!(!entry.matches("npub1different", "any-identifier")); } #[test] fn test_whitelist_entry_matches_repository() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let entry = WhitelistEntry::Repository { npub: test_npub.clone(), identifier: "linux".to_string(), }; assert!(entry.matches(&test_npub, "linux")); assert!(!entry.matches(&test_npub, "bitcoin")); assert!(!entry.matches("npub1different", "linux")); } #[test] fn test_whitelist_entry_matches_identifier() { let entry = WhitelistEntry::Identifier("bitcoin-core".to_string()); assert!(entry.matches("npub1alice", "bitcoin-core")); assert!(entry.matches("npub1bob", "bitcoin-core")); assert!(!entry.matches("npub1alice", "other-repo")); } #[test] fn test_archive_config_enabled() { let config = ArchiveConfig::default(); assert!(!config.enabled()); let config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), grasp_services: Vec::new(), read_only: true, }; assert!(config.enabled()); let config = ArchiveConfig { archive_all: false, whitelist: vec![WhitelistEntry::Identifier("test".into())], grasp_services: Vec::new(), read_only: true, }; assert!(config.enabled()); } #[test] fn test_archive_config_matches() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = ArchiveConfig { archive_all: false, whitelist: vec![ WhitelistEntry::Pubkey(test_npub.clone()), WhitelistEntry::Identifier("bitcoin-core".into()), ], grasp_services: Vec::new(), read_only: false, }; assert!(config.matches(&test_npub, "any-repo")); assert!(config.matches("npub1bob", "bitcoin-core")); assert!(!config.matches("npub1bob", "other-repo")); } #[test] fn test_archive_config_matches_archive_all() { let config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), grasp_services: Vec::new(), read_only: true, }; assert!(config.matches("npub1alice", "any-repo")); assert!(config.matches("npub1bob", "other-repo")); } #[test] fn test_parse_whitelist_empty() { let whitelist = WhitelistEntry::parse_whitelist(""); assert!(whitelist.is_empty()); let whitelist = WhitelistEntry::parse_whitelist(" "); assert!(whitelist.is_empty()); } #[test] fn test_parse_whitelist_multiple() { let keys1 = Keys::generate(); let keys2 = Keys::generate(); let test_npub1 = keys1.public_key().to_bech32().unwrap(); let test_npub2 = keys2.public_key().to_bech32().unwrap(); let whitelist = WhitelistEntry::parse_whitelist(&format!( "{},bitcoin-core,{}/linux", test_npub1, test_npub2 )); assert_eq!(whitelist.len(), 3); } #[test] fn test_parse_whitelist_invalid_npub_skipped() { // Invalid entries should be skipped with warnings, not fail let whitelist = WhitelistEntry::parse_whitelist("npub1invalid,bitcoin-core"); assert_eq!(whitelist.len(), 1); // Only bitcoin-core should be parsed assert!(matches!( &whitelist[0], WhitelistEntry::Identifier(id) if id == "bitcoin-core" )); } #[test] fn test_archive_config_parsing() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { archive_whitelist: format!("{},bitcoin-core", test_npub), ..Config::for_testing() }; let archive_config = config.archive_config(); assert_eq!(archive_config.whitelist.len(), 2); } #[test] fn test_archive_read_only_defaults() { // Default: false when no archive mode let config = Config::for_testing(); assert!(!config.archive_config().read_only); // Default: true when archive_all is set let config = Config { archive_all: true, ..Config::for_testing() }; assert!(config.archive_config().read_only); // Default: true when archive_whitelist is set let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { archive_whitelist: test_npub, ..Config::for_testing() }; assert!(config.archive_config().read_only); } #[test] fn test_archive_read_only_explicit() { // Explicit true with archive_all let config = Config { archive_all: true, archive_read_only: Some(true), ..Config::for_testing() }; assert!(config.archive_config().read_only); // Explicit false with archive_all (unusual but allowed) let config = Config { archive_all: true, archive_read_only: Some(false), ..Config::for_testing() }; assert!(!config.archive_config().read_only); // Explicit false without archive mode let config = Config { archive_read_only: Some(false), ..Config::for_testing() }; assert!(!config.archive_config().read_only); } #[test] fn test_archive_read_only_validation_error() { // Error: true without archive mode should fail validation let config = Config { archive_read_only: Some(true), ..Config::for_testing() }; let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("requires either")); } #[test] fn test_repository_whitelist_parsing() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { repository_whitelist: format!("{},bitcoin-core", test_npub), ..Config::for_testing() }; let repo_config = config.repository_config(); assert_eq!(repo_config.whitelist.len(), 2); assert!(repo_config.enabled()); } #[test] fn test_repository_whitelist_empty() { let config = Config::for_testing(); let repo_config = config.repository_config(); assert!(repo_config.whitelist.is_empty()); assert!(!repo_config.enabled()); } #[test] fn test_repository_whitelist_validation_incompatible_with_archive_read_only() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { archive_all: true, archive_read_only: Some(true), repository_whitelist: test_npub, ..Config::for_testing() }; let result = config.validate(); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("cannot be used with")); assert!(err.contains("NGIT_ARCHIVE_READ_ONLY=true")); } #[test] fn test_repository_whitelist_compatible_with_archive_read_only_false() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { archive_all: true, archive_read_only: Some(false), repository_whitelist: test_npub, ..Config::for_testing() }; // Should not error on validation assert!(config.validate().is_ok()); } #[test] fn test_repository_config_matches() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = RepositoryConfig { whitelist: vec![ WhitelistEntry::Pubkey(test_npub.clone()), WhitelistEntry::Identifier("bitcoin-core".into()), ], }; assert!(config.matches(&test_npub, "any-repo")); assert!(config.matches("npub1bob", "bitcoin-core")); assert!(!config.matches("npub1bob", "other-repo")); } #[test] fn test_validate_success_with_valid_config() { // Valid config should pass validation let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { archive_whitelist: format!("{},bitcoin-core", test_npub), archive_read_only: Some(false), repository_whitelist: "rust".to_string(), ..Config::for_testing() }; assert!(config.validate().is_ok()); } #[test] fn test_validate_with_all_invalid_whitelist_entries() { // All invalid entries should be skipped with warnings, but validation should succeed let config = Config { archive_whitelist: "npub1invalid,npub1bad,npub1wrong".to_string(), ..Config::for_testing() }; assert!(config.validate().is_ok()); // All entries should be skipped let archive_config = config.archive_config(); assert_eq!(archive_config.whitelist.len(), 0); assert!(!archive_config.enabled()); } #[test] fn test_validate_with_mixed_valid_invalid_entries() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); // Mixed valid and invalid entries - should keep valid ones let config = Config { repository_whitelist: format!("npub1invalid,{},bitcoin-core,npub1bad", test_npub), ..Config::for_testing() }; assert!(config.validate().is_ok()); let repo_config = config.repository_config(); // Should have 2 valid entries: the test_npub and bitcoin-core assert_eq!(repo_config.whitelist.len(), 2); } #[test] fn test_whitelist_entry_with_extra_whitespace() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); // Whitespace should be trimmed let whitelist = WhitelistEntry::parse_whitelist(&format!(" {} , bitcoin-core , rust ", test_npub)); assert_eq!(whitelist.len(), 3); } #[test] fn test_archive_config_with_all_invalid_entries_not_enabled() { // If all whitelist entries are invalid, archive mode should not be enabled let config = Config { archive_whitelist: "npub1invalid,npub1bad".to_string(), ..Config::for_testing() }; let archive_config = config.archive_config(); assert!(!archive_config.enabled()); assert_eq!(archive_config.whitelist.len(), 0); } #[test] fn test_validate_detects_invalid_relay_owner_nsec() { // Invalid nsec should fail validation let config = Config { relay_owner_nsec: Some("nsec1invalid".to_string()), ..Config::for_testing() }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Invalid relay_owner_nsec")); } #[test] fn test_validate_requires_relay_owner_nsec() { // Missing nsec should fail validation let config = Config { relay_owner_nsec: None, ..Config::for_testing() }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("relay_owner_nsec not set")); } #[test] fn test_blacklist_config_parsing() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = Config { repository_blacklist: format!("{},bitcoin-core", test_npub), ..Config::for_testing() }; let blacklist_config = config.blacklist_config(); assert_eq!(blacklist_config.blacklist.len(), 2); assert!(blacklist_config.enabled()); } #[test] fn test_blacklist_config_empty() { let config = Config::for_testing(); let blacklist_config = config.blacklist_config(); assert!(blacklist_config.blacklist.is_empty()); assert!(!blacklist_config.enabled()); } #[test] fn test_blacklist_check_npub() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = BlacklistConfig { blacklist: vec![WhitelistEntry::Pubkey(test_npub.clone())], }; let result = config.check(&test_npub, "any-repo"); assert!(result.is_some()); let reason = result.unwrap(); assert!(reason.contains("owner")); assert!(reason.contains(&test_npub)); } #[test] fn test_blacklist_check_identifier() { let config = BlacklistConfig { blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], }; let result = config.check("npub1alice", "banned-repo"); assert!(result.is_some()); let reason = result.unwrap(); assert!(reason.contains("identifier")); assert!(reason.contains("banned-repo")); } #[test] fn test_blacklist_check_repository() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = BlacklistConfig { blacklist: vec![WhitelistEntry::Repository { npub: test_npub.clone(), identifier: "specific-repo".to_string(), }], }; let result = config.check(&test_npub, "specific-repo"); assert!(result.is_some()); let reason = result.unwrap(); assert!(reason.contains(&test_npub)); assert!(reason.contains("specific-repo")); } #[test] fn test_blacklist_check_not_blacklisted() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = BlacklistConfig { blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], }; let result = config.check(&test_npub, "allowed-repo"); assert!(result.is_none()); } #[test] fn test_event_blacklist_config_parsing() { let keys1 = Keys::generate(); let keys2 = Keys::generate(); let npub1 = keys1.public_key().to_bech32().unwrap(); let npub2 = keys2.public_key().to_bech32().unwrap(); let config = Config { event_blacklist: format!("{},{}", npub1, npub2), ..Config::for_testing() }; let event_blacklist_config = config.event_blacklist_config(); assert_eq!(event_blacklist_config.blacklisted_npubs.len(), 2); assert!(event_blacklist_config.enabled()); assert!(event_blacklist_config.blacklisted_npubs.contains(&npub1)); assert!(event_blacklist_config.blacklisted_npubs.contains(&npub2)); } #[test] fn test_event_blacklist_config_empty() { let config = Config::for_testing(); let event_blacklist_config = config.event_blacklist_config(); assert!(event_blacklist_config.blacklisted_npubs.is_empty()); assert!(!event_blacklist_config.enabled()); } #[test] fn test_event_blacklist_check_blacklisted() { let keys = Keys::generate(); let test_npub = keys.public_key().to_bech32().unwrap(); let config = EventBlacklistConfig { blacklisted_npubs: vec![test_npub.clone()], }; let result = config.check(&test_npub); assert!(result.is_some()); let reason = result.unwrap(); assert!(reason.contains("author")); assert!(reason.contains(&test_npub)); } #[test] fn test_event_blacklist_check_not_blacklisted() { let keys1 = Keys::generate(); let keys2 = Keys::generate(); let banned_npub = keys1.public_key().to_bech32().unwrap(); let allowed_npub = keys2.public_key().to_bech32().unwrap(); let config = EventBlacklistConfig { blacklisted_npubs: vec![banned_npub], }; 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!(archive_config.read_only); // 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!(config.archive_config().read_only); } #[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!(!config.archive_config().read_only); } #[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(),]) ); } }