1use std::path::Path;
25
26use crate::Error;
27use crate::aliases;
28use crate::config;
29
30const DEFAULT_REMOTE: &str = "origin";
31
32#[derive(Debug, thiserror::Error)]
33pub enum EndpointError {
34 #[error(transparent)]
35 Git(#[from] Error),
36 #[error("no LFS endpoint could be determined for remote {0:?}")]
37 Unresolved(String),
38 #[error("invalid remote URL {url:?}: {reason}")]
39 InvalidUrl { url: String, reason: String },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SshInfo {
46 pub user_and_host: String,
48 pub path: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct EndpointInfo {
60 pub url: String,
61 pub ssh: Option<SshInfo>,
62}
63
64pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
68 Ok(resolve_endpoint(cwd, remote)?.url)
69}
70
71pub fn resolve_endpoint(cwd: &Path, remote: Option<&str>) -> Result<EndpointInfo, EndpointError> {
76 let caller_specified_remote = remote.is_some();
77 let mut remote = remote.unwrap_or(DEFAULT_REMOTE).to_owned();
78
79 if let Some(v) = std::env::var_os("GIT_LFS_URL") {
80 let s = v.to_string_lossy().into_owned();
81 if !s.is_empty() {
82 return direct_endpoint(cwd, &s);
83 }
84 }
85
86 if let Some(v) = config::get_effective(cwd, "lfs.url")? {
87 return direct_endpoint(cwd, &v);
88 }
89
90 if !caller_specified_remote && remote_url(cwd, &remote)?.is_none() {
96 let remotes = list_remotes(cwd)?;
97 if remotes.len() == 1 {
98 remote = remotes.into_iter().next().expect("len==1");
99 }
100 }
101
102 let remote_lfsurl_key = format!("remote.{remote}.lfsurl");
103 if let Some(v) = config::get_effective(cwd, &remote_lfsurl_key)? {
104 return direct_endpoint(cwd, &v);
105 }
106
107 if let Some(remote_url) = remote_url(cwd, &remote)? {
108 let rewritten = aliases::rewrite(cwd, &remote_url)?;
112 return Ok(EndpointInfo {
113 url: derive_lfs_url(&rewritten)?,
114 ssh: parse_ssh_url(&rewritten),
115 });
116 }
117
118 if looks_like_url(&remote) {
125 let rewritten = aliases::rewrite(cwd, &remote)?;
126 return Ok(EndpointInfo {
127 url: derive_lfs_url(&rewritten)?,
128 ssh: parse_ssh_url(&rewritten),
129 });
130 }
131
132 Err(EndpointError::Unresolved(remote))
133}
134
135fn direct_endpoint(cwd: &Path, value: &str) -> Result<EndpointInfo, EndpointError> {
142 let rewritten = aliases::rewrite(cwd, value)?;
143 let ssh = parse_ssh_url(&rewritten);
144 Ok(EndpointInfo {
145 url: rewritten,
146 ssh,
147 })
148}
149
150fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
154 let out = std::process::Command::new("git")
155 .arg("-C")
156 .arg(cwd)
157 .args(["remote"])
158 .output()
159 .map_err(Error::Io)?;
160 if !out.status.success() {
161 return Ok(Vec::new());
162 }
163 Ok(String::from_utf8_lossy(&out.stdout)
164 .lines()
165 .filter(|l| !l.is_empty())
166 .map(str::to_owned)
167 .collect())
168}
169
170pub fn looks_like_url(s: &str) -> bool {
174 s.starts_with("http://")
175 || s.starts_with("https://")
176 || s.starts_with("ssh://")
177 || s.starts_with("git+ssh://")
178 || s.starts_with("ssh+git://")
179 || s.starts_with("git://")
180 || s.starts_with("file://")
181 || s.contains("://")
182 || s.contains('@')
183}
184
185fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
192 config::get_effective(cwd, &format!("remote.{remote}.url"))
193}
194
195pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
206 let trimmed = remote_url.trim();
207 if trimmed.is_empty() {
208 return Err(EndpointError::InvalidUrl {
209 url: remote_url.to_owned(),
210 reason: "empty URL".into(),
211 });
212 }
213
214 if let Some(rest) = trimmed.strip_prefix("file://") {
215 return Ok(format!("file://{rest}"));
219 }
220
221 if let Some(rest) = trimmed.strip_prefix("https://") {
223 return Ok(append_lfs_path(&format!("https://{rest}")));
224 }
225 if let Some(rest) = trimmed.strip_prefix("http://") {
226 return Ok(append_lfs_path(&format!("http://{rest}")));
227 }
228 if let Some(rest) = trimmed.strip_prefix("ssh://") {
229 return ssh_to_https(rest, "ssh://");
230 }
231 if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
232 return ssh_to_https(rest, "git+ssh://");
233 }
234 if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
235 return ssh_to_https(rest, "ssh+git://");
236 }
237 if let Some(rest) = trimmed.strip_prefix("git://") {
238 return Ok(append_lfs_path(&format!("https://{rest}")));
240 }
241
242 if let Some((host_part, path)) = bare_ssh_split(trimmed) {
246 let host = host_part.split('@').next_back().unwrap_or(host_part);
247 return Ok(append_lfs_path(&format!(
248 "https://{host}/{}",
249 path.trim_start_matches('/'),
250 )));
251 }
252
253 Err(EndpointError::InvalidUrl {
254 url: remote_url.to_owned(),
255 reason: "unrecognized URL form".into(),
256 })
257}
258
259pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
268 let trimmed = rawurl.trim();
269 let ssh_rest = trimmed
273 .strip_prefix("ssh://")
274 .or_else(|| trimmed.strip_prefix("git+ssh://"))
275 .or_else(|| trimmed.strip_prefix("ssh+git://"));
276 if let Some(rest) = ssh_rest {
277 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
278 if authority.is_empty() {
279 return None;
280 }
281 let user_and_host = authority
285 .rsplit_once(':')
286 .map(|(host, _port)| host)
287 .unwrap_or(authority);
288 return Some(SshInfo {
289 user_and_host: user_and_host.to_owned(),
290 path: format!("/{}", path.trim_start_matches('/')),
292 });
293 }
294 if trimmed.starts_with("http://")
296 || trimmed.starts_with("https://")
297 || trimmed.starts_with("git://")
298 || trimmed.starts_with("file://")
299 {
300 return None;
301 }
302 let (host, path) = bare_ssh_split(trimmed)?;
305 Some(SshInfo {
306 user_and_host: host.to_owned(),
307 path: path.trim_start_matches('/').to_owned(),
308 })
309}
310
311fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
315 if rawurl.starts_with('/') || rawurl.starts_with('.') {
317 return None;
318 }
319 if rawurl.contains('\\') {
320 return None;
321 }
322
323 let (host, path) = rawurl.split_once(':')?;
324 if host.is_empty() || path.is_empty() {
325 return None;
326 }
327 if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
331 return None;
332 }
333 Some((host, path))
334}
335
336fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
339 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
340 if authority.is_empty() {
341 return Err(EndpointError::InvalidUrl {
342 url: format!("{scheme_for_error}{rest}"),
343 reason: "missing host".into(),
344 });
345 }
346 let host_with_port = authority.split('@').next_back().unwrap_or(authority);
348 let host = host_with_port.split(':').next().unwrap_or(host_with_port);
350 Ok(append_lfs_path(&format!(
351 "https://{host}/{}",
352 path.trim_start_matches('/'),
353 )))
354}
355
356fn append_lfs_path(url: &str) -> String {
360 let trimmed = url.trim_end_matches('/');
361 if trimmed.ends_with(".git") {
362 format!("{trimmed}/info/lfs")
363 } else {
364 format!("{trimmed}.git/info/lfs")
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
375 fn https_url_without_dotgit_gets_dotgit_info_lfs() {
376 assert_eq!(
377 derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
378 "https://git-server.com/foo/bar.git/info/lfs",
379 );
380 }
381
382 #[test]
383 fn https_url_with_dotgit_gets_just_info_lfs() {
384 assert_eq!(
385 derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
386 "https://git-server.com/foo/bar.git/info/lfs",
387 );
388 }
389
390 #[test]
391 fn http_url_is_preserved_as_http() {
392 assert_eq!(
393 derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
394 "http://localhost:8080/foo/bar.git/info/lfs",
395 );
396 }
397
398 #[test]
399 fn trailing_slash_is_collapsed() {
400 assert_eq!(
401 derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
402 "https://git-server.com/foo/bar.git/info/lfs",
403 );
404 }
405
406 #[test]
407 fn ssh_url_becomes_https() {
408 assert_eq!(
409 derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
410 "https://git-server.com/foo/bar.git/info/lfs",
411 );
412 }
413
414 #[test]
415 fn ssh_url_strips_user_and_port() {
416 assert_eq!(
417 derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
418 "https://git-server.com/foo/bar.git/info/lfs",
419 );
420 }
421
422 #[test]
423 fn bare_ssh_url_becomes_https() {
424 assert_eq!(
425 derive_lfs_url("git@github.com:user/repo.git").unwrap(),
426 "https://github.com/user/repo.git/info/lfs",
427 );
428 }
429
430 #[test]
431 fn bare_ssh_without_user_becomes_https() {
432 assert_eq!(
434 derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
435 "https://git-server.com/foo/bar.git/info/lfs",
436 );
437 }
438
439 #[test]
440 fn git_protocol_url_becomes_https() {
441 assert_eq!(
442 derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
443 "https://git-server.com/foo/bar.git/info/lfs",
444 );
445 }
446
447 #[test]
448 fn ssh_git_variants_are_recognized() {
449 for prefix in ["git+ssh", "ssh+git"] {
450 let url = format!("{prefix}://git@git-server.com/foo/bar.git");
451 assert_eq!(
452 derive_lfs_url(&url).unwrap(),
453 "https://git-server.com/foo/bar.git/info/lfs",
454 );
455 }
456 }
457
458 #[test]
459 fn file_url_is_passed_through_unchanged() {
460 assert_eq!(
461 derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
462 "file:///srv/repos/foo.git",
463 );
464 }
465
466 #[test]
467 fn empty_url_errors() {
468 assert!(matches!(
469 derive_lfs_url(""),
470 Err(EndpointError::InvalidUrl { .. }),
471 ));
472 }
473
474 #[test]
475 fn windows_path_is_not_misread_as_ssh() {
476 assert!(derive_lfs_url("C:\\repos\\foo").is_err());
479 }
480
481 #[test]
482 fn relative_path_is_rejected_not_treated_as_ssh() {
483 assert!(derive_lfs_url("./relative/path").is_err());
484 assert!(derive_lfs_url("/abs/path").is_err());
485 }
486
487 #[test]
490 fn ssh_metadata_for_bare_user_at_host() {
491 let info = parse_ssh_url("git@github.com:user/repo.git").unwrap();
492 assert_eq!(info.user_and_host, "git@github.com");
493 assert_eq!(info.path, "user/repo.git");
494 }
495
496 #[test]
497 fn ssh_metadata_for_bare_host_only() {
498 let info = parse_ssh_url("badalias:rest").unwrap();
499 assert_eq!(info.user_and_host, "badalias");
500 assert_eq!(info.path, "rest");
501 }
502
503 #[test]
504 fn ssh_metadata_for_ssh_scheme_keeps_leading_slash() {
505 let info = parse_ssh_url("ssh://git@host.example/path/to/repo.git").unwrap();
506 assert_eq!(info.user_and_host, "git@host.example");
507 assert_eq!(info.path, "/path/to/repo.git");
508 }
509
510 #[test]
511 fn ssh_metadata_for_ssh_scheme_drops_port_from_host() {
512 let info = parse_ssh_url("ssh://git@host.example:2222/path").unwrap();
513 assert_eq!(info.user_and_host, "git@host.example");
514 assert_eq!(info.path, "/path");
515 }
516
517 #[test]
518 fn ssh_metadata_for_https_returns_none() {
519 assert!(parse_ssh_url("https://host.example/path").is_none());
520 assert!(parse_ssh_url("http://host.example/path").is_none());
521 }
522
523 #[test]
524 fn ssh_metadata_for_git_protocol_returns_none() {
525 assert!(parse_ssh_url("git://host.example/path").is_none());
526 }
527
528 #[test]
529 fn ssh_metadata_for_file_url_returns_none() {
530 assert!(parse_ssh_url("file:///srv/repos/foo.git").is_none());
531 }
532
533 #[test]
534 fn ssh_metadata_for_local_path_returns_none() {
535 assert!(parse_ssh_url("/abs/path").is_none());
536 assert!(parse_ssh_url("./relative").is_none());
537 }
538
539 use std::sync::{Mutex, MutexGuard};
547 use tempfile::TempDir;
548
549 static ENV_LOCK: Mutex<()> = Mutex::new(());
550
551 fn lock_env() -> MutexGuard<'static, ()> {
552 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
556 }
557
558 fn fresh_repo() -> TempDir {
559 let tmp = TempDir::new().unwrap();
560 let s = std::process::Command::new("git")
561 .args(["init", "--quiet"])
562 .arg(tmp.path())
563 .status()
564 .unwrap();
565 assert!(s.success());
566 tmp
567 }
568
569 fn git_in(repo: &Path, args: &[&str]) {
570 let s = std::process::Command::new("git")
571 .arg("-C")
572 .arg(repo)
573 .args(args)
574 .status()
575 .unwrap();
576 assert!(s.success(), "git {args:?} failed");
577 }
578
579 #[test]
580 fn endpoint_prefers_explicit_lfs_url() {
581 let _g = lock_env();
582 unsafe { std::env::remove_var("GIT_LFS_URL") };
583 let repo = fresh_repo();
584 git_in(
585 repo.path(),
586 &["config", "--local", "lfs.url", "https://example.com/lfs"],
587 );
588 git_in(
589 repo.path(),
590 &[
591 "config",
592 "--local",
593 "remote.origin.url",
594 "git@github.com:x/y.git",
595 ],
596 );
597 let url = endpoint_for_remote(repo.path(), None).unwrap();
598 assert_eq!(url, "https://example.com/lfs");
599 }
600
601 #[test]
602 fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
603 let _g = lock_env();
604 unsafe { std::env::remove_var("GIT_LFS_URL") };
605 let repo = fresh_repo();
606 git_in(
607 repo.path(),
608 &[
609 "config",
610 "--local",
611 "remote.origin.lfsurl",
612 "https://lfs.dev/repo",
613 ],
614 );
615 git_in(
616 repo.path(),
617 &[
618 "config",
619 "--local",
620 "remote.origin.url",
621 "git@github.com:x/y.git",
622 ],
623 );
624 let url = endpoint_for_remote(repo.path(), None).unwrap();
625 assert_eq!(url, "https://lfs.dev/repo");
626 }
627
628 #[test]
629 fn endpoint_derives_from_remote_url() {
630 let _g = lock_env();
631 unsafe { std::env::remove_var("GIT_LFS_URL") };
632 let repo = fresh_repo();
633 git_in(
634 repo.path(),
635 &[
636 "config",
637 "--local",
638 "remote.origin.url",
639 "git@github.com:x/y.git",
640 ],
641 );
642 let url = endpoint_for_remote(repo.path(), None).unwrap();
643 assert_eq!(url, "https://github.com/x/y.git/info/lfs");
644 }
645
646 #[test]
647 fn endpoint_uses_named_remote_over_origin() {
648 let _g = lock_env();
649 unsafe { std::env::remove_var("GIT_LFS_URL") };
650 let repo = fresh_repo();
651 git_in(
652 repo.path(),
653 &[
654 "config",
655 "--local",
656 "remote.upstream.url",
657 "https://other.example.com/foo",
658 ],
659 );
660 let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
661 assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
662 }
663
664 #[test]
665 fn endpoint_reads_lfsconfig_at_repo_root() {
666 let _g = lock_env();
667 unsafe { std::env::remove_var("GIT_LFS_URL") };
668 let repo = fresh_repo();
669 std::fs::write(
671 repo.path().join(".lfsconfig"),
672 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
673 )
674 .unwrap();
675 let url = endpoint_for_remote(repo.path(), None).unwrap();
676 assert_eq!(url, "https://from-lfsconfig.example/");
677 }
678
679 #[test]
680 fn endpoint_local_config_overrides_lfsconfig() {
681 let _g = lock_env();
682 unsafe { std::env::remove_var("GIT_LFS_URL") };
683 let repo = fresh_repo();
684 std::fs::write(
685 repo.path().join(".lfsconfig"),
686 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
687 )
688 .unwrap();
689 git_in(
690 repo.path(),
691 &[
692 "config",
693 "--local",
694 "lfs.url",
695 "https://from-localconfig.example/",
696 ],
697 );
698 let url = endpoint_for_remote(repo.path(), None).unwrap();
699 assert_eq!(url, "https://from-localconfig.example/");
700 }
701
702 #[test]
703 fn endpoint_unresolved_when_nothing_configured() {
704 let _g = lock_env();
705 unsafe { std::env::remove_var("GIT_LFS_URL") };
706 let repo = fresh_repo();
707 let err = endpoint_for_remote(repo.path(), None).unwrap_err();
708 assert!(matches!(err, EndpointError::Unresolved(_)));
709 }
710
711 #[test]
712 fn endpoint_env_var_wins_over_everything() {
713 let _g = lock_env();
714 let repo = fresh_repo();
715 git_in(
716 repo.path(),
717 &["config", "--local", "lfs.url", "https://lo.cal/lfs"],
718 );
719
720 let prev = std::env::var_os("GIT_LFS_URL");
721 unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
722 let url = endpoint_for_remote(repo.path(), None).unwrap();
723 assert_eq!(url, "https://from-env.example/");
724 unsafe {
725 match prev {
726 Some(v) => std::env::set_var("GIT_LFS_URL", v),
727 None => std::env::remove_var("GIT_LFS_URL"),
728 }
729 }
730 }
731}