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)]
840mod tests {
841 use super::*;
842 use serde_json::json;
843 use wiremock::matchers::{header, method, path};
844 use wiremock::{Mock, MockServer, ResponseTemplate};
845
846 async fn server_and_backend() -> (MockServer, Arc<RemoteGitBackend>) {
847 let server = MockServer::start().await;
848 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
849 .bearer_token("test-token")
850 .request_timeout(Duration::from_secs(5));
851 let backend = RemoteGitBackend::new(cfg).unwrap();
852 (server, backend)
853 }
854
855 #[test]
856 fn config_defaults_are_documented() {
857 let cfg = RemoteGitBackendConfig::new("http://localhost", "r");
858 assert!(cfg.bearer_token.is_none());
859 assert!(cfg.client_cert_pem.is_none());
860 assert!(cfg.request_timeout.is_none());
861 assert!(cfg.max_diff_bytes.is_none());
862 assert!(cfg.max_log_entries.is_none());
863 }
864
865 #[test]
866 fn endpoint_url_format_matches_rfc() {
867 let cfg = RemoteGitBackendConfig::new("http://localhost:8080/", "u1/s1");
868 let backend = RemoteGitBackend::new(cfg).unwrap();
869 assert_eq!(backend.base_url(), "http://localhost:8080");
871 assert_eq!(
872 backend.endpoint("status"),
873 "http://localhost:8080/v1/repos/u1/s1/git/status"
874 );
875 assert_eq!(
876 backend.endpoint("branches/create"),
877 "http://localhost:8080/v1/repos/u1/s1/git/branches/create"
878 );
879 }
880
881 #[test]
882 fn mtls_requires_both_cert_and_key() {
883 let cfg = RemoteGitBackendConfig::new("http://localhost", "r").client_cert_pem("/dev/null");
884 let err = RemoteGitBackend::new(cfg).unwrap_err();
885 assert!(
886 err.to_string().contains("client_key_pem"),
887 "missing-key error must name the missing field, got: {}",
888 err
889 );
890
891 let cfg = RemoteGitBackendConfig::new("http://localhost", "r").client_key_pem("/dev/null");
892 let err = RemoteGitBackend::new(cfg).unwrap_err();
893 assert!(
894 err.to_string().contains("client_cert_pem"),
895 "missing-cert error must name the missing field, got: {}",
896 err
897 );
898 }
899
900 #[test]
901 fn mtls_rejects_invalid_pem_blob() {
902 let tmp = tempfile::tempdir().unwrap();
903 let cert = tmp.path().join("cert.pem");
904 let key = tmp.path().join("key.pem");
905 std::fs::write(&cert, b"not a pem").unwrap();
906 std::fs::write(&key, b"also not a pem").unwrap();
907
908 let cfg = RemoteGitBackendConfig::new("http://localhost", "r")
909 .client_cert_pem(&cert)
910 .client_key_pem(&key);
911 let err = RemoteGitBackend::new(cfg).unwrap_err();
912 let msg = err.to_string();
913 assert!(
914 msg.contains("PEM"),
915 "PEM-parse failure must surface clearly, got: {}",
916 msg
917 );
918 assert!(
919 msg.contains(cert.to_str().unwrap()),
920 "error must include the cert path for debugging, got: {}",
921 msg
922 );
923 }
924
925 #[test]
926 fn mtls_accepts_self_signed_pair_from_rcgen() {
927 let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
930 .expect("rcgen self-signed cert");
931 let tmp = tempfile::tempdir().unwrap();
932 let cert_path = tmp.path().join("client.cert.pem");
933 let key_path = tmp.path().join("client.key.pem");
934 std::fs::write(&cert_path, cert.cert.pem()).unwrap();
935 std::fs::write(&key_path, cert.key_pair.serialize_pem()).unwrap();
936
937 let cfg = RemoteGitBackendConfig::new("http://localhost", "r")
938 .bearer_token("t")
939 .client_cert_pem(&cert_path)
940 .client_key_pem(&key_path);
941 let backend = RemoteGitBackend::new(cfg)
942 .expect("valid rcgen-generated PEM pair must produce a backend");
943 assert_eq!(backend.base_url(), "http://localhost");
947 }
948
949 #[test]
950 fn safe_utf8_truncate_respects_boundaries() {
951 assert_eq!(safe_utf8_truncate("hello", 3), 3);
953 assert_eq!(safe_utf8_truncate("hello", 100), 5);
954 let s = "héllo";
956 assert_eq!(safe_utf8_truncate(s, 2), 1);
958 assert_eq!(safe_utf8_truncate(s, 3), 3);
959 }
960
961 #[tokio::test]
962 async fn status_happy_path() {
963 let (server, backend) = server_and_backend().await;
964 Mock::given(method("POST"))
965 .and(path("/v1/repos/test/git/status"))
966 .and(header("authorization", "Bearer test-token"))
967 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
968 "branch": "main",
969 "commit": "abc123",
970 "is_worktree": false,
971 "is_dirty": true,
972 "dirty_count": 3,
973 })))
974 .mount(&server)
975 .await;
976
977 let status = backend.status().await.unwrap();
978 assert_eq!(status.branch, "main");
979 assert_eq!(status.commit, "abc123");
980 assert!(status.is_dirty);
981 assert_eq!(status.dirty_count, 3);
982 }
983
984 #[tokio::test]
985 async fn log_respects_client_max_log_entries() {
986 let server = MockServer::start().await;
987 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
988 .bearer_token("t")
989 .max_log_entries(5);
990 let backend = RemoteGitBackend::new(cfg).unwrap();
991
992 Mock::given(method("POST"))
993 .and(path("/v1/repos/test/git/log"))
994 .and(wiremock::matchers::body_json(json!({"max_count": 5})))
995 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
996 "commits": [
997 {"id":"a","message":"m","author":"x","date":"d"}
998 ]
999 })))
1000 .mount(&server)
1001 .await;
1002
1003 let commits = backend.log(100).await.unwrap();
1005 assert_eq!(commits.len(), 1);
1006 assert_eq!(commits[0].id, "a");
1007 }
1008
1009 #[tokio::test]
1010 async fn list_branches_maps_response() {
1011 let (server, backend) = server_and_backend().await;
1012 Mock::given(method("POST"))
1013 .and(path("/v1/repos/test/git/branches"))
1014 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1015 "branches": [
1016 {"name":"main", "is_current":true},
1017 {"name":"feat/x"}
1018 ]
1019 })))
1020 .mount(&server)
1021 .await;
1022
1023 let branches = backend.list_branches().await.unwrap();
1024 assert_eq!(branches.len(), 2);
1025 assert!(branches[0].is_current);
1026 assert!(!branches[1].is_current);
1027 }
1028
1029 #[tokio::test]
1030 async fn create_branch_succeeds_on_201() {
1031 let (server, backend) = server_and_backend().await;
1032 Mock::given(method("POST"))
1033 .and(path("/v1/repos/test/git/branches/create"))
1034 .and(wiremock::matchers::body_json(json!({
1035 "name":"feat/x","base":"main"
1036 })))
1037 .respond_with(ResponseTemplate::new(201).set_body_json(json!({})))
1038 .mount(&server)
1039 .await;
1040
1041 backend
1042 .create_branch(WorkspaceGitCreateBranchRequest {
1043 name: "feat/x".into(),
1044 base: "main".into(),
1045 })
1046 .await
1047 .unwrap();
1048 }
1049
1050 #[tokio::test]
1051 async fn create_branch_409_yields_remote_git_conflict() {
1052 let (server, backend) = server_and_backend().await;
1053 Mock::given(method("POST"))
1054 .and(path("/v1/repos/test/git/branches/create"))
1055 .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1056 "error":{"code":"BRANCH_EXISTS","message":"branch 'feat/x' already exists"}
1057 })))
1058 .mount(&server)
1059 .await;
1060
1061 let err = backend
1062 .create_branch(WorkspaceGitCreateBranchRequest {
1063 name: "feat/x".into(),
1064 base: "main".into(),
1065 })
1066 .await
1067 .unwrap_err();
1068 let conflict = err
1069 .downcast_ref::<RemoteGitConflict>()
1070 .expect("409 must downcast to RemoteGitConflict");
1071 assert_eq!(conflict.code, "BRANCH_EXISTS");
1072 assert!(conflict.message.contains("feat/x"));
1073 }
1074
1075 #[tokio::test]
1076 async fn checkout_returns_stdout() {
1077 let (server, backend) = server_and_backend().await;
1078 Mock::given(method("POST"))
1079 .and(path("/v1/repos/test/git/checkout"))
1080 .and(wiremock::matchers::body_json(json!({
1081 "refspec":"feat/x","force":false
1082 })))
1083 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1084 "stdout":"Switched to branch 'feat/x'"
1085 })))
1086 .mount(&server)
1087 .await;
1088
1089 let out = backend
1090 .checkout(WorkspaceGitCheckoutRequest {
1091 refspec: "feat/x".into(),
1092 force: false,
1093 })
1094 .await
1095 .unwrap();
1096 assert!(out.stdout.contains("feat/x"));
1097 }
1098
1099 #[tokio::test]
1100 async fn checkout_409_dirty_yields_conflict() {
1101 let (server, backend) = server_and_backend().await;
1102 Mock::given(method("POST"))
1103 .and(path("/v1/repos/test/git/checkout"))
1104 .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1105 "error":{"code":"WORKING_TREE_DIRTY","message":"please stash first"}
1106 })))
1107 .mount(&server)
1108 .await;
1109
1110 let err = backend
1111 .checkout(WorkspaceGitCheckoutRequest {
1112 refspec: "main".into(),
1113 force: false,
1114 })
1115 .await
1116 .unwrap_err();
1117 let c = err.downcast_ref::<RemoteGitConflict>().unwrap();
1118 assert_eq!(c.code, "WORKING_TREE_DIRTY");
1119 }
1120
1121 #[tokio::test]
1122 async fn diff_passes_target_through_and_surfaces_server_truncation() {
1123 let (server, backend) = server_and_backend().await;
1124 Mock::given(method("POST"))
1125 .and(path("/v1/repos/test/git/diff"))
1126 .and(wiremock::matchers::body_json(json!({"target":"main"})))
1127 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1128 "diff":"<huge diff>",
1129 "truncated": true
1130 })))
1131 .mount(&server)
1132 .await;
1133
1134 let diff = backend
1135 .diff(WorkspaceGitDiffRequest {
1136 target: Some("main".to_string()),
1137 })
1138 .await
1139 .unwrap();
1140 assert!(diff.contains("truncated by gitserver"));
1141 }
1142
1143 #[tokio::test]
1144 async fn diff_enforces_client_max_diff_bytes() {
1145 let server = MockServer::start().await;
1146 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1147 .bearer_token("t")
1148 .max_diff_bytes(8);
1149 let backend = RemoteGitBackend::new(cfg).unwrap();
1150
1151 Mock::given(method("POST"))
1152 .and(path("/v1/repos/test/git/diff"))
1153 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1154 "diff":"AAAAAAAAAAAAAAAAAAAAAA", "truncated": false
1156 })))
1157 .mount(&server)
1158 .await;
1159
1160 let diff = backend
1161 .diff(WorkspaceGitDiffRequest { target: None })
1162 .await
1163 .unwrap();
1164 assert!(diff.contains("truncated by client max_diff_bytes"));
1165 assert!(diff.starts_with("AAAAAAAA"));
1167 }
1168
1169 #[tokio::test]
1177 async fn diff_rejects_oversized_content_length_upfront() {
1178 let server = MockServer::start().await;
1179 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1180 .bearer_token("t")
1181 .max_diff_bytes(8);
1182 let backend = RemoteGitBackend::new(cfg).unwrap();
1183
1184 let huge_body = vec![b'A'; 1024 * 1024];
1186 Mock::given(method("POST"))
1187 .and(path("/v1/repos/test/git/diff"))
1188 .respond_with(
1189 ResponseTemplate::new(200)
1190 .insert_header("content-type", "application/json")
1191 .set_body_bytes(huge_body),
1192 )
1193 .mount(&server)
1194 .await;
1195
1196 let err = backend
1197 .diff(WorkspaceGitDiffRequest { target: None })
1198 .await
1199 .expect_err("oversized body must be rejected");
1200 let msg = err.to_string();
1201 assert!(
1202 msg.contains("Content-Length") && msg.contains("exceeds client cap"),
1203 "expected eager Content-Length rejection, got: {}",
1204 msg
1205 );
1206 }
1207
1208 #[tokio::test]
1213 async fn diff_aborts_mid_stream_on_cap_exceeded() {
1214 let server = MockServer::start().await;
1215 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1216 .bearer_token("t")
1217 .max_diff_bytes(8);
1218 let backend = RemoteGitBackend::new(cfg).unwrap();
1219
1220 let big_body = vec![b'A'; 256 * 1024];
1223 Mock::given(method("POST"))
1224 .and(path("/v1/repos/test/git/diff"))
1225 .respond_with(
1226 ResponseTemplate::new(200)
1227 .insert_header("transfer-encoding", "chunked")
1228 .set_body_bytes(big_body),
1229 )
1230 .mount(&server)
1231 .await;
1232
1233 let err = backend
1234 .diff(WorkspaceGitDiffRequest { target: None })
1235 .await
1236 .expect_err("oversized streamed body must be rejected");
1237 let msg = err.to_string();
1238 assert!(
1241 msg.contains("exceeds client cap")
1242 || msg.contains("exceeded client cap")
1243 || msg.contains("Content-Length"),
1244 "expected oversize rejection, got: {}",
1245 msg
1246 );
1247 }
1248
1249 #[tokio::test]
1250 async fn list_remotes_defaults_direction() {
1251 let (server, backend) = server_and_backend().await;
1252 Mock::given(method("POST"))
1253 .and(path("/v1/repos/test/git/remotes"))
1254 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1255 "remotes":[{"name":"origin","url":"git@x:y.git"}]
1256 })))
1257 .mount(&server)
1258 .await;
1259
1260 let rs = backend.list_remotes().await.unwrap();
1261 assert_eq!(rs.len(), 1);
1262 assert_eq!(rs[0].direction, "fetch");
1263 }
1264
1265 #[tokio::test]
1266 async fn is_repository_returns_bool() {
1267 let (server, backend) = server_and_backend().await;
1268 Mock::given(method("POST"))
1269 .and(path("/v1/repos/test/git/exists"))
1270 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1271 "is_repository": true
1272 })))
1273 .mount(&server)
1274 .await;
1275
1276 assert!(backend.is_repository().await.unwrap());
1277 }
1278
1279 #[tokio::test]
1280 async fn list_stashes_maps_response() {
1281 let (server, backend) = server_and_backend().await;
1282 Mock::given(method("POST"))
1283 .and(path("/v1/repos/test/git/stashes"))
1284 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1285 "stashes":[{"index":0,"message":"WIP"}]
1286 })))
1287 .mount(&server)
1288 .await;
1289
1290 let s = backend.list_stashes().await.unwrap();
1291 assert_eq!(s.len(), 1);
1292 assert_eq!(s[0].message, "WIP");
1293 }
1294
1295 #[tokio::test]
1296 async fn stash_create_409_nothing_to_stash() {
1297 let (server, backend) = server_and_backend().await;
1298 Mock::given(method("POST"))
1299 .and(path("/v1/repos/test/git/stashes/create"))
1300 .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1301 "error":{"code":"NOTHING_TO_STASH","message":"clean tree"}
1302 })))
1303 .mount(&server)
1304 .await;
1305
1306 let err = backend
1307 .stash(WorkspaceGitStashRequest {
1308 message: None,
1309 include_untracked: false,
1310 })
1311 .await
1312 .unwrap_err();
1313 let c = err.downcast_ref::<RemoteGitConflict>().unwrap();
1314 assert_eq!(c.code, "NOTHING_TO_STASH");
1315 }
1316
1317 #[tokio::test]
1318 async fn not_found_404_is_generic_anyhow() {
1319 let (server, backend) = server_and_backend().await;
1320 Mock::given(method("POST"))
1321 .and(path("/v1/repos/test/git/status"))
1322 .respond_with(ResponseTemplate::new(404).set_body_json(json!({
1323 "error":{"code":"REPO_NOT_FOUND","message":"unknown repo"}
1324 })))
1325 .mount(&server)
1326 .await;
1327
1328 let err = backend.status().await.unwrap_err();
1329 assert!(err.to_string().contains("not found"), "msg: {}", err);
1330 assert!(err.downcast_ref::<RemoteGitConflict>().is_none());
1331 }
1332
1333 #[tokio::test]
1334 async fn auth_failure_401_is_generic_anyhow() {
1335 let (server, backend) = server_and_backend().await;
1336 Mock::given(method("POST"))
1337 .and(path("/v1/repos/test/git/status"))
1338 .respond_with(ResponseTemplate::new(401).set_body_json(json!({
1339 "error":{"code":"INVALID_TOKEN","message":"bad bearer"}
1340 })))
1341 .mount(&server)
1342 .await;
1343
1344 let err = backend.status().await.unwrap_err();
1345 assert!(err.to_string().contains("auth failed"), "msg: {}", err);
1346 assert!(err.downcast_ref::<RemoteGitConflict>().is_none());
1347 }
1348
1349 #[tokio::test]
1350 async fn server_500_is_generic_anyhow() {
1351 let (server, backend) = server_and_backend().await;
1352 Mock::given(method("POST"))
1353 .and(path("/v1/repos/test/git/status"))
1354 .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
1355 .mount(&server)
1356 .await;
1357
1358 let err = backend.status().await.unwrap_err();
1359 assert!(err.to_string().contains("server error"), "msg: {}", err);
1360 }
1361
1362 #[tokio::test]
1363 async fn non_json_error_body_falls_back_to_http_code() {
1364 let (server, backend) = server_and_backend().await;
1365 Mock::given(method("POST"))
1366 .and(path("/v1/repos/test/git/status"))
1367 .respond_with(ResponseTemplate::new(409).set_body_string("not json"))
1368 .mount(&server)
1369 .await;
1370
1371 let err = backend.status().await.unwrap_err();
1372 let c = err
1376 .downcast_ref::<RemoteGitConflict>()
1377 .expect("409 must yield conflict regardless of body shape");
1378 assert_eq!(c.code, "HTTP_409");
1379 assert_eq!(c.message, "not json");
1380 }
1381
1382 #[tokio::test]
1383 async fn with_remote_git_wires_git_and_stash() {
1384 let services = super::super::WorkspaceServices::local(std::env::temp_dir());
1385 let upgraded = services
1386 .with_remote_git(RemoteGitBackendConfig::new("http://localhost", "r").bearer_token("t"))
1387 .unwrap();
1388 assert!(upgraded.git().is_some());
1389 assert!(upgraded.git_stash().is_some());
1390 assert!(upgraded.git_worktree().is_none());
1393 assert!(upgraded.capabilities().git);
1394 }
1395
1396 #[tokio::test]
1404 async fn with_remote_git_preserves_local_root_and_unrelated_capabilities() {
1405 let temp = tempfile::tempdir().unwrap();
1406 let base = super::super::WorkspaceServices::local(temp.path());
1407 assert!(
1408 base.local_root().is_some(),
1409 "precondition: local() must set local_root"
1410 );
1411 assert!(
1412 base.command_runner().is_some(),
1413 "precondition: local() must wire bash runner"
1414 );
1415 let base_root = base.local_root().map(|p| p.to_path_buf());
1416
1417 let upgraded = base
1418 .with_remote_git(RemoteGitBackendConfig::new("http://localhost", "r").bearer_token("t"))
1419 .unwrap();
1420
1421 assert!(upgraded.git().is_some());
1423 assert!(upgraded.capabilities().git);
1424 assert_eq!(
1426 upgraded.local_root().map(|p| p.to_path_buf()),
1427 base_root,
1428 "local_root must survive with_remote_git"
1429 );
1430 assert!(
1431 upgraded.command_runner().is_some(),
1432 "command_runner must survive with_remote_git"
1433 );
1434 assert!(
1435 upgraded.search().is_some(),
1436 "search provider must survive with_remote_git"
1437 );
1438 assert!(upgraded.git_worktree().is_none());
1440 }
1441
1442 #[tokio::test]
1450 async fn git_tool_status_works_through_remote_backend() {
1451 use crate::tools::{Tool, ToolContext};
1452
1453 let server = MockServer::start().await;
1454 Mock::given(method("POST"))
1456 .and(path("/v1/repos/u1/s1/git/exists"))
1457 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"is_repository": true})))
1458 .mount(&server)
1459 .await;
1460 Mock::given(method("POST"))
1461 .and(path("/v1/repos/u1/s1/git/status"))
1462 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1463 "branch":"main",
1464 "commit":"deadbeef",
1465 "is_worktree": false,
1466 "is_dirty": false,
1467 "dirty_count": 0,
1468 })))
1469 .mount(&server)
1470 .await;
1471
1472 let base = super::super::WorkspaceServices::local(std::env::temp_dir());
1473 let services = base
1474 .with_remote_git(RemoteGitBackendConfig::new(server.uri(), "u1/s1").bearer_token("tok"))
1475 .unwrap();
1476
1477 let tool = crate::tools::builtin::git::GitTool;
1478 let ctx = ToolContext::new(std::env::temp_dir()).with_workspace_services(services);
1479
1480 let result = tool
1481 .execute(&json!({"command": "status"}), &ctx)
1482 .await
1483 .unwrap();
1484 assert!(result.success, "tool output: {}", result.content);
1485 assert!(
1486 result.content.contains("main"),
1487 "expected branch name in output: {}",
1488 result.content
1489 );
1490 assert!(
1491 result.content.contains("deadbeef"),
1492 "expected commit hash in output: {}",
1493 result.content
1494 );
1495 assert!(
1496 result.content.contains("clean"),
1497 "expected clean status in output: {}",
1498 result.content
1499 );
1500 }
1501}