diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 15:20:59 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 15:20:59 +0000 |
| commit | 113928aa84894ea8f65c247d9987527e792b32a9 (patch) | |
| tree | ec967d6195d9f7ec4f061449596611afe3a0950f /src/git | |
| parent | 26f608e5011b9d1ad6036da75b89272835e69695 (diff) | |
| parent | e0ad39a489b3398f8208713bf728db0cb11475b0 (diff) | |
Merge master into 3ca0-announcements-purgatory
Diffstat (limited to 'src/git')
| -rw-r--r-- | src/git/authorization.rs | 102 | ||||
| -rw-r--r-- | src/git/handlers.rs | 108 | ||||
| -rw-r--r-- | src/git/mod.rs | 2 | ||||
| -rw-r--r-- | src/git/sync.rs | 6 |
4 files changed, 203 insertions, 15 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 69a0751..df780bb 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs | |||
| @@ -609,6 +609,28 @@ pub async fn get_state_authorization_for_specific_owner_repo( | |||
| 609 | owner_pubkey | 609 | owner_pubkey |
| 610 | ); | 610 | ); |
| 611 | 611 | ||
| 612 | // Accept pushes where all refs are already at the desired state (old_oid == new_oid) | ||
| 613 | // This handles race conditions where state events are applied between fetch and push | ||
| 614 | if !pushed_refs.is_empty() { | ||
| 615 | let all_refs_unchanged = pushed_refs | ||
| 616 | .iter() | ||
| 617 | .all(|(old_oid, new_oid, _)| old_oid == new_oid); | ||
| 618 | |||
| 619 | if all_refs_unchanged { | ||
| 620 | debug!( | ||
| 621 | "All pushed refs unchanged (old_oid == new_oid) for {} owned by {}, accepting without purgatory check", | ||
| 622 | identifier, owner_pubkey | ||
| 623 | ); | ||
| 624 | return Ok(AuthorizationResult { | ||
| 625 | authorized: true, | ||
| 626 | reason: "Push accepted: all refs already at desired state (no-op)".to_string(), | ||
| 627 | state: None, | ||
| 628 | maintainers: authorized.into_iter().collect(), | ||
| 629 | purgatory_events: vec![], | ||
| 630 | }); | ||
| 631 | } | ||
| 632 | } | ||
| 633 | |||
| 612 | // Check purgatory for matching state events | 634 | // Check purgatory for matching state events |
| 613 | // Convert pushed refs to RefUpdate (filter out refs/nostr/* refs) | 635 | // Convert pushed refs to RefUpdate (filter out refs/nostr/* refs) |
| 614 | let pushed_updates: Vec<RefUpdate> = pushed_refs | 636 | let pushed_updates: Vec<RefUpdate> = pushed_refs |
| @@ -699,12 +721,88 @@ pub async fn get_state_authorization_for_specific_owner_repo( | |||
| 699 | debug!("Purgatory events found but none from authorized authors"); | 721 | debug!("Purgatory events found but none from authorized authors"); |
| 700 | } | 722 | } |
| 701 | } else { | 723 | } else { |
| 702 | debug!("No matching state events found in purgatory"); | 724 | // Check if there are ANY state events in purgatory for this identifier |
| 725 | let all_purgatory_states = purgatory.find_state(identifier); | ||
| 726 | |||
| 727 | if !all_purgatory_states.is_empty() { | ||
| 728 | // There are state events but none match the push - diagnose why | ||
| 729 | debug!( | ||
| 730 | "Found {} state event(s) in purgatory for {} but none match the push", | ||
| 731 | all_purgatory_states.len(), | ||
| 732 | identifier | ||
| 733 | ); | ||
| 734 | |||
| 735 | // Count authorized state events and collect diagnostic info | ||
| 736 | let mut authorized_count = 0; | ||
| 737 | let mut diagnostic_reasons = Vec::new(); | ||
| 738 | |||
| 739 | // Diagnose why each authorized state event doesn't match | ||
| 740 | for entry in all_purgatory_states.iter() { | ||
| 741 | let author_hex = entry.event.pubkey.to_hex(); | ||
| 742 | if authorized.contains(&author_hex) { | ||
| 743 | authorized_count += 1; | ||
| 744 | if let Some(reason) = crate::purgatory::diagnose_state_mismatch( | ||
| 745 | &entry.event, | ||
| 746 | &pushed_updates, | ||
| 747 | &local_refs, | ||
| 748 | ) { | ||
| 749 | debug!( | ||
| 750 | "State event {} from authorized author {} doesn't match push: {}", | ||
| 751 | entry.event.id, | ||
| 752 | entry | ||
| 753 | .event | ||
| 754 | .pubkey | ||
| 755 | .to_bech32() | ||
| 756 | .unwrap_or_else(|_| author_hex.clone()), | ||
| 757 | reason | ||
| 758 | ); | ||
| 759 | diagnostic_reasons.push(reason); | ||
| 760 | } | ||
| 761 | } | ||
| 762 | } | ||
| 763 | |||
| 764 | // Create concise WARN message summarizing the rejection | ||
| 765 | let summary = if authorized_count > 0 { | ||
| 766 | let reason_summary = if !diagnostic_reasons.is_empty() { | ||
| 767 | // Take the first diagnostic reason as representative | ||
| 768 | format!(" ({})", diagnostic_reasons[0]) | ||
| 769 | } else { | ||
| 770 | String::new() | ||
| 771 | }; | ||
| 772 | format!( | ||
| 773 | "{} state event{} in purgatory from authorized publisher{} but doesn't match push{}", | ||
| 774 | authorized_count, | ||
| 775 | if authorized_count == 1 { "" } else { "s" }, | ||
| 776 | if authorized_count == 1 { "" } else { "s" }, | ||
| 777 | reason_summary | ||
| 778 | ) | ||
| 779 | } else { | ||
| 780 | format!( | ||
| 781 | "{} state event{} in purgatory but none from authorized publishers", | ||
| 782 | all_purgatory_states.len(), | ||
| 783 | if all_purgatory_states.len() == 1 { | ||
| 784 | "" | ||
| 785 | } else { | ||
| 786 | "s" | ||
| 787 | } | ||
| 788 | ) | ||
| 789 | }; | ||
| 790 | |||
| 791 | warn!("Push rejected for {}: {}", identifier, summary); | ||
| 792 | return Ok(AuthorizationResult::denied(summary)); | ||
| 793 | } else { | ||
| 794 | debug!("No state events found in purgatory for {}", identifier); | ||
| 795 | warn!( | ||
| 796 | "Push rejected for {}: No state events in purgatory", | ||
| 797 | identifier | ||
| 798 | ); | ||
| 799 | return Ok(AuthorizationResult::denied("No state events in purgatory")); | ||
| 800 | } | ||
| 703 | } | 801 | } |
| 704 | 802 | ||
| 705 | // No matching state found in purgatory | 803 | // No matching state found in purgatory |
| 706 | Ok(AuthorizationResult::denied( | 804 | Ok(AuthorizationResult::denied( |
| 707 | "No state event found in purgatory from authorized publishers", | 805 | "No matching state event found in purgatory from authorized publishers", |
| 708 | )) | 806 | )) |
| 709 | } | 807 | } |
| 710 | 808 | ||
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 13d6ba0..f43cbb6 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -100,6 +100,42 @@ pub async fn handle_info_refs( | |||
| 100 | .unwrap()) | 100 | .unwrap()) |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | /// Build an HTTP 200 OK response with an ERR pkt-line for git protocol errors. | ||
| 104 | /// | ||
| 105 | /// Per the git smart HTTP protocol spec, protocol-level errors (like "not our ref") | ||
| 106 | /// should be returned as HTTP 200 OK with the error message in pkt-line format: | ||
| 107 | /// `PKT-LINE("ERR" SP explanation-text)` | ||
| 108 | /// | ||
| 109 | /// This allows git clients to properly parse and display the error message. | ||
| 110 | fn build_git_protocol_error_response( | ||
| 111 | service: GitService, | ||
| 112 | error_message: &str, | ||
| 113 | ) -> Response<Full<Bytes>> { | ||
| 114 | // Format: "ERR <message>\n" | ||
| 115 | let err_content = format!("ERR {}\n", error_message.trim()); | ||
| 116 | let err_pktline = PktLine::data(err_content.as_bytes()).encode(); | ||
| 117 | |||
| 118 | Response::builder() | ||
| 119 | .status(StatusCode::OK) | ||
| 120 | .header("content-type", service.result_content_type()) | ||
| 121 | .header("cache-control", "no-cache") | ||
| 122 | .body(Full::new(Bytes::from(err_pktline))) | ||
| 123 | .unwrap() | ||
| 124 | } | ||
| 125 | |||
| 126 | /// Check if a git process failure is a protocol error (vs transport error). | ||
| 127 | /// | ||
| 128 | /// Protocol errors are communicated via stderr when git exits with code 128. | ||
| 129 | /// These should be returned to the client as HTTP 200 with ERR pkt-line. | ||
| 130 | /// | ||
| 131 | /// Transport errors (process spawn failures, I/O errors, signals) should | ||
| 132 | /// remain as HTTP 500 errors. | ||
| 133 | fn is_git_protocol_error(exit_code: Option<i32>, stderr: &[u8]) -> bool { | ||
| 134 | // Git uses exit code 128 for protocol/usage errors | ||
| 135 | // If there's stderr content, it's a protocol error message | ||
| 136 | exit_code == Some(128) && !stderr.is_empty() | ||
| 137 | } | ||
| 138 | |||
| 103 | /// Handle POST /git-upload-pack (clone/fetch) | 139 | /// Handle POST /git-upload-pack (clone/fetch) |
| 104 | pub async fn handle_upload_pack( | 140 | pub async fn handle_upload_pack( |
| 105 | repo_path: PathBuf, | 141 | repo_path: PathBuf, |
| @@ -121,7 +157,10 @@ pub async fn handle_upload_pack( | |||
| 121 | stdin | 157 | stdin |
| 122 | .write_all(&request_body) | 158 | .write_all(&request_body) |
| 123 | .await | 159 | .await |
| 124 | .map_err(GitError::IoError)?; | 160 | .map_err(|e| { |
| 161 | error!("Failed to write to git upload-pack stdin: {}", e); | ||
| 162 | GitError::IoError(e) | ||
| 163 | })?; | ||
| 125 | // Close stdin to signal end of input | 164 | // Close stdin to signal end of input |
| 126 | drop(stdin); | 165 | drop(stdin); |
| 127 | } | 166 | } |
| @@ -135,7 +174,10 @@ pub async fn handle_upload_pack( | |||
| 135 | stdout | 174 | stdout |
| 136 | .read_to_end(&mut output) | 175 | .read_to_end(&mut output) |
| 137 | .await | 176 | .await |
| 138 | .map_err(GitError::IoError)?; | 177 | .map_err(|e| { |
| 178 | error!("Failed to read git upload-pack stdout: {}", e); | ||
| 179 | GitError::IoError(e) | ||
| 180 | })?; | ||
| 139 | } | 181 | } |
| 140 | 182 | ||
| 141 | if let Some(stderr) = git.take_stderr() { | 183 | if let Some(stderr) = git.take_stderr() { |
| @@ -143,14 +185,35 @@ pub async fn handle_upload_pack( | |||
| 143 | stderr | 185 | stderr |
| 144 | .read_to_end(&mut stderr_output) | 186 | .read_to_end(&mut stderr_output) |
| 145 | .await | 187 | .await |
| 146 | .map_err(GitError::IoError)?; | 188 | .map_err(|e| { |
| 189 | error!("Failed to read git upload-pack stderr: {}", e); | ||
| 190 | GitError::IoError(e) | ||
| 191 | })?; | ||
| 147 | } | 192 | } |
| 148 | 193 | ||
| 149 | // Wait for process | 194 | // Wait for process |
| 150 | let status = git.wait().await.map_err(GitError::IoError)?; | 195 | let status = git.wait().await.map_err(|e| { |
| 196 | error!("Failed to wait for git upload-pack process: {}", e); | ||
| 197 | GitError::IoError(e) | ||
| 198 | })?; | ||
| 151 | 199 | ||
| 152 | if !status.success() { | 200 | if !status.success() { |
| 153 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 201 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| 202 | |||
| 203 | // Check if this is a git protocol error (exit code 128 with stderr) | ||
| 204 | // Protocol errors should be returned as HTTP 200 with ERR pkt-line | ||
| 205 | if is_git_protocol_error(status.code(), &stderr_output) { | ||
| 206 | warn!( | ||
| 207 | "Git upload-pack protocol error (returning ERR pkt-line): {}", | ||
| 208 | stderr_str | ||
| 209 | ); | ||
| 210 | return Ok(build_git_protocol_error_response( | ||
| 211 | GitService::UploadPack, | ||
| 212 | &stderr_str, | ||
| 213 | )); | ||
| 214 | } | ||
| 215 | |||
| 216 | // Transport errors (spawn failures, signals, etc.) remain as HTTP 500 | ||
| 154 | error!("Git upload-pack failed: {}", stderr_str); | 217 | error!("Git upload-pack failed: {}", stderr_str); |
| 155 | return Err(GitError::GitFailed(status.code())); | 218 | return Err(GitError::GitFailed(status.code())); |
| 156 | } | 219 | } |
| @@ -206,7 +269,7 @@ pub async fn handle_receive_pack( | |||
| 206 | } | 269 | } |
| 207 | 270 | ||
| 208 | // GRASP Authorization Check | 271 | // GRASP Authorization Check |
| 209 | info!( | 272 | debug!( |
| 210 | "Authorizing push for {} owned by {} via database query", | 273 | "Authorizing push for {} owned by {} via database query", |
| 211 | identifier, owner_pubkey | 274 | identifier, owner_pubkey |
| 212 | ); | 275 | ); |
| @@ -251,7 +314,10 @@ pub async fn handle_receive_pack( | |||
| 251 | stdin | 314 | stdin |
| 252 | .write_all(&request_body) | 315 | .write_all(&request_body) |
| 253 | .await | 316 | .await |
| 254 | .map_err(GitError::IoError)?; | 317 | .map_err(|e| { |
| 318 | error!("Failed to write to git receive-pack stdin: {}", e); | ||
| 319 | GitError::IoError(e) | ||
| 320 | })?; | ||
| 255 | drop(stdin); | 321 | drop(stdin); |
| 256 | } | 322 | } |
| 257 | 323 | ||
| @@ -264,7 +330,10 @@ pub async fn handle_receive_pack( | |||
| 264 | stdout | 330 | stdout |
| 265 | .read_to_end(&mut output) | 331 | .read_to_end(&mut output) |
| 266 | .await | 332 | .await |
| 267 | .map_err(GitError::IoError)?; | 333 | .map_err(|e| { |
| 334 | error!("Failed to read git receive-pack stdout: {}", e); | ||
| 335 | GitError::IoError(e) | ||
| 336 | })?; | ||
| 268 | } | 337 | } |
| 269 | 338 | ||
| 270 | if let Some(stderr) = git.take_stderr() { | 339 | if let Some(stderr) = git.take_stderr() { |
| @@ -272,14 +341,35 @@ pub async fn handle_receive_pack( | |||
| 272 | stderr | 341 | stderr |
| 273 | .read_to_end(&mut stderr_output) | 342 | .read_to_end(&mut stderr_output) |
| 274 | .await | 343 | .await |
| 275 | .map_err(GitError::IoError)?; | 344 | .map_err(|e| { |
| 345 | error!("Failed to read git receive-pack stderr: {}", e); | ||
| 346 | GitError::IoError(e) | ||
| 347 | })?; | ||
| 276 | } | 348 | } |
| 277 | 349 | ||
| 278 | // Wait for process | 350 | // Wait for process |
| 279 | let status = git.wait().await.map_err(GitError::IoError)?; | 351 | let status = git.wait().await.map_err(|e| { |
| 352 | error!("Failed to wait for git receive-pack process: {}", e); | ||
| 353 | GitError::IoError(e) | ||
| 354 | })?; | ||
| 280 | 355 | ||
| 281 | if !status.success() { | 356 | if !status.success() { |
| 282 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 357 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| 358 | |||
| 359 | // Check if this is a git protocol error (exit code 128 with stderr) | ||
| 360 | // Protocol errors should be returned as HTTP 200 with ERR pkt-line | ||
| 361 | if is_git_protocol_error(status.code(), &stderr_output) { | ||
| 362 | warn!( | ||
| 363 | "Git receive-pack protocol error (returning ERR pkt-line): {}", | ||
| 364 | stderr_str | ||
| 365 | ); | ||
| 366 | return Ok(build_git_protocol_error_response( | ||
| 367 | GitService::ReceivePack, | ||
| 368 | &stderr_str, | ||
| 369 | )); | ||
| 370 | } | ||
| 371 | |||
| 372 | // Transport errors (spawn failures, signals, etc.) remain as HTTP 500 | ||
| 283 | error!("Git receive-pack failed: {}", stderr_str); | 373 | error!("Git receive-pack failed: {}", stderr_str); |
| 284 | return Err(GitError::GitFailed(status.code())); | 374 | return Err(GitError::GitFailed(status.code())); |
| 285 | } | 375 | } |
diff --git a/src/git/mod.rs b/src/git/mod.rs index b3fee69..1255b6f 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs | |||
| @@ -253,7 +253,7 @@ pub fn update_ref(repo_path: &Path, ref_name: &str, commit_hash: &str) -> Result | |||
| 253 | return Err(format!("git update-ref failed: {}", stderr)); | 253 | return Err(format!("git update-ref failed: {}", stderr)); |
| 254 | } | 254 | } |
| 255 | 255 | ||
| 256 | info!( | 256 | debug!( |
| 257 | "Updated ref {} to {} in {}", | 257 | "Updated ref {} to {} in {}", |
| 258 | ref_name, | 258 | ref_name, |
| 259 | commit_hash, | 259 | commit_hash, |
diff --git a/src/git/sync.rs b/src/git/sync.rs index 8401736..c24d16b 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs | |||
| @@ -673,7 +673,7 @@ pub fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) -> | |||
| 673 | match git::update_ref(repo_path, ref_name, expected_commit) { | 673 | match git::update_ref(repo_path, ref_name, expected_commit) { |
| 674 | Ok(()) => { | 674 | Ok(()) => { |
| 675 | if current_commit.is_some() { | 675 | if current_commit.is_some() { |
| 676 | info!( | 676 | debug!( |
| 677 | "Updated {} to {} in {}", | 677 | "Updated {} to {} in {}", |
| 678 | ref_name, | 678 | ref_name, |
| 679 | expected_commit, | 679 | expected_commit, |
| @@ -681,7 +681,7 @@ pub fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) -> | |||
| 681 | ); | 681 | ); |
| 682 | result.refs_updated += 1; | 682 | result.refs_updated += 1; |
| 683 | } else { | 683 | } else { |
| 684 | info!( | 684 | debug!( |
| 685 | "Created {} at {} in {}", | 685 | "Created {} at {} in {}", |
| 686 | ref_name, | 686 | ref_name, |
| 687 | expected_commit, | 687 | expected_commit, |
| @@ -707,7 +707,7 @@ pub fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) -> | |||
| 707 | if let Some(head_commit) = state.get_branch_commit(branch_name) { | 707 | if let Some(head_commit) = state.get_branch_commit(branch_name) { |
| 708 | match git::try_set_head_if_available(repo_path, head_ref, head_commit) { | 708 | match git::try_set_head_if_available(repo_path, head_ref, head_commit) { |
| 709 | Ok(true) => { | 709 | Ok(true) => { |
| 710 | info!("Set HEAD to {} in {}", head_ref, repo_path.display()); | 710 | debug!("Set HEAD to {} in {}", head_ref, repo_path.display()); |
| 711 | result.head_set = true; | 711 | result.head_set = true; |
| 712 | } | 712 | } |
| 713 | Ok(false) => { | 713 | Ok(false) => { |