mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
1706 lines
62 KiB
Rust
1706 lines
62 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;
|
|
use std::time::Duration;
|
|
|
|
/// 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,
|
|
|
|
/// Retention window in seconds for deleted events kept in holding DB.
|
|
#[arg(
|
|
long,
|
|
env = "NGIT_HOLDING_RETENTION_SECS",
|
|
default_value_t = crate::nostr::holding::DEFAULT_RETENTION.as_secs()
|
|
)]
|
|
pub holding_retention_secs: u64,
|
|
|
|
/// Interval in seconds between background holding cleanup passes.
|
|
#[arg(
|
|
long,
|
|
env = "NGIT_HOLDING_CLEANUP_INTERVAL_SECS",
|
|
default_value_t = crate::nostr::holding::DEFAULT_CLEANUP_INTERVAL.as_secs()
|
|
)]
|
|
pub holding_cleanup_interval_secs: 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);
|
|
|
|
if self.holding_retention_secs == 0 {
|
|
return Err(anyhow!(
|
|
"NGIT_HOLDING_RETENTION_SECS must be greater than 0"
|
|
));
|
|
}
|
|
|
|
if self.holding_cleanup_interval_secs == 0 {
|
|
return Err(anyhow!(
|
|
"NGIT_HOLDING_CLEANUP_INTERVAL_SECS must be greater than 0"
|
|
));
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|
|
/// Holding DB retention as a duration.
|
|
pub fn holding_retention(&self) -> Duration {
|
|
Duration::from_secs(self.holding_retention_secs)
|
|
}
|
|
|
|
/// Holding DB periodic cleanup interval as a duration.
|
|
pub fn holding_cleanup_interval(&self) -> Duration {
|
|
Duration::from_secs(self.holding_cleanup_interval_secs)
|
|
}
|
|
|
|
/// 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,
|
|
holding_retention_secs: crate::nostr::holding::DEFAULT_RETENTION.as_secs(),
|
|
holding_cleanup_interval_secs: crate::nostr::holding::DEFAULT_CLEANUP_INTERVAL
|
|
.as_secs(),
|
|
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(),])
|
|
);
|
|
}
|
|
}
|