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/// Canonicalize a git URL for identity comparison and cache keying.
58///
59/// SSH and HTTPS forms of the same repo produce the same canonical form.
60///
61/// Canonical form: `host/path` (lowercase host, no protocol, no `.git`,
62/// no trailing slash, no userinfo, SCP colon converted to slash).
63///
64/// Port handling: Explicit ports in URL-style forms (`host:port/path`) are
65/// preserved. SCP-style colon (`host:path`) is distinguished by checking
66/// whether the text between the colon and the next `/` (or end of string) is
67/// **entirely digits** (a real port) vs. a path segment (convert to slash).
68/// This correctly handles digit-leading path segments like `123team/repo`.
69///
70/// # Examples
71///
72/// ```text
73/// canonicalize_git_url("https://github.com/foo/bar")   → "github.com/foo/bar"
74/// canonicalize_git_url("git@github.com:foo/bar.git")   → "github.com/foo/bar"
75/// canonicalize_git_url("ssh://git@github.com/foo/bar") → "github.com/foo/bar"
76/// canonicalize_git_url("GITHUB.COM/Foo/Bar")           → "github.com/Foo/Bar"
77/// ```
78pub fn canonicalize_git_url(url: &str) -> String {
79    let mut s = url.to_string();
80
81    // 1. Strip protocol prefixes
82    for prefix in &["https://", "http://", "ssh://", "git://"] {
83        if let Some(rest) = s.strip_prefix(prefix) {
84            s = rest.to_string();
85            break;
86        }
87    }
88
89    // 2. Strip userinfo (git@, user:pass@, etc.)
90    //    Only strip if the '@' comes before the first '/' (it's a userinfo,
91    //    not part of the path).
92    if let Some(at_pos) = s.find('@') {
93        let slash_pos = s.find('/').unwrap_or(s.len());
94        if at_pos < slash_pos {
95            s = s[at_pos + 1..].to_string();
96        }
97    }
98
99    // 3. Handle SCP-style colon vs port colon.
100    //    After stripping userinfo we may have:
101    //      - `github.com:foo/bar`    (SCP – colon is a separator, convert to /)
102    //      - `github.com:123team/r`  (SCP – digit-leading path, convert to /)
103    //      - `github.com:1234/path`  (URL with port – keep the colon)
104    //      - `github.com/foo/bar`    (already URL-style – no colon)
105    if let Some(colon_pos) = s.find(':') {
106        let before_colon = &s[..colon_pos];
107        let after_colon = &s[colon_pos + 1..];
108        // Only treat as a port when all characters up to the next '/' (or end of
109        // string) are ASCII digits.  A digit-leading path segment like `123team`
110        // must NOT be treated as a port.
111        if !before_colon.contains('/') && !after_colon.starts_with("//") {
112            let port_candidate = after_colon.split('/').next().unwrap_or("");
113            let is_port =
114                !port_candidate.is_empty() && port_candidate.chars().all(|c| c.is_ascii_digit());
115            if !is_port {
116                // SCP-style colon: convert to slash
117                s.replace_range(colon_pos..colon_pos + 1, "/");
118            }
119        }
120    }
121
122    // 4. Strip trailing `.git` suffix
123    if let Some(rest) = s.strip_suffix(".git") {
124        s = rest.to_string();
125    }
126
127    // 5. Strip trailing slash
128    if let Some(rest) = s.strip_suffix('/') {
129        s = rest.to_string();
130    }
131
132    // 6. Lowercase the host portion only (everything before the first `/`).
133    //    Path segments are case-sensitive on most git hosts.
134    if let Some(slash_pos) = s.find('/') {
135        let host = s[..slash_pos].to_ascii_lowercase();
136        s = format!("{host}{}", &s[slash_pos..]);
137    } else {
138        // No slash means the whole string is a host (bare domain URL).
139        s = s.to_ascii_lowercase();
140    }
141
142    s
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    // ── Protocol forms ──────────────────────────────────────────────────────
150
151    #[test]
152    fn https_form() {
153        assert_eq!(
154            canonicalize_git_url("https://github.com/foo/bar"),
155            "github.com/foo/bar"
156        );
157    }
158
159    #[test]
160    fn http_form() {
161        assert_eq!(
162            canonicalize_git_url("http://gitlab.com/org/repo"),
163            "gitlab.com/org/repo"
164        );
165    }
166
167    #[test]
168    fn ssh_url_form() {
169        assert_eq!(
170            canonicalize_git_url("ssh://git@github.com/foo/bar"),
171            "github.com/foo/bar"
172        );
173    }
174
175    #[test]
176    fn git_protocol_form() {
177        assert_eq!(
178            canonicalize_git_url("git://github.com/foo/bar"),
179            "github.com/foo/bar"
180        );
181    }
182
183    #[test]
184    fn scp_form_git_at() {
185        assert_eq!(
186            canonicalize_git_url("git@github.com:foo/bar.git"),
187            "github.com/foo/bar"
188        );
189    }
190
191    #[test]
192    fn bare_domain_form() {
193        assert_eq!(
194            canonicalize_git_url("github.com/meridian-flow/meridian-base"),
195            "github.com/meridian-flow/meridian-base"
196        );
197    }
198
199    // ── All three protocol forms for the same repo converge ────────────────
200
201    #[test]
202    fn ssh_and_https_converge() {
203        let https = canonicalize_git_url("https://github.com/foo/bar");
204        let ssh = canonicalize_git_url("git@github.com:foo/bar.git");
205        let ssh_url = canonicalize_git_url("ssh://git@github.com/foo/bar");
206        assert_eq!(https, ssh);
207        assert_eq!(https, ssh_url);
208    }
209
210    // ── `.git` suffix tolerance ─────────────────────────────────────────────
211
212    #[test]
213    fn strips_git_suffix_https() {
214        assert_eq!(
215            canonicalize_git_url("https://github.com/foo/bar.git"),
216            "github.com/foo/bar"
217        );
218    }
219
220    #[test]
221    fn strips_git_suffix_bare() {
222        assert_eq!(
223            canonicalize_git_url("github.com/foo/bar.git"),
224            "github.com/foo/bar"
225        );
226    }
227
228    // ── Trailing slash tolerance ────────────────────────────────────────────
229
230    #[test]
231    fn strips_trailing_slash() {
232        assert_eq!(
233            canonicalize_git_url("https://github.com/foo/bar/"),
234            "github.com/foo/bar"
235        );
236    }
237
238    // ── Lowercase host, case-preserved path ────────────────────────────────
239
240    #[test]
241    fn lowercases_host_only() {
242        assert_eq!(
243            canonicalize_git_url("GITHUB.COM/Foo/Bar"),
244            "github.com/Foo/Bar"
245        );
246    }
247
248    #[test]
249    fn lowercases_host_in_https_url() {
250        assert_eq!(
251            canonicalize_git_url("https://GITHUB.COM/Foo/Bar"),
252            "github.com/Foo/Bar"
253        );
254    }
255
256    // ── Userinfo stripped ──────────────────────────────────────────────────
257
258    #[test]
259    fn strips_userinfo_user_password() {
260        assert_eq!(
261            canonicalize_git_url("https://user:pass@github.com/foo/bar"),
262            "github.com/foo/bar"
263        );
264    }
265
266    #[test]
267    fn strips_userinfo_bare_user() {
268        assert_eq!(
269            canonicalize_git_url("https://user@github.com/foo/bar"),
270            "github.com/foo/bar"
271        );
272    }
273
274    // ── Port preservation ──────────────────────────────────────────────────
275
276    #[test]
277    fn preserves_explicit_port_in_url_form() {
278        // A URL-style port (digit after colon) is kept as-is.
279        assert_eq!(
280            canonicalize_git_url("git://gitlab.localtest.me:19424/group/pkg.git"),
281            "gitlab.localtest.me:19424/group/pkg"
282        );
283    }
284
285    #[test]
286    fn preserves_explicit_port_in_https() {
287        assert_eq!(
288            canonicalize_git_url("https://git.example.com:8443/org/repo"),
289            "git.example.com:8443/org/repo"
290        );
291    }
292
293    // ── SCP colon becomes slash, port digit is preserved ───────────────────
294
295    #[test]
296    fn scp_colon_converted_to_slash() {
297        assert_eq!(
298            canonicalize_git_url("git@github.com:org/repo.git"),
299            "github.com/org/repo"
300        );
301    }
302
303    // ── SCP path with digit-leading segment (regression: was mis-detected as port)
304
305    #[test]
306    fn scp_digit_leading_path_segment() {
307        // "123team" starts with a digit but is NOT a port — must become a slash.
308        assert_eq!(
309            canonicalize_git_url("git@example.com:123team/repo.git"),
310            "example.com/123team/repo"
311        );
312    }
313
314    #[test]
315    fn scp_digit_leading_path_segment_no_git_suffix() {
316        assert_eq!(
317            canonicalize_git_url("git@example.com:9front/repo"),
318            "example.com/9front/repo"
319        );
320    }
321
322    // ── Real ports are preserved even when path follows ────────────────────
323
324    #[test]
325    fn port_only_digits_is_preserved_https() {
326        assert_eq!(
327            canonicalize_git_url("https://gitlab.com:8443/org/repo"),
328            "gitlab.com:8443/org/repo"
329        );
330    }
331
332    #[test]
333    fn port_only_digits_is_preserved_git_protocol() {
334        assert_eq!(
335            canonicalize_git_url("git://custom.host:19424/group/pkg.git"),
336            "custom.host:19424/group/pkg"
337        );
338    }
339
340    // ── Subgroup / nested paths preserved ──────────────────────────────────
341
342    #[test]
343    fn subgroup_path_preserved() {
344        assert_eq!(
345            canonicalize_git_url("https://gitlab.com/group/subgroup/repo"),
346            "gitlab.com/group/subgroup/repo"
347        );
348    }
349
350    #[test]
351    fn subgroup_path_ssh_preserved() {
352        assert_eq!(
353            canonicalize_git_url("git@gitlab.com:group/subgroup/repo.git"),
354            "gitlab.com/group/subgroup/repo"
355        );
356    }
357
358    // ── No-slash bare host ─────────────────────────────────────────────────
359
360    #[test]
361    fn bare_host_no_path_lowercased() {
362        assert_eq!(canonicalize_git_url("GITHUB.COM"), "github.com");
363    }
364
365    // ── Idempotence ────────────────────────────────────────────────────────
366
367    #[test]
368    fn idempotent_on_already_canonical_form() {
369        let canonical = "github.com/foo/bar";
370        assert_eq!(canonicalize_git_url(canonical), canonical);
371    }
372
373    // ── Classifier: SSH shorthand, local paths, default scheme ─────────────
374
375    #[test]
376    fn default_scheme_table() {
377        let unchanged = [
378            "git@host:/srv/repo.git",
379            "git@host.example:org/repo.git",
380            "git@host:org/repo",
381            "github.com:org/repo",
382            "example.com:123team/repo",
383            "host:~user/repo",
384            "~user@host:repo",
385            "./a:b",
386            "~/x:y",
387            "[::1]:8080/org/repo.git",
388            "localhost:8080/repo",
389            "C:/x/repo.d",
390            "C:\\x\\repo",
391            "D:repo.git/x",
392            "./rel/x.git",
393            "../up/x.git",
394            "/abs/x.y/repo",
395            "~/x.y/repo",
396            "file:/x.y/r",
397            "file:///x.y/r",
398            "ssh://git@github.com/org/repo",
399            "https://github.com/org/repo",
400            "org/repo",
401        ];
402        for url in unchanged {
403            assert_eq!(with_default_scheme(url), url, "{url}");
404        }
405        assert_eq!(
406            with_default_scheme("github.com/org/repo"),
407            "https://github.com/org/repo"
408        );
409        assert_eq!(
410            with_default_scheme("example.com:8080/org/repo"),
411            "https://example.com:8080/org/repo"
412        );
413    }
414
415    #[test]
416    fn ssh_shorthand_table() {
417        for url in [
418            "git@host:/srv/repo.git",
419            "git@host:org/repo",
420            "git@host:8080/repo",
421            "github.com:org/repo",
422            "example.com:123team/repo",
423            "host:/srv/repo.git",
424            "host:~user/repo",
425            "~user@host:repo",
426        ] {
427            assert!(is_ssh_shorthand(url), "{url}");
428        }
429        for url in [
430            "example.com:8080/org/repo",
431            "git@host:",
432            "@host:org/repo",
433            "ssh://git@host/org/repo",
434            "/srv/repo:x",
435            "./a:b",
436            "~/x:y",
437            "C:/x",
438            "\\\\?\\C:\\x",
439            "file:/x",
440            "org/repo",
441        ] {
442            assert!(!is_ssh_shorthand(url), "{url}");
443        }
444    }
445}