1use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18
19use strop_core::worker::CancelToken;
20use strop_remote::RemoteEndpoint;
21
22use crate::diff::FileDiff;
23use crate::exec::{GitExec, GitExecError, GitRun};
24use crate::repo::{gutter_from_contents, hunks_from_buffers};
25use crate::ssh::parse_effective_hostname;
26use crate::target::RepoTarget;
27use crate::{GitContext, Hunk};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum RemoteGitError {
33 Exec(String),
37 Exit {
40 op: &'static str,
41 code: i32,
42 stderr: String,
43 },
44 Parse(&'static str),
47 Utf8(&'static str),
49 Truncated { op: &'static str, dropped: u64 },
52}
53
54impl std::fmt::Display for RemoteGitError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::Exec(message) => write!(f, "{message}"),
58 Self::Exit { op, code, stderr } => {
59 write!(f, "{op}: git exited {code}: {}", stderr.trim())
60 }
61 Self::Parse(what) => write!(f, "{what}: unparseable git output"),
62 Self::Utf8(what) => write!(f, "{what} is not UTF-8"),
63 Self::Truncated { op, dropped } => {
64 write!(f, "{op}: remote output truncated ({dropped} bytes dropped)")
65 }
66 }
67 }
68}
69
70impl std::error::Error for RemoteGitError {}
71
72impl From<GitExecError> for RemoteGitError {
73 fn from(error: GitExecError) -> Self {
74 Self::Exec(error.to_string())
75 }
76}
77
78fn records(
81 exec: &GitExec,
82 op: &'static str,
83 argv: &[OsString],
84 cancel: &CancelToken,
85) -> Result<Vec<u8>, RemoteGitError> {
86 let run = exec.run(argv, cancel)?;
87 exit_or_bytes(op, &run)
88}
89
90fn exit_or_bytes(op: &'static str, run: &GitRun) -> Result<Vec<u8>, RemoteGitError> {
91 if run.stdout_dropped > 0 {
92 return Err(RemoteGitError::Truncated {
93 op,
94 dropped: run.stdout_dropped,
95 });
96 }
97 match run.code {
98 Some(0) => Ok(run.stdout.clone()),
99 Some(code) => Err(RemoteGitError::Exit {
100 op,
101 code,
102 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
103 }),
104 None => Err(RemoteGitError::Exit {
105 op,
106 code: -1,
107 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
108 }),
109 }
110}
111
112fn remotes_from_run(config_run: &GitRun) -> Result<Vec<(String, String)>, RemoteGitError> {
113 const OP: &str = "config --get-regexp remote.*.url";
114 if config_run.code != Some(0) && config_run.code != Some(1) {
115 return Err(RemoteGitError::Exit {
116 op: OP,
117 code: config_run.code.unwrap_or(-1),
118 stderr: String::from_utf8_lossy(&config_run.stderr)
119 .trim_end()
120 .to_string(),
121 });
122 }
123 if config_run.stdout_dropped > 0 {
124 return Err(RemoteGitError::Truncated {
125 op: OP,
126 dropped: config_run.stdout_dropped,
127 });
128 }
129 parse_remote_config(&config_run.stdout)
130}
131
132pub fn discover(
138 endpoint: &RemoteEndpoint,
139 from: &Path,
140 cancel: &CancelToken,
141) -> Result<Option<PathBuf>, RemoteGitError> {
142 let exec = GitExec::Remote {
143 endpoint: endpoint.clone(),
144 workdir: from,
145 };
146 let run = exec.run(&["rev-parse".into(), "--show-toplevel".into()], cancel)?;
147 if run.success {
148 let stdout = exit_or_bytes("rev-parse --show-toplevel", &run)?;
149 return parse_toplevel(&stdout).map(Some);
150 }
151 let stderr = String::from_utf8_lossy(&run.stderr);
155 if run.code == Some(128) && stderr.contains("not a git repository") {
156 return Ok(None);
157 }
158 Err(RemoteGitError::Exit {
159 op: "rev-parse --show-toplevel",
160 code: run.code.unwrap_or(-1),
161 stderr: stderr.trim_end().to_string(),
162 })
163}
164
165pub fn context(
170 endpoint: &RemoteEndpoint,
171 workdir: &Path,
172 cancel: &CancelToken,
173) -> Result<GitContext, RemoteGitError> {
174 let exec = GitExec::Remote {
175 endpoint: endpoint.clone(),
176 workdir,
177 };
178 let head_sha = match exec.run(
180 &[
181 "rev-parse".into(),
182 "--verify".into(),
183 "--quiet".into(),
184 "HEAD".into(),
185 ],
186 cancel,
187 )? {
188 run if run.success => Some(parse_sha(&run.stdout, "rev-parse HEAD")?),
189 run if run.code == Some(1) => None,
190 run => {
191 return Err(RemoteGitError::Exit {
192 op: "rev-parse HEAD",
193 code: run.code.unwrap_or(-1),
194 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
195 })
196 }
197 };
198 let head_branch = match exec.run(
201 &["symbolic-ref".into(), "--short".into(), "HEAD".into()],
202 cancel,
203 )? {
204 run if run.success => {
205 let name = String::from_utf8_lossy(&run.stdout).trim().to_string();
206 (!name.is_empty()).then_some(name)
207 }
208 run if run.code == Some(128) => None,
209 run => {
210 return Err(RemoteGitError::Exit {
211 op: "symbolic-ref HEAD",
212 code: run.code.unwrap_or(-1),
213 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
214 })
215 }
216 };
217 let config_run = exec.run(
219 &[
220 "config".into(),
221 "-z".into(),
222 "--get-regexp".into(),
223 "^remote\\.[^.]+\\.url$".into(),
224 ],
225 cancel,
226 )?;
227 let remotes = remotes_from_run(&config_run)?;
228 Ok(GitContext {
229 repo: RepoTarget::Remote {
230 endpoint: endpoint.clone(),
231 workdir: workdir.to_path_buf(),
232 },
233 head_sha,
234 head_branch,
235 remotes,
236 })
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct FileContents {
245 pub head: Option<Vec<u8>>,
246 pub index: Option<Vec<u8>>,
247}
248
249pub fn gutter(
255 endpoint: &RemoteEndpoint,
256 workdir: &Path,
257 head_sha: Option<&str>,
258 rel: &Path,
259 text: &str,
260 cancel: &CancelToken,
261) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), RemoteGitError> {
262 let contents = file_contents(endpoint, workdir, head_sha, rel, cancel)?;
263 let head = contents
264 .head
265 .as_deref()
266 .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("HEAD blob")))
267 .transpose()?;
268 let index = contents
269 .index
270 .as_deref()
271 .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("index blob")))
272 .transpose()?;
273 gutter_from_contents(head, index, text, rel)
274 .map_err(|error| RemoteGitError::Exec(error.to_string()))
275}
276
277pub fn file_contents(
282 endpoint: &RemoteEndpoint,
283 workdir: &Path,
284 head_sha: Option<&str>,
285 rel: &Path,
286 cancel: &CancelToken,
287) -> Result<FileContents, RemoteGitError> {
288 let exec = GitExec::Remote {
289 endpoint: endpoint.clone(),
290 workdir,
291 };
292 let head = match head_sha {
293 Some(sha) => {
294 let stdout = records(
295 &exec,
296 "ls-tree HEAD",
297 &[
298 "ls-tree".into(),
299 "-z".into(),
300 sha.into(),
301 "--".into(),
302 rel.as_os_str().into(),
303 ],
304 cancel,
305 )?;
306 match parse_tree_entry(&stdout)? {
307 Some((oid, path)) if path == rel => {
308 Some(blob_bytes(&exec, &oid, "HEAD blob", cancel)?)
309 }
310 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree HEAD")),
314 None => None,
315 }
316 }
317 None => None,
318 };
319 let stdout = records(
320 &exec,
321 "ls-files --stage",
322 &[
323 "ls-files".into(),
324 "-z".into(),
325 "--stage".into(),
326 "--".into(),
327 rel.as_os_str().into(),
328 ],
329 cancel,
330 )?;
331 let index = match parse_index_entry(&stdout)? {
332 Some((oid, path)) if path == rel => Some(blob_bytes(&exec, &oid, "index blob", cancel)?),
333 Some((_, _)) => return Err(RemoteGitError::Parse("ls-files --stage")),
334 None => None,
335 };
336 Ok(FileContents { head, index })
337}
338
339fn blob_bytes(
340 exec: &GitExec,
341 oid: &str,
342 what: &'static str,
343 cancel: &CancelToken,
344) -> Result<Vec<u8>, RemoteGitError> {
345 records(
346 exec,
347 what,
348 &["cat-file".into(), "-p".into(), oid.into()],
349 cancel,
350 )
351}
352
353pub fn commit_file_diff(
357 endpoint: &RemoteEndpoint,
358 workdir: &Path,
359 sha: &str,
360 rel: &Path,
361 cancel: &CancelToken,
362) -> Result<FileDiff, RemoteGitError> {
363 let exec = GitExec::Remote {
364 endpoint: endpoint.clone(),
365 workdir,
366 };
367 let stdout = records(
368 &exec,
369 "rev-list --parents",
370 &[
371 "rev-list".into(),
372 "--parents".into(),
373 "-n".into(),
374 "1".into(),
375 sha.into(),
376 ],
377 cancel,
378 )?;
379 let (commit, parent) = parse_commit_parents(&stdout)?;
380 if commit != sha {
381 return Err(RemoteGitError::Parse("rev-list --parents"));
382 }
383 let parent_blob = match parent.as_deref() {
384 Some(parent) => {
385 let stdout = records(
386 &exec,
387 "ls-tree parent",
388 &[
389 "ls-tree".into(),
390 "-z".into(),
391 parent.into(),
392 "--".into(),
393 rel.as_os_str().into(),
394 ],
395 cancel,
396 )?;
397 match parse_tree_entry(&stdout)? {
398 Some((oid, path)) if path == rel => {
399 Some(blob_bytes(&exec, &oid, "parent blob", cancel)?)
400 }
401 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree parent")),
402 None => None,
403 }
404 }
405 None => None,
406 };
407 let stdout = records(
408 &exec,
409 "ls-tree commit",
410 &[
411 "ls-tree".into(),
412 "-z".into(),
413 commit.clone().into(),
414 "--".into(),
415 rel.as_os_str().into(),
416 ],
417 cancel,
418 )?;
419 let commit_blob = match parse_tree_entry(&stdout)? {
420 Some((oid, path)) if path == rel => blob_bytes(&exec, &oid, "commit blob", cancel)?,
421 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree commit")),
422 None => return Err(RemoteGitError::Exec("no diff for path".into())),
423 };
424 let hunks = hunks_from_buffers(parent_blob.as_deref(), &commit_blob, rel)
425 .map_err(|error| RemoteGitError::Exec(error.to_string()))?;
426 Ok(FileDiff::from_hunks(rel.to_path_buf(), hunks))
427}
428
429pub fn effective_host(
435 endpoint: &RemoteEndpoint,
436 workdir: &Path,
437 remote: &crate::permalink::AliasRemote,
438 cancel: &CancelToken,
439) -> Result<String, crate::ssh::EffectiveHostError> {
440 use crate::ssh::EffectiveHostError;
441 let host = remote.host();
442 if !crate::permalink::is_safe_host(host) {
443 return Err(EffectiveHostError::InvalidHost);
444 }
445 let mut args = vec!["-G".into()];
446 if let Some(user) = &remote.user {
447 args.extend(["-l".into(), user.into()]);
448 }
449 if let Some(port) = remote.port {
450 args.extend(["-p".into(), port.to_string().into()]);
451 }
452 args.push(host.into());
453 let command = strop_remote::RemoteCommand::new("ssh", args, workdir)
454 .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
455 let run = strop_remote::run(endpoint, &command, cancel)
456 .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
457 if !run.status.success() {
458 return Err(EffectiveHostError::Failed(
459 String::from_utf8_lossy(&run.stderr).trim().to_string(),
460 ));
461 }
462 if run.stdout_dropped > 0 {
463 return Err(EffectiveHostError::Failed(
464 "output truncated before a hostname line".into(),
465 ));
466 }
467 let stdout = String::from_utf8_lossy(&run.stdout);
468 let hostname = parse_effective_hostname(&stdout).ok_or(EffectiveHostError::NoHostname)?;
469 if hostname == host && !hostname.contains('.') {
470 return Err(EffectiveHostError::Unresolved);
471 }
472 Ok(hostname)
473}
474
475fn parse_toplevel(bytes: &[u8]) -> Result<PathBuf, RemoteGitError> {
485 let trimmed = bytes.strip_suffix(b"\n").unwrap_or(bytes);
486 if trimmed.is_empty() || trimmed.first() != Some(&b'/') {
487 return Err(RemoteGitError::Parse("rev-parse --show-toplevel"));
488 }
489 Ok(bytes_to_path(trimmed))
490}
491
492fn parse_sha(bytes: &[u8], what: &'static str) -> Result<String, RemoteGitError> {
494 let text = String::from_utf8_lossy(bytes);
495 let sha = text.trim();
496 let valid = (40..=64).contains(&sha.len())
497 && !sha.is_empty()
498 && sha.bytes().all(|b| b.is_ascii_hexdigit());
499 if !valid {
500 return Err(RemoteGitError::Parse(what));
501 }
502 Ok(sha.to_string())
503}
504
505fn parse_remote_config(bytes: &[u8]) -> Result<Vec<(String, String)>, RemoteGitError> {
509 let mut remotes = Vec::new();
510 for record in bytes.split(|&b| b == 0) {
511 if record.is_empty() {
512 continue;
513 }
514 let Some((key, url)) = split_record(record, b'\n') else {
515 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
516 };
517 let key = std::str::from_utf8(key).map_err(|_| RemoteGitError::Utf8("remote name"))?;
518 let url = std::str::from_utf8(url).map_err(|_| RemoteGitError::Utf8("remote url"))?;
519 let Some(name) = key
520 .strip_prefix("remote.")
521 .and_then(|rest| rest.strip_suffix(".url"))
522 else {
523 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
524 };
525 if name.is_empty() {
526 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
527 }
528 remotes.push((name.to_string(), url.to_owned()));
529 }
530 Ok(remotes)
531}
532
533fn parse_tree_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
537 let Some(record) = first_record(bytes) else {
538 return Ok(None);
539 };
540 let Some((meta, path)) = split_record(record, b'\t') else {
541 return Err(RemoteGitError::Parse("ls-tree"));
542 };
543 let mut fields = meta.split(|&b| b == b' ');
544 let oid = fields.nth(2).ok_or(RemoteGitError::Parse("ls-tree"))?;
545 let oid = parse_sha(oid, "ls-tree oid")?;
546 Ok(Some((oid, bytes_to_path(path))))
547}
548
549fn parse_index_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
551 let Some(record) = first_record(bytes) else {
552 return Ok(None);
553 };
554 let Some((meta, path)) = split_record(record, b'\t') else {
555 return Err(RemoteGitError::Parse("ls-files --stage"));
556 };
557 let mut fields = meta.split(|&b| b == b' ');
558 let oid = fields
559 .nth(1)
560 .ok_or(RemoteGitError::Parse("ls-files --stage"))?;
561 let oid = parse_sha(oid, "ls-files oid")?;
562 Ok(Some((oid, bytes_to_path(path))))
563}
564
565fn parse_commit_parents(bytes: &[u8]) -> Result<(String, Option<String>), RemoteGitError> {
569 let text = String::from_utf8_lossy(bytes);
570 let mut shas = text
571 .split_whitespace()
572 .map(|token| parse_sha(token.as_bytes(), "rev-list --parents"));
573 let commit = shas
574 .next()
575 .ok_or(RemoteGitError::Parse("rev-list --parents"))??;
576 let parent = shas.next().transpose()?;
577 Ok((commit, parent))
578}
579
580fn first_record(bytes: &[u8]) -> Option<&[u8]> {
581 bytes.split(|&b| b == 0).find(|record| !record.is_empty())
582}
583
584fn split_record(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
585 let index = bytes.iter().position(|&byte| byte == separator)?;
586 Some((&bytes[..index], &bytes[index + 1..]))
587}
588
589#[cfg(unix)]
590fn bytes_to_path(bytes: &[u8]) -> PathBuf {
591 use std::os::unix::ffi::OsStrExt;
592 PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
593}
594
595#[cfg(not(unix))]
596fn bytes_to_path(bytes: &[u8]) -> PathBuf {
597 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
609 fn toplevel_is_absolute_native_path() {
610 assert_eq!(
611 parse_toplevel(b"/srv/proj with space\n").unwrap(),
612 PathBuf::from("/srv/proj with space")
613 );
614 assert!(
615 parse_toplevel(b"srv/proj\n").is_err(),
616 "relative is refused"
617 );
618 assert!(parse_toplevel(b"").is_err(), "empty is refused");
619 }
620
621 #[test]
622 fn shas_are_validated_object_names() {
623 assert_eq!(
624 parse_sha(b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45\n", "x").unwrap(),
625 "c59d8ceb7aeb96a1cdccff5646ec485acce32d45"
626 );
627 assert!(parse_sha(b"main\n", "x").is_err());
628 assert!(parse_sha(b"head is at 1234\n", "x").is_err());
629 assert!(parse_sha(b"\n", "x").is_err());
630 }
631
632 #[test]
635 fn remote_config_records_parse_native() {
636 let bytes = b"remote.origin.url\nhttps://example.com/acme/demo.git\0remote.up.url\ngit@gh:acme/other.git\0";
637 assert_eq!(
638 parse_remote_config(bytes).unwrap(),
639 vec![
640 (
641 "origin".to_string(),
642 "https://example.com/acme/demo.git".to_string()
643 ),
644 ("up".to_string(), "git@gh:acme/other.git".to_string()),
645 ]
646 );
647 assert_eq!(
648 parse_remote_config(b"").unwrap(),
649 Vec::<(String, String)>::new()
650 );
651 assert!(parse_remote_config(b"not-a-pair\0").is_err());
652 assert!(
653 parse_remote_config(b"remote..url\nx\0").is_err(),
654 "empty name"
655 );
656 assert!(
657 parse_remote_config(b"remote.o.url\nhttps://a/\xff\xfe\0").is_err(),
658 "non-UTF-8 url refused"
659 );
660 }
661
662 #[test]
666 fn tree_and_index_records_keep_native_paths() {
667 let tree = b"100644 blob 45b983be36b73c0788dc9cbcb76cbb80fc7bb057\tsrc/a b.rs\0";
668 let (oid, path) = parse_tree_entry(tree).unwrap().unwrap();
669 assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
670 assert_eq!(path, PathBuf::from("src/a b.rs"));
671 assert_eq!(parse_tree_entry(b"").unwrap(), None);
672
673 let index = b"100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0\tsrc/a b.rs\0";
674 let (oid, path) = parse_index_entry(index).unwrap().unwrap();
675 assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
676 assert_eq!(path, PathBuf::from("src/a b.rs"));
677 assert_eq!(parse_index_entry(b"").unwrap(), None);
678 assert!(parse_tree_entry(b"garbage\0").is_err());
679 assert!(parse_index_entry(b"100644 noshahere 0\tx\0").is_err());
680 }
681
682 #[test]
685 fn commit_parents_root_and_merged() {
686 let root = b"60209d7ce72dddfafc1caacd511325c314a083bf\n";
687 assert_eq!(
688 parse_commit_parents(root).unwrap(),
689 ("60209d7ce72dddfafc1caacd511325c314a083bf".to_string(), None)
690 );
691 let merged = b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45 aaaa1111111111111111111111111111111111111 bbbb2222222222222222222222222222222222222\n";
692 let (child, parent) = parse_commit_parents(merged).unwrap();
693 assert_eq!(child, "c59d8ceb7aeb96a1cdccff5646ec485acce32d45");
694 assert_eq!(
695 parent.as_deref(),
696 Some("aaaa1111111111111111111111111111111111111")
697 );
698 assert!(parse_commit_parents(b"not-a-sha\n").is_err());
699 assert!(parse_commit_parents(b"").is_err());
700 }
701
702 #[test]
706 fn nonzero_exit_is_a_typed_error() {
707 let run = GitRun {
708 success: false,
709 code: Some(128),
710 stdout: Vec::new(),
711 stderr: b"fatal: unsafe repository\n".to_vec(),
712 stdout_dropped: 0,
713 stderr_dropped: 0,
714 };
715 match exit_or_bytes("rev-parse --show-toplevel", &run) {
716 Err(RemoteGitError::Exit { op, code, stderr }) => {
717 assert_eq!(op, "rev-parse --show-toplevel");
718 assert_eq!(code, 128);
719 assert_eq!(stderr, "fatal: unsafe repository");
720 }
721 other => panic!("expected Exit, got {other:?}"),
722 }
723 }
724
725 #[test]
726 fn no_matching_remote_config_is_data_but_real_failure_is_not() {
727 let mut run = GitRun {
728 success: false,
729 code: Some(1),
730 stdout: Vec::new(),
731 stderr: Vec::new(),
732 stdout_dropped: 0,
733 stderr_dropped: 0,
734 };
735 assert!(remotes_from_run(&run).unwrap().is_empty());
736 run.code = Some(3);
737 assert!(matches!(
738 remotes_from_run(&run),
739 Err(RemoteGitError::Exit { code: 3, .. })
740 ));
741 run.code = Some(1);
742 run.stdout_dropped = 1;
743 assert!(matches!(
744 remotes_from_run(&run),
745 Err(RemoteGitError::Truncated { .. })
746 ));
747 }
748
749 #[test]
751 fn truncation_is_typed() {
752 let run = GitRun {
753 success: true,
754 code: Some(0),
755 stdout: Vec::new(),
756 stderr: Vec::new(),
757 stdout_dropped: 4096,
758 stderr_dropped: 0,
759 };
760 assert_eq!(
761 exit_or_bytes("ls-tree", &run),
762 Err(RemoteGitError::Truncated {
763 op: "ls-tree",
764 dropped: 4096
765 })
766 );
767 }
768}