diff --git a/src/config.rs b/src/config.rs index 320661f..0a867e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -562,9 +562,10 @@ impl Config { } /// Get effective git data path - /// Returns a temp directory when using memory backend, otherwise the configured path + /// Returns a temp directory when using memory backend with default path, otherwise the configured path pub fn effective_git_data_path(&self) -> String { - if self.database_backend == DatabaseBackend::Memory { + if self.database_backend == DatabaseBackend::Memory && self.git_data_path == "./data/git" { + // Only use default temp directory if git_data_path is still the default value std::env::temp_dir() .join("ngit-grasp-git") .to_string_lossy() @@ -733,15 +734,29 @@ mod tests { } #[test] - fn test_memory_backend_uses_temp_dir() { + fn test_memory_backend_uses_temp_dir_with_default_path() { + // When git_data_path is the default value, memory backend uses temp dir let config = Config { database_backend: DatabaseBackend::Memory, + git_data_path: "./data/git".to_string(), // Default value ..Config::for_testing() }; let git_path = config.effective_git_data_path(); assert!(git_path.contains("ngit-grasp-git")); } + #[test] + fn test_memory_backend_respects_custom_path() { + // When git_data_path is explicitly set, memory backend respects it + let config = Config { + database_backend: DatabaseBackend::Memory, + git_data_path: "./custom/git/path".to_string(), + ..Config::for_testing() + }; + let git_path = config.effective_git_data_path(); + assert_eq!(git_path, "./custom/git/path"); + } + #[test] fn test_lmdb_backend_uses_configured_path() { let config = Config { diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index ef1b700..34014db 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -103,7 +103,7 @@ impl Nip34WritePolicy { let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); match self.announcement_policy.validate(event).await { - AnnouncementResult::Accept => { + AnnouncementResult::Accept | AnnouncementResult::AcceptArchive => { // Parse announcement to get repository details match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { @@ -166,34 +166,6 @@ impl Nip34WritePolicy { } } } - AnnouncementResult::AcceptArchive => { - // GRASP-05: Archive mode - accept announcement but don't create bare repository - match RepositoryAnnouncement::from_event(event.clone()) { - Ok(announcement) => { - tracing::info!( - "Accepted archive announcement {} for {}/{} (GRASP-05 read-only mirror)", - event_id_str, - announcement.owner_npub(), - announcement.identifier - ); - // Don't create bare repository for archived announcements - - // Check purgatory for state events that might now be authorized - self.check_purgatory_state_events_for_identifier(&announcement.identifier) - .await; - - WritePolicyResult::Accept - } - Err(e) => { - tracing::warn!( - "Failed to parse archive announcement {}: {}", - event_id_str, - e - ); - WritePolicyResult::reject(format!("Failed to parse announcement: {}", e)) - } - } - } AnnouncementResult::Reject(reason) => { tracing::warn!( "Rejected repository announcement {}: {}", diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs new file mode 100644 index 0000000..be6959b --- /dev/null +++ b/tests/archive_read_only.rs @@ -0,0 +1,368 @@ +//! Archive Read-Only Mode Integration Tests +//! +//! Tests that verify archive_read_only mode behavior: +//! - Bare git repositories are created for announcements +//! - Git data is synced via relay-to-relay sync (purgatory sync) +//! - Git pushes are rejected (read-only mode) +//! +//! # Test Strategy +//! +//! These tests verify the GRASP-05 archive mode with read_only flag: +//! 1. Source relay has full repository (announcement + state events + git data) +//! 2. Archive relay syncs from source relay (relay-to-relay sync) +//! 3. State events trigger purgatory sync which fetches git data +//! 4. Git data is validated against Nostr state events +//! 5. Git pushes are rejected (read-only enforcement) +//! +//! # Security Model +//! +//! Archive mode uses the existing purgatory sync infrastructure to ensure: +//! - Git data is validated against Nostr state events +//! - "Naughty git servers" can't provide incorrect state +//! - Same security guarantees as normal relay operation +//! +//! # Running Tests +//! +//! ```bash +//! # Run all archive read-only tests +//! cargo test --test archive_read_only +//! +//! # Run specific test +//! cargo test --test archive_read_only test_archive_read_only_creates_bare_repo +//! +//! # With output for debugging +//! cargo test --test archive_read_only -- --nocapture +//! ``` + +mod common; + +use common::{ + check_ref_at_commit, create_repo_announcement, create_state_event, + create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection, + CommitVariant, TestRelay, +}; +use nostr_sdk::prelude::*; +use std::time::Duration; + +/// Test that archive_read_only mode creates bare git repositories and syncs data +/// via relay-to-relay sync (purgatory sync infrastructure). +/// +/// Scenario: +/// 1. Start source relay with full repository (announcement + state + git data) +/// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source +/// 3. Archive relay syncs announcement and state events from source +/// 4. State events trigger purgatory sync which fetches git data from source's clone URL +/// 5. Verify bare repository is created and git data is synced +/// 6. Verify git pushes are rejected (read-only mode) +#[tokio::test] +async fn test_archive_read_only_creates_bare_repo() { + // 1. Start source relay + let source_relay = TestRelay::start().await; + let keys = Keys::generate(); + let identifier = "archive-test-repo"; + + // Pre-allocate archive relay port so we can include it in announcement + let archive_port = TestRelay::find_free_port(); + let archive_domain = format!("127.0.0.1:{}", archive_port); + + // 2. Create test repository locally with deterministic commit + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) + .expect("Failed to create test repo"); + + let npub = keys.public_key().to_bech32().expect("Failed to get npub"); + + // 3. Create and send announcement listing BOTH relays + // This ensures the archive relay will accept the state event when it syncs + let announcement = create_repo_announcement( + &keys, + &[&source_relay.domain(), &archive_domain], + identifier, + ); + + let source_client = Client::new(keys.clone()); + source_client + .add_relay(source_relay.url()) + .await + .expect("Failed to add source relay"); + source_client.connect().await; + + // Wait for connection + tokio::time::sleep(Duration::from_millis(500)).await; + + // Send announcement to source relay + source_client + .send_event(&announcement) + .await + .expect("Failed to send announcement to source"); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // 4. Create and send state event + let clone_urls = [ + format!( + "http://{}/{}/{}.git", + source_relay.domain(), + npub, + identifier + ), + format!("http://{}/{}/{}.git", archive_domain, npub, identifier), + ]; + let relay_urls = [ + source_relay.url().to_string(), + format!("ws://{}", archive_domain), + ]; + + let state_event = create_state_event( + &keys, + identifier, + &[("main", &commit_hash)], + &[], + &[&clone_urls[0], &clone_urls[1]], + &[&relay_urls[0], &relay_urls[1]], + ) + .expect("Failed to create state event"); + + let state_event_id = state_event.id; + + // Send state event to source relay (goes to purgatory - no git data yet) + source_client + .send_event(&state_event) + .await + .expect("Failed to send state event to source"); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // 5. Push git data to source relay + // The state event in purgatory authorizes this push + push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier) + .expect("Push to source should succeed"); + + // After push, state event should be released from purgatory on source relay + wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5)) + .await + .expect("State event should be served on source relay after push"); + + // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source + let archive_relay = TestRelay::start_with_archive_and_sync( + archive_port, + Some(source_relay.url().to_string()), + false, // negentropy enabled + true, // archive_all + true, // archive_read_only + ) + .await; + + // Wait for sync connection to establish + wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5)) + .await + .expect("Sync connection should establish"); + + // 7. Wait for state event to be released on archive relay + // The sync should: + // a) Fetch the announcement and state event from source relay + // b) Accept announcement (creates bare repo structure) - via archive mode + // c) Put state event in purgatory (git data missing on archive relay) + // d) Fetch git data from source relay's clone URL + // e) Release the state event from purgatory + let found = wait_for_event_served( + archive_relay.url(), + &state_event_id, + Duration::from_secs(30), // Allow time for sync + git fetch + ) + .await; + + assert!( + found.is_ok(), + "State event should be served after sync fetches git data: {:?}", + found.err() + ); + + // 8. Verify bare repository was created + let repo_path = archive_relay + .git_data_path() + .join(format!("{}/{}.git", npub, identifier)); + + assert!( + repo_path.exists(), + "Bare repository should be created at {:?} for archive announcement", + repo_path + ); + + // 9. Verify it's a bare repository (check for config file with bare = true) + let config_path = repo_path.join("config"); + assert!( + config_path.exists(), + "Git config should exist at {:?}", + config_path + ); + + let config_content = tokio::fs::read_to_string(&config_path) + .await + .expect("Should read git config"); + assert!( + config_content.contains("bare = true"), + "Repository at {:?} should be bare (config should contain 'bare = true')", + repo_path + ); + + // 10. Verify refs are correct on archive relay + let ref_correct = check_ref_at_commit( + &archive_domain, + &npub, + identifier, + "refs/heads/main", + &commit_hash, + ) + .await + .expect("Failed to check ref"); + + assert!(ref_correct, "main branch should point to correct commit"); + + // 11. Verify git pushes are rejected (read-only mode) + // Create a new commit in the source repo + tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content") + .await + .expect("Failed to write new file"); + + let output = tokio::process::Command::new("git") + .args(["add", "."]) + .current_dir(temp_dir.path()) + .output() + .await + .expect("Failed to git add"); + assert!(output.status.success()); + + let output = tokio::process::Command::new("git") + .args(["commit", "-m", "New commit for push test"]) + .current_dir(temp_dir.path()) + .output() + .await + .expect("Failed to git commit"); + assert!(output.status.success()); + + // Try to push to archive relay (should fail in read-only mode) + let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier); + let output = tokio::process::Command::new("git") + .args(["push", &push_url, "main"]) + .current_dir(temp_dir.path()) + .output() + .await + .expect("Failed to run git push"); + + assert!( + !output.status.success(), + "Git push should be rejected in archive_read_only mode. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Cleanup + source_client.disconnect().await; + archive_relay.stop().await; + source_relay.stop().await; +} + +/// Test that archive mode without state events does NOT sync git data. +/// +/// This verifies the security model: archive mode only syncs git data +/// when there are state events to validate against. +/// +/// Scenario: +/// 1. Start source relay with announcement only (no state events) +/// 2. Start archive relay syncing from source +/// 3. Archive relay syncs announcement (creates bare repo) +/// 4. Verify git data is NOT synced (no state events to trigger purgatory sync) +#[tokio::test] +async fn test_archive_without_state_events_does_not_sync_git() { + // 1. Start source relay + let source_relay = TestRelay::start().await; + let keys = Keys::generate(); + let identifier = "archive-no-state-repo"; + + // Pre-allocate archive relay port + let archive_port = TestRelay::find_free_port(); + let archive_domain = format!("127.0.0.1:{}", archive_port); + + // 2. Create test repository locally + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) + .expect("Failed to create test repo"); + + let npub = keys.public_key().to_bech32().expect("Failed to get npub"); + + // 3. Create and send announcement listing BOTH relays (but NO state event) + let announcement = create_repo_announcement( + &keys, + &[&source_relay.domain(), &archive_domain], + identifier, + ); + + let source_client = Client::new(keys.clone()); + source_client + .add_relay(source_relay.url()) + .await + .expect("Failed to add source relay"); + source_client.connect().await; + + tokio::time::sleep(Duration::from_millis(500)).await; + + // Send announcement to source relay + source_client + .send_event(&announcement) + .await + .expect("Failed to send announcement to source"); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // 4. Push git data to source relay (but no state event to authorize it) + // This push will fail because there's no state event in purgatory + // That's expected - we're testing that archive mode doesn't blindly fetch git data + + // 5. Start archive relay + let archive_relay = TestRelay::start_with_archive_and_sync( + archive_port, + Some(source_relay.url().to_string()), + false, + true, + true, + ) + .await; + + // Wait for sync + wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5)) + .await + .expect("Sync connection should establish"); + + // Give time for any potential git sync to happen + tokio::time::sleep(Duration::from_secs(3)).await; + + // 6. Verify bare repository was created (announcement was accepted) + let repo_path = archive_relay + .git_data_path() + .join(format!("{}/{}.git", npub, identifier)); + + assert!( + repo_path.exists(), + "Bare repository should be created for archive announcement" + ); + + // 7. Verify git data was NOT synced (no state events to trigger purgatory sync) + // Check that the commit does NOT exist in the archive relay's repo + let output = tokio::process::Command::new("git") + .args(["cat-file", "-t", &commit_hash]) + .current_dir(&repo_path) + .output() + .await; + + let commit_exists = output.map(|o| o.status.success()).unwrap_or(false); + + assert!( + !commit_exists, + "Git data should NOT be synced without state events (security: validates against Nostr state)" + ); + + // Cleanup + source_client.disconnect().await; + archive_relay.stop().await; + source_relay.stop().await; +} diff --git a/tests/common/relay.rs b/tests/common/relay.rs index fb5d421..227849a 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -3,6 +3,7 @@ //! Provides automatic relay lifecycle management for integration tests. use nostr_sdk::ToBech32; +use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::Duration; use tokio::time::sleep; @@ -15,6 +16,11 @@ pub struct TestRelay { process: Child, url: String, port: u16, + /// Temporary directory for git repositories + /// Kept alive for the lifetime of the relay + _git_data_dir: tempfile::TempDir, + /// Path to git data directory (for test assertions) + git_data_path: PathBuf, } impl TestRelay { @@ -98,6 +104,37 @@ impl TestRelay { Self::start_with_full_options(Self::find_free_port(), bootstrap_relay_url, true).await } + /// Start relay with archive configuration + /// + /// This is useful for testing GRASP-05 archive mode behavior. + /// + /// # Arguments + /// * `archive_all` - Accept all repository announcements (GRASP-05) + /// * `archive_read_only` - Reject git pushes (read-only archive mode) + /// + /// # Example + /// + /// ```no_run + /// use common::TestRelay; + /// + /// #[tokio::test] + /// async fn test_archive_mode() { + /// let relay = TestRelay::start_with_archive_config(true, true).await; + /// // ... test archive behavior ... + /// relay.stop().await; + /// } + /// ``` + pub async fn start_with_archive_config(archive_all: bool, archive_read_only: bool) -> Self { + Self::start_with_archive_and_sync( + Self::find_free_port(), + None, + false, + archive_all, + archive_read_only, + ) + .await + } + /// Start relay with options (internal, maintains backward compatibility) async fn start_with_options(port: u16, bootstrap_relay_url: Option) -> Self { Self::start_with_full_options(port, bootstrap_relay_url, false).await @@ -108,6 +145,34 @@ impl TestRelay { port: u16, bootstrap_relay_url: Option, disable_negentropy: bool, + ) -> Self { + Self::start_with_archive_and_sync( + port, + bootstrap_relay_url, + disable_negentropy, + false, + false, + ) + .await + } + + /// Start relay with all options including archive configuration and sync + /// + /// This is the most flexible method for starting a test relay with all options. + /// Use this when you need both archive mode AND sync from a bootstrap relay. + /// + /// # Arguments + /// * `port` - Port to bind to + /// * `bootstrap_relay_url` - URL of relay to sync from (optional) + /// * `disable_negentropy` - Whether to disable NIP-77 negentropy sync + /// * `archive_all` - Accept all repository announcements (GRASP-05) + /// * `archive_read_only` - Reject git pushes (read-only archive mode) + pub async fn start_with_archive_and_sync( + port: u16, + bootstrap_relay_url: Option, + disable_negentropy: bool, + archive_all: bool, + archive_read_only: bool, ) -> Self { let bind_address = format!("127.0.0.1:{}", port); let url = format!("ws://127.0.0.1:{}", port); @@ -161,9 +226,26 @@ impl TestRelay { cmd.env("NGIT_SYNC_DISABLE_NEGENTROPY", "true"); } + // Add archive configuration if requested + if archive_all { + cmd.env("NGIT_ARCHIVE_ALL", "true"); + } + if archive_read_only { + cmd.env("NGIT_ARCHIVE_READ_ONLY", "true"); + } + let process = cmd.spawn().expect("Failed to start relay process"); - let relay = Self { process, url, port }; + // Store git data path for test assertions + let git_data_path = git_data_dir.path().to_path_buf(); + + let relay = Self { + process, + url, + port, + _git_data_dir: git_data_dir, + git_data_path, + }; // Wait for relay to be ready relay.wait_for_ready().await; @@ -181,6 +263,14 @@ impl TestRelay { format!("127.0.0.1:{}", self.port) } + /// Get the git data directory path + /// + /// This is useful for test assertions that need to verify + /// git repositories were created correctly. + pub fn git_data_path(&self) -> &PathBuf { + &self.git_data_path + } + /// Wait for the relay to be ready to accept connections async fn wait_for_ready(&self) { let max_attempts = 50; // 5 seconds total