feat: add archive-grasp-services configuration option

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