refactor: split Nip34WritePolicy into focused sub-policies

Split the ~900 line Nip34WritePolicy into focused sub-policies for improved
testability and maintainability:

- AnnouncementPolicy - Repository announcement validation
- StatePolicy - State event validation + ref alignment
- PrEventPolicy - PR/PR Update validation
- RelatedEventPolicy - Forward/backward reference checking

The main Nip34WritePolicy now delegates to these sub-policies via a shared
PolicyContext that provides domain, database, and git_data_path.

Also updates:
- README.md: Accurate project structure reflecting actual implementation
- docs/learnings: Marks this technical debt item as complete
This commit is contained in:
DanConwayDev
2025-12-04 15:42:00 +00:00
parent fd0c87c787
commit 819866330c
9 changed files with 1309 additions and 1206 deletions
+26 -14
View File
@@ -326,23 +326,35 @@ nix develop -c cargo tarpaulin --out Html
ngit-grasp/
├── src/
│ ├── main.rs # Entry point, server setup
│ ├── lib.rs # Library exports
│ ├── config.rs # Configuration
│ ├── git/
│ │ ├── mod.rs # Git module
│ │ ├── handler.rs # Git HTTP handlers
│ │ └── authorization.rs # Push validation logic
│ │ ├── mod.rs # Git module + repository operations
│ │ ├── handlers.rs # Git HTTP handlers
│ │ ├── authorization.rs # Push validation logic
│ │ ├── protocol.rs # Git protocol encoding
│ │ └── subprocess.rs # Git subprocess management
│ ├── nostr/
│ │ ├── mod.rs # Nostr module
│ │ ├── relay.rs # Relay setup and policies
│ │ └── events.rs # Event handlers
│ ├── storage/
│ │ ├── mod.rs # Storage abstraction
│ │ └── repository.rs # Repository management
│ └── config.rs # Configuration
├── docs/
│ └── ARCHITECTURE.md # Detailed architecture
├── tests/
│ ├── integration/ # Integration tests
│ └── fixtures/ # Test data
│ │ ├── builder.rs # Relay builder + Nip34WritePolicy
│ │ ├── events.rs # Event parsing and validation
│ │ └── policy/ # Sub-policies (split for maintainability)
│ │ ├── mod.rs # Policy module exports
│ │ ├── announcement.rs # Repository announcement validation
│ │ ├── state.rs # State event validation + ref alignment
│ │ ├── pr_event.rs # PR/PR Update validation
│ │ └── related.rs # Forward/backward reference checking
│ ├── http/
│ │ ├── mod.rs # HTTP module
│ │ ├── landing.rs # Landing page handler
│ │ └── nip11.rs # NIP-11 relay info document
│ └── metrics/
│ ├── mod.rs # Prometheus metrics
│ ├── bandwidth.rs # Bandwidth tracking
│ └── connection.rs # Connection tracking
├── docs/ # Documentation (Diátaxis framework)
├── tests/ # Integration tests
├── grasp-audit/ # Compliance audit subproject
└── README.md
```
+9 -9
View File
@@ -156,17 +156,17 @@ This enables parallel CI runs without interference.
**Better approach:** Treat architecture docs as living documents. When implementation diverges from the plan, update the doc immediately. The initial design document was valuable and should remain, but it should reflect what was built.
### 2. Smaller Nip34WritePolicy
### 2. ~~Smaller Nip34WritePolicy~~ ✅ DONE
**What happened:** The [`Nip34WritePolicy`](src/nostr/builder.rs:51) grew to ~900 lines handling all event types.
**What happened:** The `Nip34WritePolicy` grew to ~900 lines handling all event types.
**Better approach:** Split into:
- `AnnouncementPolicy` - Repository announcement validation
- `StatePolicy` - State event validation + ref alignment
- `RelatedEventPolicy` - Forward/backward reference checking
- `PrEventPolicy` - PR/PR Update validation
**Resolution:** Split into focused sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1):
- [`AnnouncementPolicy`](src/nostr/policy/announcement.rs:1) - Repository announcement validation
- [`StatePolicy`](src/nostr/policy/state.rs:1) - State event validation + ref alignment
- [`RelatedEventPolicy`](src/nostr/policy/related.rs:1) - Forward/backward reference checking
- [`PrEventPolicy`](src/nostr/policy/pr_event.rs:1) - PR/PR Update validation
This would improve testability and readability.
The main [`Nip34WritePolicy`](src/nostr/builder.rs:51) now delegates to these sub-policies, improving testability and readability.
### 3. Git Operations Module Organization
@@ -190,7 +190,7 @@ This would improve testability and readability.
### High Priority
1. **Split `Nip34WritePolicy`** - Too large, hard to test/maintain
1. ~~**Split `Nip34WritePolicy`**~~ ✅ DONE - Split into sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1)
2. **Add unit tests for policy logic** - Currently relies on integration tests
3. **Document actual architecture** - Docs describe plans, not implementation
+178 -1182
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,2 +1,6 @@
pub mod builder;
pub mod events;
pub mod policy;
/// Re-export SharedDatabase for use by policy modules
pub use builder::SharedDatabase;
+157
View File
@@ -0,0 +1,157 @@
/// Announcement Policy - Repository announcement validation
///
/// Handles validation of NIP-34 repository announcements (kind 30617)
/// according to GRASP-01 specification.
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
use super::PolicyContext;
use crate::nostr::events::{
validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT,
};
/// Result of announcement policy evaluation
#[derive(Debug)]
pub enum AnnouncementResult {
/// Accept: Event passes validation
Accept,
/// Accept as maintainer: Event accepted via maintainer exception
AcceptMaintainer,
/// Reject: Event fails validation with reason
Reject(String),
}
/// Policy for validating repository announcements
#[derive(Clone)]
pub struct AnnouncementPolicy {
ctx: PolicyContext,
}
impl AnnouncementPolicy {
pub fn new(ctx: PolicyContext) -> Self {
Self { ctx }
}
/// Validate a repository announcement event
///
/// Returns `Accept` if the announcement lists the service properly,
/// `AcceptMaintainer` if accepted via maintainer exception,
/// 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
// Try to parse the announcement to get identifier
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,
&event.pubkey,
)
.await
{
Ok(true) => AnnouncementResult::AcceptMaintainer,
Ok(false) => AnnouncementResult::Reject(validation_err.to_string()),
Err(_) => {
// Fail-secure: reject on database errors
AnnouncementResult::Reject(validation_err.to_string())
}
}
}
Err(_) => AnnouncementResult::Reject(validation_err.to_string()),
}
}
}
}
/// Create a bare git repository if it doesn't exist
/// Path format: <git_data_path>/<npub>/<identifier>.git
pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
// Check if repository already exists
if repo_path.exists() {
tracing::debug!("Repository already exists at {}", repo_path.display());
return Ok(());
}
// Create parent directory (npub directory)
let parent = repo_path
.parent()
.ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
// Initialize bare repository using git command
let output = std::process::Command::new("git")
.args(["init", "--bare", repo_path.to_str().unwrap()])
.output()
.map_err(|e| format!("Failed to execute git init: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git init failed: {}", stderr));
}
tracing::info!("Created bare repository at {}", repo_path.display());
Ok(())
}
/// Check if a pubkey is listed as a maintainer in any announcement for this identifier
///
/// A pubkey is considered a maintainer if:
/// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
/// 2. They are listed in the maintainers tag of ANY announcement with this identifier
///
/// This enables accepting announcements from maintainers even when they don't list
/// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
async fn is_maintainer_in_any_announcement(
&self,
identifier: &str,
author: &PublicKey,
) -> Result<bool, String> {
// Query all announcements with this identifier that are already in the database
let filter = Filter::new()
.kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
.custom_tag(
SingleLetterTag::lowercase(Alphabet::D),
identifier.to_string(),
);
let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
Ok(events) => events.into_iter().collect(),
Err(e) => return Err(format!("Database query failed: {}", e)),
};
if announcements.is_empty() {
// No existing announcements for this identifier - author cannot be a maintainer
return Ok(false);
}
let author_hex = author.to_hex();
// Check each announcement to see if author is listed as a maintainer
for event in &announcements {
// Check if author is the owner of this announcement
if event.pubkey == *author {
return Ok(true);
}
// Check if author is listed in the maintainers tag
if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
if announcement.maintainers.contains(&author_hex) {
return Ok(true);
}
}
}
Ok(false)
}
}
+41
View File
@@ -0,0 +1,41 @@
/// Policy module for NIP-34 write policies
///
/// This module splits the large Nip34WritePolicy into focused sub-policies:
/// - `AnnouncementPolicy` - Repository announcement validation
/// - `StatePolicy` - State event validation + ref alignment
/// - `PrEventPolicy` - PR/PR Update validation
/// - `RelatedEventPolicy` - Forward/backward reference checking
mod announcement;
mod pr_event;
mod related;
mod state;
pub use announcement::{AnnouncementPolicy, AnnouncementResult};
pub use pr_event::PrEventPolicy;
pub use related::{ReferenceResult, RelatedEventPolicy};
pub use state::{AlignmentResult, StatePolicy, StateResult};
use super::SharedDatabase;
/// Shared context for all sub-policies
#[derive(Clone)]
pub struct PolicyContext {
pub domain: String,
pub database: SharedDatabase,
pub git_data_path: std::path::PathBuf,
}
impl PolicyContext {
pub fn new(
domain: impl Into<String>,
database: SharedDatabase,
git_data_path: impl Into<std::path::PathBuf>,
) -> Self {
Self {
domain: domain.into(),
database,
git_data_path: git_data_path.into(),
}
}
}
+198
View File
@@ -0,0 +1,198 @@
/// PR Event Policy - PR/PR Update validation
///
/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619)
/// according to GRASP-01 specification.
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
use super::PolicyContext;
use crate::git;
use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT};
/// Policy for validating PR and PR Update events
#[derive(Clone)]
pub struct PrEventPolicy {
ctx: PolicyContext,
}
impl PrEventPolicy {
pub fn new(ctx: PolicyContext) -> Self {
Self { ctx }
}
/// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
///
/// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
/// this checks if a corresponding refs/nostr/<event-id> ref exists in the
/// repository and validates that it points to the correct commit (from the
/// `c` tag). If the ref exists but points to a different commit, the ref is
/// deleted.
///
/// PR and PR Update events can have multiple `a` tags to update multiple
/// repositories simultaneously.
///
/// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
/// with their corresponding events.
///
/// # Returns
/// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> {
let event_id = event.id.to_hex();
// Extract the `c` tag (commit hash) from the PR event
let expected_commit = event.tags.iter().find_map(|tag| {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() >= 2 && tag_vec[0] == "c" {
Some(tag_vec[1].clone())
} else {
None
}
});
let expected_commit = match expected_commit {
Some(c) => c,
None => {
tracing::debug!(
"PR event {} has no 'c' tag, skipping ref validation",
event_id
);
return Ok(None);
}
};
// Extract ALL `a` tags (repository references) from the PR event
// PR events can reference multiple repositories
// Format: 30617:<pubkey>:<identifier>
let repo_refs: Vec<String> = event
.tags
.iter()
.filter_map(|tag| {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
Some(tag_vec[1].clone())
} else {
None
}
})
.collect();
if repo_refs.is_empty() {
tracing::debug!(
"PR event {} has no repo 'a' tags, skipping ref validation",
event_id
);
return Ok(None);
}
let mut deleted_count = 0;
// Process each repository reference
for repo_ref in repo_refs {
// Parse the repo reference: 30617:<pubkey>:<identifier>
let parts: Vec<&str> = repo_ref.split(':').collect();
if parts.len() < 3 {
tracing::debug!(
"PR event {} has invalid 'a' tag format: {}",
event_id,
repo_ref
);
continue;
}
let repo_pubkey = match PublicKey::from_hex(parts[1]) {
Ok(pk) => pk,
Err(_) => {
tracing::debug!(
"PR event {} has invalid pubkey in 'a' tag: {}",
event_id,
parts[1]
);
continue;
}
};
let identifier = parts[2];
// Look up repository announcement to get the npub for path
let filter = Filter::new()
.kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
.author(repo_pubkey)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::D),
identifier.to_string(),
);
let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
Ok(events) => events.into_iter().collect(),
Err(e) => {
tracing::warn!(
"Failed to query for repository announcement for PR {}: {}",
event_id,
e
);
continue;
}
};
if announcements.is_empty() {
tracing::debug!(
"No repository announcement found for PR event {} (repo {}:{})",
event_id,
repo_pubkey.to_hex(),
identifier
);
continue;
}
// Process each matching announcement (there could be multiple)
for announcement_event in announcements {
let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
Ok(a) => a,
Err(e) => {
tracing::warn!(
"Failed to parse announcement for PR {} validation: {}",
event_id,
e
);
continue;
}
};
// Build repository path
let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
// Validate the ref
match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
Ok(true) => {
tracing::info!(
"Deleted mismatched refs/nostr/{} in {} (expected commit {})",
event_id,
repo_path.display(),
expected_commit
);
deleted_count += 1;
}
Ok(false) => {
tracing::debug!(
"refs/nostr/{} in {} is valid or doesn't exist",
event_id,
repo_path.display()
);
}
Err(e) => {
tracing::warn!(
"Failed to validate refs/nostr/{} in {}: {}",
event_id,
repo_path.display(),
e
);
}
}
}
}
if deleted_count > 0 {
Ok(Some(deleted_count))
} else {
Ok(None)
}
}
}
+276
View File
@@ -0,0 +1,276 @@
/// Related Event Policy - Forward/backward reference checking
///
/// Handles validation of events that reference accepted repositories or events
/// (backward references) and events that are referenced by accepted events
/// (forward references).
use nostr_relay_builder::prelude::{
Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag,
};
use super::PolicyContext;
/// Result of reference checking
#[derive(Debug)]
pub enum ReferenceResult {
/// Event references an accepted repository (addressable ref found)
ReferencesRepository(String),
/// Event references an accepted event (event ID found)
ReferencesEvent(EventId),
/// Event is referenced by an accepted event (forward reference)
ReferencedByAccepted,
/// No valid references found - event is an orphan
Orphan,
}
/// Policy for checking event references (backward and forward)
#[derive(Clone)]
pub struct RelatedEventPolicy {
ctx: PolicyContext,
}
impl RelatedEventPolicy {
pub fn new(ctx: PolicyContext) -> Self {
Self { ctx }
}
/// Check all reference types for an event
///
/// Returns the first valid reference found, or `Orphan` if none found.
pub async fn check_references(&self, event: &Event) -> Result<ReferenceResult, String> {
// Extract all reference tags from event
let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
// Check 1: Does this event reference an accepted repository?
if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? {
return Ok(ReferenceResult::ReferencesRepository(addr_ref));
}
// Check 2: Does this event reference an accepted event?
if let Some(event_ref) = self.find_accepted_event(&event_refs).await? {
return Ok(ReferenceResult::ReferencesEvent(event_ref));
}
// Check 3: Is this event referenced by an accepted event?
if self.is_referenced_by_accepted(event).await? {
return Ok(ReferenceResult::ReferencedByAccepted);
}
// No valid references found
Ok(ReferenceResult::Orphan)
}
/// Extract all reference tags from an event (a, A, q, e, E)
/// Returns (addressable_refs, event_refs)
pub fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
let mut addressable_refs = Vec::new();
let mut event_refs = Vec::new();
for tag in event.tags.iter() {
let tag_vec = tag.clone().to_vec();
if tag_vec.is_empty() {
continue;
}
match tag_vec[0].as_str() {
// Addressable event references (a, A, q with kind:pubkey:identifier format)
"a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
addressable_refs.push(tag_vec[1].clone());
}
// Event ID references (e, E, q with event ID format)
"e" | "E" if tag_vec.len() > 1 => {
if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
event_refs.push(event_id);
}
}
"q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
event_refs.push(event_id);
}
}
_ => {}
}
}
(addressable_refs, event_refs)
}
/// Check if any addressable events (repositories) exist in database
/// Returns the first matching addressable reference found, or None if none match
async fn find_accepted_repository(
&self,
addressables: &[String],
) -> Result<Option<String>, String> {
if addressables.is_empty() {
return Ok(None);
}
// Parse all addressable references
let mut parsed_refs = Vec::new();
for addr in addressables {
let parts: Vec<&str> = addr.split(':').collect();
if parts.len() < 3 {
continue; // Skip invalid format
}
let kind = match parts[0].parse::<u16>() {
Ok(k) => k,
Err(_) => continue, // Skip invalid kind
};
let pubkey = match PublicKey::from_hex(parts[1]) {
Ok(pk) => pk,
Err(_) => continue, // Skip invalid pubkey
};
let identifier = parts[2].to_string();
parsed_refs.push((addr.clone(), kind, pubkey, identifier));
}
if parsed_refs.is_empty() {
return Ok(None);
}
// Group by kind to reduce queries
use std::collections::HashMap;
let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
for (addr, kind, pubkey, identifier) in parsed_refs {
by_kind
.entry(kind)
.or_default()
.push((addr, pubkey, identifier));
}
// Query each kind group
for (kind, refs) in by_kind {
let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
match self.ctx.database.query(filter).await {
Ok(events) => {
// Check if any event matches our identifier requirements
for event in events {
for (addr, _pubkey, identifier) in &refs {
// Match identifier tag
if event.tags.iter().any(|tag| {
let tag_vec = tag.clone().to_vec();
tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
}) {
return Ok(Some(addr.clone()));
}
}
}
}
Err(e) => return Err(format!("Database query failed: {}", e)),
}
}
Ok(None)
}
/// Check if any events exist in database
/// Returns the first matching event ID found, or None if none match
async fn find_accepted_event(
&self,
event_ids: &[EventId],
) -> Result<Option<EventId>, String> {
if event_ids.is_empty() {
return Ok(None);
}
// Single query for all event IDs
let filter = Filter::new().ids(event_ids.iter().copied());
match self.ctx.database.query(filter).await {
Ok(events) => {
// Get first event from the iterator
Ok(events.into_iter().next().map(|e| e.id))
}
Err(e) => Err(format!("Database query failed: {}", e)),
}
}
/// Check if any accepted event references this event (forward reference)
///
/// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
/// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
/// For regular events: Only checks event ID reference tags (e, E, q)
async fn is_referenced_by_accepted(&self, event: &Event) -> Result<bool, String> {
let kind_u16 = event.kind.as_u16();
// Check if this is any kind of replaceable event
let is_regular_replaceable = (10000..20000).contains(&kind_u16);
let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
if is_regular_replaceable || is_parameterized_replaceable {
// Build the appropriate address format based on event type
let address = if is_parameterized_replaceable {
// For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
let identifier = event
.tags
.iter()
.find_map(|tag| {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
Some(tag_vec[1].clone())
} else {
None
}
})
.unwrap_or_default(); // Empty string if no 'd' tag
format!(
"{}:{}:{}",
event.kind.as_u16(),
event.pubkey.to_hex(),
identifier
)
} else {
// For regular replaceable: kind:pubkey format (1 colon)
format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
};
// Check addressable reference tags: a, A, q (with address format)
let addressable_tags = [
SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
];
for tag_type in &addressable_tags {
let filter = Filter::new().custom_tag(*tag_type, address.clone());
match self.ctx.database.query(filter).await {
Ok(events) => {
if !events.is_empty() {
return Ok(true);
}
}
Err(e) => return Err(format!("Database query failed: {}", e)),
}
}
} else {
// For regular events, check event ID reference tags: e, E, q (with hex ID)
let event_id_hex = event.id.to_hex();
let event_id_tags = [
SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference
];
for tag_type in &event_id_tags {
let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
match self.ctx.database.query(filter).await {
Ok(events) => {
if !events.is_empty() {
return Ok(true);
}
}
Err(e) => return Err(format!("Database query failed: {}", e)),
}
}
}
Ok(false)
}
}
+419
View File
@@ -0,0 +1,419 @@
/// State Policy - State event validation + ref alignment
///
/// Handles validation of NIP-34 repository state events (kind 30618)
/// and aligns git refs with authorized state according to GRASP-01.
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
use super::PolicyContext;
use crate::git;
use crate::nostr::events::{
validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
KIND_REPOSITORY_STATE,
};
/// Result of aligning a repository with authorized state
#[derive(Debug, Default)]
pub struct AlignmentResult {
/// Number of refs created
pub refs_created: usize,
/// Number of refs updated
pub refs_updated: usize,
/// Number of refs deleted
pub refs_deleted: usize,
/// Whether HEAD was set
pub head_set: bool,
}
impl AlignmentResult {
pub fn has_changes(&self) -> bool {
self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set
}
}
/// Result of state policy evaluation
#[derive(Debug)]
pub enum StateResult {
/// Accept: Event passes validation
Accept,
/// Reject: Event fails validation with reason
Reject(String),
}
/// Policy for validating repository state events and aligning refs
#[derive(Clone)]
pub struct StatePolicy {
ctx: PolicyContext,
}
impl StatePolicy {
pub fn new(ctx: PolicyContext) -> Self {
Self { ctx }
}
/// Validate a repository state event
pub fn validate(&self, event: &Event) -> StateResult {
match validate_state(event) {
Ok(_) => StateResult::Accept,
Err(e) => StateResult::Reject(e.to_string()),
}
}
/// Process a state event: validate and align owner repositories
///
/// Returns the number of repositories aligned if successful.
pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> {
// Parse state to get HEAD and branch info
let state = RepositoryState::from_event(event.clone())
.map_err(|e| format!("Failed to parse state: {}", e))?;
// Identify owner repositories for which this is the latest authorized state
let owner_repos = self.identify_owner_repositories(&state).await?;
let repo_count = owner_repos.len();
let mut total_aligned = 0;
// Align each owner repository with the authorized state
for (_announcement, repo_path) in owner_repos {
let result = self.align_repository_with_state(&repo_path, &state);
if result.has_changes() {
tracing::info!(
"Aligned {} with state: created={}, updated={}, deleted={}, head_set={}",
repo_path.display(),
result.refs_created,
result.refs_updated,
result.refs_deleted,
result.head_set
);
total_aligned += 1;
}
}
if repo_count > 0 {
tracing::info!(
"Processed state event for {} repo(s) ({} aligned) with identifier {}",
repo_count,
total_aligned,
state.identifier
);
} else {
tracing::debug!(
"No owner repos to align for state - git data not available yet or not latest"
);
}
Ok(total_aligned)
}
/// Check if this state event is the latest for its identifier among authorized authors
///
/// A state is considered "latest" if no other state event in the database
/// from an authorized author has a newer timestamp.
async fn is_latest_state_for_identifier(
&self,
state: &RepositoryState,
authorized_pubkeys: &[PublicKey],
) -> Result<bool, String> {
let filter = Filter::new()
.kind(Kind::from(KIND_REPOSITORY_STATE))
.custom_tag(
SingleLetterTag::lowercase(Alphabet::D),
state.identifier.clone(),
);
match self.ctx.database.query(filter).await {
Ok(events) => {
for event in events {
// Skip comparing to self (same event ID)
if event.id == state.event.id {
continue;
}
// Only consider events from authorized authors for this announcement
if !authorized_pubkeys.contains(&event.pubkey) {
continue;
}
// If any existing event from an authorized author is newer, this is not the latest
if event.created_at > state.event.created_at {
tracing::debug!(
"State {} is not latest: found newer state {} from {} (ts {} > {})",
state.event.id.to_hex(),
event.id.to_hex(),
event.pubkey.to_hex(),
event.created_at.as_secs(),
state.event.created_at.as_secs()
);
return Ok(false);
}
}
Ok(true)
}
Err(e) => Err(format!("Database query failed: {}", e)),
}
}
/// Find all repository announcements where the given pubkey is authorized
async fn find_authorized_announcements(
&self,
identifier: &str,
state_author: &PublicKey,
) -> Result<Vec<RepositoryAnnouncement>, String> {
let filter = Filter::new()
.kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
.custom_tag(
SingleLetterTag::lowercase(Alphabet::D),
identifier.to_string(),
);
match self.ctx.database.query(filter).await {
Ok(events) => {
let mut authorized = Vec::new();
let state_author_hex = state_author.to_hex();
for event in events {
if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
// Check if state author is authorized for this announcement
let is_owner = event.pubkey == *state_author;
let is_maintainer = announcement.maintainers.contains(&state_author_hex);
if is_owner || is_maintainer {
tracing::debug!(
"Found authorized announcement for {}: owner={}, maintainer={}",
identifier,
if is_owner {
event.pubkey.to_hex()
} else {
"n/a".to_string()
},
is_maintainer
);
authorized.push(announcement);
}
}
}
Ok(authorized)
}
Err(e) => Err(format!("Database query failed: {}", e)),
}
}
/// Identify all owner repositories for which this state event is the latest authorized state
async fn identify_owner_repositories(
&self,
state: &RepositoryState,
) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
// Find all announcements where state author is authorized
let announcements = self
.find_authorized_announcements(&state.identifier, &state.event.pubkey)
.await?;
if announcements.is_empty() {
tracing::debug!(
"No authorized announcements found for state {} by {}",
state.identifier,
state.event.pubkey.to_hex()
);
return Ok(Vec::new());
}
let mut owner_repos = Vec::new();
for announcement in announcements {
// Build the list of authorized pubkeys for this specific announcement
let mut authorized_pubkeys = vec![announcement.event.pubkey];
for maintainer_hex in &announcement.maintainers {
if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
authorized_pubkeys.push(pk);
}
}
// Check if this is the latest state event for THIS announcement's context
if !self
.is_latest_state_for_identifier(state, &authorized_pubkeys)
.await?
{
tracing::debug!(
"Skipping {} in {}'s repo - not the latest state event for this context",
state.identifier,
announcement.event.pubkey.to_hex()
);
continue;
}
// Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone());
owner_repos.push((announcement, repo_path));
}
Ok(owner_repos)
}
/// Align a repository's refs with the authorized state
///
/// This function:
/// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
/// 2. Updates refs that exist in state if we have the commit
/// 3. Sets HEAD if the HEAD branch's commit is available
pub fn align_repository_with_state(
&self,
repo_path: &std::path::Path,
state: &RepositoryState,
) -> AlignmentResult {
let mut result = AlignmentResult::default();
// Check if repository exists
if !repo_path.exists() {
tracing::debug!(
"Repository not found at {}, cannot align with state",
repo_path.display()
);
return result;
}
// Get current refs from the repository
let current_refs = match git::list_refs(repo_path) {
Ok(refs) => refs,
Err(e) => {
tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
return result;
}
};
// Build expected refs from state
let mut expected_refs: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for branch in &state.branches {
let ref_name = format!("refs/heads/{}", branch.name);
expected_refs.insert(ref_name, branch.commit.clone());
}
for tag in &state.tags {
let ref_name = format!("refs/tags/{}", tag.name);
expected_refs.insert(ref_name, tag.commit.clone());
}
// Process current refs: update or delete as needed
for (ref_name, current_commit) in &current_refs {
// Only process refs/heads/ and refs/tags/
if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
continue;
}
match expected_refs.get(ref_name) {
Some(expected_commit) => {
// Ref should exist - check if commit matches
if current_commit != expected_commit {
// Check if we have the expected commit
if git::commit_exists(repo_path, expected_commit) {
// Update the ref
match git::update_ref(repo_path, ref_name, expected_commit) {
Ok(()) => {
tracing::info!(
"Updated {} from {} to {} in {}",
ref_name,
current_commit,
expected_commit,
repo_path.display()
);
result.refs_updated += 1;
}
Err(e) => {
tracing::warn!(
"Failed to update {} in {}: {}",
ref_name,
repo_path.display(),
e
);
}
}
} else {
tracing::debug!(
"Commit {} not available for {} in {}",
expected_commit,
ref_name,
repo_path.display()
);
}
}
}
None => {
// Ref should not exist - delete it
match git::delete_ref(repo_path, ref_name) {
Ok(()) => {
tracing::info!(
"Deleted {} (not in state) from {}",
ref_name,
repo_path.display()
);
result.refs_deleted += 1;
}
Err(e) => {
tracing::warn!(
"Failed to delete {} from {}: {}",
ref_name,
repo_path.display(),
e
);
}
}
}
}
}
// Add refs that exist in state but not in repo (if we have the commit)
for (ref_name, expected_commit) in &expected_refs {
let exists = current_refs.iter().any(|(r, _)| r == ref_name);
if !exists && git::commit_exists(repo_path, expected_commit) {
match git::update_ref(repo_path, ref_name, expected_commit) {
Ok(()) => {
tracing::info!(
"Created {} at {} in {}",
ref_name,
expected_commit,
repo_path.display()
);
result.refs_created += 1;
}
Err(e) => {
tracing::warn!(
"Failed to create {} in {}: {}",
ref_name,
repo_path.display(),
e
);
}
}
}
}
// Set HEAD if specified in state
if let Some(head_ref) = &state.head {
if let Some(branch_name) = state.get_head_branch() {
if let Some(head_commit) = state.get_branch_commit(branch_name) {
match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
Ok(true) => {
tracing::info!(
"Set HEAD to {} in {} (from state by {})",
head_ref,
repo_path.display(),
state.event.pubkey.to_hex()
);
result.head_set = true;
}
Ok(false) => {
tracing::debug!(
"HEAD commit {} not available yet in {}",
head_commit,
repo_path.display()
);
}
Err(e) => {
tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
}
}
}
}
}
result
}
}