1use super::{
34 WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
35 WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitDiffRequest,
36 WorkspaceGitRemote, WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest,
37 WorkspaceGitStatus,
38};
39use anyhow::{anyhow, Result};
40use async_trait::async_trait;
41use reqwest::{Client, StatusCode};
42use serde::{Deserialize, Serialize};
43use std::path::PathBuf;
44use std::sync::Arc;
45use std::time::Duration;
46
47pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
49
50pub const DEFAULT_MAX_DIFF_BYTES: u64 = 1024 * 1024;
54
55pub const DEFAULT_MAX_LOG_ENTRIES: usize = 200;
58const MAX_REMOTE_JSON_BYTES: u64 = 4 * 1024 * 1024;
59
60#[derive(Debug, Clone)]
65pub struct RemoteGitBackendConfig {
66 pub base_url: String,
67 pub repo_id: String,
68 pub bearer_token: Option<String>,
69 pub client_cert_pem: Option<PathBuf>,
75 pub client_key_pem: Option<PathBuf>,
78 pub request_timeout: Option<Duration>,
79 pub max_diff_bytes: Option<u64>,
80 pub max_log_entries: Option<usize>,
81}
82
83impl RemoteGitBackendConfig {
84 pub fn new(base_url: impl Into<String>, repo_id: impl Into<String>) -> Self {
85 Self {
86 base_url: base_url.into(),
87 repo_id: repo_id.into(),
88 bearer_token: None,
89 client_cert_pem: None,
90 client_key_pem: None,
91 request_timeout: None,
92 max_diff_bytes: None,
93 max_log_entries: None,
94 }
95 }
96
97 pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
98 self.bearer_token = Some(token.into());
99 self
100 }
101
102 pub fn request_timeout(mut self, timeout: Duration) -> Self {
103 self.request_timeout = Some(timeout);
104 self
105 }
106
107 pub fn max_diff_bytes(mut self, bytes: u64) -> Self {
108 self.max_diff_bytes = Some(bytes);
109 self
110 }
111
112 pub fn max_log_entries(mut self, n: usize) -> Self {
113 self.max_log_entries = Some(n);
114 self
115 }
116
117 pub fn client_cert_pem(mut self, path: impl Into<PathBuf>) -> Self {
118 self.client_cert_pem = Some(path.into());
119 self
120 }
121
122 pub fn client_key_pem(mut self, path: impl Into<PathBuf>) -> Self {
123 self.client_key_pem = Some(path.into());
124 self
125 }
126}
127
128#[derive(Debug, Clone, thiserror::Error)]
132#[error("remote git conflict: {code}: {message}")]
133pub struct RemoteGitConflict {
134 pub code: String,
135 pub message: String,
136}
137
138#[derive(Debug, Clone)]
140pub struct RemoteGitBackend {
141 http: Client,
142 base_url: String,
143 repo_id: String,
144 bearer_token: Option<String>,
145 max_diff_bytes: u64,
146 max_log_entries: usize,
147}
148
149impl RemoteGitBackend {
150 pub fn new(config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
152 if config
153 .bearer_token
154 .as_deref()
155 .map(str::is_empty)
156 .unwrap_or(true)
157 && config.client_cert_pem.is_none()
158 {
159 tracing::warn!(
160 "RemoteGitBackend constructed without bearer token or mTLS; \
161 this is only safe on a trusted localhost gitserver"
162 );
163 }
164
165 let mut builder = Client::builder()
166 .no_proxy()
167 .timeout(config.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT));
168
169 match (
171 config.client_cert_pem.as_deref(),
172 config.client_key_pem.as_deref(),
173 ) {
174 (Some(cert_path), Some(key_path)) => {
175 let identity = load_mtls_identity(cert_path, key_path)?;
176 builder = builder.identity(identity);
177 }
178 (Some(_), None) => {
179 return Err(anyhow!(
180 "client_cert_pem was set without client_key_pem; both must be provided for mTLS"
181 ));
182 }
183 (None, Some(_)) => {
184 return Err(anyhow!(
185 "client_key_pem was set without client_cert_pem; both must be provided for mTLS"
186 ));
187 }
188 (None, None) => {}
189 }
190
191 let http = builder
192 .build()
193 .map_err(|e| anyhow!("failed to build reqwest client: {}", e))?;
194
195 let base_url = config.base_url.trim_end_matches('/').to_string();
196 Ok(Arc::new(Self {
197 http,
198 base_url,
199 repo_id: config.repo_id,
200 bearer_token: config.bearer_token,
201 max_diff_bytes: config.max_diff_bytes.unwrap_or(DEFAULT_MAX_DIFF_BYTES),
202 max_log_entries: config.max_log_entries.unwrap_or(DEFAULT_MAX_LOG_ENTRIES),
203 }))
204 }
205
206 pub fn base_url(&self) -> &str {
208 &self.base_url
209 }
210
211 pub fn repo_id(&self) -> &str {
213 &self.repo_id
214 }
215
216 pub fn max_diff_bytes(&self) -> u64 {
217 self.max_diff_bytes
218 }
219
220 pub fn max_log_entries(&self) -> usize {
221 self.max_log_entries
222 }
223
224 fn endpoint(&self, op: &str) -> String {
225 format!("{}/v1/repos/{}/git/{}", self.base_url, self.repo_id, op)
226 }
227
228 async fn post_json<Req, Resp>(&self, op: &'static str, body: &Req) -> Result<Resp>
229 where
230 Req: Serialize + ?Sized,
231 Resp: for<'de> Deserialize<'de>,
232 {
233 let url = self.endpoint(op);
234 let mut req = self.http.post(&url).json(body);
235 if let Some(token) = self.bearer_token.as_deref() {
236 if !token.is_empty() {
237 req = req.bearer_auth(token);
238 }
239 }
240
241 let start = std::time::Instant::now();
242 let send_result = req.send().await;
243 let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
244 let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
245 emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
246
247 let resp =
248 send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
249
250 let status = resp.status();
251 let body = read_response_bytes_bounded(resp, MAX_REMOTE_JSON_BYTES).await?;
252 if status.is_success() {
253 let parsed = serde_json::from_slice::<Resp>(&body)
254 .map_err(|e| anyhow!("remote git '{}' response body decode error: {}", op, e))?;
255 return Ok(parsed);
256 }
257
258 let body_text = String::from_utf8_lossy(&body).into_owned();
259 Err(map_error_response(op, status, &body_text))
260 }
261
262 async fn post_unit<Req>(&self, op: &'static str, body: &Req) -> Result<()>
263 where
264 Req: Serialize + ?Sized,
265 {
266 let url = self.endpoint(op);
267 let mut req = self.http.post(&url).json(body);
268 if let Some(token) = self.bearer_token.as_deref() {
269 if !token.is_empty() {
270 req = req.bearer_auth(token);
271 }
272 }
273
274 let start = std::time::Instant::now();
275 let send_result = req.send().await;
276 let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
277 let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
278 emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
279
280 let resp =
281 send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
282
283 let status = resp.status();
284 if status.is_success() {
285 return Ok(());
286 }
287 let body = read_response_bytes_bounded(resp, MAX_REMOTE_JSON_BYTES).await?;
288 let body_text = String::from_utf8_lossy(&body).into_owned();
289 Err(map_error_response(op, status, &body_text))
290 }
291
292 async fn post_streamed<Req>(
306 &self,
307 op: &'static str,
308 body: &Req,
309 max_bytes: u64,
310 ) -> Result<Vec<u8>>
311 where
312 Req: Serialize + ?Sized,
313 {
314 use futures::StreamExt;
315
316 let url = self.endpoint(op);
317 let mut req = self.http.post(&url).json(body);
318 if let Some(token) = self.bearer_token.as_deref() {
319 if !token.is_empty() {
320 req = req.bearer_auth(token);
321 }
322 }
323
324 let start = std::time::Instant::now();
325 let send_result = req.send().await;
326 let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
327 let resp = match send_result {
328 Ok(r) => r,
329 Err(e) => {
330 emit_remote_git_event(op, &self.repo_id, status_code, false, start.elapsed(), None);
331 return Err(anyhow!("remote git call '{}' transport error: {}", op, e));
332 }
333 };
334
335 if let Some(len) = resp.content_length() {
337 if len > max_bytes {
338 emit_remote_git_event(
339 op,
340 &self.repo_id,
341 status_code,
342 false,
343 start.elapsed(),
344 Some(len),
345 );
346 return Err(anyhow!(
347 "remote git '{}' Content-Length {} exceeds client cap {} bytes; \
348 refusing to download. Raise max_diff_bytes if the body is legitimate.",
349 op,
350 len,
351 max_bytes
352 ));
353 }
354 }
355
356 let status = resp.status();
358 let mut stream = resp.bytes_stream();
359 let mut buf: Vec<u8> = Vec::new();
360 while let Some(chunk) = stream.next().await {
361 let chunk = chunk.map_err(|e| anyhow!("remote git '{}' stream error: {}", op, e))?;
362 if (buf.len() as u64).saturating_add(chunk.len() as u64) > max_bytes {
363 emit_remote_git_event(
364 op,
365 &self.repo_id,
366 status_code,
367 false,
368 start.elapsed(),
369 Some(buf.len() as u64),
370 );
371 return Err(anyhow!(
372 "remote git '{}' response body exceeded client cap {} bytes mid-stream; \
373 aborting",
374 op,
375 max_bytes
376 ));
377 }
378 buf.extend_from_slice(&chunk);
379 }
380
381 emit_remote_git_event(
382 op,
383 &self.repo_id,
384 status_code,
385 status.is_success(),
386 start.elapsed(),
387 Some(buf.len() as u64),
388 );
389
390 if !status.is_success() {
391 let body_text = String::from_utf8_lossy(&buf).into_owned();
392 return Err(map_error_response(op, status, &body_text));
393 }
394 Ok(buf)
395 }
396}
397
398async fn read_response_bytes_bounded(
401 response: reqwest::Response,
402 max_bytes: u64,
403) -> Result<Vec<u8>> {
404 use futures::StreamExt;
405
406 if response
407 .content_length()
408 .is_some_and(|length| length > max_bytes)
409 {
410 return Err(anyhow!(
411 "remote git response Content-Length exceeds client cap {} bytes",
412 max_bytes
413 ));
414 }
415 let mut stream = response.bytes_stream();
416 let mut body = Vec::new();
417 while let Some(chunk) = stream.next().await {
418 let chunk = chunk.map_err(|error| anyhow!("remote git response stream error: {error}"))?;
419 if body.len() as u64 + chunk.len() as u64 > max_bytes {
420 return Err(anyhow!(
421 "remote git response body exceeded client cap {} bytes",
422 max_bytes
423 ));
424 }
425 body.extend_from_slice(&chunk);
426 }
427 Ok(body)
428}
429
430#[derive(Serialize)]
431struct EmptyReq;
432
433#[derive(Deserialize)]
434struct StatusResp {
435 branch: String,
436 commit: String,
437 #[serde(default)]
438 is_worktree: bool,
439 #[serde(default)]
440 is_dirty: bool,
441 #[serde(default)]
442 dirty_count: usize,
443}
444
445#[derive(Serialize)]
446struct LogReq {
447 max_count: usize,
448}
449
450#[derive(Deserialize)]
451struct LogResp {
452 commits: Vec<CommitDto>,
453}
454
455#[derive(Deserialize)]
456struct CommitDto {
457 id: String,
458 message: String,
459 author: String,
460 date: String,
461}
462
463#[derive(Deserialize)]
464struct BranchesResp {
465 branches: Vec<BranchDto>,
466}
467
468#[derive(Deserialize)]
469struct BranchDto {
470 name: String,
471 #[serde(default)]
472 is_current: bool,
473}
474
475#[derive(Serialize)]
476struct CreateBranchReq<'a> {
477 name: &'a str,
478 base: &'a str,
479}
480
481#[derive(Serialize)]
482struct CheckoutReq<'a> {
483 refspec: &'a str,
484 force: bool,
485}
486
487#[derive(Deserialize)]
488struct CheckoutResp {
489 #[serde(default)]
490 stdout: String,
491}
492
493#[derive(Serialize)]
494struct DiffReq<'a> {
495 target: Option<&'a str>,
496}
497
498#[derive(Deserialize)]
499struct DiffResp {
500 diff: String,
501 #[serde(default)]
502 truncated: bool,
503}
504
505#[derive(Deserialize)]
506struct RemotesResp {
507 remotes: Vec<RemoteDto>,
508}
509
510#[derive(Deserialize)]
511struct RemoteDto {
512 name: String,
513 url: String,
514 #[serde(default = "default_direction")]
515 direction: String,
516}
517
518fn default_direction() -> String {
519 "fetch".to_string()
520}
521
522#[derive(Deserialize)]
523struct ExistsResp {
524 #[serde(default)]
525 is_repository: bool,
526}
527
528#[derive(Deserialize)]
529struct StashesResp {
530 stashes: Vec<StashDto>,
531}
532
533#[derive(Deserialize)]
534struct StashDto {
535 index: usize,
536 #[serde(default)]
537 message: String,
538}
539
540#[derive(Serialize)]
541struct StashCreateReq {
542 #[serde(skip_serializing_if = "Option::is_none")]
543 message: Option<String>,
544 include_untracked: bool,
545}
546
547#[async_trait]
548impl WorkspaceGit for RemoteGitBackend {
549 async fn is_repository(&self) -> Result<bool> {
550 let resp: ExistsResp = self.post_json("exists", &EmptyReq).await?;
551 Ok(resp.is_repository)
552 }
553
554 async fn status(&self) -> Result<WorkspaceGitStatus> {
555 let resp: StatusResp = self.post_json("status", &EmptyReq).await?;
556 Ok(WorkspaceGitStatus {
557 branch: resp.branch,
558 commit: resp.commit,
559 is_worktree: resp.is_worktree,
560 is_dirty: resp.is_dirty,
561 dirty_count: resp.dirty_count,
562 })
563 }
564
565 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
566 let capped = max_count.min(self.max_log_entries);
567 let resp: LogResp = self.post_json("log", &LogReq { max_count: capped }).await?;
568 Ok(resp
569 .commits
570 .into_iter()
571 .map(|c| WorkspaceGitCommit {
572 id: c.id,
573 message: c.message,
574 author: c.author,
575 date: c.date,
576 })
577 .collect())
578 }
579
580 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
581 let resp: BranchesResp = self.post_json("branches", &EmptyReq).await?;
582 Ok(resp
583 .branches
584 .into_iter()
585 .map(|b| WorkspaceGitBranch {
586 name: b.name,
587 is_current: b.is_current,
588 })
589 .collect())
590 }
591
592 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
593 self.post_unit(
594 "branches/create",
595 &CreateBranchReq {
596 name: &request.name,
597 base: &request.base,
598 },
599 )
600 .await
601 }
602
603 async fn checkout(
604 &self,
605 request: WorkspaceGitCheckoutRequest,
606 ) -> Result<WorkspaceGitCheckoutOutput> {
607 let resp: CheckoutResp = self
608 .post_json(
609 "checkout",
610 &CheckoutReq {
611 refspec: &request.refspec,
612 force: request.force,
613 },
614 )
615 .await?;
616 Ok(WorkspaceGitCheckoutOutput {
617 stdout: resp.stdout,
618 })
619 }
620
621 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
622 const DIFF_HARD_CAP_FLOOR: u64 = 64 * 1024;
634 let hard_cap = self
635 .max_diff_bytes
636 .saturating_mul(4)
637 .max(DIFF_HARD_CAP_FLOOR);
638
639 let bytes = self
640 .post_streamed(
641 "diff",
642 &DiffReq {
643 target: request.target.as_deref(),
644 },
645 hard_cap,
646 )
647 .await?;
648 let resp: DiffResp = serde_json::from_slice(&bytes)
649 .map_err(|e| anyhow!("remote git 'diff' response body decode error: {}", e))?;
650
651 if (resp.diff.len() as u64) > self.max_diff_bytes {
652 tracing::debug!(
653 "remote git diff body {} bytes exceeds max_diff_bytes {} — \
654 client-side display truncation",
655 resp.diff.len(),
656 self.max_diff_bytes
657 );
658 let cap = self.max_diff_bytes as usize;
659 let mut trimmed = resp.diff;
660 trimmed.truncate(safe_utf8_truncate(&trimmed, cap));
661 trimmed.push_str("\n... [truncated by client max_diff_bytes]\n");
662 return Ok(trimmed);
663 }
664 if resp.truncated {
665 return Ok(format!("{}\n... [truncated by gitserver]\n", resp.diff));
666 }
667 Ok(resp.diff)
668 }
669
670 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
671 let resp: RemotesResp = self.post_json("remotes", &EmptyReq).await?;
672 Ok(resp
673 .remotes
674 .into_iter()
675 .map(|r| WorkspaceGitRemote {
676 name: r.name,
677 url: r.url,
678 direction: r.direction,
679 })
680 .collect())
681 }
682}
683
684#[async_trait]
685impl WorkspaceGitStashProvider for RemoteGitBackend {
686 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
687 let resp: StashesResp = self.post_json("stashes", &EmptyReq).await?;
688 Ok(resp
689 .stashes
690 .into_iter()
691 .map(|s| WorkspaceGitStash {
692 index: s.index,
693 message: s.message,
694 })
695 .collect())
696 }
697
698 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
699 self.post_unit(
700 "stashes/create",
701 &StashCreateReq {
702 message: request.message,
703 include_untracked: request.include_untracked,
704 },
705 )
706 .await
707 }
708}
709
710fn load_mtls_identity(
720 cert_path: &std::path::Path,
721 key_path: &std::path::Path,
722) -> Result<reqwest::Identity> {
723 let cert =
724 crate::bounded_io::read_file_bounded(cert_path, crate::bounded_io::MAX_CONFIG_FILE_BYTES)
725 .map_err(|e| {
726 anyhow!(
727 "failed to read mTLS client_cert_pem at {}: {}",
728 cert_path.display(),
729 e
730 )
731 })?;
732 let key =
733 crate::bounded_io::read_file_bounded(key_path, crate::bounded_io::MAX_CONFIG_FILE_BYTES)
734 .map_err(|e| {
735 anyhow!(
736 "failed to read mTLS client_key_pem at {}: {}",
737 key_path.display(),
738 e
739 )
740 })?;
741
742 let mut pem = Vec::with_capacity(cert.len() + key.len() + 1);
743 pem.extend_from_slice(&cert);
744 if !cert.ends_with(b"\n") {
745 pem.push(b'\n');
746 }
747 pem.extend_from_slice(&key);
748
749 reqwest::Identity::from_pem(&pem).map_err(|e| {
750 anyhow!(
751 "failed to parse mTLS PEM material (cert={}, key={}): {}",
752 cert_path.display(),
753 key_path.display(),
754 e
755 )
756 })
757}
758
759fn safe_utf8_truncate(s: &str, max_bytes: usize) -> usize {
762 if s.len() <= max_bytes {
763 return s.len();
764 }
765 let mut idx = max_bytes;
766 while idx > 0 && !s.is_char_boundary(idx) {
767 idx -= 1;
768 }
769 idx
770}
771
772fn map_error_response(op: &'static str, status: StatusCode, body: &str) -> anyhow::Error {
780 let parsed: Option<RemoteErrorBody> = serde_json::from_str(body).ok();
781
782 let (code, message) = match parsed {
783 Some(b) => (b.error.code, b.error.message),
784 None => (format!("HTTP_{}", status.as_u16()), body.to_string()),
785 };
786
787 let status_u16 = status.as_u16();
788 if status_u16 == 409 || status_u16 == 422 {
789 return anyhow::Error::new(RemoteGitConflict { code, message });
790 }
791
792 match status_u16 {
793 400 => anyhow!("remote git '{}' bad request: {}: {}", op, code, message),
794 401 | 403 => anyhow!("remote git '{}' auth failed: {}: {}", op, code, message),
795 404 => anyhow!("remote git '{}' not found: {}: {}", op, code, message),
796 500..=599 => anyhow!(
797 "remote git '{}' server error ({}): {}: {}",
798 op,
799 status_u16,
800 code,
801 message
802 ),
803 _ => anyhow!(
804 "remote git '{}' unexpected status {}: {}: {}",
805 op,
806 status_u16,
807 code,
808 message
809 ),
810 }
811}
812
813#[derive(Deserialize)]
814struct RemoteErrorBody {
815 error: RemoteErrorDetail,
816}
817
818#[derive(Deserialize)]
819struct RemoteErrorDetail {
820 code: String,
821 #[serde(default)]
822 message: String,
823}
824
825fn emit_remote_git_event(
839 op: &'static str,
840 repo_id: &str,
841 status: Option<u16>,
842 ok: bool,
843 elapsed: Duration,
844 bytes: Option<u64>,
845) {
846 tracing::debug!(
847 op = format!("git.{}", op),
848 repo_id = %repo_id,
849 status = status.unwrap_or(0),
850 outcome = if ok { "ok" } else { "error" },
851 bytes = bytes.unwrap_or(0),
852 duration_ms = elapsed.as_millis() as u64,
853 );
854}
855
856impl super::WorkspaceServices {
857 pub fn with_remote_git(self: Arc<Self>, config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
868 let backend = RemoteGitBackend::new(config)?;
869 let git: Arc<dyn WorkspaceGit> = backend.clone();
870 let stash: Arc<dyn WorkspaceGitStashProvider> = backend;
871 Ok(self.with_git_provider(git, Some(stash)))
872 }
873}
874
875#[cfg(test)]
876#[path = "remote_git/tests.rs"]
877mod tests;