/// NIP-34 Git Repository Event Handling /// /// This module handles Git repository announcements (kind 30617) and /// repository state announcements (kind 30618) according to NIP-34 and GRASP-01. /// /// Reference: /// - NIP-34: https://nips.nostr.com/34 /// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md use anyhow::{anyhow, Result}; use nostr_sdk::{Event, Kind, TagKind, ToBech32}; // NOTE: Using rust-nostr Kind variants instead of hardcoded constants: // - KIND_REPOSITORY_ANNOUNCEMENT -> Kind::GitRepoAnnouncement (30617) // - KIND_REPOSITORY_STATE -> Kind::RepoState (30618) // - KIND_PR -> Kind::GitPullRequest (1618) // - KIND_PR_UPDATE -> Kind::GitPullRequestUpdate (1619) // - KIND_USER_GRASP_LIST -> Kind::GitUserGraspList (10317) /// Repository announcement details extracted from NIP-34 event #[derive(Debug, Clone)] pub struct RepositoryAnnouncement { pub event: Event, pub identifier: String, pub name: Option, pub description: Option, pub clone_urls: Vec, pub relays: Vec, pub web_urls: Vec, pub maintainers: Vec, } impl RepositoryAnnouncement { /// Parse a repository announcement from a NIP-34 kind 30617 event pub fn from_event(event: Event) -> Result { if event.kind != Kind::GitRepoAnnouncement { return Err(anyhow!( "Invalid event kind: expected {}, got {}", Kind::GitRepoAnnouncement, event.kind )); } // Extract identifier (required) let identifier = event .tags .iter() .find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) .ok_or_else(|| anyhow!("Repository announcement missing 'd' tag (identifier)"))? .to_string(); // Extract optional name let name = event .tags .iter() .find(|t| matches!(t.kind(), TagKind::Name)) .and_then(|t| t.content()) .map(|s| s.to_string()); // Extract description from content let description = if event.content.is_empty() { None } else { Some(event.content.clone()) }; // Extract clone URLs let clone_urls = event .tags .iter() .filter(|t| matches!(t.kind(), TagKind::Clone)) .flat_map(|t| { let vec = t.clone().to_vec(); // Skip first element (tag name), rest are values vec.into_iter().skip(1) }) .collect(); // return error if mutliple clone tags (incorrect formatting) let clone_tag_count = event .tags .iter() .filter(|t| matches!(t.kind(), TagKind::Clone)) .count(); if clone_tag_count > 1 { return Err(anyhow::anyhow!("multiple clone tags found. correct format is single clone tag with multiple values")); } // Extract relays let relays = event .tags .iter() .filter(|t| matches!(t.kind(), TagKind::Relays)) .flat_map(|t| { let vec = t.clone().to_vec(); // Skip first element (tag name), rest are values vec.into_iter().skip(1) }) .collect(); // Extract web URLs let web_urls = event .tags .iter() .filter(|t| { if let TagKind::Custom(s) = t.kind() { s.as_ref() == "web" } else { false } }) .flat_map(|t| { let vec = t.clone().to_vec(); // Skip first element (tag name), rest are values vec.into_iter().skip(1) }) .collect(); // Extract maintainers from "maintainers" tag per NIP-34 // Format: ["maintainers", "", "", ...] let maintainers = event .tags .iter() .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("maintainers")) .map(|tag| { tag.as_slice()[1..] // Skip the "maintainers" tag name .iter() .map(|s| s.to_string()) .collect() }) .unwrap_or_default(); Ok(RepositoryAnnouncement { event, identifier, name, description, clone_urls, relays, web_urls, maintainers, }) } /// Normalize a URL by removing trailing slashes for consistent comparison /// /// See test_validate_announcement_with_trailing_slash_in_relay for why we need this fn normalize_url_for_comparison(url: &str) -> &str { url.trim_end_matches('/') } /// Check if this announcement lists the given domain in clone URLs pub fn has_clone_url(&self, domain: &str) -> bool { let normalized_domain = Self::normalize_url_for_comparison(domain); self.clone_urls.iter().any(|url| { let normalized_url = Self::normalize_url_for_comparison(url); normalized_url.contains(normalized_domain) }) } /// Check if this announcement lists the given relay pub fn has_relay(&self, relay: &str) -> bool { let normalized_relay = Self::normalize_url_for_comparison(relay); self.relays.iter().any(|r| { let normalized_r = Self::normalize_url_for_comparison(r); normalized_r.contains(normalized_relay) }) } /// Check if this announcement lists the service (both clone and relay) /// /// GRASP-01 requirement: MUST reject announcements that do not list /// the service in both `clone` and `relays` tags unless implementing GRASP-05. pub fn lists_service(&self, domain: &str) -> bool { self.has_clone_url(domain) && self.has_relay(domain) } /// Get the npub of the repository owner pub fn owner_npub(&self) -> String { self.event.pubkey.to_bech32().unwrap_or_default() } /// Get the repository path: /.git pub fn repo_path(&self) -> String { format!("{}/{}.git", self.owner_npub(), self.identifier) } } /// Repository state details extracted from NIP-34 event #[derive(Debug, Clone)] pub struct RepositoryState { pub event: Event, pub identifier: String, pub branches: Vec, pub tags: Vec, /// HEAD reference (e.g., "refs/heads/main") if specified pub head: Option, } /// Branch state (ref with commit hash) #[derive(Debug, Clone)] pub struct BranchState { pub name: String, pub commit: String, } /// Tag state (ref with commit hash) #[derive(Debug, Clone)] pub struct TagState { pub name: String, pub commit: String, } impl RepositoryState { /// Parse a repository state from a NIP-34 kind 30618 event pub fn from_event(event: Event) -> Result { if event.kind != Kind::RepoState { return Err(anyhow!( "Invalid event kind: expected {}, got {}", Kind::RepoState, event.kind )); } // Extract identifier (required) let identifier = event .tags .iter() .find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) .ok_or_else(|| anyhow!("Repository state missing 'd' tag (identifier)"))? .to_string(); // Extract branches (refs/heads/*) // Tag format: ["refs/heads/main", "commit_hash"] let branches = event .tags .iter() .filter_map(|t| { if let TagKind::Custom(s) = t.kind() { if s.as_ref().starts_with("refs/heads/") { let parts = t.clone().to_vec(); if parts.len() >= 2 { Some(BranchState { name: s.as_ref().strip_prefix("refs/heads/").unwrap().to_string(), commit: parts[1].clone(), }) } else { None } } else { None } } else { None } }) .collect(); // Extract tags (refs/tags/*) // Tag format: ["refs/tags/v1.0", "commit_hash"] // Exclude peeled tag notation ("refs/tags/v1.0^{}") — these are git's internal // dereference markers pointing to the underlying commit, not real refs. let tags = event .tags .iter() .filter_map(|t| { if let TagKind::Custom(s) = t.kind() { if s.as_ref().starts_with("refs/tags/") && !s.as_ref().ends_with("^{}") { let parts = t.clone().to_vec(); if parts.len() >= 2 { Some(TagState { name: s.as_ref().strip_prefix("refs/tags/").unwrap().to_string(), commit: parts[1].clone(), }) } else { None } } else { None } } else { None } }) .collect(); // Extract HEAD reference per NIP-34 // Tag format: ["HEAD", "ref: refs/heads/main"] or ["HEAD", "refs/heads/main"] let head = event .tags .iter() .find(|t| { if let TagKind::Custom(s) = t.kind() { s.as_ref() == "HEAD" } else { false } }) .and_then(|t| { let parts = t.clone().to_vec(); if parts.len() >= 2 { let head_value = &parts[1]; // Handle both "ref: refs/heads/main" and "refs/heads/main" formats if let Some(stripped) = head_value.strip_prefix("ref: ") { Some(stripped.to_string()) } else { Some(head_value.clone()) } } else { None } }); Ok(RepositoryState { event, identifier, branches, tags, head, }) } /// Get the commit hash for a branch pub fn get_branch_commit(&self, branch: &str) -> Option<&str> { self.branches .iter() .find(|b| b.name == branch) .map(|b| b.commit.as_str()) } /// Get the commit hash for a tag pub fn get_tag_commit(&self, tag: &str) -> Option<&str> { self.tags .iter() .find(|t| t.name == tag) .map(|t| t.commit.as_str()) } /// Get the owner npub pub fn owner_npub(&self) -> String { self.event.pubkey.to_bech32().unwrap_or_default() } /// Get the HEAD branch name (without refs/heads/ prefix) pub fn get_head_branch(&self) -> Option<&str> { self.head .as_ref() .and_then(|h| h.strip_prefix("refs/heads/")) } /// Check if the HEAD commit is available in the git repository /// Returns true if we have the git data for the HEAD branch pub fn head_commit_available(&self) -> bool { if let Some(head_branch) = self.get_head_branch() { self.get_branch_commit(head_branch).is_some() } else { false } } } /// Validate that a repository identifier is safe for use as a filesystem path component. /// /// NIP-34 places no restriction on `d` tag characters beyond NIP-01 (any string). /// Identifiers are stored on disk verbatim and percent-encoded when used in URLs /// (per NIP-34 `nostr://` spec and GRASP-01 HTTP path spec). This function only /// rejects identifiers that cannot safely be stored as a filesystem directory name: /// /// - Empty string /// - Path separators (`/`, `\`) — would escape the repository directory /// - Null bytes — rejected by most filesystems /// - `.` or `..` — reserved path components (directory traversal) /// /// Whitespace and other characters that require percent-encoding in URLs are /// explicitly allowed here; callers are responsible for encoding them in URLs. pub fn validate_identifier(identifier: &str) -> Result<(), String> { if identifier.is_empty() { return Err("identifier must not be empty".to_string()); } if identifier == "." || identifier == ".." { return Err(format!( "identifier '{}' is a reserved path component", identifier )); } for ch in identifier.chars() { if ch == '/' || ch == '\\' { return Err(format!( "identifier '{}' contains path separator '{}'", identifier, ch )); } if ch == '\0' { return Err(format!( "identifier '{}' contains a null byte", identifier )); } } Ok(()) } /// Validate a repository announcement according to GRASP-01 and GRASP-05 /// /// Returns: /// - Accept: Announcement lists our service (GRASP-01) AND matches repository whitelist (if enabled) /// - AcceptArchive: Announcement matches archive config (GRASP-05) /// - Reject: Validation failed /// /// Blacklist takes precedence over all whitelists: /// - If blacklisted, always reject with specific reason (npub/identifier/npub+identifier) /// /// When archive_read_only is true: /// - 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, config: &crate::config::Config, ) -> crate::nostr::policy::AnnouncementResult { use crate::nostr::policy::AnnouncementResult; // Must be kind 30617 if event.kind != Kind::GitRepoAnnouncement { return AnnouncementResult::Reject(format!( "Invalid kind: expected {}", Kind::GitRepoAnnouncement )); } // Must have identifier let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d()); if !has_identifier { return AnnouncementResult::Reject("Missing required 'd' tag (identifier)".to_string()); } // Parse full announcement to validate structure let announcement = match RepositoryAnnouncement::from_event(event.clone()) { Ok(a) => a, Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), }; // Validate identifier is safe for filesystem and URL use if let Err(reason) = validate_identifier(&announcement.identifier) { return AnnouncementResult::Reject(format!("Invalid identifier: {}", reason)); } // Get validated configs (config.validate() must be called at startup) let archive_config = config.archive_config(); let repository_config = config.repository_config(); let blacklist_config = config.blacklist_config(); let npub = announcement.owner_npub(); let lists_service = announcement.lists_service(&config.domain); // Check blacklist FIRST - it overrides everything if let Some(reason) = blacklist_config.check(&npub, &announcement.identifier) { return AnnouncementResult::Reject(reason); } // 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() && !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) { return AnnouncementResult::AcceptArchive; } // GRASP-05: Archive mode - accept if announcement lists any configured GRASP service in clone URLs // Only check clone URLs (not relays) since we're archiving from OTHER services // Check if announcement matches any configured GRASP service domains if archive_config .grasp_services .iter() .any(|service| announcement.has_clone_url(service)) { return AnnouncementResult::AcceptArchive; } // Reject with appropriate error message if archive_config.read_only { AnnouncementResult::Reject(format!( "Archive read-only mode: announcement must match archive whitelist. \ Repository {}/{} not in whitelist", npub, announcement.identifier )) } else { 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 /// /// Returns Ok(()) if valid, Err with reason if invalid. pub fn validate_state(event: &Event) -> Result<()> { // Must be kind 30618 if event.kind != Kind::RepoState { return Err(anyhow!("Invalid kind: expected {}", Kind::RepoState)); } // 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)")); } // Parse full state to validate structure let _state = RepositoryState::from_event(event.clone())?; Ok(()) } #[cfg(test)] mod tests { use super::*; use nostr_sdk::{EventBuilder, Keys}; fn create_test_keys() -> Keys { Keys::generate() } fn create_announcement_event( keys: &Keys, identifier: &str, clone_urls: Vec<&str>, relays: Vec<&str>, ) -> Event { use nostr_sdk::Tag; let mut tags = vec![Tag::custom( nostr_sdk::TagKind::d(), vec![identifier.to_string()], )]; // NIP-34: Single clone tag with multiple values if !clone_urls.is_empty() { tags.push(Tag::custom( nostr_sdk::TagKind::Clone, clone_urls.iter().map(|s| s.to_string()), )); } // NIP-34: Single relays tag with multiple values if !relays.is_empty() { tags.push(Tag::custom( nostr_sdk::TagKind::Relays, relays.iter().map(|s| s.to_string()), )); } EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository") .tags(tags) .sign_with_keys(keys) .unwrap() } fn create_state_event(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> Event { use nostr_sdk::Tag; let mut tags = vec![Tag::custom( nostr_sdk::TagKind::d(), vec![identifier.to_string()], )]; for (branch, commit) in branches { tags.push(Tag::custom( nostr_sdk::TagKind::Custom(format!("refs/heads/{}", branch).into()), vec![commit.to_string()], )); } EventBuilder::new(Kind::RepoState, "") .tags(tags) .sign_with_keys(keys) .unwrap() } #[test] fn test_parse_announcement() { let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://gitnostr.com/alice/test-repo.git"], vec!["wss://gitnostr.com"], ); let announcement = RepositoryAnnouncement::from_event(event).unwrap(); assert_eq!(announcement.identifier, "test-repo"); assert_eq!(announcement.clone_urls.len(), 1); assert_eq!(announcement.relays.len(), 1); assert!(announcement.has_clone_url("gitnostr.com")); assert!(announcement.has_relay("gitnostr.com")); assert!(announcement.lists_service("gitnostr.com")); } #[test] fn test_parse_announcement_missing_identifier() { let keys = create_test_keys(); let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository") .sign_with_keys(&keys) .unwrap(); let result = RepositoryAnnouncement::from_event(event); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("identifier")); } #[test] fn test_parse_state() { let keys = create_test_keys(); let event = create_state_event( &keys, "test-repo", vec![("main", "a1b2c3d4"), ("develop", "e5f6g7h8")], ); let state = RepositoryState::from_event(event).unwrap(); assert_eq!(state.identifier, "test-repo"); assert_eq!(state.branches.len(), 2); assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4")); assert_eq!(state.get_branch_commit("develop"), Some("e5f6g7h8")); } #[test] fn test_validate_announcement_success() { use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://gitnostr.com/alice/test-repo.git"], vec!["wss://gitnostr.com"], ); 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::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec![], // No clone URLs vec!["wss://gitnostr.com"], ); 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 { panic!("Expected Reject, got {:?}", result); } } #[test] fn test_validate_announcement_missing_relay() { use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://gitnostr.com/alice/test-repo.git"], vec![], // No relays ); 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 { panic!("Expected Reject, got {:?}", result); } } #[test] fn test_validate_announcement_wrong_domain() { use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://other-service.com/alice/test-repo.git"], vec!["wss://other-service.com"], ); let config = Config { domain: "gitnostr.com".to_string(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_validate_state_success() { let keys = create_test_keys(); let event = create_state_event(&keys, "test-repo", vec![("main", "a1b2c3d4")]); let result = validate_state(&event); assert!(result.is_ok()); } #[test] fn test_validate_state_missing_identifier() { let keys = create_test_keys(); let event = EventBuilder::new(Kind::RepoState, "") .sign_with_keys(&keys) .unwrap(); let result = validate_state(&event); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("identifier")); } #[test] fn test_announcement_maintainers() { use nostr_sdk::Tag; let keys = create_test_keys(); let maintainer_keys = create_test_keys(); let mut tags = vec![ Tag::custom(nostr_sdk::TagKind::d(), vec!["test-repo".to_string()]), Tag::custom( nostr_sdk::TagKind::Clone, vec!["https://gitnostr.com/alice/test-repo.git".to_string()], ), Tag::custom( nostr_sdk::TagKind::Relays, vec!["wss://gitnostr.com".to_string()], ), ]; // Add maintainer using NIP-34 "maintainers" tag format // Format: ["maintainers", "", "", ...] tags.push(Tag::custom( nostr_sdk::TagKind::Custom("maintainers".into()), vec![maintainer_keys.public_key().to_hex()], )); let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository") .tags(tags) .sign_with_keys(&keys) .unwrap(); let announcement = RepositoryAnnouncement::from_event(event).unwrap(); assert_eq!(announcement.maintainers.len(), 1); assert_eq!( announcement.maintainers[0], maintainer_keys.public_key().to_hex() ); } #[test] fn test_state_with_tags() { use nostr_sdk::Tag; let keys = create_test_keys(); let mut tags = vec![Tag::custom( nostr_sdk::TagKind::d(), vec!["test-repo".to_string()], )]; // Add branch tags.push(Tag::custom( nostr_sdk::TagKind::Custom("refs/heads/main".into()), vec!["a1b2c3d4".to_string()], )); // Add tag tags.push(Tag::custom( nostr_sdk::TagKind::Custom("refs/tags/v1.0.0".into()), vec!["e5f6g7h8".to_string()], )); let event = EventBuilder::new(Kind::RepoState, "") .tags(tags) .sign_with_keys(&keys) .unwrap(); let state = RepositoryState::from_event(event).unwrap(); assert_eq!(state.branches.len(), 1); assert_eq!(state.tags.len(), 1); assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4")); assert_eq!(state.get_tag_commit("v1.0.0"), Some("e5f6g7h8")); } #[test] fn test_state_with_head_ref_prefix() { use nostr_sdk::Tag; let keys = create_test_keys(); let mut tags = vec![Tag::custom( nostr_sdk::TagKind::d(), vec!["test-repo".to_string()], )]; // Add branch tags.push(Tag::custom( nostr_sdk::TagKind::Custom("refs/heads/main".into()), vec!["a1b2c3d4e5f6g7h8".to_string()], )); // Add HEAD with "ref: " prefix (common NIP-34 format) tags.push(Tag::custom( nostr_sdk::TagKind::Custom("HEAD".into()), vec!["ref: refs/heads/main".to_string()], )); let event = EventBuilder::new(Kind::RepoState, "") .tags(tags) .sign_with_keys(&keys) .unwrap(); let state = RepositoryState::from_event(event).unwrap(); assert_eq!(state.head, Some("refs/heads/main".to_string())); assert_eq!(state.get_head_branch(), Some("main")); assert!(state.head_commit_available()); } #[test] fn test_state_with_head_no_prefix() { use nostr_sdk::Tag; let keys = create_test_keys(); let mut tags = vec![Tag::custom( nostr_sdk::TagKind::d(), vec!["test-repo".to_string()], )]; // Add branch tags.push(Tag::custom( nostr_sdk::TagKind::Custom("refs/heads/develop".into()), vec!["deadbeefcafe".to_string()], )); // Add HEAD without "ref: " prefix (also valid) tags.push(Tag::custom( nostr_sdk::TagKind::Custom("HEAD".into()), vec!["refs/heads/develop".to_string()], )); let event = EventBuilder::new(Kind::RepoState, "") .tags(tags) .sign_with_keys(&keys) .unwrap(); let state = RepositoryState::from_event(event).unwrap(); assert_eq!(state.head, Some("refs/heads/develop".to_string())); assert_eq!(state.get_head_branch(), Some("develop")); assert!(state.head_commit_available()); } #[test] fn test_state_without_head() { use nostr_sdk::Tag; let keys = create_test_keys(); let tags = vec![ Tag::custom(nostr_sdk::TagKind::d(), vec!["test-repo".to_string()]), Tag::custom( nostr_sdk::TagKind::Custom("refs/heads/main".into()), vec!["a1b2c3d4".to_string()], ), ]; let event = EventBuilder::new(Kind::RepoState, "") .tags(tags) .sign_with_keys(&keys) .unwrap(); let state = RepositoryState::from_event(event).unwrap(); assert_eq!(state.head, None); assert_eq!(state.get_head_branch(), None); assert!(!state.head_commit_available()); } #[test] fn test_state_head_commit_not_available() { use nostr_sdk::Tag; let keys = create_test_keys(); let mut tags = vec![Tag::custom( nostr_sdk::TagKind::d(), vec!["test-repo".to_string()], )]; // Add branch for "main" tags.push(Tag::custom( nostr_sdk::TagKind::Custom("refs/heads/main".into()), vec!["a1b2c3d4".to_string()], )); // HEAD points to "develop" which doesn't exist in branches tags.push(Tag::custom( nostr_sdk::TagKind::Custom("HEAD".into()), vec!["refs/heads/develop".to_string()], )); let event = EventBuilder::new(Kind::RepoState, "") .tags(tags) .sign_with_keys(&keys) .unwrap(); let state = RepositoryState::from_event(event).unwrap(); assert_eq!(state.head, Some("refs/heads/develop".to_string())); assert_eq!(state.get_head_branch(), Some("develop")); // HEAD points to develop but only main branch exists in state assert!(!state.head_commit_available()); } #[test] fn test_validate_announcement_with_trailing_slash_in_relay() { use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://git.shakespeare.diy/alice/test-repo.git"], vec!["wss://git.shakespeare.diy/"], // Trailing slash in relay ); // Should accept despite trailing slash mismatch 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::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://git.shakespeare.diy/"], // Trailing slash in clone URL vec!["wss://git.shakespeare.diy"], ); // Should accept despite trailing slash mismatch 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::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://git.shakespeare.diy/alice/test-repo.git/"], // Trailing slash vec!["wss://git.shakespeare.diy/"], // Trailing slash ); // Should accept with trailing slashes in both 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::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://gitnostr.com/alice/test-repo.git"], vec!["wss://gitnostr.com"], ); // Should accept even when domain parameter has trailing slash 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_has_clone_url_with_trailing_slashes() { let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://example.com/repo.git/"], vec!["wss://example.com"], ); let announcement = RepositoryAnnouncement::from_event(event).unwrap(); // Should match with or without trailing slash assert!(announcement.has_clone_url("example.com")); assert!(announcement.has_clone_url("example.com/")); } #[test] fn test_has_relay_with_trailing_slashes() { let keys = create_test_keys(); let event = create_announcement_event( &keys, "test-repo", vec!["https://example.com/repo.git"], vec!["wss://example.com/"], ); let announcement = RepositoryAnnouncement::from_event(event).unwrap(); // Should match with or without trailing slash assert!(announcement.has_relay("example.com")); assert!(announcement.has_relay("example.com/")); } #[test] fn test_validate_announcement_archive_mode_npub() { 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 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 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, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_archive_mode_identifier() { use crate::config::Config; 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 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, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_archive_mode_repository() { 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 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 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, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_archive_all() { use crate::config::Config; 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"], ); // Config with archive_all enabled let config = Config { domain: "gitnostr.com".to_string(), archive_all: true, archive_read_only: Some(false), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_validate_announcement_reject_not_in_whitelist() { use crate::config::Config; 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"], ); // 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, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_validate_announcement_grasp01_takes_precedence() { use crate::config::Config; 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"], ); // With archive_read_only=false, GRASP-01 Accept takes precedence let config = Config { domain: "gitnostr.com".to_string(), archive_all: true, archive_read_only: Some(false), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } #[test] fn test_archive_read_only_rejects_non_whitelisted() { use crate::config::Config; 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"], ); // With archive_read_only=true and whitelist that doesn't include this repo, // should reject even though it lists our service 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, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_archive_read_only_accepts_whitelisted() { 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"], ); // With archive_read_only=true and whitelist that DOES include this repo, // should accept as AcceptArchive let config = Config { domain: "gitnostr.com".to_string(), archive_whitelist: npub, archive_read_only: Some(true), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::AcceptArchive)); } #[test] fn test_archive_read_only_with_archive_all() { 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, "any-repo", vec!["https://gitnostr.com/alice/any-repo.git"], vec!["wss://gitnostr.com"], ); // With archive_read_only=true and archive_all=true, // should accept as AcceptArchive let config = Config { domain: "gitnostr.com".to_string(), archive_all: true, archive_read_only: Some(true), ..Config::for_testing() }; 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(_))); } #[test] fn test_blacklist_rejects_npub() { 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 blacklist for this npub let config = Config { domain: "gitnostr.com".to_string(), repository_blacklist: npub.clone(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); if let AnnouncementResult::Reject(reason) = result { assert!(reason.contains("owner")); assert!(reason.contains(&npub)); } else { panic!("Expected Reject, got {:?}", result); } } #[test] fn test_blacklist_rejects_identifier() { 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, "banned-repo", vec!["https://gitnostr.com/alice/banned-repo.git"], vec!["wss://gitnostr.com"], ); // Config with blacklist for this identifier let config = Config { domain: "gitnostr.com".to_string(), repository_blacklist: "banned-repo".to_string(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); if let AnnouncementResult::Reject(reason) = result { assert!(reason.contains("identifier")); assert!(reason.contains("banned-repo")); } else { panic!("Expected Reject, got {:?}", result); } } #[test] fn test_blacklist_rejects_specific_repository() { 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, "specific-repo", vec!["https://gitnostr.com/alice/specific-repo.git"], vec!["wss://gitnostr.com"], ); // Config with blacklist for this specific repo let config = Config { domain: "gitnostr.com".to_string(), repository_blacklist: format!("{}/specific-repo", npub), ..Config::for_testing() }; let result = validate_announcement(&event, &config); if let AnnouncementResult::Reject(reason) = result { assert!(reason.contains(&npub)); assert!(reason.contains("specific-repo")); } else { panic!("Expected Reject, got {:?}", result); } } #[test] fn test_blacklist_overrides_repository_whitelist() { 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 both whitelist and blacklist - blacklist should win let config = Config { domain: "gitnostr.com".to_string(), repository_whitelist: npub.clone(), repository_blacklist: npub.clone(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_blacklist_overrides_archive_whitelist() { 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 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"], ); // Config with archive whitelist and blacklist - blacklist should win let config = Config { domain: "gitnostr.com".to_string(), archive_whitelist: npub.clone(), archive_read_only: Some(false), repository_blacklist: npub.clone(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } #[test] fn test_blacklist_allows_non_blacklisted() { 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, "allowed-repo", vec!["https://gitnostr.com/alice/allowed-repo.git"], vec!["wss://gitnostr.com"], ); // Config with blacklist for different identifier let config = Config { domain: "gitnostr.com".to_string(), repository_blacklist: "banned-repo".to_string(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } // ------------------------------------------------------------------------- // validate_identifier tests // ------------------------------------------------------------------------- #[test] fn test_validate_identifier_valid() { assert!(validate_identifier("my-repo").is_ok()); assert!(validate_identifier("my_repo").is_ok()); assert!(validate_identifier("repo123").is_ok()); assert!(validate_identifier("kuboslopp").is_ok()); // Whitespace is valid — identifiers are stored verbatim on disk and // percent-encoded when used in URLs (NIP-34 / GRASP-01) assert!(validate_identifier("kuboslopp by Shakespeare").is_ok()); assert!(validate_identifier("my\trepo").is_ok()); assert!(validate_identifier("my repo").is_ok()); } #[test] fn test_validate_identifier_rejects_empty() { assert!(validate_identifier("").is_err()); } #[test] fn test_validate_identifier_rejects_dot_components() { assert!(validate_identifier(".").is_err()); assert!(validate_identifier("..").is_err()); } #[test] fn test_validate_identifier_rejects_path_separators() { assert!(validate_identifier("foo/bar").is_err()); assert!(validate_identifier("foo\\bar").is_err()); } #[test] fn test_validate_announcement_accepts_identifier_with_spaces() { use crate::config::Config; use crate::nostr::policy::AnnouncementResult; let keys = create_test_keys(); // Identifier contains spaces — valid per NIP-34; must be percent-encoded in URLs let event = create_announcement_event( &keys, "kuboslopp by Shakespeare", vec!["https://gitnostr.com/alice/kuboslopp%20by%20Shakespeare.git"], vec!["wss://gitnostr.com"], ); let config = Config { domain: "gitnostr.com".to_string(), ..Config::for_testing() }; let result = validate_announcement(&event, &config); assert!( matches!(result, AnnouncementResult::Accept), "Expected Accept for identifier with spaces, got {:?}", result ); } }