diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 11:56:49 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 11:58:34 +0000 |
| commit | 7a78815e29b01c83f3d0ec195ba717a2eba8cd37 (patch) | |
| tree | 4c5ccd9b812f1d1d75ed218501192ddc5459fd12 /src/git | |
| parent | e6ceab90de1acad154624022a6036efac18abab6 (diff) | |
reject push when refs/nostr/<event-id> doesnt match known event and delete incorrect ref on event receive
Diffstat (limited to 'src/git')
| -rw-r--r-- | src/git/authorization.rs | 142 | ||||
| -rw-r--r-- | src/git/handlers.rs | 211 | ||||
| -rw-r--r-- | src/git/mod.rs | 210 |
3 files changed, 403 insertions, 160 deletions
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; | |||
| 35 | use tracing::debug; | 35 | use tracing::debug; |
| 36 | 36 | ||
| 37 | use crate::nostr::events::{ | 37 | use crate::nostr::events::{ |
| 38 | RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, | 38 | RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, |
| 39 | KIND_REPOSITORY_STATE, | ||
| 39 | }; | 40 | }; |
| 40 | 41 | ||
| 41 | /// Repository data fetched from the database | 42 | /// Repository data fetched from the database |
| @@ -172,9 +173,9 @@ fn get_maintainers_recursive( | |||
| 172 | checked.insert(pubkey.to_string()); // Mark as checked | 173 | checked.insert(pubkey.to_string()); // Mark as checked |
| 173 | 174 | ||
| 174 | // Find the announcement event for this pubkey+identifier | 175 | // Find the announcement event for this pubkey+identifier |
| 175 | let announcement = announcements.iter().find(|a| { | 176 | let announcement = announcements |
| 176 | a.event.pubkey.to_hex() == pubkey && a.identifier == identifier | 177 | .iter() |
| 177 | }); | 178 | .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier); |
| 178 | 179 | ||
| 179 | let Some(announcement) = announcement else { | 180 | let Some(announcement) = announcement else { |
| 180 | return; // No announcement found for this pubkey | 181 | return; // No announcement found for this pubkey |
| @@ -195,19 +196,19 @@ pub fn collect_all_authorized_maintainers( | |||
| 195 | ) -> HashSet<String> { | 196 | ) -> HashSet<String> { |
| 196 | let by_owner = collect_authorized_maintainers(announcements); | 197 | let by_owner = collect_authorized_maintainers(announcements); |
| 197 | let mut all_authorized = HashSet::new(); | 198 | let mut all_authorized = HashSet::new(); |
| 198 | 199 | ||
| 199 | for maintainers in by_owner.values() { | 200 | for maintainers in by_owner.values() { |
| 200 | for maintainer in maintainers { | 201 | for maintainer in maintainers { |
| 201 | all_authorized.insert(maintainer.clone()); | 202 | all_authorized.insert(maintainer.clone()); |
| 202 | } | 203 | } |
| 203 | } | 204 | } |
| 204 | 205 | ||
| 205 | debug!( | 206 | debug!( |
| 206 | "Collected {} total authorized maintainers from {} owners", | 207 | "Collected {} total authorized maintainers from {} owners", |
| 207 | all_authorized.len(), | 208 | all_authorized.len(), |
| 208 | by_owner.len() | 209 | by_owner.len() |
| 209 | ); | 210 | ); |
| 210 | 211 | ||
| 211 | all_authorized | 212 | all_authorized |
| 212 | } | 213 | } |
| 213 | 214 | ||
| @@ -601,10 +602,7 @@ pub fn validate_push_refs( | |||
| 601 | pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) | 602 | pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) |
| 602 | ) -> Result<()> { | 603 | ) -> Result<()> { |
| 603 | for (old_oid, new_oid, ref_name) in pushed_refs { | 604 | for (old_oid, new_oid, ref_name) in pushed_refs { |
| 604 | debug!( | 605 | debug!("Validating push: {} {} -> {}", ref_name, old_oid, new_oid); |
| 605 | "Validating push: {} {} -> {}", | ||
| 606 | ref_name, old_oid, new_oid | ||
| 607 | ); | ||
| 608 | 606 | ||
| 609 | // Handle branch updates | 607 | // Handle branch updates |
| 610 | if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { | 608 | if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { |
| @@ -657,7 +655,10 @@ pub fn validate_push_refs( | |||
| 657 | )); | 655 | )); |
| 658 | } | 656 | } |
| 659 | // Valid EventId format - allow push (skip state event check) | 657 | // Valid EventId format - allow push (skip state event check) |
| 660 | debug!("refs/nostr/{} push authorized (valid EventId)", event_id_str); | 658 | debug!( |
| 659 | "refs/nostr/{} push authorized (valid EventId)", | ||
| 660 | event_id_str | ||
| 661 | ); | ||
| 661 | continue; // Skip the rest of ref validation for this ref | 662 | continue; // Skip the rest of ref validation for this ref |
| 662 | } else { | 663 | } else { |
| 663 | return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); | 664 | return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); |
| @@ -805,6 +806,119 @@ pub fn npub_to_pubkey(npub: &str) -> Result<String> { | |||
| 805 | Ok(pk.to_hex()) | 806 | Ok(pk.to_hex()) |
| 806 | } | 807 | } |
| 807 | 808 | ||
| 809 | /// Fetch an event by ID from the database and extract the `c` tag commit hash | ||
| 810 | /// | ||
| 811 | /// This is used for validating pushes to refs/nostr/<event-id>. Per GRASP-01, | ||
| 812 | /// if a PR or PR Update event with this ID exists in the database, the pushed | ||
| 813 | /// commit must match the commit in the event's `c` tag. | ||
| 814 | /// | ||
| 815 | /// # Returns | ||
| 816 | /// - `Ok(Some(commit))` if the event exists and has a valid `c` tag | ||
| 817 | /// - `Ok(None)` if the event doesn't exist (push should be allowed) | ||
| 818 | /// - `Err(_)` on database errors | ||
| 819 | pub async fn get_event_commit_tag( | ||
| 820 | database: &Arc<MemoryDatabase>, | ||
| 821 | event_id: &EventId, | ||
| 822 | ) -> Result<Option<String>> { | ||
| 823 | // Query for PR (1618) and PR Update (1619) events with this ID | ||
| 824 | let filter = Filter::new() | ||
| 825 | .ids([*event_id]) | ||
| 826 | .kinds([Kind::from(KIND_PR), Kind::from(KIND_PR_UPDATE)]); | ||
| 827 | |||
| 828 | let events: Vec<Event> = database | ||
| 829 | .query(filter) | ||
| 830 | .await | ||
| 831 | .map_err(|e| anyhow!("Database query failed: {}", e))? | ||
| 832 | .into_iter() | ||
| 833 | .collect(); | ||
| 834 | |||
| 835 | if events.is_empty() { | ||
| 836 | debug!("No PR/PR Update event found with ID {}", event_id); | ||
| 837 | return Ok(None); | ||
| 838 | } | ||
| 839 | |||
| 840 | // Get the first (should be only) event | ||
| 841 | let event = &events[0]; | ||
| 842 | |||
| 843 | // Extract the `c` tag (commit hash) | ||
| 844 | // Per NIP-34, PR events have a `c` tag with the head commit | ||
| 845 | let commit = event | ||
| 846 | .tags | ||
| 847 | .iter() | ||
| 848 | .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c")) | ||
| 849 | .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string())); | ||
| 850 | |||
| 851 | debug!( | ||
| 852 | "Found PR event {} with commit tag: {:?}", | ||
| 853 | event_id, | ||
| 854 | commit.as_ref() | ||
| 855 | ); | ||
| 856 | |||
| 857 | Ok(commit) | ||
| 858 | } | ||
| 859 | |||
| 860 | /// Validate refs/nostr/ pushes against existing PR/PR Update events | ||
| 861 | /// | ||
| 862 | /// For each ref being pushed to refs/nostr/<event-id>: | ||
| 863 | /// 1. Validate the event ID format (error if invalid) | ||
| 864 | /// 2. Check if a corresponding event exists in the database | ||
| 865 | /// 3. If event exists, verify the pushed commit matches the `c` tag | ||
| 866 | /// | ||
| 867 | /// # Arguments | ||
| 868 | /// * `database` - The nostr database to query | ||
| 869 | /// * `pushed_refs` - List of (old_oid, new_oid, ref_name) tuples | ||
| 870 | /// | ||
| 871 | /// # Returns | ||
| 872 | /// * `Ok(())` if all refs/nostr/ pushes are valid | ||
| 873 | /// * `Err(_)` if any ref has invalid event ID format or fails commit validation | ||
| 874 | pub async fn validate_nostr_ref_pushes( | ||
| 875 | database: &Arc<MemoryDatabase>, | ||
| 876 | pushed_refs: &[(String, String, String)], | ||
| 877 | ) -> Result<()> { | ||
| 878 | for (_, new_oid, ref_name) in pushed_refs { | ||
| 879 | // Only check refs/nostr/ refs | ||
| 880 | if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { | ||
| 881 | // Parse the event ID - error on invalid format | ||
| 882 | let event_id = EventId::parse(event_id_str).map_err(|_| { | ||
| 883 | anyhow!( | ||
| 884 | "Invalid event ID format '{}' in ref: {}", | ||
| 885 | event_id_str, | ||
| 886 | ref_name | ||
| 887 | ) | ||
| 888 | })?; | ||
| 889 | |||
| 890 | // Check if event exists and get commit tag | ||
| 891 | match get_event_commit_tag(database, &event_id).await? { | ||
| 892 | Some(expected_commit) => { | ||
| 893 | // Event exists - verify commit matches | ||
| 894 | if new_oid != &expected_commit { | ||
| 895 | return Err(anyhow!( | ||
| 896 | "Push to {} rejected: event {} specifies commit {}, but push contains {}", | ||
| 897 | ref_name, | ||
| 898 | event_id_str, | ||
| 899 | expected_commit, | ||
| 900 | new_oid | ||
| 901 | )); | ||
| 902 | } | ||
| 903 | debug!( | ||
| 904 | "Push to {} validated: commit {} matches event's c tag", | ||
| 905 | ref_name, new_oid | ||
| 906 | ); | ||
| 907 | } | ||
| 908 | None => { | ||
| 909 | // No event exists yet - allow push | ||
| 910 | debug!( | ||
| 911 | "Push to {} allowed: no PR/PR Update event with ID {} found yet", | ||
| 912 | ref_name, event_id_str | ||
| 913 | ); | ||
| 914 | } | ||
| 915 | } | ||
| 916 | } | ||
| 917 | } | ||
| 918 | |||
| 919 | Ok(()) | ||
| 920 | } | ||
| 921 | |||
| 808 | #[cfg(test)] | 922 | #[cfg(test)] |
| 809 | mod tests { | 923 | mod tests { |
| 810 | use super::*; | 924 | use super::*; |
| @@ -920,7 +1034,7 @@ mod tests { | |||
| 920 | let eve = create_test_keys(); // Not authorized | 1034 | let eve = create_test_keys(); // Not authorized |
| 921 | let identifier = "test-repo"; | 1035 | let identifier = "test-repo"; |
| 922 | 1036 | ||
| 923 | // Alice lists Bob as maintainer | 1037 | // Alice lists Bob as maintainer |
| 924 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | 1038 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); |
| 925 | 1039 | ||
| 926 | let events = vec![alice_announcement]; | 1040 | let events = vec![alice_announcement]; |
| @@ -1084,4 +1198,4 @@ mod tests { | |||
| 1084 | let back_to_hex = npub_to_pubkey(&npub).unwrap(); | 1198 | let back_to_hex = npub_to_pubkey(&npub).unwrap(); |
| 1085 | assert_eq!(hex, back_to_hex); | 1199 | assert_eq!(hex, back_to_hex); |
| 1086 | } | 1200 | } |
| 1087 | } \ No newline at end of file | 1201 | } |
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 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module implements the HTTP handlers for Git Smart HTTP protocol. | 3 | //! This module implements the HTTP handlers for Git Smart HTTP protocol. |
| 4 | 4 | ||
| 5 | use std::path::PathBuf; | ||
| 6 | use std::sync::Arc; | ||
| 7 | use hyper::{body::Bytes, Response, StatusCode}; | ||
| 8 | use http_body_util::Full; | 5 | use http_body_util::Full; |
| 6 | use hyper::{body::Bytes, Response, StatusCode}; | ||
| 9 | use nostr_relay_builder::prelude::MemoryDatabase; | 7 | use nostr_relay_builder::prelude::MemoryDatabase; |
| 10 | use nostr_sdk::EventId; | 8 | use nostr_sdk::EventId; |
| 9 | use std::path::PathBuf; | ||
| 10 | use std::sync::Arc; | ||
| 11 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 11 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 12 | use tracing::{debug, error, info, warn}; | 12 | use tracing::{debug, error, info, warn}; |
| 13 | 13 | ||
| 14 | use super::authorization::{ | 14 | use super::authorization::{ |
| 15 | get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult, | 15 | get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs, |
| 16 | AuthorizationResult, | ||
| 16 | }; | 17 | }; |
| 17 | use super::protocol::{GitService, PktLine}; | 18 | use super::protocol::{GitService, PktLine}; |
| 18 | use super::subprocess::GitSubprocess; | 19 | use super::subprocess::GitSubprocess; |
| @@ -27,7 +28,10 @@ pub async fn handle_info_refs( | |||
| 27 | repo_path: PathBuf, | 28 | repo_path: PathBuf, |
| 28 | service: GitService, | 29 | service: GitService, |
| 29 | ) -> Result<Response<Full<Bytes>>, GitError> { | 30 | ) -> Result<Response<Full<Bytes>>, GitError> { |
| 30 | debug!("Handling info/refs for {:?} with service {:?}", repo_path, service); | 31 | debug!( |
| 32 | "Handling info/refs for {:?} with service {:?}", | ||
| 33 | repo_path, service | ||
| 34 | ); | ||
| 31 | 35 | ||
| 32 | // Check if repository exists | 36 | // Check if repository exists |
| 33 | if !repo_path.exists() { | 37 | if !repo_path.exists() { |
| @@ -36,55 +40,54 @@ pub async fn handle_info_refs( | |||
| 36 | } | 40 | } |
| 37 | 41 | ||
| 38 | // Spawn git with --advertise-refs | 42 | // Spawn git with --advertise-refs |
| 39 | let mut git = GitSubprocess::spawn(service, &repo_path, true) | 43 | let mut git = GitSubprocess::spawn(service, &repo_path, true).map_err(|e| { |
| 40 | .map_err(|e| { | 44 | error!("Failed to spawn git process: {}", e); |
| 41 | error!("Failed to spawn git process: {}", e); | 45 | GitError::ProcessSpawnFailed(e) |
| 42 | GitError::ProcessSpawnFailed(e) | 46 | })?; |
| 43 | })?; | ||
| 44 | 47 | ||
| 45 | // Read the output from git | 48 | // Read the output from git |
| 46 | let mut output = Vec::new(); | 49 | let mut output = Vec::new(); |
| 47 | let mut stderr_output = Vec::new(); | 50 | let mut stderr_output = Vec::new(); |
| 48 | 51 | ||
| 49 | if let Some(stdout) = git.take_stdout() { | 52 | if let Some(stdout) = git.take_stdout() { |
| 50 | let mut stdout = stdout; | 53 | let mut stdout = stdout; |
| 51 | stdout.read_to_end(&mut output).await | 54 | stdout.read_to_end(&mut output).await.map_err(|e| { |
| 52 | .map_err(|e| { | 55 | error!("Failed to read git output: {}", e); |
| 53 | error!("Failed to read git output: {}", e); | 56 | GitError::IoError(e) |
| 54 | GitError::IoError(e) | 57 | })?; |
| 55 | })?; | ||
| 56 | } | 58 | } |
| 57 | 59 | ||
| 58 | if let Some(stderr) = git.take_stderr() { | 60 | if let Some(stderr) = git.take_stderr() { |
| 59 | let mut stderr = stderr; | 61 | let mut stderr = stderr; |
| 60 | stderr.read_to_end(&mut stderr_output).await | 62 | stderr.read_to_end(&mut stderr_output).await.map_err(|e| { |
| 61 | .map_err(|e| { | 63 | error!("Failed to read git stderr: {}", e); |
| 62 | error!("Failed to read git stderr: {}", e); | 64 | GitError::IoError(e) |
| 63 | GitError::IoError(e) | 65 | })?; |
| 64 | })?; | ||
| 65 | } | 66 | } |
| 66 | 67 | ||
| 67 | // Wait for process to complete | 68 | // Wait for process to complete |
| 68 | let status = git.wait().await | 69 | let status = git.wait().await.map_err(|e| { |
| 69 | .map_err(|e| { | 70 | error!("Failed to wait for git process: {}", e); |
| 70 | error!("Failed to wait for git process: {}", e); | 71 | GitError::IoError(e) |
| 71 | GitError::IoError(e) | 72 | })?; |
| 72 | })?; | ||
| 73 | 73 | ||
| 74 | if !status.success() { | 74 | if !status.success() { |
| 75 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 75 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| 76 | error!("Git process failed with status: {:?}, stderr: {}", status, stderr_str); | 76 | error!( |
| 77 | "Git process failed with status: {:?}, stderr: {}", | ||
| 78 | status, stderr_str | ||
| 79 | ); | ||
| 77 | return Err(GitError::GitFailed(status.code())); | 80 | return Err(GitError::GitFailed(status.code())); |
| 78 | } | 81 | } |
| 79 | 82 | ||
| 80 | // Build response with pkt-line header | 83 | // Build response with pkt-line header |
| 81 | let mut response_body = Vec::new(); | 84 | let mut response_body = Vec::new(); |
| 82 | 85 | ||
| 83 | // First line: service advertisement | 86 | // First line: service advertisement |
| 84 | let service_line = format!("# service={}\n", service.as_str()); | 87 | let service_line = format!("# service={}\n", service.as_str()); |
| 85 | response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); | 88 | response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); |
| 86 | response_body.extend_from_slice(&PktLine::flush().encode()); | 89 | response_body.extend_from_slice(&PktLine::flush().encode()); |
| 87 | 90 | ||
| 88 | // Then the git output | 91 | // Then the git output |
| 89 | response_body.extend_from_slice(&output); | 92 | response_body.extend_from_slice(&output); |
| 90 | 93 | ||
| @@ -113,7 +116,9 @@ pub async fn handle_upload_pack( | |||
| 113 | 116 | ||
| 114 | // Write request to git's stdin | 117 | // Write request to git's stdin |
| 115 | if let Some(mut stdin) = git.take_stdin() { | 118 | if let Some(mut stdin) = git.take_stdin() { |
| 116 | stdin.write_all(&request_body).await | 119 | stdin |
| 120 | .write_all(&request_body) | ||
| 121 | .await | ||
| 117 | .map_err(GitError::IoError)?; | 122 | .map_err(GitError::IoError)?; |
| 118 | // Close stdin to signal end of input | 123 | // Close stdin to signal end of input |
| 119 | drop(stdin); | 124 | drop(stdin); |
| @@ -122,22 +127,25 @@ pub async fn handle_upload_pack( | |||
| 122 | // Read response from git's stdout | 127 | // Read response from git's stdout |
| 123 | let mut output = Vec::new(); | 128 | let mut output = Vec::new(); |
| 124 | let mut stderr_output = Vec::new(); | 129 | let mut stderr_output = Vec::new(); |
| 125 | 130 | ||
| 126 | if let Some(stdout) = git.take_stdout() { | 131 | if let Some(stdout) = git.take_stdout() { |
| 127 | let mut stdout = stdout; | 132 | let mut stdout = stdout; |
| 128 | stdout.read_to_end(&mut output).await | 133 | stdout |
| 134 | .read_to_end(&mut output) | ||
| 135 | .await | ||
| 129 | .map_err(GitError::IoError)?; | 136 | .map_err(GitError::IoError)?; |
| 130 | } | 137 | } |
| 131 | 138 | ||
| 132 | if let Some(stderr) = git.take_stderr() { | 139 | if let Some(stderr) = git.take_stderr() { |
| 133 | let mut stderr = stderr; | 140 | let mut stderr = stderr; |
| 134 | stderr.read_to_end(&mut stderr_output).await | 141 | stderr |
| 142 | .read_to_end(&mut stderr_output) | ||
| 143 | .await | ||
| 135 | .map_err(GitError::IoError)?; | 144 | .map_err(GitError::IoError)?; |
| 136 | } | 145 | } |
| 137 | 146 | ||
| 138 | // Wait for process | 147 | // Wait for process |
| 139 | let status = git.wait().await | 148 | let status = git.wait().await.map_err(GitError::IoError)?; |
| 140 | .map_err(GitError::IoError)?; | ||
| 141 | 149 | ||
| 142 | if !status.success() { | 150 | if !status.success() { |
| 143 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 151 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| @@ -194,10 +202,7 @@ pub async fn handle_receive_pack( | |||
| 194 | match authorize_push(db, identifier, owner_pubkey, &request_body).await { | 202 | match authorize_push(db, identifier, owner_pubkey, &request_body).await { |
| 195 | Ok(auth_result) => { | 203 | Ok(auth_result) => { |
| 196 | if !auth_result.authorized { | 204 | if !auth_result.authorized { |
| 197 | warn!( | 205 | warn!("Push rejected for {}: {}", identifier, auth_result.reason); |
| 198 | "Push rejected for {}: {}", | ||
| 199 | identifier, auth_result.reason | ||
| 200 | ); | ||
| 201 | return Err(GitError::Unauthorized); | 206 | return Err(GitError::Unauthorized); |
| 202 | } | 207 | } |
| 203 | info!( | 208 | info!( |
| @@ -209,10 +214,7 @@ pub async fn handle_receive_pack( | |||
| 209 | authorized_state = auth_result.state; | 214 | authorized_state = auth_result.state; |
| 210 | } | 215 | } |
| 211 | Err(e) => { | 216 | Err(e) => { |
| 212 | warn!( | 217 | warn!("Authorization check failed for {}: {}", identifier, e); |
| 213 | "Authorization check failed for {}: {}", | ||
| 214 | identifier, e | ||
| 215 | ); | ||
| 216 | return Err(GitError::Unauthorized); | 218 | return Err(GitError::Unauthorized); |
| 217 | } | 219 | } |
| 218 | } | 220 | } |
| @@ -226,7 +228,9 @@ pub async fn handle_receive_pack( | |||
| 226 | 228 | ||
| 227 | // Write request to git's stdin | 229 | // Write request to git's stdin |
| 228 | if let Some(mut stdin) = git.take_stdin() { | 230 | if let Some(mut stdin) = git.take_stdin() { |
| 229 | stdin.write_all(&request_body).await | 231 | stdin |
| 232 | .write_all(&request_body) | ||
| 233 | .await | ||
| 230 | .map_err(GitError::IoError)?; | 234 | .map_err(GitError::IoError)?; |
| 231 | drop(stdin); | 235 | drop(stdin); |
| 232 | } | 236 | } |
| @@ -234,22 +238,25 @@ pub async fn handle_receive_pack( | |||
| 234 | // Read response from git's stdout | 238 | // Read response from git's stdout |
| 235 | let mut output = Vec::new(); | 239 | let mut output = Vec::new(); |
| 236 | let mut stderr_output = Vec::new(); | 240 | let mut stderr_output = Vec::new(); |
| 237 | 241 | ||
| 238 | if let Some(stdout) = git.take_stdout() { | 242 | if let Some(stdout) = git.take_stdout() { |
| 239 | let mut stdout = stdout; | 243 | let mut stdout = stdout; |
| 240 | stdout.read_to_end(&mut output).await | 244 | stdout |
| 245 | .read_to_end(&mut output) | ||
| 246 | .await | ||
| 241 | .map_err(GitError::IoError)?; | 247 | .map_err(GitError::IoError)?; |
| 242 | } | 248 | } |
| 243 | 249 | ||
| 244 | if let Some(stderr) = git.take_stderr() { | 250 | if let Some(stderr) = git.take_stderr() { |
| 245 | let mut stderr = stderr; | 251 | let mut stderr = stderr; |
| 246 | stderr.read_to_end(&mut stderr_output).await | 252 | stderr |
| 253 | .read_to_end(&mut stderr_output) | ||
| 254 | .await | ||
| 247 | .map_err(GitError::IoError)?; | 255 | .map_err(GitError::IoError)?; |
| 248 | } | 256 | } |
| 249 | 257 | ||
| 250 | // Wait for process | 258 | // Wait for process |
| 251 | let status = git.wait().await | 259 | let status = git.wait().await.map_err(GitError::IoError)?; |
| 252 | .map_err(GitError::IoError)?; | ||
| 253 | 260 | ||
| 254 | if !status.success() { | 261 | if !status.success() { |
| 255 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 262 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| @@ -266,10 +273,7 @@ pub async fn handle_receive_pack( | |||
| 266 | if let Some(commit) = state.get_branch_commit(branch_name) { | 273 | if let Some(commit) = state.get_branch_commit(branch_name) { |
| 267 | match try_set_head_if_available(&repo_path, head_ref, commit) { | 274 | match try_set_head_if_available(&repo_path, head_ref, commit) { |
| 268 | Ok(true) => { | 275 | Ok(true) => { |
| 269 | info!( | 276 | info!("Set HEAD to {} after push to {:?}", head_ref, repo_path); |
| 270 | "Set HEAD to {} after push to {:?}", | ||
| 271 | head_ref, repo_path | ||
| 272 | ); | ||
| 273 | } | 277 | } |
| 274 | Ok(false) => { | 278 | Ok(false) => { |
| 275 | debug!( | 279 | debug!( |
| @@ -278,10 +282,7 @@ pub async fn handle_receive_pack( | |||
| 278 | ); | 282 | ); |
| 279 | } | 283 | } |
| 280 | Err(e) => { | 284 | Err(e) => { |
| 281 | warn!( | 285 | warn!("Failed to set HEAD after push: {}", e); |
| 282 | "Failed to set HEAD after push: {}", | ||
| 283 | e | ||
| 284 | ); | ||
| 285 | } | 286 | } |
| 286 | } | 287 | } |
| 287 | } | 288 | } |
| @@ -291,7 +292,10 @@ pub async fn handle_receive_pack( | |||
| 291 | 292 | ||
| 292 | Ok(Response::builder() | 293 | Ok(Response::builder() |
| 293 | .status(StatusCode::OK) | 294 | .status(StatusCode::OK) |
| 294 | .header("content-type", GitService::ReceivePack.result_content_type()) | 295 | .header( |
| 296 | "content-type", | ||
| 297 | GitService::ReceivePack.result_content_type(), | ||
| 298 | ) | ||
| 295 | .header("cache-control", "no-cache") | 299 | .header("cache-control", "no-cache") |
| 296 | .body(Full::new(Bytes::from(output))) | 300 | .body(Full::new(Bytes::from(output))) |
| 297 | .unwrap()) | 301 | .unwrap()) |
| @@ -305,6 +309,7 @@ pub async fn handle_receive_pack( | |||
| 305 | /// 3. Collects authorized publishers from that announcement (owner + maintainers) | 309 | /// 3. Collects authorized publishers from that announcement (owner + maintainers) |
| 306 | /// 4. Gets the latest authorized state from those publishers | 310 | /// 4. Gets the latest authorized state from those publishers |
| 307 | /// 5. Validates that pushed refs match the state | 311 | /// 5. Validates that pushed refs match the state |
| 312 | /// 6. Validates refs/nostr/<event-id> has valid event id and if event exists, `c` tag matches ref | ||
| 308 | async fn authorize_push( | 313 | async fn authorize_push( |
| 309 | database: &Arc<MemoryDatabase>, | 314 | database: &Arc<MemoryDatabase>, |
| 310 | identifier: &str, | 315 | identifier: &str, |
| @@ -323,59 +328,79 @@ async fn authorize_push( | |||
| 323 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | 328 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); |
| 324 | } | 329 | } |
| 325 | 330 | ||
| 326 | // Check if ALL pushed refs are to refs/nostr/ with valid EventId format | 331 | // Separate refs/nostr/ refs from other refs |
| 327 | // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" | 332 | // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" |
| 328 | // These pushes only require EventId format validation, not state validation | 333 | let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs |
| 329 | let all_refs_nostr_valid = !pushed_refs.is_empty() | 334 | .iter() |
| 330 | && pushed_refs.iter().all(|(_, _, ref_name)| { | 335 | .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); |
| 331 | if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { | 336 | |
| 332 | // Validate it parses as a valid EventId | 337 | // Validate refs/nostr/ refs if any exist |
| 333 | EventId::parse(event_id_str).is_ok() | 338 | if !nostr_refs.is_empty() { |
| 334 | } else { | 339 | debug!( |
| 335 | false | 340 | "Found {} refs/nostr/ refs - validating against events", |
| 336 | } | 341 | nostr_refs.len() |
| 337 | }); | 342 | ); |
| 338 | 343 | ||
| 339 | if all_refs_nostr_valid { | 344 | // Validate refs/nostr/ pushes: checks event ID format and commit matching |
| 340 | debug!("All refs are refs/nostr/ with valid EventId format - authorized without state check"); | 345 | let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs |
| 341 | // Return success for refs/nostr/ pushes without requiring state | 346 | .into_iter() |
| 347 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) | ||
| 348 | .collect(); | ||
| 349 | if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { | ||
| 350 | warn!("refs/nostr/ validation failed: {}", e); | ||
| 351 | return Ok(AuthorizationResult::denied(format!( | ||
| 352 | "refs/nostr/ validation failed: {}", | ||
| 353 | e | ||
| 354 | ))); | ||
| 355 | } | ||
| 356 | debug!("refs/nostr/ push validated successfully"); | ||
| 357 | } | ||
| 358 | |||
| 359 | // If only refs/nostr/ refs, we're done - return success | ||
| 360 | if other_refs.is_empty() { | ||
| 361 | debug!("Only refs/nostr/ refs in push - authorization complete"); | ||
| 342 | return Ok(AuthorizationResult { | 362 | return Ok(AuthorizationResult { |
| 343 | authorized: true, | 363 | authorized: true, |
| 344 | reason: "Push to refs/nostr/ with valid EventId format".to_string(), | 364 | reason: "Push to refs/nostr/ validated against events".to_string(), |
| 345 | state: None, | 365 | state: None, |
| 346 | maintainers: vec![], | 366 | maintainers: vec![], |
| 347 | }); | 367 | }); |
| 348 | } | 368 | } |
| 349 | 369 | ||
| 350 | // For non-refs/nostr/ pushes, require state validation as normal | 370 | // For non-refs/nostr/ refs, require state validation |
| 351 | debug!("Non-refs/nostr/ push detected - checking state authorization"); | 371 | debug!( |
| 372 | "Found {} non-refs/nostr/ refs - checking state authorization", | ||
| 373 | other_refs.len() | ||
| 374 | ); | ||
| 352 | let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; | 375 | let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; |
| 353 | 376 | ||
| 354 | if !auth_result.authorized { | 377 | if !auth_result.authorized { |
| 355 | return Ok(auth_result); | 378 | return Ok(auth_result); |
| 356 | } | 379 | } |
| 357 | 380 | ||
| 358 | // Parse refs from the push request | 381 | // Convert other_refs for validation |
| 359 | let pushed_refs = parse_pushed_refs(request_body); | 382 | let other_refs_owned: Vec<(String, String, String)> = other_refs |
| 360 | debug!("Parsed {} refs from push request", pushed_refs.len()); | 383 | .into_iter() |
| 361 | for (old_oid, new_oid, ref_name) in &pushed_refs { | 384 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) |
| 362 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | 385 | .collect(); |
| 363 | } | ||
| 364 | 386 | ||
| 365 | // Validate refs against state | 387 | // Validate non-refs/nostr/ refs against state |
| 366 | if let Some(ref state) = auth_result.state { | 388 | if let Some(ref state) = auth_result.state { |
| 367 | debug!("Validating against state with {} branches", state.branches.len()); | 389 | debug!( |
| 368 | 390 | "Validating against state with {} branches", | |
| 391 | state.branches.len() | ||
| 392 | ); | ||
| 393 | |||
| 369 | // If we have a state event but couldn't parse any refs, reject the push. | 394 | // If we have a state event but couldn't parse any refs, reject the push. |
| 370 | // This protects against parsing failures allowing unauthorized pushes. | 395 | // This protects against parsing failures allowing unauthorized pushes. |
| 371 | if pushed_refs.is_empty() && !state.branches.is_empty() { | 396 | if other_refs_owned.is_empty() && !state.branches.is_empty() { |
| 372 | warn!("No refs parsed from push request but state event has branches - rejecting"); | 397 | warn!("No refs parsed from push request but state event has branches - rejecting"); |
| 373 | return Ok(AuthorizationResult::denied( | 398 | return Ok(AuthorizationResult::denied( |
| 374 | "Failed to parse refs from push request - cannot validate against state" | 399 | "Failed to parse refs from push request - cannot validate against state", |
| 375 | )); | 400 | )); |
| 376 | } | 401 | } |
| 377 | 402 | ||
| 378 | if let Err(e) = validate_push_refs(state, &pushed_refs) { | 403 | if let Err(e) = validate_push_refs(state, &other_refs_owned) { |
| 379 | warn!("Ref validation failed: {}", e); | 404 | warn!("Ref validation failed: {}", e); |
| 380 | return Ok(AuthorizationResult::denied(format!( | 405 | return Ok(AuthorizationResult::denied(format!( |
| 381 | "Ref validation failed: {}", | 406 | "Ref validation failed: {}", |
| @@ -423,4 +448,4 @@ impl GitError { | |||
| 423 | _ => StatusCode::INTERNAL_SERVER_ERROR, | 448 | _ => StatusCode::INTERNAL_SERVER_ERROR, |
| 424 | } | 449 | } |
| 425 | } | 450 | } |
| 426 | } \ No newline at end of file | 451 | } |
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}; | |||
| 40 | pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { | 40 | pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { |
| 41 | // Remove .git suffix if present | 41 | // Remove .git suffix if present |
| 42 | let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); | 42 | let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); |
| 43 | 43 | ||
| 44 | PathBuf::from(git_data_path) | 44 | PathBuf::from(git_data_path) |
| 45 | .join(npub) | 45 | .join(npub) |
| 46 | .join(format!("{}.git", identifier)) | 46 | .join(format!("{}.git", identifier)) |
| @@ -89,7 +89,10 @@ pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool { | |||
| 89 | pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { | 89 | pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { |
| 90 | // Validate the ref format | 90 | // Validate the ref format |
| 91 | if !head_ref.starts_with("refs/heads/") { | 91 | if !head_ref.starts_with("refs/heads/") { |
| 92 | return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref)); | 92 | return Err(format!( |
| 93 | "Invalid HEAD ref: {} (must start with refs/heads/)", | ||
| 94 | head_ref | ||
| 95 | )); | ||
| 93 | } | 96 | } |
| 94 | 97 | ||
| 95 | debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); | 98 | debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); |
| @@ -130,7 +133,10 @@ pub fn try_set_head_if_available( | |||
| 130 | ) -> Result<bool, String> { | 133 | ) -> Result<bool, String> { |
| 131 | // Check if repository exists | 134 | // Check if repository exists |
| 132 | if !repo_path.exists() { | 135 | if !repo_path.exists() { |
| 133 | debug!("Repository not found at {}, cannot set HEAD", repo_path.display()); | 136 | debug!( |
| 137 | "Repository not found at {}, cannot set HEAD", | ||
| 138 | repo_path.display() | ||
| 139 | ); | ||
| 134 | return Ok(false); | 140 | return Ok(false); |
| 135 | } | 141 | } |
| 136 | 142 | ||
| @@ -149,6 +155,115 @@ pub fn try_set_head_if_available( | |||
| 149 | Ok(true) | 155 | Ok(true) |
| 150 | } | 156 | } |
| 151 | 157 | ||
| 158 | /// Get the commit hash that a ref points to | ||
| 159 | /// | ||
| 160 | /// # Arguments | ||
| 161 | /// * `repo_path` - Path to the bare git repository | ||
| 162 | /// * `ref_name` - The ref name (e.g., "refs/nostr/<event-id>") | ||
| 163 | /// | ||
| 164 | /// # Returns | ||
| 165 | /// Some(commit_hash) if the ref exists, None otherwise | ||
| 166 | pub fn get_ref_commit(repo_path: &Path, ref_name: &str) -> Option<String> { | ||
| 167 | let output = Command::new("git") | ||
| 168 | .args(["rev-parse", ref_name]) | ||
| 169 | .current_dir(repo_path) | ||
| 170 | .output() | ||
| 171 | .ok()?; | ||
| 172 | |||
| 173 | if output.status.success() { | ||
| 174 | Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) | ||
| 175 | } else { | ||
| 176 | None | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Delete a git ref from the repository | ||
| 181 | /// | ||
| 182 | /// # Arguments | ||
| 183 | /// * `repo_path` - Path to the bare git repository | ||
| 184 | /// * `ref_name` - The ref name to delete (e.g., "refs/nostr/<event-id>") | ||
| 185 | /// | ||
| 186 | /// # Returns | ||
| 187 | /// Ok(()) if successful, Err with error message otherwise | ||
| 188 | pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> { | ||
| 189 | debug!("Deleting ref {} from {}", ref_name, repo_path.display()); | ||
| 190 | |||
| 191 | let output = Command::new("git") | ||
| 192 | .args(["update-ref", "-d", ref_name]) | ||
| 193 | .current_dir(repo_path) | ||
| 194 | .output() | ||
| 195 | .map_err(|e| format!("Failed to execute git update-ref: {}", e))?; | ||
| 196 | |||
| 197 | if !output.status.success() { | ||
| 198 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 199 | return Err(format!("git update-ref -d failed: {}", stderr)); | ||
| 200 | } | ||
| 201 | |||
| 202 | info!("Deleted ref {} from {}", ref_name, repo_path.display()); | ||
| 203 | Ok(()) | ||
| 204 | } | ||
| 205 | |||
| 206 | /// Validate refs/nostr/<event-id> ref against expected commit | ||
| 207 | /// | ||
| 208 | /// If the ref exists but points to a different commit than expected, | ||
| 209 | /// the ref is deleted. This is called when a PR event is received to | ||
| 210 | /// ensure refs/nostr refs are consistent with their corresponding events. | ||
| 211 | /// | ||
| 212 | /// # Arguments | ||
| 213 | /// * `repo_path` - Path to the bare git repository | ||
| 214 | /// * `event_id` - The event ID (hex string) | ||
| 215 | /// * `expected_commit` - The commit hash from the event's `c` tag | ||
| 216 | /// | ||
| 217 | /// # Returns | ||
| 218 | /// Ok(true) if ref was deleted (mismatch), Ok(false) if no action taken, Err on failure | ||
| 219 | pub fn validate_nostr_ref( | ||
| 220 | repo_path: &Path, | ||
| 221 | event_id: &str, | ||
| 222 | expected_commit: &str, | ||
| 223 | ) -> Result<bool, String> { | ||
| 224 | let ref_name = format!("refs/nostr/{}", event_id); | ||
| 225 | |||
| 226 | // Check if repository exists | ||
| 227 | if !repo_path.exists() { | ||
| 228 | debug!( | ||
| 229 | "Repository not found at {}, skipping ref validation", | ||
| 230 | repo_path.display() | ||
| 231 | ); | ||
| 232 | return Ok(false); | ||
| 233 | } | ||
| 234 | |||
| 235 | // Check if the ref exists | ||
| 236 | let current_commit = match get_ref_commit(repo_path, &ref_name) { | ||
| 237 | Some(commit) => commit, | ||
| 238 | None => { | ||
| 239 | debug!("Ref {} does not exist in {}", ref_name, repo_path.display()); | ||
| 240 | return Ok(false); | ||
| 241 | } | ||
| 242 | }; | ||
| 243 | |||
| 244 | // Compare commits | ||
| 245 | if current_commit == expected_commit { | ||
| 246 | debug!( | ||
| 247 | "Ref {} points to correct commit {} in {}", | ||
| 248 | ref_name, | ||
| 249 | expected_commit, | ||
| 250 | repo_path.display() | ||
| 251 | ); | ||
| 252 | return Ok(false); | ||
| 253 | } | ||
| 254 | |||
| 255 | // Commit mismatch - delete the ref | ||
| 256 | info!( | ||
| 257 | "Deleting mismatched ref {} in {}: expected {}, found {}", | ||
| 258 | ref_name, | ||
| 259 | repo_path.display(), | ||
| 260 | expected_commit, | ||
| 261 | current_commit | ||
| 262 | ); | ||
| 263 | delete_ref(repo_path, &ref_name)?; | ||
| 264 | Ok(true) | ||
| 265 | } | ||
| 266 | |||
| 152 | /// Get the current HEAD ref from a repository | 267 | /// Get the current HEAD ref from a repository |
| 153 | /// | 268 | /// |
| 154 | /// # Arguments | 269 | /// # Arguments |
| @@ -178,25 +293,25 @@ pub fn get_repository_head(repo_path: &Path) -> Option<String> { | |||
| 178 | pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { | 293 | pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { |
| 179 | // Remove leading slash | 294 | // Remove leading slash |
| 180 | let path = path.strip_prefix('/').unwrap_or(path); | 295 | let path = path.strip_prefix('/').unwrap_or(path); |
| 181 | 296 | ||
| 182 | // Split into components | 297 | // Split into components |
| 183 | let parts: Vec<&str> = path.splitn(3, '/').collect(); | 298 | let parts: Vec<&str> = path.splitn(3, '/').collect(); |
| 184 | 299 | ||
| 185 | if parts.len() < 3 { | 300 | if parts.len() < 3 { |
| 186 | return None; | 301 | return None; |
| 187 | } | 302 | } |
| 188 | 303 | ||
| 189 | let npub = parts[0]; | 304 | let npub = parts[0]; |
| 190 | let repo_part = parts[1]; | 305 | let repo_part = parts[1]; |
| 191 | let subpath = parts[2]; | 306 | let subpath = parts[2]; |
| 192 | 307 | ||
| 193 | // Extract identifier (remove .git suffix if present for the middle part) | 308 | // Extract identifier (remove .git suffix if present for the middle part) |
| 194 | let identifier = if repo_part.ends_with(".git") { | 309 | let identifier = if repo_part.ends_with(".git") { |
| 195 | &repo_part[..repo_part.len() - 4] | 310 | &repo_part[..repo_part.len() - 4] |
| 196 | } else { | 311 | } else { |
| 197 | repo_part | 312 | repo_part |
| 198 | }; | 313 | }; |
| 199 | 314 | ||
| 200 | Some((npub, identifier, subpath)) | 315 | Some((npub, identifier, subpath)) |
| 201 | } | 316 | } |
| 202 | 317 | ||
| @@ -210,13 +325,13 @@ mod tests { | |||
| 210 | fn create_test_repo() -> (TempDir, PathBuf) { | 325 | fn create_test_repo() -> (TempDir, PathBuf) { |
| 211 | let temp_dir = TempDir::new().unwrap(); | 326 | let temp_dir = TempDir::new().unwrap(); |
| 212 | let repo_path = temp_dir.path().join("test.git"); | 327 | let repo_path = temp_dir.path().join("test.git"); |
| 213 | 328 | ||
| 214 | // Initialize bare repository | 329 | // Initialize bare repository |
| 215 | Command::new("git") | 330 | Command::new("git") |
| 216 | .args(["init", "--bare", repo_path.to_str().unwrap()]) | 331 | .args(["init", "--bare", repo_path.to_str().unwrap()]) |
| 217 | .output() | 332 | .output() |
| 218 | .unwrap(); | 333 | .unwrap(); |
| 219 | 334 | ||
| 220 | (temp_dir, repo_path) | 335 | (temp_dir, repo_path) |
| 221 | } | 336 | } |
| 222 | 337 | ||
| @@ -225,19 +340,23 @@ mod tests { | |||
| 225 | let temp_dir = TempDir::new().unwrap(); | 340 | let temp_dir = TempDir::new().unwrap(); |
| 226 | let work_dir = temp_dir.path().join("work"); | 341 | let work_dir = temp_dir.path().join("work"); |
| 227 | let bare_repo = temp_dir.path().join("test.git"); | 342 | let bare_repo = temp_dir.path().join("test.git"); |
| 228 | 343 | ||
| 229 | // Initialize bare repository | 344 | // Initialize bare repository |
| 230 | Command::new("git") | 345 | Command::new("git") |
| 231 | .args(["init", "--bare", bare_repo.to_str().unwrap()]) | 346 | .args(["init", "--bare", "--initial-branch=main", bare_repo.to_str().unwrap()]) |
| 232 | .output() | 347 | .output() |
| 233 | .unwrap(); | 348 | .unwrap(); |
| 234 | 349 | ||
| 235 | // Clone to working directory | 350 | // Clone to working directory |
| 236 | Command::new("git") | 351 | Command::new("git") |
| 237 | .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()]) | 352 | .args([ |
| 353 | "clone", | ||
| 354 | bare_repo.to_str().unwrap(), | ||
| 355 | work_dir.to_str().unwrap(), | ||
| 356 | ]) | ||
| 238 | .output() | 357 | .output() |
| 239 | .unwrap(); | 358 | .unwrap(); |
| 240 | 359 | ||
| 241 | // Configure git for commits | 360 | // Configure git for commits |
| 242 | Command::new("git") | 361 | Command::new("git") |
| 243 | .args(["config", "user.email", "test@test.com"]) | 362 | .args(["config", "user.email", "test@test.com"]) |
| @@ -249,7 +368,7 @@ mod tests { | |||
| 249 | .current_dir(&work_dir) | 368 | .current_dir(&work_dir) |
| 250 | .output() | 369 | .output() |
| 251 | .unwrap(); | 370 | .unwrap(); |
| 252 | 371 | ||
| 253 | // Create a file and commit | 372 | // Create a file and commit |
| 254 | fs::write(work_dir.join("README.md"), "# Test").unwrap(); | 373 | fs::write(work_dir.join("README.md"), "# Test").unwrap(); |
| 255 | Command::new("git") | 374 | Command::new("git") |
| @@ -262,7 +381,7 @@ mod tests { | |||
| 262 | .current_dir(&work_dir) | 381 | .current_dir(&work_dir) |
| 263 | .output() | 382 | .output() |
| 264 | .unwrap(); | 383 | .unwrap(); |
| 265 | 384 | ||
| 266 | // Get commit hash | 385 | // Get commit hash |
| 267 | let output = Command::new("git") | 386 | let output = Command::new("git") |
| 268 | .args(["rev-parse", "HEAD"]) | 387 | .args(["rev-parse", "HEAD"]) |
| @@ -270,41 +389,27 @@ mod tests { | |||
| 270 | .output() | 389 | .output() |
| 271 | .unwrap(); | 390 | .unwrap(); |
| 272 | let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); | 391 | let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 273 | 392 | ||
| 274 | // Push to bare repo | 393 | // Push to bare repo |
| 275 | Command::new("git") | 394 | Command::new("git") |
| 276 | .args(["push", "origin", "master"]) | 395 | .args(["push", "origin", "main"]) |
| 277 | .current_dir(&work_dir) | 396 | .current_dir(&work_dir) |
| 278 | .output() | 397 | .output() |
| 279 | .unwrap(); | 398 | .unwrap(); |
| 280 | 399 | ||
| 281 | (temp_dir, bare_repo, commit_hash) | 400 | (temp_dir, bare_repo, commit_hash) |
| 282 | } | 401 | } |
| 283 | 402 | ||
| 284 | #[test] | 403 | #[test] |
| 285 | fn test_resolve_repo_path() { | 404 | fn test_resolve_repo_path() { |
| 286 | let path = resolve_repo_path( | 405 | let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo"); |
| 287 | "/data/git", | 406 | assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); |
| 288 | "npub1abc123", | ||
| 289 | "my-repo" | ||
| 290 | ); | ||
| 291 | assert_eq!( | ||
| 292 | path, | ||
| 293 | PathBuf::from("/data/git/npub1abc123/my-repo.git") | ||
| 294 | ); | ||
| 295 | } | 407 | } |
| 296 | 408 | ||
| 297 | #[test] | 409 | #[test] |
| 298 | fn test_resolve_repo_path_with_git_suffix() { | 410 | fn test_resolve_repo_path_with_git_suffix() { |
| 299 | let path = resolve_repo_path( | 411 | let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git"); |
| 300 | "/data/git", | 412 | assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); |
| 301 | "npub1abc123", | ||
| 302 | "my-repo.git" | ||
| 303 | ); | ||
| 304 | assert_eq!( | ||
| 305 | path, | ||
| 306 | PathBuf::from("/data/git/npub1abc123/my-repo.git") | ||
| 307 | ); | ||
| 308 | } | 413 | } |
| 309 | 414 | ||
| 310 | #[test] | 415 | #[test] |
| @@ -332,7 +437,10 @@ mod tests { | |||
| 332 | #[test] | 437 | #[test] |
| 333 | fn test_commit_exists_nonexistent() { | 438 | fn test_commit_exists_nonexistent() { |
| 334 | let (_temp_dir, repo_path) = create_test_repo(); | 439 | let (_temp_dir, repo_path) = create_test_repo(); |
| 335 | assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef")); | 440 | assert!(!commit_exists( |
| 441 | &repo_path, | ||
| 442 | "deadbeef1234567890abcdef1234567890abcdef" | ||
| 443 | )); | ||
| 336 | } | 444 | } |
| 337 | 445 | ||
| 338 | #[test] | 446 | #[test] |
| @@ -344,11 +452,11 @@ mod tests { | |||
| 344 | #[test] | 452 | #[test] |
| 345 | fn test_set_repository_head() { | 453 | fn test_set_repository_head() { |
| 346 | let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); | 454 | let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); |
| 347 | 455 | ||
| 348 | // Default HEAD might be refs/heads/master | 456 | // Default HEAD might be refs/heads/master |
| 349 | let result = set_repository_head(&repo_path, "refs/heads/main"); | 457 | let result = set_repository_head(&repo_path, "refs/heads/main"); |
| 350 | assert!(result.is_ok()); | 458 | assert!(result.is_ok()); |
| 351 | 459 | ||
| 352 | let head = get_repository_head(&repo_path); | 460 | let head = get_repository_head(&repo_path); |
| 353 | assert_eq!(head, Some("refs/heads/main".to_string())); | 461 | assert_eq!(head, Some("refs/heads/main".to_string())); |
| 354 | } | 462 | } |
| @@ -356,7 +464,7 @@ mod tests { | |||
| 356 | #[test] | 464 | #[test] |
| 357 | fn test_set_repository_head_invalid_ref() { | 465 | fn test_set_repository_head_invalid_ref() { |
| 358 | let (_temp_dir, repo_path) = create_test_repo(); | 466 | let (_temp_dir, repo_path) = create_test_repo(); |
| 359 | 467 | ||
| 360 | // Invalid ref format should fail | 468 | // Invalid ref format should fail |
| 361 | let result = set_repository_head(&repo_path, "main"); | 469 | let result = set_repository_head(&repo_path, "main"); |
| 362 | assert!(result.is_err()); | 470 | assert!(result.is_err()); |
| @@ -366,13 +474,13 @@ mod tests { | |||
| 366 | #[test] | 474 | #[test] |
| 367 | fn test_try_set_head_if_available_commit_missing() { | 475 | fn test_try_set_head_if_available_commit_missing() { |
| 368 | let (_temp_dir, repo_path) = create_test_repo(); | 476 | let (_temp_dir, repo_path) = create_test_repo(); |
| 369 | 477 | ||
| 370 | let result = try_set_head_if_available( | 478 | let result = try_set_head_if_available( |
| 371 | &repo_path, | 479 | &repo_path, |
| 372 | "refs/heads/main", | 480 | "refs/heads/main", |
| 373 | "deadbeef1234567890abcdef1234567890abcdef", | 481 | "deadbeef1234567890abcdef1234567890abcdef", |
| 374 | ); | 482 | ); |
| 375 | 483 | ||
| 376 | // Should return Ok(false) - commit not found | 484 | // Should return Ok(false) - commit not found |
| 377 | assert!(result.is_ok()); | 485 | assert!(result.is_ok()); |
| 378 | assert!(!result.unwrap()); | 486 | assert!(!result.unwrap()); |
| @@ -381,19 +489,15 @@ mod tests { | |||
| 381 | #[test] | 489 | #[test] |
| 382 | fn test_try_set_head_if_available_success() { | 490 | fn test_try_set_head_if_available_success() { |
| 383 | let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); | 491 | let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); |
| 384 | 492 | ||
| 385 | let result = try_set_head_if_available( | 493 | let result = try_set_head_if_available(&repo_path, "refs/heads/main", &commit_hash); |
| 386 | &repo_path, | 494 | |
| 387 | "refs/heads/main", | ||
| 388 | &commit_hash, | ||
| 389 | ); | ||
| 390 | |||
| 391 | // Should return Ok(true) - HEAD was set | 495 | // Should return Ok(true) - HEAD was set |
| 392 | assert!(result.is_ok()); | 496 | assert!(result.is_ok()); |
| 393 | assert!(result.unwrap()); | 497 | assert!(result.unwrap()); |
| 394 | 498 | ||
| 395 | // Verify HEAD was set | 499 | // Verify HEAD was set |
| 396 | let head = get_repository_head(&repo_path); | 500 | let head = get_repository_head(&repo_path); |
| 397 | assert_eq!(head, Some("refs/heads/main".to_string())); | 501 | assert_eq!(head, Some("refs/heads/main".to_string())); |
| 398 | } | 502 | } |
| 399 | } \ No newline at end of file | 503 | } |