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
112pub fn discover(
118 endpoint: &RemoteEndpoint,
119 from: &Path,
120 cancel: &CancelToken,
121) -> Result<Option<PathBuf>, RemoteGitError> {
122 let exec = GitExec::Remote {
123 endpoint: endpoint.clone(),
124 workdir: from,
125 };
126 let run = exec.run(&["rev-parse".into(), "--show-toplevel".into()], cancel)?;
127 if run.success {
128 let stdout = exit_or_bytes("rev-parse --show-toplevel", &run)?;
129 return parse_toplevel(&stdout).map(Some);
130 }
131 let stderr = String::from_utf8_lossy(&run.stderr);
135 if run.code == Some(128) && stderr.contains("not a git repository") {
136 return Ok(None);
137 }
138 Err(RemoteGitError::Exit {
139 op: "rev-parse --show-toplevel",
140 code: run.code.unwrap_or(-1),
141 stderr: stderr.trim_end().to_string(),
142 })
143}
144
145pub fn context(
150 endpoint: &RemoteEndpoint,
151 workdir: &Path,
152 cancel: &CancelToken,
153) -> Result<GitContext, RemoteGitError> {
154 let exec = GitExec::Remote {
155 endpoint: endpoint.clone(),
156 workdir,
157 };
158 let head_sha = match exec.run(
160 &[
161 "rev-parse".into(),
162 "--verify".into(),
163 "--quiet".into(),
164 "HEAD".into(),
165 ],
166 cancel,
167 )? {
168 run if run.success => Some(parse_sha(&run.stdout, "rev-parse HEAD")?),
169 run if run.code == Some(1) => None,
170 run => {
171 return Err(RemoteGitError::Exit {
172 op: "rev-parse HEAD",
173 code: run.code.unwrap_or(-1),
174 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
175 })
176 }
177 };
178 let head_branch = match exec.run(
181 &["symbolic-ref".into(), "--short".into(), "HEAD".into()],
182 cancel,
183 )? {
184 run if run.success => {
185 let name = String::from_utf8_lossy(&run.stdout).trim().to_string();
186 (!name.is_empty()).then_some(name)
187 }
188 run if run.code == Some(128) => None,
189 run => {
190 return Err(RemoteGitError::Exit {
191 op: "symbolic-ref HEAD",
192 code: run.code.unwrap_or(-1),
193 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
194 })
195 }
196 };
197 let config_run = exec.run(
199 &[
200 "config".into(),
201 "-z".into(),
202 "--get-regexp".into(),
203 "^remote\\.[^.]+\\.url$".into(),
204 ],
205 cancel,
206 )?;
207 if config_run.code != Some(0) && config_run.code != Some(1) {
208 return Err(RemoteGitError::Exit {
209 op: "config --get-regexp remote.*.url",
210 code: config_run.code.unwrap_or(-1),
211 stderr: String::from_utf8_lossy(&config_run.stderr)
212 .trim_end()
213 .to_string(),
214 });
215 }
216 let stdout = exit_or_bytes("config --get-regexp remote.*.url", &config_run)?;
217 let remotes = parse_remote_config(&stdout)?;
218 Ok(GitContext {
219 repo: RepoTarget::Remote {
220 endpoint: endpoint.clone(),
221 workdir: workdir.to_path_buf(),
222 },
223 head_sha,
224 head_branch,
225 remotes,
226 })
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct FileContents {
235 pub head: Option<Vec<u8>>,
236 pub index: Option<Vec<u8>>,
237}
238
239pub fn gutter(
245 endpoint: &RemoteEndpoint,
246 workdir: &Path,
247 head_sha: Option<&str>,
248 rel: &Path,
249 text: &str,
250 cancel: &CancelToken,
251) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), RemoteGitError> {
252 let contents = file_contents(endpoint, workdir, head_sha, rel, cancel)?;
253 let head = contents
254 .head
255 .as_deref()
256 .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("HEAD blob")))
257 .transpose()?;
258 let index = contents
259 .index
260 .as_deref()
261 .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("index blob")))
262 .transpose()?;
263 gutter_from_contents(head, index, text, rel)
264 .map_err(|error| RemoteGitError::Exec(error.to_string()))
265}
266
267pub fn file_contents(
272 endpoint: &RemoteEndpoint,
273 workdir: &Path,
274 head_sha: Option<&str>,
275 rel: &Path,
276 cancel: &CancelToken,
277) -> Result<FileContents, RemoteGitError> {
278 let exec = GitExec::Remote {
279 endpoint: endpoint.clone(),
280 workdir,
281 };
282 let head = match head_sha {
283 Some(sha) => {
284 let stdout = records(
285 &exec,
286 "ls-tree HEAD",
287 &[
288 "ls-tree".into(),
289 "-z".into(),
290 sha.into(),
291 "--".into(),
292 rel.as_os_str().into(),
293 ],
294 cancel,
295 )?;
296 match parse_tree_entry(&stdout)? {
297 Some((oid, path)) if path == rel => {
298 Some(blob_bytes(&exec, &oid, "HEAD blob", cancel)?)
299 }
300 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree HEAD")),
304 None => None,
305 }
306 }
307 None => None,
308 };
309 let stdout = records(
310 &exec,
311 "ls-files --stage",
312 &[
313 "ls-files".into(),
314 "-z".into(),
315 "--stage".into(),
316 "--".into(),
317 rel.as_os_str().into(),
318 ],
319 cancel,
320 )?;
321 let index = match parse_index_entry(&stdout)? {
322 Some((oid, path)) if path == rel => Some(blob_bytes(&exec, &oid, "index blob", cancel)?),
323 Some((_, _)) => return Err(RemoteGitError::Parse("ls-files --stage")),
324 None => None,
325 };
326 Ok(FileContents { head, index })
327}
328
329fn blob_bytes(
330 exec: &GitExec,
331 oid: &str,
332 what: &'static str,
333 cancel: &CancelToken,
334) -> Result<Vec<u8>, RemoteGitError> {
335 records(
336 exec,
337 what,
338 &["cat-file".into(), "-p".into(), oid.into()],
339 cancel,
340 )
341}
342
343pub fn commit_file_diff(
347 endpoint: &RemoteEndpoint,
348 workdir: &Path,
349 sha: &str,
350 rel: &Path,
351 cancel: &CancelToken,
352) -> Result<FileDiff, RemoteGitError> {
353 let exec = GitExec::Remote {
354 endpoint: endpoint.clone(),
355 workdir,
356 };
357 let stdout = records(
358 &exec,
359 "rev-list --parents",
360 &[
361 "rev-list".into(),
362 "--parents".into(),
363 "-n".into(),
364 "1".into(),
365 sha.into(),
366 ],
367 cancel,
368 )?;
369 let (commit, parent) = parse_commit_parents(&stdout)?;
370 if commit != sha {
371 return Err(RemoteGitError::Parse("rev-list --parents"));
372 }
373 let parent_blob = match parent.as_deref() {
374 Some(parent) => {
375 let stdout = records(
376 &exec,
377 "ls-tree parent",
378 &[
379 "ls-tree".into(),
380 "-z".into(),
381 parent.into(),
382 "--".into(),
383 rel.as_os_str().into(),
384 ],
385 cancel,
386 )?;
387 match parse_tree_entry(&stdout)? {
388 Some((oid, path)) if path == rel => {
389 Some(blob_bytes(&exec, &oid, "parent blob", cancel)?)
390 }
391 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree parent")),
392 None => None,
393 }
394 }
395 None => None,
396 };
397 let stdout = records(
398 &exec,
399 "ls-tree commit",
400 &[
401 "ls-tree".into(),
402 "-z".into(),
403 commit.clone().into(),
404 "--".into(),
405 rel.as_os_str().into(),
406 ],
407 cancel,
408 )?;
409 let commit_blob = match parse_tree_entry(&stdout)? {
410 Some((oid, path)) if path == rel => blob_bytes(&exec, &oid, "commit blob", cancel)?,
411 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree commit")),
412 None => return Err(RemoteGitError::Exec("no diff for path".into())),
413 };
414 let hunks = hunks_from_buffers(parent_blob.as_deref(), &commit_blob, rel)
415 .map_err(|error| RemoteGitError::Exec(error.to_string()))?;
416 Ok(FileDiff::from_hunks(rel.to_path_buf(), hunks))
417}
418
419pub fn effective_host(
425 endpoint: &RemoteEndpoint,
426 workdir: &Path,
427 remote: &crate::permalink::AliasRemote,
428 cancel: &CancelToken,
429) -> Result<String, crate::ssh::EffectiveHostError> {
430 use crate::ssh::EffectiveHostError;
431 let host = remote.host();
432 if !crate::permalink::is_safe_host(host) {
433 return Err(EffectiveHostError::InvalidHost);
434 }
435 let mut args = vec!["-G".into()];
436 if let Some(user) = &remote.user {
437 args.extend(["-l".into(), user.into()]);
438 }
439 if let Some(port) = remote.port {
440 args.extend(["-p".into(), port.to_string().into()]);
441 }
442 args.push(host.into());
443 let command = strop_remote::RemoteCommand::new("ssh", args, workdir)
444 .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
445 let run = strop_remote::run(endpoint, &command, cancel)
446 .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
447 if !run.status.success() {
448 return Err(EffectiveHostError::Failed(
449 String::from_utf8_lossy(&run.stderr).trim().to_string(),
450 ));
451 }
452 if run.stdout_dropped > 0 {
453 return Err(EffectiveHostError::Failed(
454 "output truncated before a hostname line".into(),
455 ));
456 }
457 let stdout = String::from_utf8_lossy(&run.stdout);
458 let hostname = parse_effective_hostname(&stdout).ok_or(EffectiveHostError::NoHostname)?;
459 if hostname == host && !hostname.contains('.') {
460 return Err(EffectiveHostError::Unresolved);
461 }
462 Ok(hostname)
463}
464
465fn parse_toplevel(bytes: &[u8]) -> Result<PathBuf, RemoteGitError> {
475 let trimmed = bytes.strip_suffix(b"\n").unwrap_or(bytes);
476 if trimmed.is_empty() || trimmed.first() != Some(&b'/') {
477 return Err(RemoteGitError::Parse("rev-parse --show-toplevel"));
478 }
479 Ok(bytes_to_path(trimmed))
480}
481
482fn parse_sha(bytes: &[u8], what: &'static str) -> Result<String, RemoteGitError> {
484 let text = String::from_utf8_lossy(bytes);
485 let sha = text.trim();
486 let valid = (40..=64).contains(&sha.len())
487 && !sha.is_empty()
488 && sha.bytes().all(|b| b.is_ascii_hexdigit());
489 if !valid {
490 return Err(RemoteGitError::Parse(what));
491 }
492 Ok(sha.to_string())
493}
494
495fn parse_remote_config(bytes: &[u8]) -> Result<Vec<(String, String)>, RemoteGitError> {
499 let mut remotes = Vec::new();
500 for record in bytes.split(|&b| b == 0) {
501 if record.is_empty() {
502 continue;
503 }
504 let Some((key, url)) = split_record(record, b'\n') else {
505 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
506 };
507 let key = std::str::from_utf8(key).map_err(|_| RemoteGitError::Utf8("remote name"))?;
508 let url = std::str::from_utf8(url).map_err(|_| RemoteGitError::Utf8("remote url"))?;
509 let Some(name) = key
510 .strip_prefix("remote.")
511 .and_then(|rest| rest.strip_suffix(".url"))
512 else {
513 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
514 };
515 if name.is_empty() {
516 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
517 }
518 remotes.push((name.to_string(), url.to_owned()));
519 }
520 Ok(remotes)
521}
522
523fn parse_tree_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
527 let Some(record) = first_record(bytes) else {
528 return Ok(None);
529 };
530 let Some((meta, path)) = split_record(record, b'\t') else {
531 return Err(RemoteGitError::Parse("ls-tree"));
532 };
533 let mut fields = meta.split(|&b| b == b' ');
534 let oid = fields.nth(2).ok_or(RemoteGitError::Parse("ls-tree"))?;
535 let oid = parse_sha(oid, "ls-tree oid")?;
536 Ok(Some((oid, bytes_to_path(path))))
537}
538
539fn parse_index_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
541 let Some(record) = first_record(bytes) else {
542 return Ok(None);
543 };
544 let Some((meta, path)) = split_record(record, b'\t') else {
545 return Err(RemoteGitError::Parse("ls-files --stage"));
546 };
547 let mut fields = meta.split(|&b| b == b' ');
548 let oid = fields
549 .nth(1)
550 .ok_or(RemoteGitError::Parse("ls-files --stage"))?;
551 let oid = parse_sha(oid, "ls-files oid")?;
552 Ok(Some((oid, bytes_to_path(path))))
553}
554
555fn parse_commit_parents(bytes: &[u8]) -> Result<(String, Option<String>), RemoteGitError> {
559 let text = String::from_utf8_lossy(bytes);
560 let mut shas = text
561 .split_whitespace()
562 .map(|token| parse_sha(token.as_bytes(), "rev-list --parents"));
563 let commit = shas
564 .next()
565 .ok_or(RemoteGitError::Parse("rev-list --parents"))??;
566 let parent = shas.next().transpose()?;
567 Ok((commit, parent))
568}
569
570fn first_record(bytes: &[u8]) -> Option<&[u8]> {
571 bytes.split(|&b| b == 0).find(|record| !record.is_empty())
572}
573
574fn split_record(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
575 let index = bytes.iter().position(|&byte| byte == separator)?;
576 Some((&bytes[..index], &bytes[index + 1..]))
577}
578
579#[cfg(unix)]
580fn bytes_to_path(bytes: &[u8]) -> PathBuf {
581 use std::os::unix::ffi::OsStrExt;
582 PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
583}
584
585#[cfg(not(unix))]
586fn bytes_to_path(bytes: &[u8]) -> PathBuf {
587 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
599 fn toplevel_is_absolute_native_path() {
600 assert_eq!(
601 parse_toplevel(b"/srv/proj with space\n").unwrap(),
602 PathBuf::from("/srv/proj with space")
603 );
604 assert!(
605 parse_toplevel(b"srv/proj\n").is_err(),
606 "relative is refused"
607 );
608 assert!(parse_toplevel(b"").is_err(), "empty is refused");
609 }
610
611 #[test]
612 fn shas_are_validated_object_names() {
613 assert_eq!(
614 parse_sha(b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45\n", "x").unwrap(),
615 "c59d8ceb7aeb96a1cdccff5646ec485acce32d45"
616 );
617 assert!(parse_sha(b"main\n", "x").is_err());
618 assert!(parse_sha(b"head is at 1234\n", "x").is_err());
619 assert!(parse_sha(b"\n", "x").is_err());
620 }
621
622 #[test]
625 fn remote_config_records_parse_native() {
626 let bytes = b"remote.origin.url\nhttps://example.com/acme/demo.git\0remote.up.url\ngit@gh:acme/other.git\0";
627 assert_eq!(
628 parse_remote_config(bytes).unwrap(),
629 vec![
630 (
631 "origin".to_string(),
632 "https://example.com/acme/demo.git".to_string()
633 ),
634 ("up".to_string(), "git@gh:acme/other.git".to_string()),
635 ]
636 );
637 assert_eq!(
638 parse_remote_config(b"").unwrap(),
639 Vec::<(String, String)>::new()
640 );
641 assert!(parse_remote_config(b"not-a-pair\0").is_err());
642 assert!(
643 parse_remote_config(b"remote..url\nx\0").is_err(),
644 "empty name"
645 );
646 assert!(
647 parse_remote_config(b"remote.o.url\nhttps://a/\xff\xfe\0").is_err(),
648 "non-UTF-8 url refused"
649 );
650 }
651
652 #[test]
656 fn tree_and_index_records_keep_native_paths() {
657 let tree = b"100644 blob 45b983be36b73c0788dc9cbcb76cbb80fc7bb057\tsrc/a b.rs\0";
658 let (oid, path) = parse_tree_entry(tree).unwrap().unwrap();
659 assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
660 assert_eq!(path, PathBuf::from("src/a b.rs"));
661 assert_eq!(parse_tree_entry(b"").unwrap(), None);
662
663 let index = b"100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0\tsrc/a b.rs\0";
664 let (oid, path) = parse_index_entry(index).unwrap().unwrap();
665 assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
666 assert_eq!(path, PathBuf::from("src/a b.rs"));
667 assert_eq!(parse_index_entry(b"").unwrap(), None);
668 assert!(parse_tree_entry(b"garbage\0").is_err());
669 assert!(parse_index_entry(b"100644 noshahere 0\tx\0").is_err());
670 }
671
672 #[test]
675 fn commit_parents_root_and_merged() {
676 let root = b"60209d7ce72dddfafc1caacd511325c314a083bf\n";
677 assert_eq!(
678 parse_commit_parents(root).unwrap(),
679 ("60209d7ce72dddfafc1caacd511325c314a083bf".to_string(), None)
680 );
681 let merged = b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45 aaaa1111111111111111111111111111111111111 bbbb2222222222222222222222222222222222222\n";
682 let (child, parent) = parse_commit_parents(merged).unwrap();
683 assert_eq!(child, "c59d8ceb7aeb96a1cdccff5646ec485acce32d45");
684 assert_eq!(
685 parent.as_deref(),
686 Some("aaaa1111111111111111111111111111111111111")
687 );
688 assert!(parse_commit_parents(b"not-a-sha\n").is_err());
689 assert!(parse_commit_parents(b"").is_err());
690 }
691
692 #[test]
696 fn nonzero_exit_is_a_typed_error() {
697 let run = GitRun {
698 success: false,
699 code: Some(128),
700 stdout: Vec::new(),
701 stderr: b"fatal: unsafe repository\n".to_vec(),
702 stdout_dropped: 0,
703 stderr_dropped: 0,
704 };
705 match exit_or_bytes("rev-parse --show-toplevel", &run) {
706 Err(RemoteGitError::Exit { op, code, stderr }) => {
707 assert_eq!(op, "rev-parse --show-toplevel");
708 assert_eq!(code, 128);
709 assert_eq!(stderr, "fatal: unsafe repository");
710 }
711 other => panic!("expected Exit, got {other:?}"),
712 }
713 }
714
715 #[test]
717 fn truncation_is_typed() {
718 let run = GitRun {
719 success: true,
720 code: Some(0),
721 stdout: Vec::new(),
722 stderr: Vec::new(),
723 stdout_dropped: 4096,
724 stderr_dropped: 0,
725 };
726 assert_eq!(
727 exit_or_bytes("ls-tree", &run),
728 Err(RemoteGitError::Truncated {
729 op: "ls-tree",
730 dropped: 4096
731 })
732 );
733 assert_eq!(
734 RemoteGitError::Truncated {
735 op: "ls-tree",
736 dropped: 9
737 }
738 .to_string(),
739 "ls-tree: remote output truncated (9 bytes dropped)"
740 );
741 }
742}