Skip to main content

mars_schema/
git_url.rs

1/// A bare `host/path` URL (`github.com/org/repo`) spelled with `https://`; any other URL is
2/// returned as written. Reading an entry applies it, so a consumer's `mars.toml` and a dependency's
3/// manifest fetch the same repository from the same text.
4pub fn with_default_scheme(url: &str) -> String {
5    if !url.contains("://")
6        && !is_local_path(url)
7        && !is_ssh_shorthand(url)
8        && url.contains('/')
9        && url.contains('.')
10    {
11        format!("https://{url}")
12    } else {
13        url.to_string()
14    }
15}
16
17/// A filesystem path or `file:` URL, which never takes a scheme. Git's own rule: with a colon, it
18/// is local only if a `/` comes before the first one (`./a:b`, `~/x:y`) or it opens with a drive
19/// letter (`C:/x`, `C:\x`); so `~user@host:repo` is SSH. Without a colon, a leading `/`, `.` or `~`.
20fn is_local_path(url: &str) -> bool {
21    let bytes = url.as_bytes();
22    if url.starts_with("file:")
23        || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
24    {
25        return true;
26    }
27    match url.split_once(':') {
28        Some((before_colon, _)) => before_colon.contains('/'),
29        None => url.starts_with(['/', '.', '~']),
30    }
31}
32
33/// Git's scp-like SSH form: `user@host:path` whatever the path (`git@host:/srv/repo.git` included),
34/// or `host:path` without a user unless it is `host:<port>/…`. A `scheme://` URL or a local path is
35/// not one.
36pub fn is_ssh_shorthand(url: &str) -> bool {
37    if url.contains("://") || is_local_path(url) {
38        return false;
39    }
40    let Some((authority, path)) = url.split_once(':') else {
41        return false;
42    };
43    if path.is_empty() || authority.contains(['/', '\\']) {
44        return false;
45    }
46    match authority.split_once('@') {
47        Some((user, host)) => !user.is_empty() && !host.is_empty(),
48        None => {
49            let (first, rest) = path.split_once('/').unwrap_or((path, ""));
50            let is_port =
51                !rest.is_empty() && !first.is_empty() && first.bytes().all(|b| b.is_ascii_digit());
52            !authority.is_empty() && !is_port
53        }
54    }
55}
56
57/// SSH and HTTPS forms share identity; ports and path case remain significant.
58pub fn canonicalize_git_url(url: &str) -> String {
59    let mut s = url.to_string();
60
61    for prefix in &["https://", "http://", "ssh://", "git://"] {
62        if let Some(rest) = s.strip_prefix(prefix) {
63            s = rest.to_string();
64            break;
65        }
66    }
67
68    // Paths can contain '@'; userinfo precedes the first '/'.
69    if let Some(at_pos) = s.find('@') {
70        let slash_pos = s.find('/').unwrap_or(s.len());
71        if at_pos < slash_pos {
72            s = s[at_pos + 1..].to_string();
73        }
74    }
75
76    if let Some(colon_pos) = s.find(':') {
77        let before_colon = &s[..colon_pos];
78        let after_colon = &s[colon_pos + 1..];
79        // Only treat as a port when all characters up to the next '/' (or end of
80        // string) are ASCII digits.  A digit-leading path segment like `123team`
81        // must NOT be treated as a port.
82        if !before_colon.contains('/') && !after_colon.starts_with("//") {
83            let port_candidate = after_colon.split('/').next().unwrap_or("");
84            let is_port =
85                !port_candidate.is_empty() && port_candidate.chars().all(|c| c.is_ascii_digit());
86            if !is_port {
87                s.replace_range(colon_pos..colon_pos + 1, "/");
88            }
89        }
90    }
91
92    if let Some(rest) = s.strip_suffix(".git") {
93        s = rest.to_string();
94    }
95
96    if let Some(rest) = s.strip_suffix('/') {
97        s = rest.to_string();
98    }
99
100    // Path segments are case-sensitive on most git hosts.
101    if let Some(slash_pos) = s.find('/') {
102        let host = s[..slash_pos].to_ascii_lowercase();
103        s = format!("{host}{}", &s[slash_pos..]);
104    } else {
105        s = s.to_ascii_lowercase();
106    }
107
108    s
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn https_form() {
117        assert_eq!(
118            canonicalize_git_url("https://github.com/foo/bar"),
119            "github.com/foo/bar"
120        );
121    }
122
123    #[test]
124    fn http_form() {
125        assert_eq!(
126            canonicalize_git_url("http://gitlab.com/org/repo"),
127            "gitlab.com/org/repo"
128        );
129    }
130
131    #[test]
132    fn ssh_url_form() {
133        assert_eq!(
134            canonicalize_git_url("ssh://git@github.com/foo/bar"),
135            "github.com/foo/bar"
136        );
137    }
138
139    #[test]
140    fn git_protocol_form() {
141        assert_eq!(
142            canonicalize_git_url("git://github.com/foo/bar"),
143            "github.com/foo/bar"
144        );
145    }
146
147    #[test]
148    fn scp_form_git_at() {
149        assert_eq!(
150            canonicalize_git_url("git@github.com:foo/bar.git"),
151            "github.com/foo/bar"
152        );
153    }
154
155    #[test]
156    fn bare_domain_form() {
157        assert_eq!(
158            canonicalize_git_url("github.com/meridian-flow/meridian-base"),
159            "github.com/meridian-flow/meridian-base"
160        );
161    }
162
163    #[test]
164    fn ssh_and_https_converge() {
165        let https = canonicalize_git_url("https://github.com/foo/bar");
166        let ssh = canonicalize_git_url("git@github.com:foo/bar.git");
167        let ssh_url = canonicalize_git_url("ssh://git@github.com/foo/bar");
168        assert_eq!(https, ssh);
169        assert_eq!(https, ssh_url);
170    }
171
172    #[test]
173    fn strips_git_suffix_https() {
174        assert_eq!(
175            canonicalize_git_url("https://github.com/foo/bar.git"),
176            "github.com/foo/bar"
177        );
178    }
179
180    #[test]
181    fn strips_git_suffix_bare() {
182        assert_eq!(
183            canonicalize_git_url("github.com/foo/bar.git"),
184            "github.com/foo/bar"
185        );
186    }
187
188    #[test]
189    fn strips_trailing_slash() {
190        assert_eq!(
191            canonicalize_git_url("https://github.com/foo/bar/"),
192            "github.com/foo/bar"
193        );
194    }
195
196    #[test]
197    fn lowercases_host_only() {
198        assert_eq!(
199            canonicalize_git_url("GITHUB.COM/Foo/Bar"),
200            "github.com/Foo/Bar"
201        );
202    }
203
204    #[test]
205    fn lowercases_host_in_https_url() {
206        assert_eq!(
207            canonicalize_git_url("https://GITHUB.COM/Foo/Bar"),
208            "github.com/Foo/Bar"
209        );
210    }
211
212    #[test]
213    fn strips_userinfo_user_password() {
214        assert_eq!(
215            canonicalize_git_url("https://user:pass@github.com/foo/bar"),
216            "github.com/foo/bar"
217        );
218    }
219
220    #[test]
221    fn strips_userinfo_bare_user() {
222        assert_eq!(
223            canonicalize_git_url("https://user@github.com/foo/bar"),
224            "github.com/foo/bar"
225        );
226    }
227
228    #[test]
229    fn preserves_explicit_port_in_url_form() {
230        assert_eq!(
231            canonicalize_git_url("git://gitlab.localtest.me:19424/group/pkg.git"),
232            "gitlab.localtest.me:19424/group/pkg"
233        );
234    }
235
236    #[test]
237    fn preserves_explicit_port_in_https() {
238        assert_eq!(
239            canonicalize_git_url("https://git.example.com:8443/org/repo"),
240            "git.example.com:8443/org/repo"
241        );
242    }
243
244    #[test]
245    fn scp_colon_converted_to_slash() {
246        assert_eq!(
247            canonicalize_git_url("git@github.com:org/repo.git"),
248            "github.com/org/repo"
249        );
250    }
251
252    #[test]
253    fn scp_digit_leading_path_segment() {
254        assert_eq!(
255            canonicalize_git_url("git@example.com:123team/repo.git"),
256            "example.com/123team/repo"
257        );
258    }
259
260    #[test]
261    fn scp_digit_leading_path_segment_no_git_suffix() {
262        assert_eq!(
263            canonicalize_git_url("git@example.com:9front/repo"),
264            "example.com/9front/repo"
265        );
266    }
267
268    #[test]
269    fn port_only_digits_is_preserved_https() {
270        assert_eq!(
271            canonicalize_git_url("https://gitlab.com:8443/org/repo"),
272            "gitlab.com:8443/org/repo"
273        );
274    }
275
276    #[test]
277    fn port_only_digits_is_preserved_git_protocol() {
278        assert_eq!(
279            canonicalize_git_url("git://custom.host:19424/group/pkg.git"),
280            "custom.host:19424/group/pkg"
281        );
282    }
283
284    #[test]
285    fn subgroup_path_preserved() {
286        assert_eq!(
287            canonicalize_git_url("https://gitlab.com/group/subgroup/repo"),
288            "gitlab.com/group/subgroup/repo"
289        );
290    }
291
292    #[test]
293    fn subgroup_path_ssh_preserved() {
294        assert_eq!(
295            canonicalize_git_url("git@gitlab.com:group/subgroup/repo.git"),
296            "gitlab.com/group/subgroup/repo"
297        );
298    }
299
300    #[test]
301    fn bare_host_no_path_lowercased() {
302        assert_eq!(canonicalize_git_url("GITHUB.COM"), "github.com");
303    }
304
305    #[test]
306    fn idempotent_on_already_canonical_form() {
307        let canonical = "github.com/foo/bar";
308        assert_eq!(canonicalize_git_url(canonical), canonical);
309    }
310
311    #[test]
312    fn default_scheme_table() {
313        let unchanged = [
314            "git@host:/srv/repo.git",
315            "git@host.example:org/repo.git",
316            "git@host:org/repo",
317            "github.com:org/repo",
318            "example.com:123team/repo",
319            "host:~user/repo",
320            "~user@host:repo",
321            "./a:b",
322            "~/x:y",
323            "[::1]:8080/org/repo.git",
324            "localhost:8080/repo",
325            "C:/x/repo.d",
326            "C:\\x\\repo",
327            "D:repo.git/x",
328            "./rel/x.git",
329            "../up/x.git",
330            "/abs/x.y/repo",
331            "~/x.y/repo",
332            "file:/x.y/r",
333            "file:///x.y/r",
334            "ssh://git@github.com/org/repo",
335            "https://github.com/org/repo",
336            "org/repo",
337        ];
338        for url in unchanged {
339            assert_eq!(with_default_scheme(url), url, "{url}");
340        }
341        assert_eq!(
342            with_default_scheme("github.com/org/repo"),
343            "https://github.com/org/repo"
344        );
345        assert_eq!(
346            with_default_scheme("example.com:8080/org/repo"),
347            "https://example.com:8080/org/repo"
348        );
349    }
350
351    #[test]
352    fn ssh_shorthand_table() {
353        for url in [
354            "git@host:/srv/repo.git",
355            "git@host:org/repo",
356            "git@host:8080/repo",
357            "github.com:org/repo",
358            "example.com:123team/repo",
359            "host:/srv/repo.git",
360            "host:~user/repo",
361            "~user@host:repo",
362        ] {
363            assert!(is_ssh_shorthand(url), "{url}");
364        }
365        for url in [
366            "example.com:8080/org/repo",
367            "git@host:",
368            "@host:org/repo",
369            "ssh://git@host/org/repo",
370            "/srv/repo:x",
371            "./a:b",
372            "~/x:y",
373            "C:/x",
374            "\\\\?\\C:\\x",
375            "file:/x",
376            "org/repo",
377        ] {
378            assert!(!is_ssh_shorthand(url), "{url}");
379        }
380    }
381}