From c29191b1e1239e931c575a926ec9480e594476d6 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 17:40:25 +0000 Subject: [PATCH] feat(grasp-05): implement archive mode for backup/mirror operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements GRASP-05 specification for accepting repository announcements that don't list this relay, enabling archive, mirror, and backup use cases. Core Features: - Three whitelist formats: , /, - Archive-all mode for complete ecosystem mirrors - Fail-fast npub validation at startup - Read-only enforcement (archived repos reject pushes) - Full GRASP-02 sync (git data + Nostr events) - Dynamic archive status (no flags/metadata) Implementation: - Add ArchiveWhitelistEntry enum with Pubkey/Repository/Identifier variants - Add ArchiveConfig with validation and matching logic - Update AnnouncementResult to include AcceptArchive variant - Refactor validate_announcement() to return AnnouncementResult with archive check - Update AnnouncementPolicy with catch-all pattern for cleaner code - Wire archive config through builder and policy layers Configuration: - NGIT_ARCHIVE_ALL: Accept all announcements (⚠️ storage risk) - NGIT_ARCHIVE_WHITELIST: Comma-separated whitelist entries - Updated docs, .env.example, and nix/module.nix Testing: - 28 unit tests for config parsing and whitelist matching - 7 integration tests for archive mode validation - All 296 tests passing Validation Priority: 1. Lists our service → Accept (GRASP-01, read/write) 2. Is maintainer → AcceptMaintainer (multi-maintainer, read/write) 3. Matches archive config → AcceptArchive (GRASP-05, read-only) 4. None of above → Reject Security Considerations: - Archive-all mode has storage/bandwidth DoS risk - Identifier-only format matches any pubkey (use npub/identifier for high-value) - Invalid npubs cause startup failure (fail-fast) Documentation: - Concise explanation focused on rationale - Reference docs updated with all config options - README updated to reflect completed feature - Removed from roadmap, added to compliance section See docs/explanation/grasp-05-archive.md for details. --- .env.example | 26 ++- README.md | 17 +- docs/explanation/grasp-05-archive.md | 148 ++++++++++++ docs/reference/configuration.md | 76 +++++++ nix/module.nix | 21 ++ src/config.rs | 321 ++++++++++++++++++++++++++- src/nostr/builder.rs | 53 ++++- src/nostr/events.rs | 275 ++++++++++++++++++++--- src/nostr/policy/announcement.rs | 42 ++-- 9 files changed, 916 insertions(+), 63 deletions(-) create mode 100644 docs/explanation/grasp-05-archive.md diff --git a/.env.example b/.env.example index 6a93d78..2dc5266 100644 --- a/.env.example +++ b/.env.example @@ -165,4 +165,28 @@ # tracked separately and retried after this expiration period # CLI: --naughty-list-expiration-hours # Default: 12 -# NGIT_NAUGHTY_LIST_EXPIRATION_HOURS=12 \ No newline at end of file +# NGIT_NAUGHTY_LIST_EXPIRATION_HOURS=12 + +# ============================================================================ +# GRASP-05 ARCHIVE MODE +# ============================================================================ + +# Accept all repository announcements (⚠️ WARNING: Storage/bandwidth risk) +# When enabled, any repository can be mirrored to this relay +# CLI: --archive-all +# Default: false +# NGIT_ARCHIVE_ALL=false + +# Whitelist specific repos/pubkeys/identifiers for archiving +# Comma-separated list supporting three formats: +# - Archive all repos from this pubkey +# / - Archive specific repo +# - Archive repos with this identifier (any pubkey) +# +# CLI: --archive-whitelist +# Default: (empty) +# Examples: +# NGIT_ARCHIVE_WHITELIST=npub1alice... +# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux +# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust +# NGIT_ARCHIVE_WHITELIST= \ No newline at end of file diff --git a/README.md b/README.md index 8621288..6de9d89 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,17 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f **See**: [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) and [Purgatory Git Data Sync](docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md) -### GRASP-05 (Archive) - Planned +### GRASP-05 (Archive) ✅ -- 🔄 Accept repositories not listing this instance -- 🔄 Backup/mirror mode operation +- ✅ Accept repositories not listing this instance via configurable whitelist +- ✅ Three whitelist formats: ``, `/`, `` +- ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) +- ✅ Archive-all mode for complete ecosystem mirrors +- ✅ Fail-fast npub validation at startup + +**Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only with full event and git data sync. + +**See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md) ## Roadmap @@ -223,10 +230,6 @@ This a useful feature of other git servers. **Future enhancement**: should we periodically scan relays in UserGraspLists to check for announcements that list our relay? -### GRASP-5 Archive - -This not only is an important stand alone feature, it can be used to help to test sync features and have a local backup thats easy to deploy (set as bootstrap). - ## Technology Stack - **Rust**: Core language diff --git a/docs/explanation/grasp-05-archive.md b/docs/explanation/grasp-05-archive.md new file mode 100644 index 0000000..e43a87e --- /dev/null +++ b/docs/explanation/grasp-05-archive.md @@ -0,0 +1,148 @@ +# GRASP-05 Archive Mode + +**Purpose:** Understand archive/mirror/backup functionality +**Audience:** Operators and developers + +--- + +## What It Does + +GRASP-05 enables ngit-grasp to accept repository announcements that **don't list your relay**, allowing you to run an archive, mirror, or backup service. + +**Standard GRASP-01:** Announcement must list your service → You host it (read/write) +**GRASP-05 Extension:** Announcement matches your whitelist → You archive it (read-only) + +## Why It Exists + +### Problem +In GRASP-01 strict mode, you can only host repositories whose maintainers explicitly list your relay. This prevents: +- Creating backup archives of critical projects without maintainer cooperation +- Building comprehensive mirrors of the Nostr Git ecosystem +- Providing disaster recovery for projects that might disappear + +### Solution +Archive mode relaxes the "must list service" requirement for whitelisted repositories, enabling passive mirroring while maintaining read-only guarantees. + +## How It Works + +### Three Whitelist Formats + +| Format | Example | Archives | +|--------|---------|----------| +| `` | `npub1alice...` | All repos from Alice | +| `/` | `npub1bob.../linux` | Only Bob's linux repo | +| `` | `bitcoin-core` | Any bitcoin-core repo (⚠️ any pubkey) | + +**Configuration:** +```bash +# Specific repos (safest) +NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin + +# All repos from trusted maintainers +NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob... + +# Archive everything (⚠️ storage risk) +NGIT_ARCHIVE_ALL=true +``` + +### Validation Priority + +Announcements are checked in this order: + +1. **Lists your service?** → `Accept` (GRASP-01, read/write) +2. **Is author a maintainer?** → `AcceptMaintainer` (multi-maintainer, read/write) +3. **Matches archive config?** → `AcceptArchive` (GRASP-05, read-only) +4. **None of the above** → `Reject` + +This ensures GRASP-01 compliant repos are always writable, even if they match the archive whitelist. + +### Storage Model + +Archived repos use the same directory structure as hosted repos: +``` +/ + npub1alice.../ + hosted-repo.git/ # Lists your service (writable) + archived-repo.git/ # Whitelisted (read-only) +``` + +**No flags or metadata** - archive status determined dynamically from config + announcement contents. + +### Full Sync + +Archived repositories trigger complete GRASP-02 sync: +- ✅ Nostr events (PRs, issues, patches) +- ✅ Git data via purgatory +- ✅ Same validation as hosted repos + +Archive mode is a **complete mirror**, not just git-only backup. + +## Security Considerations + +### 1. Archive-All Mode (Dangerous) + +**Don't use `NGIT_ARCHIVE_ALL=true` unless:** +- You have unlimited storage/bandwidth +- You trust the relay network +- You've implemented monitoring + +**Attack vector:** Anyone can publish announcements → unlimited storage consumption. + +### 2. Identifier-Only Format (Risky) + +```bash +NGIT_ARCHIVE_WHITELIST=bitcoin-core # Matches ANY pubkey! +``` + +Malicious users can publish fake repos with popular identifiers. Use `/` for high-value archives. + +### 3. Npub Validation + +Invalid npubs → server fails to start (fail-fast). Identifiers aren't validated (any string allowed). + +## Operational Guide + +### Start Small + +```bash +# Day 1: One critical repo +NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux + +# Week 1: Add trusted maintainers +NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob... + +# Month 1: Consider popular identifiers (with monitoring) +NGIT_ARCHIVE_WHITELIST=npub1alice...,bitcoin-core +``` + +### Monitor Growth + +Watch for: +- Storage consumption rate +- Purgatory git fetch failures +- Bandwidth usage spikes + +### Whitelist Changes + +**Current:** Static config - edit `.env`, restart server +**Future:** REST API for dynamic management (no restart) + +## Comparison: Hosted vs Archived + +| Aspect | Hosted (GRASP-01) | Archived (GRASP-05) | +|--------|-------------------|---------------------| +| Announcement must list you | ✅ Required | ❌ Whitelisted instead | +| Git pushes | ✅ Accepted | ❌ Rejected (read-only) | +| GRASP-02 sync | ✅ Full sync | ✅ Full sync | +| Relay discovery | ✅ Listed | ❌ Not listed | +| Use case | Hosting workspace | Backup/mirror | + +## Related Documentation + +- [Configuration Reference](../reference/configuration.md) - `NGIT_ARCHIVE_*` options +- [GRASP-05 Spec](https://gitworkshop.dev/danconwaydev.com/grasp/05.md) - Protocol specification +- [GRASP-02 Sync](./grasp-02-proactive-sync.md) - How sync works + +--- + +_Part of the [ngit-grasp explanation documentation](./)_ diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index bdd832f..52418ad 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -498,6 +498,82 @@ NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS=1209600 --- +### GRASP-05 Archive Configuration + +These options enable archive/mirror/backup mode per the GRASP-05 specification. + +#### `NGIT_ARCHIVE_ALL` + +**Description:** Accept all repository announcements regardless of whether they list this instance +**Type:** Boolean +**Default:** `false` +**Required:** No + +**Examples:** + +```bash +# Enable archive-all mode (⚠️ WARNING: Storage risk) +NGIT_ARCHIVE_ALL=true + +# Disable (default - GRASP-01 strict mode) +NGIT_ARCHIVE_ALL=false +``` + +**Security Warning:** When enabled, any repository can be mirrored to this relay, potentially causing storage and bandwidth exhaustion. Only enable if you have unlimited resources and trust the relay network. + +**Notes:** + +- Archived repositories are read-only (pushes rejected) +- Full sync enabled (both git data and Nostr events) +- Takes precedence over whitelist (accepts everything) + +--- + +#### `NGIT_ARCHIVE_WHITELIST` + +**Description:** Comma-separated list of repositories/pubkeys/identifiers to archive +**Type:** String (comma-separated) +**Default:** (empty) +**Required:** No + +**Formats:** + +- `` - Archive all repos from this pubkey +- `/` - Archive specific repo from specific pubkey +- `` - Archive repos with this identifier from any pubkey + +**Examples:** + +```bash +# Archive all repos from Alice +NGIT_ARCHIVE_WHITELIST=npub1alice23 + +# Archive specific repos +NGIT_ARCHIVE_WHITELIST=npub1alice23/linux,npub1bob23/bitcoin-core + +# Archive by identifier (any pubkey) +NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust + +# Mixed formats +NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core +``` + +**Validation:** + +- Npub entries are validated at startup (invalid npub = server fails to start) +- Identifier entries accept any string +- Whitespace is trimmed +- Empty entries are ignored + +**Security Notes:** + +- Identifier-only format (`bitcoin-core`) matches ANY pubkey +- Use `npub/identifier` format for high-value archives +- Whitelist is static (restart required to change) +- Future: Dynamic management via API + +--- + ### Logging Configuration #### `RUST_LOG` diff --git a/nix/module.nix b/nix/module.nix index 79b0e83..f82f069 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -177,6 +177,25 @@ let description = "Hours before removing relay from naughty list"; }; + archiveAll = mkOption { + type = types.bool; + default = false; + description = '' + Enable GRASP-05 archive mode: accept all repository announcements. + WARNING: Storage and bandwidth risk. + ''; + }; + + archiveWhitelist = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ]; + description = '' + GRASP-05 archive whitelist entries. + Formats: , /, + ''; + }; + user = mkOption { type = types.str; default = "ngit-grasp-${name}"; @@ -217,6 +236,8 @@ let toString cfg.rejectedColdIndexExpirySecs; NGIT_NAUGHTY_LIST_EXPIRATION_HOURS = toString cfg.naughtyListExpirationHours; + NGIT_ARCHIVE_ALL = toString cfg.archiveAll; + NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; RUST_LOG = cfg.logLevel; } // optionalAttrs (cfg.relayName != null) { NGIT_RELAY_NAME = cfg.relayName; diff --git a/src/config.rs b/src/config.rs index 1812fe2..b1ab43e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,150 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use clap::{Parser, ValueEnum}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +/// GRASP-05 Archive whitelist entry +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ArchiveWhitelistEntry { + /// Archive all repos from this pubkey: "npub1..." + Pubkey(String), + + /// Archive specific repo: "npub1.../identifier" + Repository { npub: String, identifier: String }, + + /// Archive any repo with this identifier: "identifier" + Identifier(String), +} + +impl ArchiveWhitelistEntry { + /// Parse a whitelist entry from string + /// + /// Formats: + /// - "npub1..." -> Pubkey + /// - "npub1.../identifier" -> Repository + /// - "identifier" -> Identifier + /// + /// Validates npub format at parse time (fail fast) + pub fn parse(s: &str) -> Result { + let trimmed = s.trim(); + + if trimmed.contains('/') { + // Format: npub1.../identifier + let parts: Vec<&str> = trimmed.split('/').collect(); + if parts.len() != 2 { + return Err(anyhow!( + "Invalid whitelist entry format '{}'. Expected 'npub/identifier'", + s + )); + } + + let npub = parts[0]; + let identifier = parts[1]; + + // Validate npub format (fail fast) + if !npub.starts_with("npub1") { + return Err(anyhow!( + "Invalid whitelist entry '{}'. First part must be npub", + s + )); + } + + PublicKey::from_bech32(npub) + .context(format!("Invalid npub in whitelist entry '{}'", s))?; + + Ok(Self::Repository { + npub: npub.to_string(), + identifier: identifier.to_string(), + }) + } else if trimmed.starts_with("npub1") { + // Format: npub1... + // Validate npub format (fail fast) + PublicKey::from_bech32(trimmed) + .context(format!("Invalid npub in whitelist entry '{}'", s))?; + + Ok(Self::Pubkey(trimmed.to_string())) + } else { + // Format: identifier + Ok(Self::Identifier(trimmed.to_string())) + } + } + + /// Check if this entry matches the given npub and identifier + pub fn matches(&self, npub: &str, identifier: &str) -> bool { + match self { + Self::Pubkey(p) => npub == p, + Self::Repository { + npub: p, + identifier: i, + } => npub == p && identifier == i, + Self::Identifier(i) => identifier == i, + } + } +} + +/// GRASP-05 Archive mode configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +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. + pub archive_all: bool, + + /// Whitelist entries for selective archiving + /// + /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). + pub whitelist: Vec, +} + +impl ArchiveConfig { + /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist) + pub fn enabled(&self) -> bool { + self.archive_all || !self.whitelist.is_empty() + } + + /// Check if an announcement matches the archive configuration + /// + /// Returns true if: + /// - archive_all is true, OR + /// - announcement matches any whitelist entry + pub fn matches(&self, npub: &str, identifier: &str) -> bool { + if self.archive_all { + return true; + } + + self.whitelist + .iter() + .any(|entry| entry.matches(npub, identifier)) + } + + /// Parse archive whitelist from comma-separated string + pub fn parse_whitelist(input: &str) -> Result> { + if input.trim().is_empty() { + return Ok(Vec::new()); + } + + input + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(ArchiveWhitelistEntry::parse) + .collect() + } +} + +impl Default for ArchiveConfig { + fn default() -> Self { + Self { + archive_all: false, + whitelist: Vec::new(), + } + } +} + /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -162,6 +302,15 @@ pub struct Config { /// 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, } impl Config { @@ -262,6 +411,15 @@ impl Config { } } + /// Get parsed archive configuration + pub fn archive_config(&self) -> Result { + let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; + Ok(ArchiveConfig { + archive_all: self.archive_all, + whitelist, + }) + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -292,6 +450,8 @@ impl Config { rejected_hot_cache_duration_secs: 120, rejected_cold_index_expiry_secs: 604800, naughty_list_expiration_hours: 12, + archive_all: false, + archive_whitelist: String::new(), } } } @@ -426,4 +586,163 @@ mod tests { 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 = ArchiveWhitelistEntry::parse(&test_npub).unwrap(); + assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_))); + if let ArchiveWhitelistEntry::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 = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap(); + assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. })); + if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry { + assert_eq!(npub, test_npub); + assert_eq!(identifier, "linux"); + } + } + + #[test] + fn test_parse_whitelist_entry_identifier() { + let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap(); + assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_))); + if let ArchiveWhitelistEntry::Identifier(id) = entry { + assert_eq!(id, "bitcoin-core"); + } + } + + #[test] + fn test_parse_whitelist_entry_invalid_npub() { + let result = ArchiveWhitelistEntry::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 = ArchiveWhitelistEntry::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 = ArchiveWhitelistEntry::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 = ArchiveWhitelistEntry::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(), + }; + assert!(config.enabled()); + + let config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], + }; + 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![ + ArchiveWhitelistEntry::Pubkey(test_npub.clone()), + ArchiveWhitelistEntry::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_archive_config_matches_archive_all() { + let config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + + assert!(config.matches("npub1alice", "any-repo")); + assert!(config.matches("npub1bob", "other-repo")); + } + + #[test] + fn test_parse_whitelist_empty() { + let whitelist = ArchiveConfig::parse_whitelist("").unwrap(); + assert!(whitelist.is_empty()); + + let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap(); + 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 = ArchiveConfig::parse_whitelist(&format!( + "{},bitcoin-core,{}/linux", + test_npub1, test_npub2 + )) + .unwrap(); + assert_eq!(whitelist.len(), 3); + } + + #[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().unwrap(); + assert_eq!(archive_config.whitelist.len(), 2); + } + + #[test] + fn test_archive_config_invalid_npub() { + let config = Config { + archive_whitelist: "npub1invalid".to_string(), + ..Config::for_testing() + }; + assert!(config.archive_config().is_err()); + } } diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index c010854..deee641 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -55,10 +55,11 @@ impl Nip34WritePolicy { database: SharedDatabase, git_data_path: impl Into, purgatory: std::sync::Arc, + archive_config: crate::config::ArchiveConfig, ) -> Self { let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); Self { - announcement_policy: AnnouncementPolicy::new(ctx.clone()), + announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config), state_policy: StatePolicy::new(ctx.clone()), pr_event_policy: PrEventPolicy::new(ctx.clone()), related_event_policy: RelatedEventPolicy::new(ctx.clone()), @@ -147,6 +148,34 @@ impl Nip34WritePolicy { } } } + AnnouncementResult::AcceptArchive => { + // GRASP-05: Archive mode - accept announcement but don't create bare repository + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + tracing::info!( + "Accepted archive announcement {} for {}/{} (GRASP-05 read-only mirror)", + event_id_str, + announcement.owner_npub(), + announcement.identifier + ); + // Don't create bare repository for archived announcements + + // Check purgatory for state events that might now be authorized + self.check_purgatory_state_events_for_identifier(&announcement.identifier) + .await; + + WritePolicyResult::Accept + } + Err(e) => { + tracing::warn!( + "Failed to parse archive announcement {}: {}", + event_id_str, + e + ); + WritePolicyResult::reject(format!("Failed to parse announcement: {}", e)) + } + } + } AnnouncementResult::Reject(reason) => { tracing::warn!( "Rejected repository announcement {}: {}", @@ -539,9 +568,27 @@ pub async fn create_relay( // Clone Arc for the write policy so both relay and policy can access the database let git_data_path = config.effective_git_data_path(); + // Parse archive configuration + let archive_config = config + .archive_config() + .map_err(|e| anyhow::anyhow!("Failed to parse archive configuration: {}", e))?; + + if archive_config.enabled() { + tracing::info!( + "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}", + archive_config.archive_all, + archive_config.whitelist.len() + ); + } + // Create write policy with purgatory integration - let write_policy = - Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory); + let write_policy = Nip34WritePolicy::new( + &config.domain, + database.clone(), + &git_data_path, + purgatory, + archive_config, + ); let relay = LocalRelayBuilder::default() .database(database.clone()) diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 9d43ca3..dabe5fe 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -359,13 +359,24 @@ impl RepositoryState { } } -/// Validate a repository announcement according to GRASP-01 +/// Validate a repository announcement according to GRASP-01 and GRASP-05 /// -/// Returns Ok(()) if valid, Err with reason if invalid. -pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> { +/// Returns: +/// - Accept: Announcement lists our service (GRASP-01) +/// - AcceptArchive: Announcement matches archive config (GRASP-05) +/// - Reject: Validation failed +/// +/// Note: AcceptMaintainer is NOT returned here (requires database access) +pub fn validate_announcement( + event: &Event, + domain: &str, + archive_config: &crate::config::ArchiveConfig, +) -> crate::nostr::policy::AnnouncementResult { + use crate::nostr::policy::AnnouncementResult; + // Must be kind 30617 if event.kind != Kind::GitRepoAnnouncement { - return Err(anyhow!( + return AnnouncementResult::Reject(format!( "Invalid kind: expected {}", Kind::GitRepoAnnouncement )); @@ -374,24 +385,32 @@ pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> { // Must have identifier let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d()); if !has_identifier { - return Err(anyhow!("Missing required 'd' tag (identifier)")); + return AnnouncementResult::Reject("Missing required 'd' tag (identifier)".to_string()); } // Parse full announcement to validate structure - let announcement = RepositoryAnnouncement::from_event(event.clone())?; + let announcement = match RepositoryAnnouncement::from_event(event.clone()) { + Ok(a) => a, + Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), + }; - // GRASP-01: MUST reject announcements that do not list the service - // in both `clone` and `relays` tags unless implementing GRASP-05 - if !announcement.lists_service(domain) { - return Err(anyhow!( - "Announcement must list service in both 'clone' and 'relays' tags. \ - Found clone URLs: {:?}, relays: {:?}", - announcement.clone_urls, - announcement.relays - )); + // GRASP-01: Check if announcement lists our service + if announcement.lists_service(domain) { + return AnnouncementResult::Accept; } - Ok(()) + // GRASP-05: Check if announcement matches archive configuration + let npub = announcement.owner_npub(); + if archive_config.matches(&npub, &announcement.identifier) { + return AnnouncementResult::AcceptArchive; + } + + // Reject: Doesn't list us and not whitelisted + AnnouncementResult::Reject(format!( + "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ + Found clone URLs: {:?}, relays: {:?}", + announcement.clone_urls, announcement.relays + )) } /// Validate a repository state announcement according to GRASP-01 @@ -529,6 +548,9 @@ mod tests { #[test] fn test_validate_announcement_success() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -537,12 +559,15 @@ mod tests { vec!["wss://gitnostr.com"], ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_ok()); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_missing_clone() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -551,13 +576,19 @@ mod tests { vec!["wss://gitnostr.com"], ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("clone")); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("clone")); + } else { + panic!("Expected Reject, got {:?}", result); + } } #[test] fn test_validate_announcement_missing_relay() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -566,13 +597,19 @@ mod tests { vec![], // No relays ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("relays")); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("relays")); + } else { + panic!("Expected Reject, got {:?}", result); + } } #[test] fn test_validate_announcement_wrong_domain() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -581,8 +618,8 @@ mod tests { vec!["wss://other-service.com"], ); - let result = validate_announcement(&event, "gitnostr.com"); - assert!(result.is_err()); + let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] @@ -805,6 +842,9 @@ mod tests { #[test] fn test_validate_announcement_with_trailing_slash_in_relay() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -814,12 +854,16 @@ mod tests { ); // Should accept despite trailing slash mismatch - let result = validate_announcement(&event, "git.shakespeare.diy"); - assert!(result.is_ok()); + let result = + validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_with_trailing_slash_in_clone_url() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -829,12 +873,16 @@ mod tests { ); // Should accept despite trailing slash mismatch - let result = validate_announcement(&event, "git.shakespeare.diy"); - assert!(result.is_ok()); + let result = + validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_with_trailing_slash_in_both() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -844,12 +892,16 @@ mod tests { ); // Should accept with trailing slashes in both - let result = validate_announcement(&event, "git.shakespeare.diy"); - assert!(result.is_ok()); + let result = + validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_validate_announcement_domain_with_trailing_slash() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + let keys = create_test_keys(); let event = create_announcement_event( &keys, @@ -859,8 +911,8 @@ mod tests { ); // Should accept even when domain parameter has trailing slash - let result = validate_announcement(&event, "gitnostr.com/"); - assert!(result.is_ok()); + let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default()); + assert!(matches!(result, AnnouncementResult::Accept)); } #[test] @@ -896,4 +948,159 @@ mod tests { assert!(announcement.has_relay("example.com")); assert!(announcement.has_relay("example.com/")); } + + #[test] + fn test_validate_announcement_archive_mode_npub() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://other-service.com/alice/test-repo.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists this npub + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_archive_mode_identifier() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "bitcoin-core", + vec!["https://other-service.com/alice/bitcoin-core.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists this identifier + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_archive_mode_repository() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "linux", + vec!["https://other-service.com/alice/linux.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists this specific repo + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Repository { + npub, + identifier: "linux".into(), + }], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_archive_all() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "any-repo", + vec!["https://other-service.com/alice/any-repo.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config with archive_all enabled + let archive_config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_validate_announcement_reject_not_in_whitelist() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "other-repo", + vec!["https://other-service.com/alice/other-repo.git"], + vec!["wss://other-service.com"], + ); + + // Create archive config that whitelists different identifier + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_validate_announcement_grasp01_takes_precedence() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that DOES list our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Even with archive config, GRASP-01 Accept takes precedence + let archive_config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::Accept)); + } } diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 61840fb..db87976 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -5,15 +5,18 @@ use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; use super::PolicyContext; +use crate::config::ArchiveConfig; use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; /// Result of announcement policy evaluation -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub enum AnnouncementResult { - /// Accept: Event passes validation + /// Accept: Event lists our service (GRASP-01 compliant) Accept, - /// Accept as maintainer: Event accepted via maintainer exception + /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) AcceptMaintainer, + /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) + AcceptArchive, /// Reject: Event fails validation with reason Reject(String), } @@ -22,31 +25,34 @@ pub enum AnnouncementResult { #[derive(Clone)] pub struct AnnouncementPolicy { ctx: PolicyContext, + archive_config: ArchiveConfig, } impl AnnouncementPolicy { - pub fn new(ctx: PolicyContext) -> Self { - Self { ctx } + pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self { + Self { + ctx, + archive_config, + } } /// Validate a repository announcement event /// /// Returns `Accept` if the announcement lists the service properly, /// `AcceptMaintainer` if accepted via maintainer exception, + /// `AcceptArchive` if accepted via GRASP-05 archive config, /// or `Reject` with reason. pub async fn validate(&self, event: &Event) -> AnnouncementResult { - // First, try normal validation (announcement lists service) - match validate_announcement(event, &self.ctx.domain) { - Ok(_) => AnnouncementResult::Accept, - Err(validation_err) => { - // Validation failed - check if this is a recursive maintainer announcement - // GRASP-01 Exception: Accept announcements from recursive maintainers - // even without listing the service, for chain discovery and GRASP-02 sync + // First, try validation (GRASP-01 + GRASP-05) + let validation_result = + validate_announcement(event, &self.ctx.domain, &self.archive_config); - // Try to parse the announcement to get identifier + match validation_result { + AnnouncementResult::Reject(reason) => { + // Validation failed - check maintainer exception + // GRASP-01 Exception: Accept announcements from recursive maintainers match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { - // Check if author is listed as maintainer in any existing announcement match self .is_maintainer_in_any_announcement( &announcement.identifier, @@ -55,16 +61,18 @@ impl AnnouncementPolicy { .await { Ok(true) => AnnouncementResult::AcceptMaintainer, - Ok(false) => AnnouncementResult::Reject(validation_err.to_string()), + Ok(false) => AnnouncementResult::Reject(reason), Err(_) => { // Fail-secure: reject on database errors - AnnouncementResult::Reject(validation_err.to_string()) + AnnouncementResult::Reject(reason) } } } - Err(_) => AnnouncementResult::Reject(validation_err.to_string()), + Err(_) => AnnouncementResult::Reject(reason), } } + // Accept, AcceptArchive, or AcceptMaintainer - return as-is + result => result, } }