mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
feat(config): add repository whitelist for curated GRASP-01 acceptance
Adds NGIT_REPOSITORY_WHITELIST option for curated relay operation that accepts only whitelisted repositories while maintaining GRASP-01 compliance (announcements must list the service). This differs from archive whitelist which enables GRASP-05 mode and doesn't require service listing. Key features: - Supports three whitelist formats: npub, npub/identifier, identifier - Enforces mutual exclusivity with archive read-only mode - Updates NIP-11 curation field when whitelist is enabled - Maintains GRASP-01 compliance (doesn't add GRASP-05 support) Configuration synced across all four sources: src/config.rs, docs/reference/configuration.md, nix/module.nix, and .env.example as required by AGENTS.md.
This commit is contained in:
+26
-1
@@ -202,4 +202,29 @@
|
||||
# CLI: --archive-read-only
|
||||
# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise
|
||||
# Note: Setting to true without archive config causes startup error
|
||||
# NGIT_ARCHIVE_READ_ONLY=
|
||||
# Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive)
|
||||
# NGIT_ARCHIVE_READ_ONLY=
|
||||
|
||||
# ============================================================================
|
||||
# REPOSITORY WHITELIST
|
||||
# ============================================================================
|
||||
|
||||
# Whitelist specific repos/pubkeys/identifiers for GRASP-01 acceptance
|
||||
# Comma-separated list supporting three formats (same as archive whitelist):
|
||||
# <npub> - Accept all repos from this pubkey (if they list our service)
|
||||
# <npub>/<identifier> - Accept specific repo (if it lists our service)
|
||||
# <identifier> - Accept repos with this identifier (if they list our service)
|
||||
#
|
||||
# Difference from archive whitelist:
|
||||
# - Repository whitelist: Announcements MUST list our service AND match whitelist
|
||||
# - Archive whitelist: Announcements don't need to list our service, just match whitelist
|
||||
#
|
||||
# CLI: --repository-whitelist <list>
|
||||
# Default: (empty - all repos listing our service are accepted)
|
||||
# Examples:
|
||||
# NGIT_REPOSITORY_WHITELIST=npub1alice...
|
||||
# NGIT_REPOSITORY_WHITELIST=npub1alice...,npub1bob.../linux
|
||||
# NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
|
||||
# Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive)
|
||||
# Note: When set, NIP-11 curation field will indicate curated repository acceptance
|
||||
# NGIT_REPOSITORY_WHITELIST=
|
||||
@@ -36,6 +36,7 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15
|
||||
- **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself
|
||||
- **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation
|
||||
- **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization
|
||||
- **Repository Whitelist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode)
|
||||
- **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization
|
||||
- **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE
|
||||
- **Live & Historic Sync**: Real-time event streaming plus catch-up for past events
|
||||
|
||||
@@ -617,6 +617,13 @@ NGIT_ARCHIVE_ALL=false
|
||||
NGIT_ARCHIVE_WHITELIST=
|
||||
# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either
|
||||
# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set"
|
||||
|
||||
# ERROR: Cannot use repository whitelist with archive read-only
|
||||
NGIT_ARCHIVE_READ_ONLY=true
|
||||
NGIT_ARCHIVE_WHITELIST=npub1alice...
|
||||
NGIT_REPOSITORY_WHITELIST=npub1bob...
|
||||
# → Server fails to start: "NGIT_REPOSITORY_WHITELIST cannot be used with
|
||||
# NGIT_ARCHIVE_READ_ONLY=true"
|
||||
```
|
||||
|
||||
**NIP-11 Impact:**
|
||||
@@ -645,6 +652,98 @@ NGIT_ARCHIVE_READ_ONLY=false
|
||||
|
||||
---
|
||||
|
||||
### Repository Whitelist
|
||||
|
||||
#### `NGIT_REPOSITORY_WHITELIST`
|
||||
|
||||
**Description:** Whitelist specific repositories/pubkeys/identifiers for GRASP-01 acceptance
|
||||
**Type:** Comma-separated list
|
||||
**Default:** Empty (all repos listing our service are accepted)
|
||||
**Required:** No
|
||||
|
||||
**Format:** Same as `NGIT_ARCHIVE_WHITELIST`:
|
||||
- `npub1...` - Accept all repos from this pubkey (if they list our service)
|
||||
- `npub1.../identifier` - Accept specific repo (if it lists our service)
|
||||
- `identifier` - Accept repos with this identifier (if they list our service)
|
||||
|
||||
**Difference from Archive Whitelist:**
|
||||
- **Repository whitelist**: Announcements **MUST** list our service **AND** match whitelist
|
||||
- **Archive whitelist**: Announcements don't need to list our service, just match whitelist
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Accept only repos from specific pubkey (that list our service)
|
||||
NGIT_REPOSITORY_WHITELIST=npub1alice23
|
||||
|
||||
# Accept specific repos only
|
||||
NGIT_REPOSITORY_WHITELIST=npub1alice23/linux,npub1bob23/bitcoin-core
|
||||
|
||||
# Accept repos with specific identifiers
|
||||
NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
|
||||
|
||||
# Combined whitelist
|
||||
NGIT_REPOSITORY_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- When set:
|
||||
- Announcements **must** list our service in both `clone` and `relays` tags (GRASP-01 requirement)
|
||||
- Announcements **must** match the whitelist (pubkey, repo, or identifier)
|
||||
- NIP-11 `curation` field set to: `"Accepts only whitelisted repositories and maintainers that list this service"`
|
||||
- When empty (default):
|
||||
- All announcements listing our service are accepted (standard GRASP-01 behavior)
|
||||
|
||||
**Error Conditions:**
|
||||
|
||||
```bash
|
||||
# ERROR: Cannot use with archive read-only mode
|
||||
NGIT_ARCHIVE_READ_ONLY=true
|
||||
NGIT_ARCHIVE_WHITELIST=npub1archive...
|
||||
NGIT_REPOSITORY_WHITELIST=npub1bob...
|
||||
# → Server fails to start: "NGIT_REPOSITORY_WHITELIST cannot be used with
|
||||
# NGIT_ARCHIVE_READ_ONLY=true. Either set NGIT_ARCHIVE_READ_ONLY=false
|
||||
# or use NGIT_ARCHIVE_WHITELIST instead"
|
||||
```
|
||||
|
||||
**NIP-11 Impact:**
|
||||
|
||||
When `NGIT_REPOSITORY_WHITELIST` is set:
|
||||
- `curation`: `"Accepts only whitelisted repositories and maintainers that list this service"`
|
||||
- `supported_grasps`: Does **not** include `GRASP-05` (still GRASP-01 compliant)
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
```bash
|
||||
# Curated relay for specific projects (GRASP-01 mode)
|
||||
NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
|
||||
|
||||
# Personal relay for self and trusted collaborators
|
||||
NGIT_REPOSITORY_WHITELIST=npub1me...,npub1alice...,npub1bob...
|
||||
|
||||
# Project-specific relay (e.g., Rust ecosystem)
|
||||
NGIT_REPOSITORY_WHITELIST=rust,cargo,rustc,tokio,serde
|
||||
|
||||
# Hybrid: specific projects AND specific maintainer's repos
|
||||
NGIT_REPOSITORY_WHITELIST=bitcoin-core,npub1alice...
|
||||
```
|
||||
|
||||
**Comparison Table:**
|
||||
|
||||
| Configuration | Lists Service? | Matches Whitelist? | Result |
|
||||
|---------------|----------------|-------------------|---------|
|
||||
| No whitelist | Yes | N/A | ✅ Accept (GRASP-01) |
|
||||
| No whitelist | No | N/A | ❌ Reject |
|
||||
| Repository whitelist | Yes | Yes | ✅ Accept (GRASP-01) |
|
||||
| Repository whitelist | Yes | No | ❌ Reject (not whitelisted) |
|
||||
| Repository whitelist | No | Yes | ❌ Reject (doesn't list service) |
|
||||
| Archive whitelist (read-only=true) | No | Yes | ✅ Accept (GRASP-05) |
|
||||
| Archive whitelist (read-only=false) | Yes | N/A | ✅ Accept (GRASP-01) |
|
||||
| Archive whitelist (read-only=false) | No | Yes | ✅ Accept (GRASP-05) |
|
||||
|
||||
---
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
#### `RUST_LOG`
|
||||
|
||||
@@ -207,6 +207,20 @@ let
|
||||
- Repository announcements not listing this service are accepted per whitelist/archive-all
|
||||
Default: true if archiveAll or archiveWhitelist is set, false otherwise
|
||||
Note: Setting to true without archive config causes startup error
|
||||
Note: Cannot be used with repositoryWhitelist (mutually exclusive)
|
||||
'';
|
||||
};
|
||||
|
||||
repositoryWhitelist = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ];
|
||||
description = ''
|
||||
Repository whitelist for GRASP-01 acceptance.
|
||||
Announcements must BOTH list our service AND match this whitelist.
|
||||
Formats: <npub>, <npub>/<identifier>, <identifier>
|
||||
Cannot be used with archiveReadOnly=true (mutually exclusive)
|
||||
When set, NIP-11 curation field indicates curated repository acceptance
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -252,6 +266,7 @@ let
|
||||
toString cfg.naughtyListExpirationHours;
|
||||
NGIT_ARCHIVE_ALL = toString cfg.archiveAll;
|
||||
NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
|
||||
NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
|
||||
RUST_LOG = cfg.logLevel;
|
||||
} // optionalAttrs (cfg.relayName != null) {
|
||||
NGIT_RELAY_NAME = cfg.relayName;
|
||||
|
||||
+171
-41
@@ -5,21 +5,21 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// GRASP-05 Archive whitelist entry
|
||||
/// Whitelist entry for repository/archive filtering
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ArchiveWhitelistEntry {
|
||||
/// Archive all repos from this pubkey: "npub1..."
|
||||
pub enum WhitelistEntry {
|
||||
/// All repos from this pubkey: "npub1..."
|
||||
Pubkey(String),
|
||||
|
||||
/// Archive specific repo: "npub1.../identifier"
|
||||
/// Specific repo: "npub1.../identifier"
|
||||
Repository { npub: String, identifier: String },
|
||||
|
||||
/// Archive any repo with this identifier: "identifier"
|
||||
/// Any repo with this identifier: "identifier"
|
||||
Identifier(String),
|
||||
}
|
||||
|
||||
impl ArchiveWhitelistEntry {
|
||||
impl WhitelistEntry {
|
||||
/// Parse a whitelist entry from string
|
||||
///
|
||||
/// Formats:
|
||||
@@ -83,6 +83,20 @@ impl ArchiveWhitelistEntry {
|
||||
Self::Identifier(i) => identifier == i,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse whitelist from comma-separated string
|
||||
pub fn parse_whitelist(input: &str) -> Result<Vec<Self>> {
|
||||
if input.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(Self::parse)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// GRASP-05 Archive mode configuration
|
||||
@@ -97,7 +111,7 @@ pub struct ArchiveConfig {
|
||||
/// Whitelist entries for selective archiving
|
||||
///
|
||||
/// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
|
||||
pub whitelist: Vec<ArchiveWhitelistEntry>,
|
||||
pub whitelist: Vec<WhitelistEntry>,
|
||||
|
||||
/// Read-only archive mode: relay is a read-only sync of archived repositories
|
||||
///
|
||||
@@ -127,20 +141,6 @@ impl ArchiveConfig {
|
||||
.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 {
|
||||
@@ -153,6 +153,39 @@ impl Default for ArchiveConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository whitelist configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepositoryConfig {
|
||||
/// Whitelist entries for selective repository acceptance
|
||||
///
|
||||
/// If empty, all repositories listing the service are accepted (GRASP-01 mode).
|
||||
pub whitelist: Vec<WhitelistEntry>,
|
||||
}
|
||||
|
||||
impl RepositoryConfig {
|
||||
/// Check if repository whitelist is enabled (non-empty whitelist)
|
||||
pub fn enabled(&self) -> bool {
|
||||
!self.whitelist.is_empty()
|
||||
}
|
||||
|
||||
/// Check if an announcement matches the repository whitelist
|
||||
///
|
||||
/// Returns true if announcement matches any whitelist entry
|
||||
pub fn matches(&self, npub: &str, identifier: &str) -> bool {
|
||||
self.whitelist
|
||||
.iter()
|
||||
.any(|entry| entry.matches(npub, identifier))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RepositoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
whitelist: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database backend type for the relay
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -325,6 +358,12 @@ pub struct Config {
|
||||
/// Throws error if set to true without archive_all or archive_whitelist
|
||||
#[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")]
|
||||
pub archive_read_only: Option<bool>,
|
||||
|
||||
/// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries
|
||||
/// Formats: "npub1...", "npub1.../identifier", "identifier"
|
||||
/// When set, only announcements matching the whitelist AND listing the service are accepted
|
||||
#[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")]
|
||||
pub repository_whitelist: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -430,7 +469,7 @@ impl Config {
|
||||
/// Read-only mode defaults to true if archive mode is enabled, false otherwise.
|
||||
/// Throws error if explicitly set to true without archive mode enabled.
|
||||
pub fn archive_config(&self) -> Result<ArchiveConfig> {
|
||||
let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
|
||||
let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist)?;
|
||||
let archive_enabled = self.archive_all || !whitelist.is_empty();
|
||||
|
||||
let read_only = match self.archive_read_only {
|
||||
@@ -456,6 +495,28 @@ impl Config {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get parsed repository whitelist configuration
|
||||
///
|
||||
/// Throws error if repository_whitelist is set together with archive_read_only=true
|
||||
pub fn repository_config(&self) -> Result<RepositoryConfig> {
|
||||
let whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist)?;
|
||||
|
||||
// Validate incompatible configurations
|
||||
if !whitelist.is_empty() {
|
||||
let archive_config = self.archive_config()?;
|
||||
if archive_config.read_only {
|
||||
return Err(anyhow!(
|
||||
"NGIT_REPOSITORY_WHITELIST cannot be used with NGIT_ARCHIVE_READ_ONLY=true. \
|
||||
Archive read-only mode rejects announcements that don't match the archive whitelist, \
|
||||
regardless of service listing. Either set NGIT_ARCHIVE_READ_ONLY=false or use \
|
||||
NGIT_ARCHIVE_WHITELIST instead of NGIT_REPOSITORY_WHITELIST."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RepositoryConfig { whitelist })
|
||||
}
|
||||
|
||||
/// Create config for testing
|
||||
#[cfg(test)]
|
||||
pub fn for_testing() -> Self {
|
||||
@@ -489,6 +550,7 @@ impl Config {
|
||||
archive_all: false,
|
||||
archive_whitelist: String::new(),
|
||||
archive_read_only: None,
|
||||
repository_whitelist: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -629,9 +691,9 @@ mod tests {
|
||||
// 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 {
|
||||
let entry = WhitelistEntry::parse(&test_npub).unwrap();
|
||||
assert!(matches!(entry, WhitelistEntry::Pubkey(_)));
|
||||
if let WhitelistEntry::Pubkey(npub) = entry {
|
||||
assert_eq!(npub, test_npub);
|
||||
}
|
||||
}
|
||||
@@ -640,9 +702,9 @@ mod tests {
|
||||
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 {
|
||||
let entry = WhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
|
||||
assert!(matches!(entry, WhitelistEntry::Repository { .. }));
|
||||
if let WhitelistEntry::Repository { npub, identifier } = entry {
|
||||
assert_eq!(npub, test_npub);
|
||||
assert_eq!(identifier, "linux");
|
||||
}
|
||||
@@ -650,16 +712,16 @@ mod tests {
|
||||
|
||||
#[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 {
|
||||
let entry = WhitelistEntry::parse("bitcoin-core").unwrap();
|
||||
assert!(matches!(entry, WhitelistEntry::Identifier(_)));
|
||||
if let WhitelistEntry::Identifier(id) = entry {
|
||||
assert_eq!(id, "bitcoin-core");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_whitelist_entry_invalid_npub() {
|
||||
let result = ArchiveWhitelistEntry::parse("npub1invalid");
|
||||
let result = WhitelistEntry::parse("npub1invalid");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -667,7 +729,7 @@ mod tests {
|
||||
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());
|
||||
let entry = WhitelistEntry::Pubkey(test_npub.clone());
|
||||
assert!(entry.matches(&test_npub, "any-identifier"));
|
||||
assert!(!entry.matches("npub1different", "any-identifier"));
|
||||
}
|
||||
@@ -676,7 +738,7 @@ mod tests {
|
||||
fn test_whitelist_entry_matches_repository() {
|
||||
let keys = Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let entry = ArchiveWhitelistEntry::Repository {
|
||||
let entry = WhitelistEntry::Repository {
|
||||
npub: test_npub.clone(),
|
||||
identifier: "linux".to_string(),
|
||||
};
|
||||
@@ -687,7 +749,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_whitelist_entry_matches_identifier() {
|
||||
let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string());
|
||||
let entry = WhitelistEntry::Identifier("bitcoin-core".to_string());
|
||||
assert!(entry.matches("npub1alice", "bitcoin-core"));
|
||||
assert!(entry.matches("npub1bob", "bitcoin-core"));
|
||||
assert!(!entry.matches("npub1alice", "other-repo"));
|
||||
@@ -707,7 +769,7 @@ mod tests {
|
||||
|
||||
let config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
|
||||
whitelist: vec![WhitelistEntry::Identifier("test".into())],
|
||||
read_only: true,
|
||||
};
|
||||
assert!(config.enabled());
|
||||
@@ -720,8 +782,8 @@ mod tests {
|
||||
let config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![
|
||||
ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
|
||||
ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
|
||||
WhitelistEntry::Pubkey(test_npub.clone()),
|
||||
WhitelistEntry::Identifier("bitcoin-core".into()),
|
||||
],
|
||||
read_only: false,
|
||||
};
|
||||
@@ -745,10 +807,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_whitelist_empty() {
|
||||
let whitelist = ArchiveConfig::parse_whitelist("").unwrap();
|
||||
let whitelist = WhitelistEntry::parse_whitelist("").unwrap();
|
||||
assert!(whitelist.is_empty());
|
||||
|
||||
let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap();
|
||||
let whitelist = WhitelistEntry::parse_whitelist(" ").unwrap();
|
||||
assert!(whitelist.is_empty());
|
||||
}
|
||||
|
||||
@@ -758,7 +820,7 @@ mod tests {
|
||||
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!(
|
||||
let whitelist = WhitelistEntry::parse_whitelist(&format!(
|
||||
"{},bitcoin-core,{}/linux",
|
||||
test_npub1, test_npub2
|
||||
))
|
||||
@@ -850,4 +912,72 @@ mod tests {
|
||||
.to_string()
|
||||
.contains("requires either"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_whitelist_parsing() {
|
||||
let keys = Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let config = Config {
|
||||
repository_whitelist: format!("{},bitcoin-core", test_npub),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let repo_config = config.repository_config().unwrap();
|
||||
assert_eq!(repo_config.whitelist.len(), 2);
|
||||
assert!(repo_config.enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_whitelist_empty() {
|
||||
let config = Config::for_testing();
|
||||
let repo_config = config.repository_config().unwrap();
|
||||
assert!(repo_config.whitelist.is_empty());
|
||||
assert!(!repo_config.enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_whitelist_incompatible_with_archive_read_only() {
|
||||
let keys = Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let config = Config {
|
||||
archive_all: true,
|
||||
archive_read_only: Some(true),
|
||||
repository_whitelist: test_npub,
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = config.repository_config();
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("cannot be used with"));
|
||||
assert!(err.contains("NGIT_ARCHIVE_READ_ONLY=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_whitelist_compatible_with_archive_read_only_false() {
|
||||
let keys = Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let config = Config {
|
||||
archive_all: true,
|
||||
archive_read_only: Some(false),
|
||||
repository_whitelist: test_npub,
|
||||
..Config::for_testing()
|
||||
};
|
||||
// Should not error
|
||||
assert!(config.repository_config().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_config_matches() {
|
||||
let keys = Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let config = RepositoryConfig {
|
||||
whitelist: vec![
|
||||
WhitelistEntry::Pubkey(test_npub.clone()),
|
||||
WhitelistEntry::Identifier("bitcoin-core".into()),
|
||||
],
|
||||
};
|
||||
|
||||
assert!(config.matches(&test_npub, "any-repo"));
|
||||
assert!(config.matches("npub1bob", "bitcoin-core"));
|
||||
assert!(!config.matches("npub1bob", "other-repo"));
|
||||
}
|
||||
}
|
||||
|
||||
+64
-1
@@ -74,8 +74,15 @@ impl RelayInformationDocument {
|
||||
}
|
||||
supported_grasps.push("GRASP-02".to_string());
|
||||
|
||||
// Build curation field for archive read-only mode
|
||||
// Build curation field for archive read-only mode or repository whitelist
|
||||
let repository_config = config.repository_config().ok();
|
||||
let repository_whitelist_enabled = repository_config
|
||||
.as_ref()
|
||||
.map(|rc| rc.enabled())
|
||||
.unwrap_or(false);
|
||||
|
||||
let curation = if archive_read_only {
|
||||
// Archive read-only mode (GRASP-05 only)
|
||||
if let Some(ref ac) = archive_config {
|
||||
if ac.archive_all {
|
||||
Some("Read-only sync of all repositories found on network".to_string())
|
||||
@@ -87,6 +94,18 @@ impl RelayInformationDocument {
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if archive_enabled && repository_whitelist_enabled {
|
||||
// Both archive (non-read-only) AND repository whitelist enabled
|
||||
Some(
|
||||
"Accepts whitelisted repositories (with or without service listing) and whitelisted repositories that list this service"
|
||||
.to_string(),
|
||||
)
|
||||
} else if repository_whitelist_enabled {
|
||||
// Repository whitelist only
|
||||
Some(
|
||||
"Accepts only whitelisted repositories and maintainers that list this service"
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -230,4 +249,48 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("Read-only sync of whitelisted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip11_with_repository_whitelist() {
|
||||
let keys = nostr_sdk::Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let mut config = Config::for_testing();
|
||||
config.domain = "relay.example.com".to_string();
|
||||
config.repository_whitelist = format!("{},bitcoin-core", test_npub);
|
||||
|
||||
let doc = RelayInformationDocument::from_config(&config);
|
||||
|
||||
// Repository whitelist doesn't enable GRASP-05
|
||||
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
|
||||
// Should have curation field for repository whitelist
|
||||
assert!(doc.curation.is_some());
|
||||
assert!(doc
|
||||
.curation
|
||||
.unwrap()
|
||||
.contains("Accepts only whitelisted repositories"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip11_with_archive_and_repository_whitelist() {
|
||||
let keys = nostr_sdk::Keys::generate();
|
||||
let test_npub = keys.public_key().to_bech32().unwrap();
|
||||
let mut config = Config::for_testing();
|
||||
config.domain = "relay.example.com".to_string();
|
||||
config.archive_whitelist = "bitcoin-core".to_string();
|
||||
config.archive_read_only = Some(false); // Non-read-only archive mode
|
||||
config.repository_whitelist = test_npub;
|
||||
|
||||
let doc = RelayInformationDocument::from_config(&config);
|
||||
|
||||
// Should have GRASP-05 enabled due to archive whitelist
|
||||
assert_eq!(
|
||||
doc.supported_grasps,
|
||||
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
|
||||
);
|
||||
// Should have curation field reflecting BOTH archive and repository whitelist
|
||||
assert!(doc.curation.is_some());
|
||||
let curation = doc.curation.unwrap();
|
||||
assert!(curation.contains("whitelisted repositories"));
|
||||
assert!(curation.contains("with or without service listing"));
|
||||
}
|
||||
}
|
||||
|
||||
+24
-22
@@ -51,15 +51,14 @@ impl std::fmt::Debug for Nip34WritePolicy {
|
||||
|
||||
impl Nip34WritePolicy {
|
||||
pub fn new(
|
||||
domain: impl Into<String>,
|
||||
database: SharedDatabase,
|
||||
git_data_path: impl Into<std::path::PathBuf>,
|
||||
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
|
||||
archive_config: crate::config::ArchiveConfig,
|
||||
config: crate::config::Config,
|
||||
) -> Self {
|
||||
let ctx = PolicyContext::new(domain, database, git_data_path, purgatory);
|
||||
let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory);
|
||||
Self {
|
||||
announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config),
|
||||
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()),
|
||||
state_policy: StatePolicy::new(ctx.clone()),
|
||||
pr_event_policy: PrEventPolicy::new(ctx.clone()),
|
||||
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
|
||||
@@ -568,28 +567,31 @@ 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))?;
|
||||
// Parse and log archive configuration
|
||||
if let Ok(archive_config) = config.archive_config() {
|
||||
if archive_config.enabled() {
|
||||
tracing::info!(
|
||||
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
|
||||
archive_config.archive_all,
|
||||
archive_config.whitelist.len(),
|
||||
archive_config.read_only
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if archive_config.enabled() {
|
||||
tracing::info!(
|
||||
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
|
||||
archive_config.archive_all,
|
||||
archive_config.whitelist.len(),
|
||||
archive_config.read_only
|
||||
);
|
||||
// Parse and log repository configuration
|
||||
if let Ok(repository_config) = config.repository_config() {
|
||||
if repository_config.enabled() {
|
||||
tracing::info!(
|
||||
"Repository whitelist enabled: whitelist_entries={}",
|
||||
repository_config.whitelist.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create write policy with purgatory integration
|
||||
let write_policy = Nip34WritePolicy::new(
|
||||
&config.domain,
|
||||
database.clone(),
|
||||
&git_data_path,
|
||||
purgatory,
|
||||
archive_config,
|
||||
);
|
||||
let write_policy =
|
||||
Nip34WritePolicy::new(database.clone(), &git_data_path, purgatory, config.clone());
|
||||
|
||||
let relay = LocalRelayBuilder::default()
|
||||
.database(database.clone())
|
||||
|
||||
+196
-85
@@ -362,7 +362,7 @@ impl RepositoryState {
|
||||
/// Validate a repository announcement according to GRASP-01 and GRASP-05
|
||||
///
|
||||
/// Returns:
|
||||
/// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode
|
||||
/// - Accept: Announcement lists our service (GRASP-01) AND matches repository whitelist (if enabled)
|
||||
/// - AcceptArchive: Announcement matches archive config (GRASP-05)
|
||||
/// - Reject: Validation failed
|
||||
///
|
||||
@@ -370,11 +370,13 @@ impl RepositoryState {
|
||||
/// - ONLY accept announcements matching archive whitelist/all
|
||||
/// - REJECT announcements listing our service but not in whitelist (read-only sync mode)
|
||||
///
|
||||
/// When repository_whitelist is set:
|
||||
/// - Announcements must BOTH list our service AND match the repository whitelist
|
||||
///
|
||||
/// Note: AcceptMaintainer is NOT returned here (requires database access)
|
||||
pub fn validate_announcement(
|
||||
event: &Event,
|
||||
domain: &str,
|
||||
archive_config: &crate::config::ArchiveConfig,
|
||||
config: &crate::config::Config,
|
||||
) -> crate::nostr::policy::AnnouncementResult {
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
@@ -398,12 +400,33 @@ pub fn validate_announcement(
|
||||
Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
|
||||
};
|
||||
|
||||
// GRASP-01: Normal mode - accept if announcement lists our service
|
||||
if announcement.lists_service(domain) && !archive_config.read_only {
|
||||
return AnnouncementResult::Accept;
|
||||
}
|
||||
// Get archive and repository configs (fail-secure: reject on config errors)
|
||||
let archive_config = match config.archive_config() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)),
|
||||
};
|
||||
let repository_config = match config.repository_config() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)),
|
||||
};
|
||||
|
||||
let npub = announcement.owner_npub();
|
||||
let lists_service = announcement.lists_service(&config.domain);
|
||||
|
||||
// GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled)
|
||||
if lists_service && !archive_config.read_only {
|
||||
// Check repository whitelist if enabled
|
||||
if repository_config.enabled() {
|
||||
if !repository_config.matches(&npub, &announcement.identifier) {
|
||||
return AnnouncementResult::Reject(format!(
|
||||
"Announcement lists service but does not match repository whitelist. \
|
||||
Repository {}/{} not in whitelist",
|
||||
npub, announcement.identifier
|
||||
));
|
||||
}
|
||||
}
|
||||
return AnnouncementResult::Accept;
|
||||
}
|
||||
|
||||
// GRASP-05: Archive mode - accept if announcement matches whitelist
|
||||
if archive_config.matches(&npub, &announcement.identifier) {
|
||||
@@ -561,7 +584,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_success() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -572,13 +595,17 @@ mod tests {
|
||||
vec!["wss://gitnostr.com"],
|
||||
);
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_missing_clone() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -589,7 +616,11 @@ mod tests {
|
||||
vec!["wss://gitnostr.com"],
|
||||
);
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
if let AnnouncementResult::Reject(reason) = result {
|
||||
assert!(reason.contains("clone"));
|
||||
} else {
|
||||
@@ -599,7 +630,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_missing_relay() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -610,7 +641,11 @@ mod tests {
|
||||
vec![], // No relays
|
||||
);
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
if let AnnouncementResult::Reject(reason) = result {
|
||||
assert!(reason.contains("relays"));
|
||||
} else {
|
||||
@@ -620,7 +655,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_wrong_domain() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -631,7 +666,11 @@ mod tests {
|
||||
vec!["wss://other-service.com"],
|
||||
);
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Reject(_)));
|
||||
}
|
||||
|
||||
@@ -855,7 +894,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_with_trailing_slash_in_relay() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -867,14 +906,17 @@ mod tests {
|
||||
);
|
||||
|
||||
// Should accept despite trailing slash mismatch
|
||||
let result =
|
||||
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "git.shakespeare.diy".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_with_trailing_slash_in_clone_url() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -886,14 +928,17 @@ mod tests {
|
||||
);
|
||||
|
||||
// Should accept despite trailing slash mismatch
|
||||
let result =
|
||||
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "git.shakespeare.diy".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_with_trailing_slash_in_both() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -905,14 +950,17 @@ mod tests {
|
||||
);
|
||||
|
||||
// Should accept with trailing slashes in both
|
||||
let result =
|
||||
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "git.shakespeare.diy".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_domain_with_trailing_slash() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -924,7 +972,11 @@ mod tests {
|
||||
);
|
||||
|
||||
// Should accept even when domain parameter has trailing slash
|
||||
let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default());
|
||||
let config = Config {
|
||||
domain: "gitnostr.com/".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
@@ -964,7 +1016,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_archive_mode_npub() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -978,20 +1030,21 @@ mod tests {
|
||||
vec!["wss://other-service.com"],
|
||||
);
|
||||
|
||||
// Create archive config that whitelists this npub
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
|
||||
read_only: false,
|
||||
// Create config that whitelists this npub
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_whitelist: npub,
|
||||
archive_read_only: Some(false),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::AcceptArchive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_archive_mode_identifier() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1004,20 +1057,21 @@ mod tests {
|
||||
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())],
|
||||
read_only: false,
|
||||
// Create config that whitelists this identifier
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_whitelist: "bitcoin-core".to_string(),
|
||||
archive_read_only: Some(false),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::AcceptArchive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_archive_mode_repository() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1031,23 +1085,21 @@ mod tests {
|
||||
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(),
|
||||
}],
|
||||
read_only: false,
|
||||
// Create config that whitelists this specific repo
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_whitelist: format!("{}/linux", npub),
|
||||
archive_read_only: Some(false),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::AcceptArchive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_archive_all() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1060,20 +1112,21 @@ mod tests {
|
||||
vec!["wss://other-service.com"],
|
||||
);
|
||||
|
||||
// Create archive config with archive_all enabled
|
||||
let archive_config = ArchiveConfig {
|
||||
// Config with archive_all enabled
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: false,
|
||||
archive_read_only: Some(false),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::AcceptArchive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_reject_not_in_whitelist() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1086,20 +1139,21 @@ mod tests {
|
||||
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())],
|
||||
read_only: false,
|
||||
// Config that whitelists different identifier
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_whitelist: "bitcoin-core".to_string(),
|
||||
archive_read_only: Some(false),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Reject(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_announcement_grasp01_takes_precedence() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1113,19 +1167,20 @@ mod tests {
|
||||
);
|
||||
|
||||
// With archive_read_only=false, GRASP-01 Accept takes precedence
|
||||
let archive_config = ArchiveConfig {
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: false,
|
||||
archive_read_only: Some(false),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archive_read_only_rejects_non_whitelisted() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1140,19 +1195,20 @@ mod tests {
|
||||
|
||||
// With archive_read_only=true and whitelist that doesn't include this repo,
|
||||
// should reject even though it lists our service
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
|
||||
read_only: true,
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_whitelist: "bitcoin-core".to_string(),
|
||||
archive_read_only: Some(true),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Reject(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archive_read_only_accepts_whitelisted() {
|
||||
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1168,19 +1224,20 @@ mod tests {
|
||||
|
||||
// With archive_read_only=true and whitelist that DOES include this repo,
|
||||
// should accept as AcceptArchive
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
|
||||
read_only: true,
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_whitelist: npub,
|
||||
archive_read_only: Some(true),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::AcceptArchive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archive_read_only_with_archive_all() {
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
@@ -1195,13 +1252,67 @@ mod tests {
|
||||
|
||||
// With archive_read_only=true and archive_all=true,
|
||||
// should accept as AcceptArchive
|
||||
let archive_config = ArchiveConfig {
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: true,
|
||||
archive_read_only: Some(true),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::AcceptArchive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_whitelist_accepts_matching() {
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
let npub = keys.public_key().to_bech32().unwrap();
|
||||
|
||||
// Create announcement that lists our service
|
||||
let event = create_announcement_event(
|
||||
&keys,
|
||||
"test-repo",
|
||||
vec!["https://gitnostr.com/alice/test-repo.git"],
|
||||
vec!["wss://gitnostr.com"],
|
||||
);
|
||||
|
||||
// Config with repository whitelist that includes this repo
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
repository_whitelist: npub,
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_whitelist_rejects_non_matching() {
|
||||
use crate::config::Config;
|
||||
use crate::nostr::policy::AnnouncementResult;
|
||||
|
||||
let keys = create_test_keys();
|
||||
|
||||
// Create announcement that lists our service
|
||||
let event = create_announcement_event(
|
||||
&keys,
|
||||
"test-repo",
|
||||
vec!["https://gitnostr.com/alice/test-repo.git"],
|
||||
vec!["wss://gitnostr.com"],
|
||||
);
|
||||
|
||||
// Config with repository whitelist that does NOT include this repo
|
||||
let config = Config {
|
||||
domain: "gitnostr.com".to_string(),
|
||||
repository_whitelist: "bitcoin-core".to_string(),
|
||||
..Config::for_testing()
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, &config);
|
||||
assert!(matches!(result, AnnouncementResult::Reject(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
|
||||
|
||||
use super::PolicyContext;
|
||||
use crate::config::ArchiveConfig;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
|
||||
|
||||
/// Result of announcement policy evaluation
|
||||
@@ -25,15 +25,12 @@ pub enum AnnouncementResult {
|
||||
#[derive(Clone)]
|
||||
pub struct AnnouncementPolicy {
|
||||
ctx: PolicyContext,
|
||||
archive_config: ArchiveConfig,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl AnnouncementPolicy {
|
||||
pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self {
|
||||
Self {
|
||||
ctx,
|
||||
archive_config,
|
||||
}
|
||||
pub fn new(ctx: PolicyContext, config: Config) -> Self {
|
||||
Self { ctx, config }
|
||||
}
|
||||
|
||||
/// Validate a repository announcement event
|
||||
@@ -44,8 +41,7 @@ impl AnnouncementPolicy {
|
||||
/// or `Reject` with reason.
|
||||
pub async fn validate(&self, event: &Event) -> AnnouncementResult {
|
||||
// First, try validation (GRASP-01 + GRASP-05)
|
||||
let validation_result =
|
||||
validate_announcement(event, &self.ctx.domain, &self.archive_config);
|
||||
let validation_result = validate_announcement(event, &self.config);
|
||||
|
||||
match validation_result {
|
||||
AnnouncementResult::Reject(reason) => {
|
||||
|
||||
Reference in New Issue
Block a user