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