From b94262161df99966fbb8aa6861fb46603039111f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 28 Nov 2025 12:40:31 +0000 Subject: allow push to ref/nostr/ --- grasp-audit/src/fixtures.rs | 38 ++++ grasp-audit/src/lib.rs | 2 +- .../src/specs/grasp01/push_authorization.rs | 242 ++++++++++++++++++++- src/git/authorization.rs | 18 +- src/git/handlers.rs | 35 ++- tests/push_authorization.rs | 4 +- 6 files changed, 333 insertions(+), 6 deletions(-) diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 8cee964..b6fbc79 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -1208,6 +1208,44 @@ pub fn try_push(clone_path: &Path) -> Result { Ok(output.status.success()) } +/// Attempt a git push to a specific ref and return success/failure +/// +/// This is used for testing refs/nostr/ push validation. +/// +/// # Arguments +/// * `clone_path` - Path to the git repository +/// * `ref_name` - The ref to push to (e.g., "refs/nostr/") +/// +/// # Returns +/// * `Ok(true)` - Push succeeded +/// * `Ok(false)` - Push was rejected +/// * `Err(String)` - Error executing git push +/// +/// # Example +/// ```no_run +/// # use grasp_audit::*; +/// # use std::path::Path; +/// # fn example() -> Result<(), String> { +/// let success = try_push_to_ref(Path::new("/tmp/my-repo"), "refs/nostr/abc123")?; +/// if success { +/// println!("Push to refs/nostr/abc123 succeeded"); +/// } else { +/// println!("Push was rejected"); +/// } +/// # Ok(()) +/// # } +/// ``` +pub fn try_push_to_ref(clone_path: &Path, ref_name: &str) -> Result { + let output = Command::new("git") + .args(["push", "origin", &format!("HEAD:{}", ref_name)]) + .current_dir(clone_path) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .map_err(|e| format!("Failed to execute git push: {}", e))?; + + Ok(output.status.success()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index 3d11bd8..5ee93b3 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs @@ -40,7 +40,7 @@ pub use client::AuditClient; pub use fixtures::{ // Git operation helpers clone_repo, create_commit, create_deterministic_commit, create_deterministic_commit_with_variant, - try_push, + try_push, try_push_to_ref, // Verification helpers send_and_verify_accepted, send_and_verify_rejected, // Types and constants diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 0a5b1ec..0170f6e 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -18,7 +18,7 @@ use crate::{ clone_repo, create_commit, create_deterministic_commit, create_deterministic_commit_with_variant, - try_push, AuditClient, CommitVariant, FixtureKind, TestContext, TestResult, + try_push, try_push_to_ref, AuditClient, CommitVariant, FixtureKind, TestContext, TestResult, DETERMINISTIC_COMMIT_HASH, MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, }; @@ -41,6 +41,8 @@ impl PushAuthorizationTests { 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_refs_nostr_valid_event_id(client, relay_domain).await); + results.add(Self::test_push_to_refs_nostr_invalid_event_id(client, relay_domain).await); results } @@ -1029,6 +1031,244 @@ impl PushAuthorizationTests { Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e), } } + + /// Test that push to refs/nostr/ succeeds with valid EventId format + /// + /// GRASP-01: "MUST accept pushes via this service to `refs/nostr/`" + /// The event_id must parse as a valid rust-nostr EventId (64-char hex string). + /// This does NOT require the ref to be listed in any state event - it's purely format validation. + /// + /// ## Fixture-First Pattern + /// + /// 1. **Generate**: Create repo with ValidRepo fixture (no state event needed) + /// 2. **Send**: Clone repo, create commit, push to refs/nostr/ + /// 3. **Verify**: Push should succeed because event-id format is valid + pub async fn test_push_to_refs_nostr_valid_event_id( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_push_to_refs_nostr_valid_event_id"; + + // ============================================================ + // Step 1: GENERATE - Create repo (no state event needed for refs/nostr/) + // ============================================================ + let ctx = TestContext::new(client); + + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .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 npub = repo.pubkey.to_bech32().unwrap(); + + // ============================================================ + // Step 2: SEND - Clone repo, create commit, push to refs/nostr/ + // ============================================================ + let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { + Ok(p) => p, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .fail(&e); + } + }; + let cleanup = || { + let _ = fs::remove_dir_all(&clone_path); + }; + + // Create a unique commit + if let Err(e) = create_commit(&clone_path, "Test commit for refs/nostr push") { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .fail(&e); + } + + // Generate a random event to get a valid EventId + let keys = Keys::generate(); + let event = match EventBuilder::text_note("test") + .sign(&keys) + .await + { + Ok(e) => e, + Err(e) => { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .fail(&format!("Failed to create test event: {}", e)); + } + }; + + // Use the event id as the refs/nostr/ target + let ref_name = format!("refs/nostr/{}", event.id); + + // ============================================================ + // Step 3: VERIFY - Push should succeed with valid event-id format + // ============================================================ + let push_result = try_push_to_ref(&clone_path, &ref_name); + cleanup(); + + match push_result { + Ok(true) => TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .pass(), + Ok(false) => TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .fail(&format!( + "Push to {} was rejected but should be accepted. \ + The event-id '{}' is a valid 64-character hex string (EventId format).", + ref_name, event.id + )), + Err(e) => TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ accepted", + ) + .fail(&format!("Push error: {}", e)), + } + } + + /// Test that push to refs/nostr/ is rejected with invalid EventId format + /// + /// GRASP-01: "MUST accept pushes via this service to `refs/nostr/`" + /// The event_id must parse as a valid rust-nostr EventId (64-char hex string). + /// Invalid formats (too short, non-hex, etc.) should be rejected. + /// + /// ## Fixture-First Pattern + /// + /// 1. **Generate**: Create repo with ValidRepo fixture (no state event needed) + /// 2. **Send**: Clone repo, create commit, try to push to refs/nostr/123 (invalid) + /// 3. **Verify**: Push should be rejected because event-id format is invalid + pub async fn test_push_to_refs_nostr_invalid_event_id( + client: &AuditClient, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_push_to_refs_nostr_invalid_event_id"; + + // ============================================================ + // Step 1: GENERATE - Create repo (no state event needed for refs/nostr/) + // ============================================================ + let ctx = TestContext::new(client); + + let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { + Ok(r) => r, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ rejected", + ) + .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 npub = repo.pubkey.to_bech32().unwrap(); + + // ============================================================ + // Step 2: SEND - Clone repo, create commit, try push to invalid ref + // ============================================================ + let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { + Ok(p) => p, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ rejected", + ) + .fail(&e); + } + }; + let cleanup = || { + let _ = fs::remove_dir_all(&clone_path); + }; + + // Create a unique commit + if let Err(e) = create_commit(&clone_path, "Test commit for invalid refs/nostr push") { + cleanup(); + return TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ rejected", + ) + .fail(&e); + } + + // Use an invalid event-id (too short, not a valid 64-char hex) + let invalid_event_id = "123"; + let ref_name = format!("refs/nostr/{}", invalid_event_id); + + // ============================================================ + // Step 3: VERIFY - Push should be rejected with invalid event-id format + // ============================================================ + let push_result = try_push_to_ref(&clone_path, &ref_name); + cleanup(); + + match push_result { + Ok(false) => TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ rejected", + ) + .pass(), + Ok(true) => TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ rejected", + ) + .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.", + ref_name, invalid_event_id + )), + Err(e) => TestResult::new( + test_name, + "GRASP-01", + "Push to refs/nostr/ rejected", + ) + .fail(&format!("Push error: {}", e)), + } + } } #[cfg(test)] diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 1be3de9..bb3bd01 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -29,7 +29,7 @@ use anyhow::{anyhow, Result}; use nostr_relay_builder::prelude::*; -use nostr_sdk::ToBech32; +use nostr_sdk::{EventId, ToBech32}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tracing::debug; @@ -647,7 +647,21 @@ pub fn validate_push_refs( // refs/nostr/* is handled separately per GRASP-01 if ref_name.starts_with("refs/nostr/") { - debug!("refs/nostr/ push will be validated separately"); + // Extract event_id from "refs/nostr/" + if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { + // Validate it parses as a valid EventId + if EventId::parse(event_id_str).is_err() { + return Err(anyhow!( + "Invalid event ID format in ref: {}. Expected valid nostr event ID.", + ref_name + )); + } + // Valid EventId format - allow push (skip state event check) + 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)); + } } } diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 7974d8a..23d4b5b 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use hyper::{body::Bytes, Response, StatusCode}; use http_body_util::Full; use nostr_relay_builder::prelude::MemoryDatabase; +use nostr_sdk::EventId; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::{debug, error, info, warn}; @@ -315,7 +316,39 @@ async fn authorize_push( identifier, owner_pubkey ); - // Get authorization result from database, scoped to specific owner + // Parse refs from the push request FIRST to check if this is a refs/nostr/ push + 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); + } + + // Check if ALL pushed refs are to refs/nostr/ with valid EventId format + // 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 + return Ok(AuthorizationResult { + authorized: true, + reason: "Push to refs/nostr/ with valid EventId format".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"); let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; if !auth_result.authorized { diff --git a/tests/push_authorization.rs b/tests/push_authorization.rs index 1114782..38b02d4 100644 --- a/tests/push_authorization.rs +++ b/tests/push_authorization.rs @@ -66,4 +66,6 @@ isolated_push_test!(test_push_rejected_without_state_event); isolated_push_test!(test_push_rejected_wrong_commit); isolated_push_test!(test_push_authorized_by_maintainer_state_only); isolated_push_test!(test_push_authorized_by_recursive_maintainer_state); -isolated_push_test!(test_non_maintainer_state_rejected); \ No newline at end of file +isolated_push_test!(test_non_maintainer_state_rejected); +isolated_push_test!(test_push_to_refs_nostr_valid_event_id); +isolated_push_test!(test_push_to_refs_nostr_invalid_event_id); \ No newline at end of file -- cgit v1.2.3