Skip to main content

wdl_modules/
remote.rs

1//! Conversion of Git remote strings into URLs.
2
3use std::path::Path;
4
5use url::Url;
6
7/// The URL schemes Git uses to name remote transports.
8const TRANSPORT_SCHEMES: [&str; 6] = ["file", "git", "git+ssh", "http", "https", "ssh"];
9
10/// The syntax a Git remote string is written in.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum GitRemoteKind {
13    /// An absolute URL carrying a Git transport scheme, such as
14    /// `https://github.com/openwdl/wdl.git`.
15    Url,
16    /// Git's scp-like syntax, such as `git@github.com:openwdl/wdl.git`.
17    Scp,
18    /// A filesystem path, either absolute or relative.
19    Path,
20}
21
22/// Reports which syntax `remote` is written in.
23pub fn git_remote_kind(remote: &str) -> GitRemoteKind {
24    if transport_url(remote).is_some() {
25        GitRemoteKind::Url
26    } else if scp_parts(remote).is_some() {
27        GitRemoteKind::Scp
28    } else {
29        GitRemoteKind::Path
30    }
31}
32
33/// Converts a Git remote into an absolute URL.
34///
35/// A remote that already carries a Git transport scheme is returned unchanged.
36/// Git's scp-like syntax becomes an `ssh` URL, and a filesystem path becomes a
37/// `file` URL. Returns `None` for a relative path that does not resolve and for
38/// anything else that cannot be expressed as a URL.
39///
40/// An scp-like path becomes an absolute URL path whether or not it began with a
41/// separator, because that is the form hosted forges expect. Git itself reads a
42/// separator-less scp path as relative to the login user's home directory, so a
43/// self-hosted remote of that shape needs its URL written out by hand.
44///
45/// # Examples
46///
47/// ```rust
48/// # use wdl_modules::normalize_git_remote;
49/// assert_eq!(
50///     normalize_git_remote("git@github.com:openwdl/wdl.git").map(|url| url.to_string()),
51///     Some("ssh://git@github.com/openwdl/wdl.git".to_string())
52/// );
53/// ```
54pub fn normalize_git_remote(remote: &str) -> Option<Url> {
55    let path = Path::new(remote);
56    if path.is_absolute() {
57        return Url::from_file_path(path).ok();
58    }
59    if let Some(url) = transport_url(remote) {
60        return Some(url);
61    }
62    if let Some((host, path)) = scp_parts(remote) {
63        // A server-absolute scp path already carries its leading separator, and
64        // `ssh://host//path` would ask the server for `//path`.
65        let path = path.strip_prefix('/').unwrap_or(path);
66        return Url::parse(&format!("ssh://{host}/{path}")).ok();
67    }
68    Url::from_file_path(path.canonicalize().ok()?).ok()
69}
70
71/// Parses `remote` as a URL carrying one of Git's transport schemes.
72///
73/// A bare `host:path` remote parses as a URL whose scheme is the host, so the
74/// scheme has to be checked against the transports Git understands rather than
75/// trusting [`Url::parse`] alone.
76fn transport_url(remote: &str) -> Option<Url> {
77    let url = Url::parse(remote).ok()?;
78    TRANSPORT_SCHEMES.contains(&url.scheme()).then_some(url)
79}
80
81/// Splits Git's scp-like `[user@]host:path` syntax into its host and path.
82///
83/// The path may be server-absolute, as in `git@host:/srv/repo.git`. A path
84/// starting with `//` is rejected because that is how a URL looks once it has
85/// been split on its scheme separator.
86fn scp_parts(remote: &str) -> Option<(&str, &str)> {
87    if starts_with_windows_drive(remote) {
88        return None;
89    }
90    let (host, path) = remote.split_once(':')?;
91    (!host.is_empty() && !host.contains(['/', '\\']) && !path.is_empty() && !path.starts_with("//"))
92        .then_some((host, path))
93}
94
95/// Returns `true` when `remote` starts with a Windows drive prefix such as
96/// `C:`.
97fn starts_with_windows_drive(remote: &str) -> bool {
98    let mut bytes = remote.bytes();
99    matches!(
100        (bytes.next(), bytes.next()),
101        (Some(b'A'..=b'Z' | b'a'..=b'z'), Some(b':'))
102    )
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn normalized(remote: &str) -> Option<String> {
110        normalize_git_remote(remote).map(|url| url.to_string())
111    }
112
113    #[test]
114    fn scp_syntax_becomes_an_ssh_url() {
115        assert_eq!(
116            normalized("git@github.com:stjudecloud/workflows.git").as_deref(),
117            Some("ssh://git@github.com/stjudecloud/workflows.git")
118        );
119    }
120
121    #[test]
122    fn scp_syntax_without_a_user_becomes_an_ssh_url() {
123        assert_eq!(
124            normalized("github.com:stjudecloud/workflows.git").as_deref(),
125            Some("ssh://github.com/stjudecloud/workflows.git")
126        );
127    }
128
129    #[test]
130    fn a_server_absolute_scp_path_keeps_one_separator() {
131        assert_eq!(
132            normalized("git@example.com:/srv/git/workflows.git").as_deref(),
133            Some("ssh://git@example.com/srv/git/workflows.git")
134        );
135    }
136
137    #[test]
138    fn transport_urls_pass_through_unchanged() {
139        assert_eq!(
140            normalized("https://github.com/stjudecloud/workflows.git").as_deref(),
141            Some("https://github.com/stjudecloud/workflows.git")
142        );
143    }
144
145    #[test]
146    fn absolute_paths_become_file_urls() {
147        let remote = if cfg!(windows) {
148            "C:\\repos\\workflows"
149        } else {
150            "/repos/workflows"
151        };
152
153        let normalized = normalized(remote);
154
155        assert!(
156            normalized
157                .as_deref()
158                .is_some_and(|url| url.starts_with("file://")),
159            "expected a file URL, got {normalized:?}"
160        );
161    }
162
163    #[test]
164    fn relative_paths_that_do_not_exist_are_rejected() {
165        assert_eq!(normalized("relative/path-that-does-not-exist"), None);
166    }
167
168    #[test]
169    fn scp_syntax_does_not_swallow_a_windows_drive_path() {
170        assert_eq!(git_remote_kind("C:\\repos\\workflows"), GitRemoteKind::Path);
171    }
172
173    #[test]
174    fn a_bare_host_and_path_is_scp_syntax_rather_than_a_url() {
175        assert_eq!(
176            git_remote_kind("github.com:stjudecloud/workflows.git"),
177            GitRemoteKind::Scp
178        );
179    }
180}