From 7a78815e29b01c83f3d0ec195ba717a2eba8cd37 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 1 Dec 2025 11:56:49 +0000 Subject: reject push when refs/nostr/ doesnt match known event and delete incorrect ref on event receive --- grasp-audit/src/client.rs | 23 +- grasp-audit/src/fixtures.rs | 172 +++-- .../src/specs/grasp01/push_authorization.rs | 708 +++++++++++++++------ src/git/authorization.rs | 142 ++++- src/git/handlers.rs | 211 +++--- src/git/mod.rs | 210 ++++-- src/nostr/builder.rs | 389 +++++++++-- src/nostr/events.rs | 6 + 8 files changed, 1373 insertions(+), 488 deletions(-) diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index ed76a34..e4e9f07 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -129,7 +129,9 @@ impl AuditClient { /// Get the relay URL pub async fn relay_url(&self) -> Result { let relays = self.client.relays().await; - let relay = relays.values().next() + let relay = relays + .values() + .next() .ok_or_else(|| anyhow!("No relays configured"))?; Ok(relay.url().to_string()) } @@ -522,12 +524,14 @@ mod tests { let keys = Keys::generate(); let maintainer_keys = Keys::generate(); let recursive_maintainer_keys = Keys::generate(); + let pr_author_keys = Keys::generate(); let client = AuditClient { client: Client::new(keys.clone()), config: config.clone(), keys: keys.clone(), maintainer_keys, recursive_maintainer_keys, + pr_author_keys, fixture_cache: Arc::new(Mutex::new(HashMap::new())), }; @@ -543,12 +547,14 @@ mod tests { let keys = Keys::generate(); let maintainer_keys = Keys::generate(); let recursive_maintainer_keys = Keys::generate(); + let pr_author_keys = Keys::generate(); let client = AuditClient { client: Client::new(keys.clone()), config: config.clone(), keys: keys.clone(), maintainer_keys, recursive_maintainer_keys, + pr_author_keys, fixture_cache: Arc::new(Mutex::new(HashMap::new())), }; @@ -610,13 +616,10 @@ mod tests { // Note: We can't test create_repo_announcement_with_maintainers directly in unit tests // because it requires a connected relay. Instead, we test the underlying event building // with maintainers tag to verify the tag format is correct. - + // Build an event with maintainers tag directly to test the tag format let event = client - .event_builder( - Kind::GitRepoAnnouncement, - "Test repository", - ) + .event_builder(Kind::GitRepoAnnouncement, "Test repository") .tag(Tag::identifier("test-repo")) .tag(Tag::custom( TagKind::custom("maintainers"), @@ -639,10 +642,14 @@ mod tests { // Verify the tag contains the maintainer pubkeys let tag = maintainers_tag.unwrap(); let tag_vec: Vec = tag.clone().to_vec(); - + // First element is "maintainers", rest are the pubkeys assert_eq!(tag_vec[0], "maintainers"); - assert_eq!(tag_vec.len(), 3, "Expected 3 elements: tag name + 2 pubkeys"); + assert_eq!( + tag_vec.len(), + 3, + "Expected 3 elements: tag name + 2 pubkeys" + ); assert_eq!(tag_vec[1], maintainer_pubkeys[0]); assert_eq!(tag_vec[2], maintainer_pubkeys[1]); } diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index dc4e638..4b5014d 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -59,7 +59,7 @@ pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c /// - GPG signing: disabled /// - User: "GRASP Audit Test " /// - Parent: none (root commit) -/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content +/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464"; /// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant) @@ -71,8 +71,9 @@ pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a6650 /// - GPG signing: disabled /// - User: "GRASP Audit Test " /// - Parent: none (root commit) -/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content -pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; +/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content +pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = + "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; /// Deterministic commit hash for PR test fixtures (PRTestCommit variant) /// This is the hash produced by creating a commit with: @@ -83,7 +84,7 @@ pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fb /// - GPG signing: disabled /// - User: "GRASP Audit Test " /// - Parent: none (root commit) -pub const PR_TEST_COMMIT_HASH: &str = "8935183ff722bf04e861928c6a7e50868c6ca4a6"; +pub const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb"; /// Types of test fixtures available /// @@ -157,10 +158,10 @@ pub enum FixtureKind { /// - Timestamp: 2 seconds in the past (most recent) RecursiveMaintainerRepoAndState, - /// PR (Patch/Pull Request) event for the SAME repo_id as ValidRepo + /// PR (Pull Request) event for the SAME repo_id as ValidRepo /// - Requires ValidRepo (uses same repo_id) /// - Signed by `client.pr_author_keys()` - /// - Kind 1617 (NIP-34 patch) + /// - Kind 1618 (NIP-34 PR) /// - Includes `a` tag referencing the repo /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH /// - Timestamp: 1 second in the past @@ -290,6 +291,37 @@ impl<'a> TestContext<'a> { self.mode } + /// Build a fixture event WITHOUT publishing it to the relay. + /// + /// This is useful for tests that need to get a fixture's event ID before + /// actually publishing it. For example, testing refs/nostr/ + /// behavior before the corresponding event exists on the relay. + /// + /// Note: This may still create and publish dependencies (e.g., ValidRepo + /// will be created/published if PREvent needs it), but the requested + /// fixture itself will NOT be published. + /// + /// # Example + /// + /// ```no_run + /// # use grasp_audit::*; + /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> { + /// // Build PR event to get its ID without publishing + /// let pr_event = ctx.build_fixture_only(FixtureKind::PREvent).await?; + /// let pr_event_id = pr_event.id.to_hex(); + /// + /// // Now push to refs/nostr/ before event exists + /// // ... git push ... + /// + /// // Later, publish the PR event when ready + /// ctx.client().send_event(pr_event).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn build_fixture_only(&self, kind: FixtureKind) -> Result { + self.build_fixture(kind).await + } + /// Create a fresh fixture (always creates new) async fn create_fresh(&self, kind: FixtureKind) -> Result { let event = self @@ -348,7 +380,11 @@ impl<'a> TestContext<'a> { { let mut cache = self.client.fixture_cache().lock().unwrap(); cache.insert(kind, event.clone()); - tracing::debug!("get_or_create_shared({:?}) stored in client cache ({} entries)", kind, cache.len()); + tracing::debug!( + "get_or_create_shared({:?}) stored in client cache ({} entries)", + kind, + cache.len() + ); } Ok(event) @@ -398,12 +434,17 @@ impl<'a> TestContext<'a> { FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8] ); - - let repo = self.client.create_repo_announcement(&test_name).await + + let repo = self + .client + .create_repo_announcement(&test_name) + .await .with_context(|| format!("create_repo_announcement failed for {}", test_name))?; // Send it - self.client.send_event(repo.clone()).await + self.client + .send_event(repo.clone()) + .await .with_context(|| "Failed to send repo announcement to relay")?; // Store in the appropriate cache based on mode @@ -412,13 +453,19 @@ impl<'a> TestContext<'a> { // Store in local cache for within-test fixture dependencies let mut cache = self.local_cache.lock().unwrap(); cache.insert(FixtureKind::ValidRepo, repo.clone()); - tracing::debug!("get_or_create_repo() stored in local cache ({} entries)", cache.len()); + tracing::debug!( + "get_or_create_repo() stored in local cache ({} entries)", + cache.len() + ); } ContextMode::Shared => { // Store in client cache for cross-test sharing let mut cache = self.client.fixture_cache().lock().unwrap(); cache.insert(FixtureKind::ValidRepo, repo.clone()); - tracing::debug!("get_or_create_repo() stored in client cache ({} entries)", cache.len()); + tracing::debug!( + "get_or_create_repo() stored in client cache ({} entries)", + cache.len() + ); } } @@ -448,12 +495,9 @@ impl<'a> TestContext<'a> { let repo = self.get_or_create_repo().await?; // Create the issue - let issue = self.client.create_issue( - &repo, - "Test Issue", - "Issue content for testing", - vec![], - )?; + let issue = + self.client + .create_issue(&repo, "Test Issue", "Issue content for testing", vec![])?; // Send it self.client.send_event(issue.clone()).await?; @@ -555,7 +599,7 @@ impl<'a> TestContext<'a> { // Get the owner's repo to use the SAME repo_id let owner_repo = self.get_or_create_repo().await?; - + // Extract repo_id from owner's repo announcement let repo_id = owner_repo .tags @@ -573,7 +617,7 @@ impl<'a> TestContext<'a> { // Get the owner's repo to use the SAME repo_id let owner_repo = self.get_or_create_repo().await?; - + // Extract repo_id from owner's repo announcement let repo_id = owner_repo .tags @@ -593,7 +637,7 @@ impl<'a> TestContext<'a> { // Get the owner's repo to use the SAME repo_id let owner_repo = self.get_or_create_repo().await?; - + // Extract repo_id from owner's repo announcement let repo_id = owner_repo .tags @@ -611,7 +655,7 @@ impl<'a> TestContext<'a> { // Get the owner's repo to use the SAME repo_id let owner_repo = self.get_or_create_repo().await?; - + // Extract repo_id from owner's repo announcement let repo_id = owner_repo .tags @@ -630,7 +674,7 @@ impl<'a> TestContext<'a> { // Get the owner's repo to use the SAME repo_id let owner_repo = self.get_or_create_repo().await?; - + // Extract repo_id from owner's repo announcement let repo_id = owner_repo .tags @@ -647,8 +691,12 @@ impl<'a> TestContext<'a> { self.client.send_event(maintainer_announcement).await?; // Build and send the recursive maintainer's repo announcement - let recursive_maintainer_announcement = self.build_recursive_maintainer_announcement(&repo_id).await?; - self.client.send_event(recursive_maintainer_announcement).await?; + let recursive_maintainer_announcement = self + .build_recursive_maintainer_announcement(&repo_id) + .await?; + self.client + .send_event(recursive_maintainer_announcement) + .await?; // Return the state event (caller will send it) self.build_recursive_maintainer_state(&repo_id) @@ -672,10 +720,10 @@ impl<'a> TestContext<'a> { let base_time = Timestamp::now().as_u64(); let pr_timestamp = Timestamp::from(base_time - 1); - // Build NIP-34 patch event (kind 1617) + // Build NIP-34 PR event (kind 1618) self.client .event_builder( - Kind::Custom(1617), // NIP-34 patch/PR kind + Kind::Custom(1618), // NIP-34 PR kind (has 'c' tag for commit) "Test PR for GRASP validation", ) .tag(Tag::custom( @@ -702,7 +750,8 @@ impl<'a> TestContext<'a> { use nostr_sdk::prelude::*; // Get relay URL for clone tag - let relay_url = self.client + let relay_url = self + .client .client() .relays() .await @@ -715,7 +764,8 @@ impl<'a> TestContext<'a> { .replace("wss://", "https://"); // Create maintainer's repo announcement for the SAME repo_id - let maintainer_npub = self.client + let maintainer_npub = self + .client .maintainer_keys() .public_key() .to_bech32() @@ -735,10 +785,7 @@ impl<'a> TestContext<'a> { TagKind::custom("clone"), vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], )) - .tag(Tag::custom( - TagKind::custom("relays"), - vec![relay_url], - )) + .tag(Tag::custom(TagKind::custom("relays"), vec![relay_url])) .tag(Tag::custom( TagKind::custom("maintainers"), vec![self.client.recursive_maintainer_pubkey_hex()], @@ -776,7 +823,8 @@ impl<'a> TestContext<'a> { use nostr_sdk::prelude::*; // Get relay URL for clone tag - let relay_url = self.client + let relay_url = self + .client .client() .relays() .await @@ -789,7 +837,8 @@ impl<'a> TestContext<'a> { .replace("wss://", "https://"); // Create recursive maintainer's repo announcement for the SAME repo_id - let recursive_maintainer_npub = self.client + let recursive_maintainer_npub = self + .client .recursive_maintainer_keys() .public_key() .to_bech32() @@ -807,12 +856,12 @@ impl<'a> TestContext<'a> { )) .tag(Tag::custom( TagKind::custom("clone"), - vec![format!("{}/{}/{}.git", http_url, recursive_maintainer_npub, repo_id)], - )) - .tag(Tag::custom( - TagKind::custom("relays"), - vec![relay_url], + vec![format!( + "{}/{}/{}.git", + http_url, recursive_maintainer_npub, repo_id + )], )) + .tag(Tag::custom(TagKind::custom("relays"), vec![relay_url])) .tag(Tag::custom( TagKind::custom("maintainers"), vec![ @@ -821,7 +870,12 @@ impl<'a> TestContext<'a> { ], )) .build(self.client.recursive_maintainer_keys()) - .map_err(|e| anyhow::anyhow!("Failed to build recursive maintainer repo announcement: {}", e)) + .map_err(|e| { + anyhow::anyhow!( + "Failed to build recursive maintainer repo announcement: {}", + e + ) + }) } /// Build recursive maintainer state event for the given repo_id @@ -898,7 +952,7 @@ pub async fn send_and_verify_accepted( ) -> Result<(), String> { use nostr_sdk::prelude::Filter; use std::time::Duration; - + let event_id = event.id; client @@ -952,12 +1006,12 @@ pub async fn send_and_verify_rejected( ) -> Result<(), String> { use nostr_sdk::prelude::Filter; use std::time::Duration; - + let event_id = event.id; // Try to send event - rejection may cause send_event to fail with an error let send_result = client.send_event(event).await; - + // If send succeeded, the relay might have accepted it (we'll verify below) // If send failed, check if it's a rejection error (expected) if let Err(e) = send_result { @@ -966,7 +1020,7 @@ pub async fn send_and_verify_rejected( if err_msg.contains("rejected") || err_msg.contains("blocked") { // Expected rejection - verify event is NOT in database tokio::time::sleep(Duration::from_millis(100)).await; - + let filter = Filter::new().id(event_id); let events = client .query(filter) @@ -974,9 +1028,12 @@ pub async fn send_and_verify_rejected( .map_err(|e| format!("Failed to query relay for verification: {}", e))?; if !events.is_empty() { - return Err(format!("Event was rejected but still stored: {}", description)); + return Err(format!( + "Event was rejected but still stored: {}", + description + )); } - + return Ok(()); // Rejected as expected } else { // Unexpected error (network, etc.) @@ -1029,11 +1086,7 @@ use std::process::Command; /// # Ok(()) /// # } /// ``` -pub fn clone_repo( - relay_domain: &str, - npub: &str, - repo_id: &str, -) -> Result { +pub fn clone_repo(relay_domain: &str, npub: &str, repo_id: &str) -> Result { let temp_base = std::env::temp_dir(); let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4()); let clone_path = temp_base.join(&clone_dir_name); @@ -1146,7 +1199,7 @@ impl CommitVariant { CommitVariant::PRTestCommit => "PR test deterministic commit", } } - + /// Get the commit message for this variant pub fn commit_message(&self) -> &'static str { match self { @@ -1172,11 +1225,14 @@ impl CommitVariant { /// # Returns /// * `Ok(String)` - The deterministic commit hash /// * `Err(String)` - Error message if commit failed -pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result { +pub fn create_deterministic_commit_with_variant( + clone_path: &Path, + variant: CommitVariant, +) -> Result { let test_file = clone_path.join("test.txt"); let content = variant.file_content(); let message = variant.commit_message(); - + fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?; let output = Command::new("git") @@ -1191,11 +1247,7 @@ pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: Comm // Create deterministic commit with fixed dates and GPG disabled let output = Command::new("git") - .args([ - "-c", "commit.gpgsign=false", - "commit", - "-m", message, - ]) + .args(["-c", "commit.gpgsign=false", "commit", "-m", message]) .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") .current_dir(clone_path) diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 97d068c..d8652ae 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -21,7 +21,7 @@ /// This hash is produced by creating a commit with: /// - File: test.txt containing "PR test deterministic commit" /// - Message: "PR test deterministic commit" -/// - Author: "PR Test Author " +/// - Author: "GRASP Audit Test " /// - Author date: 2024-01-01T00:00:00Z /// - Committer date: 2024-01-01T00:00:00Z /// - GPG signing: disabled @@ -29,13 +29,13 @@ /// /// Run `test_pr_test_commit_hash_discovery` to discover/verify this value. #[allow(dead_code)] -const PR_TEST_COMMIT_HASH: &str = "8935183ff722bf04e861928c6a7e50868c6ca4a6"; +const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb"; use crate::{ - clone_repo, create_commit, create_deterministic_commit, create_deterministic_commit_with_variant, - try_push, try_push_to_ref, AuditClient, CommitVariant, FixtureKind, TestContext, TestResult, - DETERMINISTIC_COMMIT_HASH, MAINTAINER_DETERMINISTIC_COMMIT_HASH, - RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, + clone_repo, create_commit, create_deterministic_commit, + create_deterministic_commit_with_variant, try_push, try_push_to_ref, AuditClient, + CommitVariant, FixtureKind, TestContext, TestResult, DETERMINISTIC_COMMIT_HASH, + MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, }; use nostr_sdk::prelude::*; use std::fs; @@ -63,37 +63,121 @@ use std::process::Command; /// * `Ok(String)` - The commit hash (should match PR_TEST_COMMIT_HASH) /// * `Err(String)` - Error message if commit creation failed fn create_pr_test_commit(clone_path: &Path) -> Result { - // Step 1: Create orphan branch (removes all history) + // Step 1: Clean up any tracked files in the working directory + // This ensures we start with a clean slate let _ = Command::new("git") - .args(["checkout", "--orphan", "pr-test-branch"]) + .args(["clean", "-fd"]) .current_dir(clone_path) .output(); - // Step 2: Clear staged files (orphan keeps files staged from previous branch) - let _ = Command::new("git") + // Step 2: Create orphan branch (removes all history) + let output = Command::new("git") + .args(["checkout", "--orphan", "pr-test-branch"]) + .current_dir(clone_path) + .output() + .map_err(|e| format!("Failed to execute git checkout --orphan: {}", e))?; + + if !output.status.success() { + return Err(format!( + "git checkout --orphan failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // Step 3: Remove ALL files from the index (staging area) + let output = Command::new("git") .args(["rm", "-rf", "--cached", "."]) .current_dir(clone_path) - .output(); + .output() + .map_err(|e| format!("Failed to execute git rm: {}", e))?; + + // Note: git rm may return error if there are no files to remove, that's OK + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Ignore "did not match any files" errors + if !stderr.contains("did not match any files") { + return Err(format!("git rm -rf --cached . failed: {}", stderr)); + } + } + + // Step 4: Remove ALL files from working directory (except .git) + // This ensures only test.txt will be in the commit + for entry in fs::read_dir(clone_path).map_err(|e| format!("Failed to read dir: {}", e))? { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if file_name != ".git" { + if path.is_dir() { + fs::remove_dir_all(&path) + .map_err(|e| format!("Failed to remove dir {}: {}", path.display(), e))?; + } else { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove file {}: {}", path.display(), e))?; + } + } + } + + // Step 5: Create deterministic commit using existing function + let commit_hash = + create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?; + + // Step 6: Verify this is actually a root commit (no parent) + let output = Command::new("git") + .args(["rev-list", "--max-parents=0", "HEAD"]) + .current_dir(clone_path) + .output() + .map_err(|e| format!("Failed to check root commit: {}", e))?; - // Step 3: Create deterministic commit using existing function - let commit_hash = create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?; + let root_commits = String::from_utf8_lossy(&output.stdout); + if !root_commits.trim().contains(&commit_hash) { + return Err(format!( + "Commit {} is not a root commit (has parent). Root commits: {}", + commit_hash, + root_commits.trim() + )); + } - // Step 4: Replace main branch with our new orphan branch + // Step 7: Replace main branch with our new orphan branch let _ = Command::new("git") .args(["branch", "-D", "main"]) .current_dir(clone_path) .output(); - let _ = Command::new("git") + let output = Command::new("git") .args(["branch", "-m", "main"]) .current_dir(clone_path) - .output(); + .output() + .map_err(|e| format!("Failed to rename branch: {}", e))?; + + if !output.status.success() { + return Err(format!( + "Failed to rename branch to main: {}", + String::from_utf8_lossy(&output.stderr) + )); + } - // Verify commit hash matches expected + // Step 8: Verify commit hash matches expected if commit_hash != PR_TEST_COMMIT_HASH { + // Debug: Show what's in the commit + let tree_output = Command::new("git") + .args(["ls-tree", "-r", "HEAD"]) + .current_dir(clone_path) + .output(); + let tree_info = tree_output + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_else(|_| "Failed to get tree".to_string()); + + let cat_output = Command::new("git") + .args(["cat-file", "-p", "HEAD"]) + .current_dir(clone_path) + .output(); + let commit_info = cat_output + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_else(|_| "Failed to get commit".to_string()); + return Err(format!( - "PR test commit hash mismatch: got {}, expected {}", - commit_hash, PR_TEST_COMMIT_HASH + "PR test commit hash mismatch: got {}, expected {}\nTree contents:\n{}\nCommit info:\n{}", + commit_hash, PR_TEST_COMMIT_HASH, tree_info, commit_info )); } @@ -166,6 +250,8 @@ struct PrRefTestSetup { repo_id: String, owner_npub: String, wrong_commit_hash: String, + /// The unpublished PR event - store it so we can publish the SAME event later + pr_event: Event, } impl PrRefTestSetup { @@ -192,17 +278,18 @@ async fn setup_repo_with_wrong_commit_pushed( ctx: &TestContext<'_>, relay_domain: &str, ) -> Result { - // Get fixtures (PREvent fixture creates the event but doesn't publish until we call get_fixture) + // Get ValidRepo fixture (publishes repo announcement to relay) let repo_event = ctx .get_fixture(FixtureKind::ValidRepo) .await .map_err(|e| format!("Failed to get repo announcement: {}", e))?; - // Get PR event fixture (creates event object but doesn't publish to relay yet) + // Build PR event WITHOUT publishing - we need its ID before the event exists on relay + // This allows testing refs/nostr/ push behavior before the event is received let pr_event = ctx - .get_fixture(FixtureKind::PREvent) + .build_fixture_only(FixtureKind::PREvent) .await - .map_err(|e| format!("Failed to get PR event fixture: {}", e))?; + .map_err(|e| format!("Failed to build PR event fixture: {}", e))?; let repo_id = repo_event .tags @@ -219,7 +306,8 @@ async fn setup_repo_with_wrong_commit_pushed( let clone_path = clone_repo(relay_domain, &owner_npub, &repo_id)?; // Create a WRONG commit (not the one expected by PR event) - let wrong_commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner)?; + let wrong_commit_hash = + create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner)?; // Verify it's actually different from expected if wrong_commit_hash == PR_TEST_COMMIT_HASH { @@ -229,7 +317,11 @@ async fn setup_repo_with_wrong_commit_pushed( // Push to refs/nostr/ (no event published yet, should succeed) let push_output = Command::new("git") - .args(["push", "origin", &format!("main:refs/nostr/{}", pr_event_id)]) + .args([ + "push", + "origin", + &format!("master:refs/nostr/{}", pr_event_id), + ]) .current_dir(&clone_path) .output() .map_err(|e| format!("Failed to execute git push: {}", e))?; @@ -249,23 +341,30 @@ async fn setup_repo_with_wrong_commit_pushed( repo_id, owner_npub, wrong_commit_hash, + pr_event, }) } -/// Publishes the PR event fixture and waits for relay to process it. +/// Publishes the SAME PR event that was built during setup. /// Call this after setup_repo_with_wrong_commit_pushed to test post-event behavior. +/// +/// IMPORTANT: We must publish the EXACT same event that was used during setup, +/// otherwise the event ID won't match the refs/nostr/ ref that was pushed. #[allow(dead_code)] -async fn publish_pr_event_and_wait(ctx: &TestContext<'_>) -> Result { - // Publishing the PR event - get_fixture publishes if not already published - let pr_event = ctx - .get_fixture(FixtureKind::PREvent) +async fn publish_pr_event_and_wait( + ctx: &TestContext<'_>, + pr_event: &Event, +) -> Result<(), String> { + // Publish the exact same PR event that was created during setup + ctx.client() + .send_event(pr_event.clone()) .await .map_err(|e| format!("Failed to publish PR event: {}", e))?; // Wait for relay to process tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - Ok(pr_event) + Ok(()) } /// Creates the correct PR test commit (matching PR_TEST_COMMIT_HASH) in an existing clone. @@ -281,7 +380,12 @@ fn reset_to_correct_pr_commit(clone_path: &Path) -> Result { #[allow(dead_code)] fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result { let push_output = Command::new("git") - .args(["push", "--force", "origin", &format!("main:refs/nostr/{}", pr_event_id)]) + .args([ + "push", + "--force", + "origin", + &format!("HEAD:refs/nostr/{}", pr_event_id), + ]) .current_dir(clone_path) .output() .map_err(|e| format!("Failed to execute git push: {}", e))?; @@ -307,22 +411,48 @@ pub struct PushAuthorizationTests; impl PushAuthorizationTests { /// Run all push authorization tests - pub async fn run_all( - client: &AuditClient, - relay_domain: &str, - ) -> crate::AuditResult { + pub async fn run_all(client: &AuditClient, relay_domain: &str) -> crate::AuditResult { let mut results = crate::AuditResult::new("GRASP-01 Push Authorization Tests"); results.add(Self::test_push_rejected_without_state_event(client, relay_domain).await); results.add(Self::test_push_authorized_by_owner_state(client, relay_domain).await); results.add(Self::test_push_rejected_wrong_commit(client, relay_domain).await); - results.add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await); - results.add(Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await); - results.add(Self::test_push_to_nostr_ref_with_invalid_event_id_rejected(client, relay_domain).await); - results.add(Self::test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received(client, relay_domain).await); - results.add(Self::test_pr_event_published_removes_nostr_ref_at_incorrect_commit(client, relay_domain).await); - results.add(Self::test_push_to_nostr_ref_with_wrong_commit_after_event_received_rejected(client, relay_domain).await); - results.add(Self::test_push_to_nostr_ref_with_correct_commit_after_event_received_accepted(client, relay_domain).await); + results + .add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await); + results.add( + Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await, + ); + results.add( + Self::test_push_to_nostr_ref_with_invalid_event_id_rejected(client, relay_domain).await, + ); + results.add( + Self::test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received( + client, + relay_domain, + ) + .await, + ); + results.add( + Self::test_pr_event_published_removes_nostr_ref_at_incorrect_commit( + client, + relay_domain, + ) + .await, + ); + results.add( + Self::test_push_to_nostr_ref_with_wrong_commit_after_event_received_rejected( + client, + relay_domain, + ) + .await, + ); + results.add( + Self::test_push_to_nostr_ref_with_correct_commit_after_event_received_accepted( + client, + relay_domain, + ) + .await, + ); results } @@ -340,26 +470,37 @@ impl PushAuthorizationTests { Ok(r) => r, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") - .fail(&format!("Failed to create repo: {}", e)) + .fail(format!("Failed to create repo: {}", e)) } }; tokio::time::sleep(std::time::Duration::from_millis(200)).await; - let repo_id = repo.tags.iter().find(|t| t.kind() == TagKind::d()) - .and_then(|t| t.content()).unwrap().to_string(); + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .unwrap() + .to_string(); let npub = repo.pubkey.to_bech32().unwrap(); // Clone and create commit let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { Ok(p) => p, - Err(e) => return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), + Err(e) => { + return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") + .fail(&e) + } + }; + let cleanup = || { + let _ = fs::remove_dir_all(&clone_path); }; - let cleanup = || { let _ = fs::remove_dir_all(&clone_path); }; if let Err(e) = create_commit(&clone_path, "Unauthorized commit") { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e); + return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") + .fail(&e); } // Do NOT publish state event - push should be rejected @@ -367,9 +508,14 @@ impl PushAuthorizationTests { cleanup(); match push_result { - Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass(), - Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail("Push accepted but should be rejected"), - Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), + Ok(false) => { + TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass() + } + Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event") + .fail("Push accepted but should be rejected"), + Err(e) => { + TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e) + } } } @@ -400,8 +546,12 @@ impl PushAuthorizationTests { let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { Ok(e) => e, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Failed to create RepoState fixture: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!("Failed to create RepoState fixture: {}", e)); } }; @@ -416,16 +566,24 @@ impl PushAuthorizationTests { { Some(id) => id.to_string(), None => { - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail("Missing repo_id in state event"); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail("Missing repo_id in state event"); } }; let npub = match state_event.pubkey.to_bech32() { Ok(n) => n, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Failed to convert pubkey to bech32: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!("Failed to convert pubkey to bech32: {}", e)); } }; @@ -435,8 +593,12 @@ impl PushAuthorizationTests { let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { Ok(p) => p, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Failed to clone repo: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!("Failed to clone repo: {}", e)); } }; @@ -450,8 +612,12 @@ impl PushAuthorizationTests { Ok(h) => h, Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Failed to create deterministic commit: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!("Failed to create deterministic commit: {}", e)); } }; @@ -459,7 +625,7 @@ impl PushAuthorizationTests { if commit_hash != DETERMINISTIC_COMMIT_HASH { cleanup(); return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!( + .fail(format!( "Commit hash mismatch: got {}, expected {}", commit_hash, DETERMINISTIC_COMMIT_HASH )); @@ -474,16 +640,24 @@ impl PushAuthorizationTests { match branch_output { Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Failed to create main branch: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!("Failed to create main branch: {}", e)); } Ok(output) if !output.status.success() => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!( - "Failed to create main branch: {}", - String::from_utf8_lossy(&output.stderr) - )); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!( + "Failed to create main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); } _ => {} } @@ -497,16 +671,24 @@ impl PushAuthorizationTests { match checkout_output { Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Failed to checkout main branch: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!("Failed to checkout main branch: {}", e)); } Ok(output) if !output.status.success() => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!( - "Failed to checkout main branch: {}", - String::from_utf8_lossy(&output.stderr) - )); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized with matching state", + ) + .fail(format!( + "Failed to checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); } _ => {} } @@ -524,17 +706,15 @@ impl PushAuthorizationTests { } Ok(false) => { TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").fail( - &format!( + format!( "Push was rejected but should have been accepted. \ The state event points to commit {} which matches the pushed commit.", DETERMINISTIC_COMMIT_HASH ), ) } - Err(e) => { - TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") - .fail(&format!("Push error: {}", e)) - } + Err(e) => TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") + .fail(format!("Push error: {}", e)), } } @@ -571,8 +751,12 @@ impl PushAuthorizationTests { let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { Ok(e) => e, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail(&format!("Failed to create RepoState fixture: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail(format!("Failed to create RepoState fixture: {}", e)); } }; @@ -587,16 +771,24 @@ impl PushAuthorizationTests { { Some(id) => id.to_string(), None => { - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail("Missing repo_id in state event"); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail("Missing repo_id in state event"); } }; let npub = match state_event.pubkey.to_bech32() { Ok(n) => n, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail(&format!("Failed to convert pubkey to bech32: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail(format!("Failed to convert pubkey to bech32: {}", e)); } }; @@ -607,8 +799,12 @@ impl PushAuthorizationTests { let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { Ok(p) => p, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail(&format!("Failed to clone repo: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail(format!("Failed to clone repo: {}", e)); } }; @@ -626,16 +822,24 @@ impl PushAuthorizationTests { match branch_output { Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail(&format!("Failed to create/checkout main branch: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail(format!("Failed to create/checkout main branch: {}", e)); } Ok(output) if !output.status.success() => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail(&format!( - "Failed to create/checkout main branch: {}", - String::from_utf8_lossy(&output.stderr) - )); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail(format!( + "Failed to create/checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); } _ => {} } @@ -644,8 +848,12 @@ impl PushAuthorizationTests { // Any commit hash different from what's authorized in the state event will work if let Err(e) = create_commit(&clone_path, "Unauthorized commit - should be rejected") { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") - .fail(&format!("Failed to create wrong commit: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Push rejected when commit not in state event", + ) + .fail(format!("Failed to create wrong commit: {}", e)); } // ============================================================ @@ -703,7 +911,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) - .fail(&format!("Failed to create RepoState fixture: {}", e)); + .fail(format!("Failed to create RepoState fixture: {}", e)); } }; @@ -717,7 +925,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) - .fail(&format!("Failed to create MaintainerState fixture: {}", e)); + .fail(format!("Failed to create MaintainerState fixture: {}", e)); } }; @@ -749,7 +957,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) - .fail(&format!("Failed to convert pubkey to bech32: {}", e)); + .fail(format!("Failed to convert pubkey to bech32: {}", e)); } }; @@ -785,19 +993,21 @@ impl PushAuthorizationTests { .output(); // Step 3: Create deterministic commit using existing function - let commit_hash = - match create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer) { - Ok(h) => h, - Err(e) => { - cleanup(); - return TestResult::new( - test_name, - "GRASP-01", - "Push authorized by maintainer state event only (no announcement)", - ) - .fail(&format!("Failed to create maintainer commit: {}", e)); - } - }; + let commit_hash = match create_deterministic_commit_with_variant( + &clone_path, + CommitVariant::Maintainer, + ) { + Ok(h) => h, + Err(e) => { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by maintainer state event only (no announcement)", + ) + .fail(format!("Failed to create maintainer commit: {}", e)); + } + }; // Step 4: Replace main branch with our new orphan branch let _ = Command::new("git") @@ -818,7 +1028,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) - .fail(&format!( + .fail(format!( "Maintainer commit hash mismatch: got {}, expected {}", commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH )); @@ -843,7 +1053,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) - .fail(&format!( + .fail(format!( "Push was rejected but should have been accepted. \ The maintainer published a state event with commit {}, \ and even without a separate announcement, the relay should \ @@ -856,7 +1066,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) - .fail(&format!("Push error: {}", e)), + .fail(format!("Push error: {}", e)), } } @@ -899,7 +1109,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!("Failed to create RepoState fixture: {}", e)); + .fail(format!("Failed to create RepoState fixture: {}", e)); } }; @@ -912,7 +1122,10 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!("Failed to create MaintainerAnnouncement fixture: {}", e)); + .fail(format!( + "Failed to create MaintainerAnnouncement fixture: {}", + e + )); } }; @@ -925,12 +1138,15 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!("Failed to create MaintainerState fixture: {}", e)); + .fail(format!("Failed to create MaintainerState fixture: {}", e)); } }; // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain) - match ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await { + match ctx + .get_fixture(FixtureKind::RecursiveMaintainerRepoAndState) + .await + { Ok(_) => {} Err(e) => { return TestResult::new( @@ -938,7 +1154,10 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!("Failed to create RecursiveMaintainerRepoAndState fixture: {}", e)); + .fail(format!( + "Failed to create RecursiveMaintainerRepoAndState fixture: {}", + e + )); } }; @@ -970,7 +1189,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!("Failed to convert pubkey to bech32: {}", e)); + .fail(format!("Failed to convert pubkey to bech32: {}", e)); } }; @@ -1006,19 +1225,24 @@ impl PushAuthorizationTests { .output(); // Step 3: Create recursive maintainer deterministic commit - let commit_hash = - match create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer) { - Ok(h) => h, - Err(e) => { - cleanup(); - return TestResult::new( - test_name, - "GRASP-01", - "Push authorized by recursive maintainer state event", - ) - .fail(&format!("Failed to create recursive maintainer commit: {}", e)); - } - }; + let commit_hash = match create_deterministic_commit_with_variant( + &clone_path, + CommitVariant::RecursiveMaintainer, + ) { + Ok(h) => h, + Err(e) => { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(format!( + "Failed to create recursive maintainer commit: {}", + e + )); + } + }; // Step 4: Replace main branch with our new orphan branch let _ = Command::new("git") @@ -1039,7 +1263,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!( + .fail(format!( "Recursive maintainer commit hash mismatch: got {}, expected {}", commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH )); @@ -1064,7 +1288,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!( + .fail(format!( "Push was rejected but should have been accepted. \ The recursive maintainer published a state event with commit {}, \ and the relay should authorize pushes matching this state event \ @@ -1076,7 +1300,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push authorized by recursive maintainer state event", ) - .fail(&format!("Push error: {}", e)), + .fail(format!("Push error: {}", e)), } } @@ -1109,8 +1333,12 @@ impl PushAuthorizationTests { let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { Ok(e) => e, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to create RepoState fixture: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to create RepoState fixture: {}", e)); } }; @@ -1125,16 +1353,24 @@ impl PushAuthorizationTests { { Some(id) => id.to_string(), None => { - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail("Missing repo_id in state event"); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail("Missing repo_id in state event"); } }; let npub = match state_event.pubkey.to_bech32() { Ok(n) => n, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to convert pubkey to bech32: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to convert pubkey to bech32: {}", e)); } }; @@ -1145,8 +1381,12 @@ impl PushAuthorizationTests { let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { Ok(p) => p, Err(e) => { - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to clone repo: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to clone repo: {}", e)); } }; @@ -1160,8 +1400,12 @@ impl PushAuthorizationTests { Ok(h) => h, Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to create deterministic commit: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to create deterministic commit: {}", e)); } }; @@ -1169,7 +1413,7 @@ impl PushAuthorizationTests { if commit_hash != DETERMINISTIC_COMMIT_HASH { cleanup(); return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!( + .fail(format!( "Commit hash mismatch: got {}, expected {}", commit_hash, DETERMINISTIC_COMMIT_HASH )); @@ -1184,16 +1428,24 @@ impl PushAuthorizationTests { match branch_output { Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to create main branch: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to create main branch: {}", e)); } Ok(output) if !output.status.success() => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!( - "Failed to create main branch: {}", - String::from_utf8_lossy(&output.stderr) - )); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!( + "Failed to create main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); } _ => {} } @@ -1207,16 +1459,24 @@ impl PushAuthorizationTests { match checkout_output { Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to checkout main branch: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to checkout main branch: {}", e)); } Ok(output) if !output.status.success() => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!( - "Failed to checkout main branch: {}", - String::from_utf8_lossy(&output.stderr) - )); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!( + "Failed to checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); } _ => {} } @@ -1231,16 +1491,24 @@ impl PushAuthorizationTests { match push_output { Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to push initial commit: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to push initial commit: {}", e)); } Ok(output) if !output.status.success() => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!( - "Failed to push initial commit: {}", - String::from_utf8_lossy(&output.stderr) - )); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!( + "Failed to push initial commit: {}", + String::from_utf8_lossy(&output.stderr) + )); } _ => {} } @@ -1253,14 +1521,18 @@ impl PushAuthorizationTests { Ok(h) => h, Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to create commit: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to create commit: {}", e)); } }; // Create a rogue keypair (NOT the maintainer) let rogue_keys = Keys::generate(); - + // Create a rogue state event announcing the new commit // This event has the correct repo_id but is signed by a non-maintainer let rogue_state = match client @@ -1275,8 +1547,12 @@ impl PushAuthorizationTests { Ok(e) => e, Err(e) => { cleanup(); - return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to build rogue state event: {}", e)); + return TestResult::new( + test_name, + "GRASP-01", + "Non-maintainer state events ignored", + ) + .fail(format!("Failed to build rogue state event: {}", e)); } }; @@ -1284,7 +1560,7 @@ impl PushAuthorizationTests { if let Err(e) = client.client().send_event(&rogue_state).await { cleanup(); return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!("Failed to send rogue state event: {}", e)); + .fail(format!("Failed to send rogue state event: {}", e)); } // Wait for event to propagate @@ -1300,7 +1576,7 @@ impl PushAuthorizationTests { match push_result { Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(), Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") - .fail(&format!( + .fail(format!( "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ a state event announcing commit {}, but the push was accepted. The relay should \ only accept state events from maintainers (pubkey: {}).", @@ -1342,7 +1618,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push to refs/nostr/ rejected", ) - .fail(&format!("Failed to create repo: {}", e)); + .fail(format!("Failed to create repo: {}", e)); } }; @@ -1408,7 +1684,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push to refs/nostr/ rejected", ) - .fail(&format!( + .fail(format!( "Push to {} was accepted but should be rejected. \ The event-id '{}' is NOT a valid 64-character hex string (EventId format). \ The relay should reject pushes to refs/nostr/ with invalid event-id format.", @@ -1419,7 +1695,7 @@ impl PushAuthorizationTests { "GRASP-01", "Push to refs/nostr/ rejected", ) - .fail(&format!("Push error: {}", e)), + .fail(format!("Push error: {}", e)), } } @@ -1434,7 +1710,8 @@ impl PushAuthorizationTests { client: &AuditClient, relay_domain: &str, ) -> TestResult { - let test_name = "test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received"; + let test_name = + "test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received"; let desc = "Push wrong commit to refs/nostr/ before PR event (should accept)"; let ctx = TestContext::new(client); @@ -1459,15 +1736,13 @@ impl PushAuthorizationTests { /// the relay should validate any existing refs/nostr/ refs and /// delete those that don't match the commit in the PR event's `c` tag. /// - /// Currently NOT_IMPLEMENTED - the relay doesn't have this cleanup logic yet. - /// /// Depends on: `setup_repo_with_wrong_commit_pushed` (wrong commit already pushed) pub async fn test_pr_event_published_removes_nostr_ref_at_incorrect_commit( client: &AuditClient, relay_domain: &str, ) -> TestResult { let test_name = "test_pr_event_published_removes_nostr_ref_at_incorrect_commit"; - let desc = "Publishing PR event should trigger cleanup of incorrect refs (NOT_IMPLEMENTED)"; + let desc = "Publishing PR event should trigger cleanup of incorrect refs"; let ctx = TestContext::new(client); // Setup: wrong commit already pushed to refs/nostr/ @@ -1479,7 +1754,7 @@ impl PushAuthorizationTests { }; // NOW publish the PR event - this should trigger cleanup validation - if let Err(e) = publish_pr_event_and_wait(&ctx).await { + if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await { setup.cleanup(); return TestResult::new(test_name, "GRASP-01", desc).fail(&e); } @@ -1496,12 +1771,16 @@ impl PushAuthorizationTests { setup.cleanup(); - // Document current behavior: relay doesn't implement automatic cleanup yet - TestResult::new(test_name, "GRASP-01", desc).fail(&format!( - "NOT_IMPLEMENTED: Relay should delete refs/nostr/ when PR event is published \ - with non-matching commit. Currently ref still exists: {}. This requires relay-side validation logic.", - refs_exist - )) + // Ref should be deleted since the pushed commit doesn't match the PR event's `c` tag + if refs_exist { + TestResult::new(test_name, "GRASP-01", desc).fail(format!( + "Expected refs/nostr/{} to be deleted when PR event published with non-matching commit, \ + but the ref still exists. The relay should delete refs that don't match the event's `c` tag.", + setup.pr_event_id + )) + } else { + TestResult::new(test_name, "GRASP-01", desc).pass() + } } /// Test 3: Push wrong commit to refs/nostr/ AFTER PR event exists @@ -1528,7 +1807,7 @@ impl PushAuthorizationTests { }; // Publish PR event FIRST (before our test push) - if let Err(e) = publish_pr_event_and_wait(&ctx).await { + if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await { setup.cleanup(); return TestResult::new(test_name, "GRASP-01", desc).fail(&e); } @@ -1577,7 +1856,7 @@ impl PushAuthorizationTests { }; // Publish PR event FIRST - if let Err(e) = publish_pr_event_and_wait(&ctx).await { + if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await { setup.cleanup(); return TestResult::new(test_name, "GRASP-01", desc).fail(&e); } @@ -1626,9 +1905,9 @@ mod tests { /// Run with: cd grasp-audit && nix develop -c cargo test --lib test_pr_test_commit_hash_discovery -- --nocapture #[test] fn test_pr_test_commit_hash_discovery() { + use std::fs; use std::process::Command; use tempfile::TempDir; - use std::fs; let temp_dir = TempDir::new().expect("Failed to create temp dir"); let path = temp_dir.path(); @@ -1639,7 +1918,11 @@ mod tests { .current_dir(path) .output() .expect("Failed to init git"); - assert!(output.status.success(), "git init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "git init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Configure git user - use PR Test Author identity let output = Command::new("git") @@ -1666,21 +1949,31 @@ mod tests { .current_dir(path) .output() .expect("git add failed"); - assert!(output.status.success(), "git add failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "git add failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Create deterministic commit with fixed dates and GPG disabled let output = Command::new("git") .args([ - "-c", "commit.gpgsign=false", + "-c", + "commit.gpgsign=false", "commit", - "-m", "PR test deterministic commit", + "-m", + "PR test deterministic commit", ]) .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") .current_dir(path) .output() .expect("git commit failed"); - assert!(output.status.success(), "git commit failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "git commit failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Get the commit hash let output = Command::new("git") @@ -1688,7 +1981,11 @@ mod tests { .current_dir(path) .output() .expect("git rev-parse failed"); - assert!(output.status.success(), "git rev-parse failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "git rev-parse failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -1698,7 +1995,10 @@ mod tests { // Verify we got a valid 40-character hex hash assert_eq!(hash.len(), 40, "Hash should be 40 hex chars, got: {}", hash); - assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "Hash should be hex chars only"); + assert!( + hash.chars().all(|c| c.is_ascii_hexdigit()), + "Hash should be hex chars only" + ); // If the constant is not PLACEHOLDER, verify it matches if PR_TEST_COMMIT_HASH != "PLACEHOLDER" { @@ -1709,4 +2009,4 @@ mod tests { ); } } -} \ No newline at end of file +} diff --git a/src/git/authorization.rs b/src/git/authorization.rs index bb3bd01..3b0e759 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -35,7 +35,8 @@ use std::sync::Arc; use tracing::debug; use crate::nostr::events::{ - RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, + RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, + KIND_REPOSITORY_STATE, }; /// Repository data fetched from the database @@ -172,9 +173,9 @@ fn get_maintainers_recursive( checked.insert(pubkey.to_string()); // Mark as checked // Find the announcement event for this pubkey+identifier - let announcement = announcements.iter().find(|a| { - a.event.pubkey.to_hex() == pubkey && a.identifier == identifier - }); + let announcement = announcements + .iter() + .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier); let Some(announcement) = announcement else { return; // No announcement found for this pubkey @@ -195,19 +196,19 @@ pub fn collect_all_authorized_maintainers( ) -> HashSet { let by_owner = collect_authorized_maintainers(announcements); let mut all_authorized = HashSet::new(); - + for maintainers in by_owner.values() { for maintainer in maintainers { all_authorized.insert(maintainer.clone()); } } - + debug!( "Collected {} total authorized maintainers from {} owners", all_authorized.len(), by_owner.len() ); - + all_authorized } @@ -601,10 +602,7 @@ pub fn validate_push_refs( pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) ) -> Result<()> { for (old_oid, new_oid, ref_name) in pushed_refs { - debug!( - "Validating push: {} {} -> {}", - ref_name, old_oid, new_oid - ); + debug!("Validating push: {} {} -> {}", ref_name, old_oid, new_oid); // Handle branch updates if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { @@ -657,7 +655,10 @@ pub fn validate_push_refs( )); } // Valid EventId format - allow push (skip state event check) - debug!("refs/nostr/{} push authorized (valid EventId)", event_id_str); + debug!( + "refs/nostr/{} push authorized (valid EventId)", + event_id_str + ); continue; // Skip the rest of ref validation for this ref } else { return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); @@ -805,6 +806,119 @@ pub fn npub_to_pubkey(npub: &str) -> Result { Ok(pk.to_hex()) } +/// Fetch an event by ID from the database and extract the `c` tag commit hash +/// +/// This is used for validating pushes to refs/nostr/. Per GRASP-01, +/// if a PR or PR Update event with this ID exists in the database, the pushed +/// commit must match the commit in the event's `c` tag. +/// +/// # Returns +/// - `Ok(Some(commit))` if the event exists and has a valid `c` tag +/// - `Ok(None)` if the event doesn't exist (push should be allowed) +/// - `Err(_)` on database errors +pub async fn get_event_commit_tag( + database: &Arc, + event_id: &EventId, +) -> Result> { + // Query for PR (1618) and PR Update (1619) events with this ID + let filter = Filter::new() + .ids([*event_id]) + .kinds([Kind::from(KIND_PR), Kind::from(KIND_PR_UPDATE)]); + + let events: Vec = database + .query(filter) + .await + .map_err(|e| anyhow!("Database query failed: {}", e))? + .into_iter() + .collect(); + + if events.is_empty() { + debug!("No PR/PR Update event found with ID {}", event_id); + return Ok(None); + } + + // Get the first (should be only) event + let event = &events[0]; + + // Extract the `c` tag (commit hash) + // Per NIP-34, PR events have a `c` tag with the head commit + let commit = event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c")) + .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string())); + + debug!( + "Found PR event {} with commit tag: {:?}", + event_id, + commit.as_ref() + ); + + Ok(commit) +} + +/// Validate refs/nostr/ pushes against existing PR/PR Update events +/// +/// For each ref being pushed to refs/nostr/: +/// 1. Validate the event ID format (error if invalid) +/// 2. Check if a corresponding event exists in the database +/// 3. If event exists, verify the pushed commit matches the `c` tag +/// +/// # Arguments +/// * `database` - The nostr database to query +/// * `pushed_refs` - List of (old_oid, new_oid, ref_name) tuples +/// +/// # Returns +/// * `Ok(())` if all refs/nostr/ pushes are valid +/// * `Err(_)` if any ref has invalid event ID format or fails commit validation +pub async fn validate_nostr_ref_pushes( + database: &Arc, + pushed_refs: &[(String, String, String)], +) -> Result<()> { + for (_, new_oid, ref_name) in pushed_refs { + // Only check refs/nostr/ refs + if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { + // Parse the event ID - error on invalid format + let event_id = EventId::parse(event_id_str).map_err(|_| { + anyhow!( + "Invalid event ID format '{}' in ref: {}", + event_id_str, + ref_name + ) + })?; + + // Check if event exists and get commit tag + match get_event_commit_tag(database, &event_id).await? { + Some(expected_commit) => { + // Event exists - verify commit matches + if new_oid != &expected_commit { + return Err(anyhow!( + "Push to {} rejected: event {} specifies commit {}, but push contains {}", + ref_name, + event_id_str, + expected_commit, + new_oid + )); + } + debug!( + "Push to {} validated: commit {} matches event's c tag", + ref_name, new_oid + ); + } + None => { + // No event exists yet - allow push + debug!( + "Push to {} allowed: no PR/PR Update event with ID {} found yet", + ref_name, event_id_str + ); + } + } + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -920,7 +1034,7 @@ mod tests { let eve = create_test_keys(); // Not authorized let identifier = "test-repo"; - // Alice lists Bob as maintainer + // Alice lists Bob as maintainer let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); let events = vec![alice_announcement]; @@ -1084,4 +1198,4 @@ mod tests { let back_to_hex = npub_to_pubkey(&npub).unwrap(); assert_eq!(hex, back_to_hex); } -} \ No newline at end of file +} diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 23d4b5b..00f2449 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -2,17 +2,18 @@ //! //! This module implements the HTTP handlers for Git Smart HTTP protocol. -use std::path::PathBuf; -use std::sync::Arc; -use hyper::{body::Bytes, Response, StatusCode}; use http_body_util::Full; +use hyper::{body::Bytes, Response, StatusCode}; use nostr_relay_builder::prelude::MemoryDatabase; use nostr_sdk::EventId; +use std::path::PathBuf; +use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::{debug, error, info, warn}; use super::authorization::{ - get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult, + get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs, + AuthorizationResult, }; use super::protocol::{GitService, PktLine}; use super::subprocess::GitSubprocess; @@ -27,7 +28,10 @@ pub async fn handle_info_refs( repo_path: PathBuf, service: GitService, ) -> Result>, GitError> { - debug!("Handling info/refs for {:?} with service {:?}", repo_path, service); + debug!( + "Handling info/refs for {:?} with service {:?}", + repo_path, service + ); // Check if repository exists if !repo_path.exists() { @@ -36,55 +40,54 @@ pub async fn handle_info_refs( } // Spawn git with --advertise-refs - let mut git = GitSubprocess::spawn(service, &repo_path, true) - .map_err(|e| { - error!("Failed to spawn git process: {}", e); - GitError::ProcessSpawnFailed(e) - })?; + let mut git = GitSubprocess::spawn(service, &repo_path, true).map_err(|e| { + error!("Failed to spawn git process: {}", e); + GitError::ProcessSpawnFailed(e) + })?; // Read the output from git let mut output = Vec::new(); let mut stderr_output = Vec::new(); - + if let Some(stdout) = git.take_stdout() { let mut stdout = stdout; - stdout.read_to_end(&mut output).await - .map_err(|e| { - error!("Failed to read git output: {}", e); - GitError::IoError(e) - })?; + stdout.read_to_end(&mut output).await.map_err(|e| { + error!("Failed to read git output: {}", e); + GitError::IoError(e) + })?; } - + if let Some(stderr) = git.take_stderr() { let mut stderr = stderr; - stderr.read_to_end(&mut stderr_output).await - .map_err(|e| { - error!("Failed to read git stderr: {}", e); - GitError::IoError(e) - })?; + stderr.read_to_end(&mut stderr_output).await.map_err(|e| { + error!("Failed to read git stderr: {}", e); + GitError::IoError(e) + })?; } // Wait for process to complete - let status = git.wait().await - .map_err(|e| { - error!("Failed to wait for git process: {}", e); - GitError::IoError(e) - })?; + let status = git.wait().await.map_err(|e| { + error!("Failed to wait for git process: {}", e); + GitError::IoError(e) + })?; if !status.success() { let stderr_str = String::from_utf8_lossy(&stderr_output); - error!("Git process failed with status: {:?}, stderr: {}", status, stderr_str); + error!( + "Git process failed with status: {:?}, stderr: {}", + status, stderr_str + ); return Err(GitError::GitFailed(status.code())); } // Build response with pkt-line header let mut response_body = Vec::new(); - + // First line: service advertisement let service_line = format!("# service={}\n", service.as_str()); response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); response_body.extend_from_slice(&PktLine::flush().encode()); - + // Then the git output response_body.extend_from_slice(&output); @@ -113,7 +116,9 @@ pub async fn handle_upload_pack( // Write request to git's stdin if let Some(mut stdin) = git.take_stdin() { - stdin.write_all(&request_body).await + stdin + .write_all(&request_body) + .await .map_err(GitError::IoError)?; // Close stdin to signal end of input drop(stdin); @@ -122,22 +127,25 @@ pub async fn handle_upload_pack( // Read response from git's stdout let mut output = Vec::new(); let mut stderr_output = Vec::new(); - + if let Some(stdout) = git.take_stdout() { let mut stdout = stdout; - stdout.read_to_end(&mut output).await + stdout + .read_to_end(&mut output) + .await .map_err(GitError::IoError)?; } - + if let Some(stderr) = git.take_stderr() { let mut stderr = stderr; - stderr.read_to_end(&mut stderr_output).await + stderr + .read_to_end(&mut stderr_output) + .await .map_err(GitError::IoError)?; } // Wait for process - let status = git.wait().await - .map_err(GitError::IoError)?; + let status = git.wait().await.map_err(GitError::IoError)?; if !status.success() { let stderr_str = String::from_utf8_lossy(&stderr_output); @@ -194,10 +202,7 @@ pub async fn handle_receive_pack( match authorize_push(db, identifier, owner_pubkey, &request_body).await { Ok(auth_result) => { if !auth_result.authorized { - warn!( - "Push rejected for {}: {}", - identifier, auth_result.reason - ); + warn!("Push rejected for {}: {}", identifier, auth_result.reason); return Err(GitError::Unauthorized); } info!( @@ -209,10 +214,7 @@ pub async fn handle_receive_pack( authorized_state = auth_result.state; } Err(e) => { - warn!( - "Authorization check failed for {}: {}", - identifier, e - ); + warn!("Authorization check failed for {}: {}", identifier, e); return Err(GitError::Unauthorized); } } @@ -226,7 +228,9 @@ pub async fn handle_receive_pack( // Write request to git's stdin if let Some(mut stdin) = git.take_stdin() { - stdin.write_all(&request_body).await + stdin + .write_all(&request_body) + .await .map_err(GitError::IoError)?; drop(stdin); } @@ -234,22 +238,25 @@ pub async fn handle_receive_pack( // Read response from git's stdout let mut output = Vec::new(); let mut stderr_output = Vec::new(); - + if let Some(stdout) = git.take_stdout() { let mut stdout = stdout; - stdout.read_to_end(&mut output).await + stdout + .read_to_end(&mut output) + .await .map_err(GitError::IoError)?; } - + if let Some(stderr) = git.take_stderr() { let mut stderr = stderr; - stderr.read_to_end(&mut stderr_output).await + stderr + .read_to_end(&mut stderr_output) + .await .map_err(GitError::IoError)?; } // Wait for process - let status = git.wait().await - .map_err(GitError::IoError)?; + let status = git.wait().await.map_err(GitError::IoError)?; if !status.success() { let stderr_str = String::from_utf8_lossy(&stderr_output); @@ -266,10 +273,7 @@ pub async fn handle_receive_pack( if let Some(commit) = state.get_branch_commit(branch_name) { match try_set_head_if_available(&repo_path, head_ref, commit) { Ok(true) => { - info!( - "Set HEAD to {} after push to {:?}", - head_ref, repo_path - ); + info!("Set HEAD to {} after push to {:?}", head_ref, repo_path); } Ok(false) => { debug!( @@ -278,10 +282,7 @@ pub async fn handle_receive_pack( ); } Err(e) => { - warn!( - "Failed to set HEAD after push: {}", - e - ); + warn!("Failed to set HEAD after push: {}", e); } } } @@ -291,7 +292,10 @@ pub async fn handle_receive_pack( Ok(Response::builder() .status(StatusCode::OK) - .header("content-type", GitService::ReceivePack.result_content_type()) + .header( + "content-type", + GitService::ReceivePack.result_content_type(), + ) .header("cache-control", "no-cache") .body(Full::new(Bytes::from(output))) .unwrap()) @@ -305,6 +309,7 @@ pub async fn handle_receive_pack( /// 3. Collects authorized publishers from that announcement (owner + maintainers) /// 4. Gets the latest authorized state from those publishers /// 5. Validates that pushed refs match the state +/// 6. Validates refs/nostr/ has valid event id and if event exists, `c` tag matches ref async fn authorize_push( database: &Arc, identifier: &str, @@ -323,59 +328,79 @@ async fn authorize_push( debug!(" {} {} -> {}", ref_name, old_oid, new_oid); } - // Check if ALL pushed refs are to refs/nostr/ with valid EventId format + // Separate refs/nostr/ refs from other refs // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/`" - // These pushes only require EventId format validation, not state validation - let all_refs_nostr_valid = !pushed_refs.is_empty() - && pushed_refs.iter().all(|(_, _, ref_name)| { - if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { - // Validate it parses as a valid EventId - EventId::parse(event_id_str).is_ok() - } else { - false - } - }); - - if all_refs_nostr_valid { - debug!("All refs are refs/nostr/ with valid EventId format - authorized without state check"); - // Return success for refs/nostr/ pushes without requiring state + let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs + .iter() + .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); + + // Validate refs/nostr/ refs if any exist + if !nostr_refs.is_empty() { + debug!( + "Found {} refs/nostr/ refs - validating against events", + nostr_refs.len() + ); + + // Validate refs/nostr/ pushes: checks event ID format and commit matching + let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs + .into_iter() + .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) + .collect(); + if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { + warn!("refs/nostr/ validation failed: {}", e); + return Ok(AuthorizationResult::denied(format!( + "refs/nostr/ validation failed: {}", + e + ))); + } + debug!("refs/nostr/ push validated successfully"); + } + + // If only refs/nostr/ refs, we're done - return success + if other_refs.is_empty() { + debug!("Only refs/nostr/ refs in push - authorization complete"); return Ok(AuthorizationResult { authorized: true, - reason: "Push to refs/nostr/ with valid EventId format".to_string(), + reason: "Push to refs/nostr/ validated against events".to_string(), state: None, maintainers: vec![], }); } - // For non-refs/nostr/ pushes, require state validation as normal - debug!("Non-refs/nostr/ push detected - checking state authorization"); + // For non-refs/nostr/ refs, require state validation + debug!( + "Found {} non-refs/nostr/ refs - checking state authorization", + other_refs.len() + ); let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; if !auth_result.authorized { return Ok(auth_result); } - // Parse refs from the push request - let pushed_refs = parse_pushed_refs(request_body); - debug!("Parsed {} refs from push request", pushed_refs.len()); - for (old_oid, new_oid, ref_name) in &pushed_refs { - debug!(" {} {} -> {}", ref_name, old_oid, new_oid); - } + // Convert other_refs for validation + let other_refs_owned: Vec<(String, String, String)> = other_refs + .into_iter() + .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) + .collect(); - // Validate refs against state + // Validate non-refs/nostr/ refs against state if let Some(ref state) = auth_result.state { - debug!("Validating against state with {} branches", state.branches.len()); - + debug!( + "Validating against state with {} branches", + state.branches.len() + ); + // If we have a state event but couldn't parse any refs, reject the push. // This protects against parsing failures allowing unauthorized pushes. - if pushed_refs.is_empty() && !state.branches.is_empty() { + if other_refs_owned.is_empty() && !state.branches.is_empty() { warn!("No refs parsed from push request but state event has branches - rejecting"); return Ok(AuthorizationResult::denied( - "Failed to parse refs from push request - cannot validate against state" + "Failed to parse refs from push request - cannot validate against state", )); } - - if let Err(e) = validate_push_refs(state, &pushed_refs) { + + if let Err(e) = validate_push_refs(state, &other_refs_owned) { warn!("Ref validation failed: {}", e); return Ok(AuthorizationResult::denied(format!( "Ref validation failed: {}", @@ -423,4 +448,4 @@ impl GitError { _ => StatusCode::INTERNAL_SERVER_ERROR, } } -} \ No newline at end of file +} diff --git a/src/git/mod.rs b/src/git/mod.rs index 076e211..494f8b9 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -40,7 +40,7 @@ use tracing::{debug, info}; pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { // Remove .git suffix if present let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); - + PathBuf::from(git_data_path) .join(npub) .join(format!("{}.git", identifier)) @@ -89,7 +89,10 @@ pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool { pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { // Validate the ref format if !head_ref.starts_with("refs/heads/") { - return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref)); + return Err(format!( + "Invalid HEAD ref: {} (must start with refs/heads/)", + head_ref + )); } debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); @@ -130,7 +133,10 @@ pub fn try_set_head_if_available( ) -> Result { // Check if repository exists if !repo_path.exists() { - debug!("Repository not found at {}, cannot set HEAD", repo_path.display()); + debug!( + "Repository not found at {}, cannot set HEAD", + repo_path.display() + ); return Ok(false); } @@ -149,6 +155,115 @@ pub fn try_set_head_if_available( Ok(true) } +/// Get the commit hash that a ref points to +/// +/// # Arguments +/// * `repo_path` - Path to the bare git repository +/// * `ref_name` - The ref name (e.g., "refs/nostr/") +/// +/// # Returns +/// Some(commit_hash) if the ref exists, None otherwise +pub fn get_ref_commit(repo_path: &Path, ref_name: &str) -> Option { + let output = Command::new("git") + .args(["rev-parse", ref_name]) + .current_dir(repo_path) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } +} + +/// Delete a git ref from the repository +/// +/// # Arguments +/// * `repo_path` - Path to the bare git repository +/// * `ref_name` - The ref name to delete (e.g., "refs/nostr/") +/// +/// # Returns +/// Ok(()) if successful, Err with error message otherwise +pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> { + debug!("Deleting ref {} from {}", ref_name, repo_path.display()); + + let output = Command::new("git") + .args(["update-ref", "-d", ref_name]) + .current_dir(repo_path) + .output() + .map_err(|e| format!("Failed to execute git update-ref: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git update-ref -d failed: {}", stderr)); + } + + info!("Deleted ref {} from {}", ref_name, repo_path.display()); + Ok(()) +} + +/// Validate refs/nostr/ ref against expected commit +/// +/// If the ref exists but points to a different commit than expected, +/// the ref is deleted. This is called when a PR event is received to +/// ensure refs/nostr refs are consistent with their corresponding events. +/// +/// # Arguments +/// * `repo_path` - Path to the bare git repository +/// * `event_id` - The event ID (hex string) +/// * `expected_commit` - The commit hash from the event's `c` tag +/// +/// # Returns +/// Ok(true) if ref was deleted (mismatch), Ok(false) if no action taken, Err on failure +pub fn validate_nostr_ref( + repo_path: &Path, + event_id: &str, + expected_commit: &str, +) -> Result { + let ref_name = format!("refs/nostr/{}", event_id); + + // Check if repository exists + if !repo_path.exists() { + debug!( + "Repository not found at {}, skipping ref validation", + repo_path.display() + ); + return Ok(false); + } + + // Check if the ref exists + let current_commit = match get_ref_commit(repo_path, &ref_name) { + Some(commit) => commit, + None => { + debug!("Ref {} does not exist in {}", ref_name, repo_path.display()); + return Ok(false); + } + }; + + // Compare commits + if current_commit == expected_commit { + debug!( + "Ref {} points to correct commit {} in {}", + ref_name, + expected_commit, + repo_path.display() + ); + return Ok(false); + } + + // Commit mismatch - delete the ref + info!( + "Deleting mismatched ref {} in {}: expected {}, found {}", + ref_name, + repo_path.display(), + expected_commit, + current_commit + ); + delete_ref(repo_path, &ref_name)?; + Ok(true) +} + /// Get the current HEAD ref from a repository /// /// # Arguments @@ -178,25 +293,25 @@ pub fn get_repository_head(repo_path: &Path) -> Option { pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { // Remove leading slash let path = path.strip_prefix('/').unwrap_or(path); - + // Split into components let parts: Vec<&str> = path.splitn(3, '/').collect(); - + if parts.len() < 3 { return None; } - + let npub = parts[0]; let repo_part = parts[1]; let subpath = parts[2]; - + // Extract identifier (remove .git suffix if present for the middle part) let identifier = if repo_part.ends_with(".git") { &repo_part[..repo_part.len() - 4] } else { repo_part }; - + Some((npub, identifier, subpath)) } @@ -210,13 +325,13 @@ mod tests { fn create_test_repo() -> (TempDir, PathBuf) { let temp_dir = TempDir::new().unwrap(); let repo_path = temp_dir.path().join("test.git"); - + // Initialize bare repository Command::new("git") .args(["init", "--bare", repo_path.to_str().unwrap()]) .output() .unwrap(); - + (temp_dir, repo_path) } @@ -225,19 +340,23 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let work_dir = temp_dir.path().join("work"); let bare_repo = temp_dir.path().join("test.git"); - + // Initialize bare repository Command::new("git") - .args(["init", "--bare", bare_repo.to_str().unwrap()]) + .args(["init", "--bare", "--initial-branch=main", bare_repo.to_str().unwrap()]) .output() .unwrap(); - + // Clone to working directory Command::new("git") - .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()]) + .args([ + "clone", + bare_repo.to_str().unwrap(), + work_dir.to_str().unwrap(), + ]) .output() .unwrap(); - + // Configure git for commits Command::new("git") .args(["config", "user.email", "test@test.com"]) @@ -249,7 +368,7 @@ mod tests { .current_dir(&work_dir) .output() .unwrap(); - + // Create a file and commit fs::write(work_dir.join("README.md"), "# Test").unwrap(); Command::new("git") @@ -262,7 +381,7 @@ mod tests { .current_dir(&work_dir) .output() .unwrap(); - + // Get commit hash let output = Command::new("git") .args(["rev-parse", "HEAD"]) @@ -270,41 +389,27 @@ mod tests { .output() .unwrap(); let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - + // Push to bare repo Command::new("git") - .args(["push", "origin", "master"]) + .args(["push", "origin", "main"]) .current_dir(&work_dir) .output() .unwrap(); - + (temp_dir, bare_repo, commit_hash) } #[test] fn test_resolve_repo_path() { - let path = resolve_repo_path( - "/data/git", - "npub1abc123", - "my-repo" - ); - assert_eq!( - path, - PathBuf::from("/data/git/npub1abc123/my-repo.git") - ); + let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo"); + assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); } #[test] fn test_resolve_repo_path_with_git_suffix() { - let path = resolve_repo_path( - "/data/git", - "npub1abc123", - "my-repo.git" - ); - assert_eq!( - path, - PathBuf::from("/data/git/npub1abc123/my-repo.git") - ); + let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git"); + assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); } #[test] @@ -332,7 +437,10 @@ mod tests { #[test] fn test_commit_exists_nonexistent() { let (_temp_dir, repo_path) = create_test_repo(); - assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef")); + assert!(!commit_exists( + &repo_path, + "deadbeef1234567890abcdef1234567890abcdef" + )); } #[test] @@ -344,11 +452,11 @@ mod tests { #[test] fn test_set_repository_head() { let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); - + // Default HEAD might be refs/heads/master let result = set_repository_head(&repo_path, "refs/heads/main"); assert!(result.is_ok()); - + let head = get_repository_head(&repo_path); assert_eq!(head, Some("refs/heads/main".to_string())); } @@ -356,7 +464,7 @@ mod tests { #[test] fn test_set_repository_head_invalid_ref() { let (_temp_dir, repo_path) = create_test_repo(); - + // Invalid ref format should fail let result = set_repository_head(&repo_path, "main"); assert!(result.is_err()); @@ -366,13 +474,13 @@ mod tests { #[test] fn test_try_set_head_if_available_commit_missing() { let (_temp_dir, repo_path) = create_test_repo(); - + let result = try_set_head_if_available( &repo_path, "refs/heads/main", "deadbeef1234567890abcdef1234567890abcdef", ); - + // Should return Ok(false) - commit not found assert!(result.is_ok()); assert!(!result.unwrap()); @@ -381,19 +489,15 @@ mod tests { #[test] fn test_try_set_head_if_available_success() { let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); - - let result = try_set_head_if_available( - &repo_path, - "refs/heads/main", - &commit_hash, - ); - + + let result = try_set_head_if_available(&repo_path, "refs/heads/main", &commit_hash); + // Should return Ok(true) - HEAD was set assert!(result.is_ok()); assert!(result.unwrap()); - + // Verify HEAD was set let head = get_repository_head(&repo_path); assert_eq!(head, Some("refs/heads/main".to_string())); } -} \ No newline at end of file +} diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 7aa2b97..8e9926a 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -14,8 +14,8 @@ use nostr_relay_builder::prelude::*; use crate::config::{Config, DatabaseBackend}; use crate::git; use crate::nostr::events::{ - validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, - KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, + validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, + KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, }; /// NIP-34 Write Policy with Full GRASP-01 Event Validation @@ -36,7 +36,11 @@ pub struct Nip34WritePolicy { } impl Nip34WritePolicy { - pub fn new(domain: impl Into, database: Arc, git_data_path: impl Into) -> Self { + pub fn new( + domain: impl Into, + database: Arc, + git_data_path: impl Into, + ) -> Self { Self { domain: domain.into(), database, @@ -48,7 +52,7 @@ impl Nip34WritePolicy { /// Path format: //.git fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { let repo_path = self.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()); @@ -56,13 +60,12 @@ impl Nip34WritePolicy { } // 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) - })?; + 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") @@ -165,7 +168,11 @@ impl Nip34WritePolicy { tracing::debug!( "Found authorized announcement for {}: owner={}, maintainer={}", identifier, - if is_owner { event.pubkey.to_hex() } else { "n/a".to_string() }, + if is_owner { + event.pubkey.to_hex() + } else { + "n/a".to_string() + }, is_maintainer ); authorized.push(announcement); @@ -198,10 +205,7 @@ impl Nip34WritePolicy { let head_ref = match &state.head { Some(h) => h, None => { - tracing::debug!( - "State event for {} has no HEAD reference", - state.identifier - ); + tracing::debug!("State event for {} has no HEAD reference", state.identifier); return Ok(0); } }; @@ -232,11 +236,9 @@ impl Nip34WritePolicy { }; // Find all announcements where state author is authorized - let announcements = Self::find_authorized_announcements( - database, - &state.identifier, - &state.event.pubkey, - ).await?; + let announcements = + Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey) + .await?; if announcements.is_empty() { tracing::debug!( @@ -271,7 +273,7 @@ impl Nip34WritePolicy { } // Build repository path: //.git - let repo_path = self.git_data_path.join(&announcement.repo_path()); + let repo_path = self.git_data_path.join(announcement.repo_path().clone()); match git::try_set_head_if_available(&repo_path, head_ref, head_commit) { Ok(true) => { @@ -291,11 +293,7 @@ impl Nip34WritePolicy { ); } Err(e) => { - tracing::warn!( - "Failed to set HEAD in {}: {}", - repo_path.display(), - e - ); + tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); } } } @@ -338,6 +336,191 @@ impl Nip34WritePolicy { (addressable_refs, event_refs) } + /// Validate refs/nostr/ 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/ 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. + /// + /// # Arguments + /// * `database` - Database for looking up repository announcements + /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619) + /// + /// # Returns + /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure + async fn validate_pr_nostr_ref( + &self, + database: &Arc, + event: &Event, + ) -> Result, 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:: + let repo_refs: Vec = 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:: + 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 = match 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.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) + } + } + /// 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( @@ -377,16 +560,17 @@ impl Nip34WritePolicy { use std::collections::HashMap; let mut by_kind: HashMap> = HashMap::new(); for (addr, kind, pubkey, identifier) in parsed_refs { - by_kind.entry(kind).or_default().push((addr, pubkey, identifier)); + by_kind + .entry(kind) + .or_default() + .push((addr, pubkey, identifier)); } // Query each kind group for (kind, refs) in by_kind { let authors: Vec = refs.iter().map(|(_, pk, _)| *pk).collect(); - - let filter = Filter::new() - .kind(Kind::from(kind)) - .authors(authors); + + let filter = Filter::new().kind(Kind::from(kind)).authors(authors); match database.query(filter).await { Ok(events) => { @@ -445,7 +629,7 @@ impl Nip34WritePolicy { event: &Event, ) -> Result { let kind_u16 = event.kind.as_u16(); - + // Check if this is any kind of replaceable event let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000; let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000; @@ -454,7 +638,9 @@ impl Nip34WritePolicy { // 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() + let identifier = event + .tags + .iter() .find_map(|tag| { let tag_vec = tag.clone().to_vec(); if tag_vec.len() >= 2 && tag_vec[0] == "d" { @@ -464,12 +650,17 @@ impl Nip34WritePolicy { } }) .unwrap_or_default(); // Empty string if no 'd' tag - format!("{}:{}:{}", event.kind.as_u16(), event.pubkey.to_hex(), identifier) + 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 @@ -479,7 +670,7 @@ impl Nip34WritePolicy { for tag_type in &addressable_tags { let filter = Filter::new().custom_tag(tag_type.clone(), address.clone()); - + match database.query(filter).await { Ok(events) => { if !events.is_empty() { @@ -492,7 +683,7 @@ impl Nip34WritePolicy { } 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 @@ -501,7 +692,7 @@ impl Nip34WritePolicy { for tag_type in &event_id_tags { let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone()); - + match database.query(filter).await { Ok(events) => { if !events.is_empty() { @@ -545,7 +736,7 @@ impl WritePolicy for Nip34WritePolicy { // Note: We still accept the event even if repo creation fails // The git operation failure shouldn't prevent event acceptance } - + tracing::debug!( "Accepted repository announcement: {}", event_id_str @@ -563,11 +754,7 @@ impl WritePolicy for Nip34WritePolicy { } } Err(e) => { - tracing::warn!( - "Rejected repository announcement {}: {}", - event_id_str, - e - ); + tracing::warn!("Rejected repository announcement {}: {}", event_id_str, e); PolicyResult::Reject(e.to_string()) } }, @@ -577,7 +764,10 @@ impl WritePolicy for Nip34WritePolicy { match RepositoryState::from_event(event.clone()) { Ok(state) => { // Try to set HEAD for all authorized repos if this is the latest state - match self.try_set_head_for_authorized_repos(&database, &state).await { + match self + .try_set_head_for_authorized_repos(&database, &state) + .await + { Ok(count) if count > 0 => { tracing::info!( "Set HEAD from state event {} for {} repo(s) with identifier {}", @@ -600,11 +790,8 @@ impl WritePolicy for Nip34WritePolicy { ); } } - - tracing::debug!( - "Accepted repository state: {}", - event_id_str - ); + + tracing::debug!("Accepted repository state: {}", event_id_str); PolicyResult::Accept } Err(e) => { @@ -620,14 +807,104 @@ impl WritePolicy for Nip34WritePolicy { } } Err(e) => { + tracing::warn!("Rejected repository state {}: {}", event_id_str, e); + PolicyResult::Reject(e.to_string()) + } + }, + // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/ refs before acceptance + KIND_PR | KIND_PR_UPDATE => { + // Validate refs/nostr refs for this PR event + // This deletes any refs/nostr/ that points to wrong commit + if let Err(e) = self.validate_pr_nostr_ref(&database, event).await { tracing::warn!( - "Rejected repository state {}: {}", + "Failed to validate refs/nostr for PR event {}: {}", event_id_str, e ); - PolicyResult::Reject(e.to_string()) + // Don't reject - just log the error and proceed with normal validation } - }, + + // Continue with standard reference checking (same as default case) + let (addressable_refs, event_refs) = Self::extract_reference_tags(event); + + // Check 1: Does this event reference an accepted repository? + match Self::find_accepted_repository(&database, &addressable_refs).await { + Ok(Some(addr_ref)) => { + tracing::debug!( + "Accepted PR event {}: references accepted repository {}", + event_id_str, + addr_ref + ); + return PolicyResult::Accept; + } + Ok(None) => { + // No matching repositories, continue to next check + } + Err(e) => { + tracing::warn!( + "Database query failed for PR {}, rejecting (fail-secure): {}", + event_id_str, + e + ); + return PolicyResult::Reject(format!("Database query failed: {}", e)); + } + } + + // Check 2: Does this event reference an accepted event? + match Self::find_accepted_event(&database, &event_refs).await { + Ok(Some(event_ref)) => { + tracing::debug!( + "Accepted PR event {}: references accepted event {}", + event_id_str, + event_ref + ); + return PolicyResult::Accept; + } + Ok(None) => { + // No matching events, continue to next check + } + Err(e) => { + tracing::warn!( + "Database query failed for PR {}, rejecting (fail-secure): {}", + event_id_str, + e + ); + return PolicyResult::Reject(format!("Database query failed: {}", e)); + } + } + + // Check 3: Is this event referenced by an accepted event? + match Self::is_referenced_by_accepted(&database, event).await { + Ok(true) => { + tracing::debug!( + "Accepted PR event {}: referenced by accepted event", + event_id_str + ); + return PolicyResult::Accept; + } + Ok(false) => { + // No forward references found, continue to rejection + } + Err(e) => { + tracing::warn!( + "Database query failed for PR {}, rejecting (fail-secure): {}", + event_id_str, + e + ); + return PolicyResult::Reject(format!("Database query failed: {}", e)); + } + } + + // No valid references found - reject as orphan event + tracing::info!( + "Rejected orphan PR event {}: no references to accepted repos or events", + event_id_str + ); + PolicyResult::Reject( + "PR event must reference an accepted repository or accepted event" + .to_string(), + ) + } // GRASP-01: Check if event references accepted repositories or events _ => { // Extract all reference tags from event @@ -709,7 +986,7 @@ impl WritePolicy for Nip34WritePolicy { event_refs.len() ); PolicyResult::Reject( - "Event must reference an accepted repository or accepted event".to_string() + "Event must reference an accepted repository or accepted event".to_string(), ) } } @@ -786,4 +1063,4 @@ pub fn create_relay(config: &Config) -> Result { relay: LocalRelay::new(builder), database, }) -} \ No newline at end of file +} diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 97688b1..6a62ccd 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -15,6 +15,12 @@ pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617; /// NIP-34 Repository State Announcement (kind 30618) pub const KIND_REPOSITORY_STATE: u16 = 30618; +/// NIP-34 Pull Request (kind 1618) - has `c` tag for commit +pub const KIND_PR: u16 = 1618; + +/// NIP-34 Pull Request Update (kind 1619) - has `c` tag for commit +pub const KIND_PR_UPDATE: u16 = 1619; + /// Repository announcement details extracted from NIP-34 event #[derive(Debug, Clone)] pub struct RepositoryAnnouncement { -- cgit v1.2.3