Files
ngit-grasp/src/config.rs
T

1664 lines
60 KiB
Rust

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<Self> {
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<Self> {
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<WhitelistEntry>,
/// 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<String>,
/// 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<WhitelistEntry>,
}
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<WhitelistEntry>,
}
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<String> {
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<String>,
}
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<String> {
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,
/// 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::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<String>,
/// 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<String>,
/// 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<String>,
/// 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<bool>,
/// Enable GRASP-06 contributor PR submission endpoint at /prs/<npub>/<identifier>.git
///
/// When enabled, the relay exposes an unauthenticated PR submission endpoint
/// at `/prs/<npub>/<identifier>.git` that accepts pushes of `refs/nostr/<event-id>`
/// from any contributor. Security relies on the signed PR/PR-Update events
/// the refs reference, not on HTTP-level auth.
///
/// Default: false. This is an opt-in feature that adds an unauthenticated
/// write surface; operators must understand the tradeoffs (see GRASP-06 spec
/// and `docs/explanation/grasp-06-contributor-pr-submission.md`) before
/// enabling it.
#[arg(long, env = "NGIT_GRASP06_ENABLE", default_value_t = false)]
pub grasp06_enable: bool,
/// 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,
/// Deletion request disrespector: ignore NIP-09 deletion requests (archival mode)
///
/// When `true`, the relay stores incoming NIP-09 (kind 5) deletion requests but
/// does NOT act on them: targeted events remain fully accessible. This makes the
/// relay an archival server, preventing "left-pad" scenarios by ensuring at least
/// some relays preserve deleted content.
///
/// This setting ONLY affects NIP-09 user-initiated deletions. Runtime
/// blacklist-triggered deletion orchestration is deferred, so there is no
/// active blacklist deletion path to bypass today.
///
/// When `true`, NIP-09 (`"deletion"`) is NOT advertised in the NIP-11 supported
/// NIPs list so clients can discover that the relay does not honour deletions.
#[arg(
long,
env = "NGIT_DELETION_REQUEST_DISRESPECTOR",
default_value_t = false
)]
pub deletion_request_disrespector: bool,
/// Maximum total connections to the relay (default: unlimited, defers to OS/infrastructure limits)
#[arg(long, env = "NGIT_MAX_CONNECTIONS")]
pub max_connections: Option<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<Self> {
// 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
pub fn load_or_generate_relay_owner_key() -> Result<String> {
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<Keys> {
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<String> {
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<String> {
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<String> {
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<String> = 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,
grasp06_enable: false,
repository_whitelist: String::new(),
repository_blacklist: String::new(),
event_blacklist: String::new(),
deletion_request_disrespector: false,
max_connections: None,
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::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_deletion_request_disrespector_default() {
let config = Config::for_testing();
assert!(!config.deletion_request_disrespector);
}
#[test]
fn test_deletion_request_disrespector_enabled() {
let config = Config {
deletion_request_disrespector: true,
..Config::for_testing()
};
assert!(config.deletion_request_disrespector);
}
#[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(),])
);
}
}