upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/git
diff options
context:
space:
mode:
Diffstat (limited to 'src/git')
-rw-r--r--src/git/authorization.rs102
-rw-r--r--src/git/handlers.rs108
-rw-r--r--src/git/mod.rs2
-rw-r--r--src/git/sync.rs6
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.
110fn 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.
133fn 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)
104pub async fn handle_upload_pack( 140pub 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) => {