mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
+13
-1
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user