mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
feat(grasp-05): implement archive mode for backup/mirror operation
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: <npub>, <npub>/<identifier>, <identifier>
- 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.
This commit is contained in:
+25
-1
@@ -165,4 +165,28 @@
|
||||
# tracked separately and retried after this expiration period
|
||||
# CLI: --naughty-list-expiration-hours <hours>
|
||||
# Default: 12
|
||||
# NGIT_NAUGHTY_LIST_EXPIRATION_HOURS=12
|
||||
# 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:
|
||||
# <npub> - Archive all repos from this pubkey
|
||||
# <npub>/<identifier> - Archive specific repo
|
||||
# <identifier> - Archive repos with this identifier (any pubkey)
|
||||
#
|
||||
# CLI: --archive-whitelist <list>
|
||||
# Default: (empty)
|
||||
# Examples:
|
||||
# NGIT_ARCHIVE_WHITELIST=npub1alice...
|
||||
# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux
|
||||
# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
|
||||
# NGIT_ARCHIVE_WHITELIST=
|
||||
@@ -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: `<npub>`, `<npub>/<identifier>`, `<identifier>`
|
||||
- ✅ 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
|
||||
|
||||
@@ -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 |
|
||||
|--------|---------|----------|
|
||||
| `<npub>` | `npub1alice...` | All repos from Alice |
|
||||
| `<npub>/<identifier>` | `npub1bob.../linux` | Only Bob's linux repo |
|
||||
| `<identifier>` | `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:
|
||||
```
|
||||
<git_data_path>/
|
||||
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 `<npub>/<identifier>` 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](./)_
|
||||
@@ -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:**
|
||||
|
||||
- `<npub>` - Archive all repos from this pubkey
|
||||
- `<npub>/<identifier>` - Archive specific repo from specific pubkey
|
||||
- `<identifier>` - 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`
|
||||
|
||||
@@ -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: <npub>, <npub>/<identifier>, <identifier>
|
||||
'';
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
+320
-1
@@ -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<Self> {
|
||||
let trimmed = s.trim();
|
||||
|
||||
if trimmed.contains('/') {
|
||||
// Format: npub1.../identifier
|
||||
let parts: Vec<&str> = trimmed.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow!(
|
||||
"Invalid whitelist entry format '{}'. Expected 'npub/identifier'",
|
||||
s
|
||||
));
|
||||
}
|
||||
|
||||
let npub = parts[0];
|
||||
let identifier = parts[1];
|
||||
|
||||
// Validate npub format (fail fast)
|
||||
if !npub.starts_with("npub1") {
|
||||
return Err(anyhow!(
|
||||
"Invalid whitelist entry '{}'. First part must be npub",
|
||||
s
|
||||
));
|
||||
}
|
||||
|
||||
PublicKey::from_bech32(npub)
|
||||
.context(format!("Invalid npub in whitelist entry '{}'", s))?;
|
||||
|
||||
Ok(Self::Repository {
|
||||
npub: npub.to_string(),
|
||||
identifier: identifier.to_string(),
|
||||
})
|
||||
} else if trimmed.starts_with("npub1") {
|
||||
// Format: npub1...
|
||||
// Validate npub format (fail fast)
|
||||
PublicKey::from_bech32(trimmed)
|
||||
.context(format!("Invalid npub in whitelist entry '{}'", s))?;
|
||||
|
||||
Ok(Self::Pubkey(trimmed.to_string()))
|
||||
} else {
|
||||
// Format: identifier
|
||||
Ok(Self::Identifier(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this entry matches the given npub and identifier
|
||||
pub fn matches(&self, npub: &str, identifier: &str) -> bool {
|
||||
match self {
|
||||
Self::Pubkey(p) => npub == p,
|
||||
Self::Repository {
|
||||
npub: p,
|
||||
identifier: i,
|
||||
} => npub == p && identifier == i,
|
||||
Self::Identifier(i) => identifier == i,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<ArchiveWhitelistEntry>,
|
||||
}
|
||||
|
||||
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<Vec<ArchiveWhitelistEntry>> {
|
||||
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<ArchiveConfig> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+50
-3
@@ -55,10 +55,11 @@ impl Nip34WritePolicy {
|
||||
database: SharedDatabase,
|
||||
git_data_path: impl Into<std::path::PathBuf>,
|
||||
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
|
||||
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())
|
||||
|
||||
+241
-34
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user