Skip to main content

mkit_git_bridge/
remoteid.rs

1//! Canonical remote identity (SPEC-GIT-IMPORT §8).
2//!
3//! One normalization used by every binding, guard, and attestation
4//! `remoteUrl` field, so trivially-equivalent spellings of one remote
5//! compare equal. This is a safety net against accidents, not a
6//! security boundary — mirrors and redirects are undetectable, and
7//! the push lease remains the backstop.
8
9use std::path::Path;
10
11/// Compute the canonical identity of a destination/source string.
12///
13/// Rules (§8): scp-style rewrites to `ssh://`; scheme+host lowercase;
14/// userinfo dropped; default ports stripped per scheme (`ssh` 22,
15/// `https` 443, `http` 80, `git` 9418); one trailing `/` and one
16/// trailing `.git` stripped; local paths and `file://` URLs collapse
17/// to the symlink-resolved absolute path (falling back to the
18/// lexical absolute path when the target does not exist yet).
19#[must_use]
20pub fn remote_identity(dest: &str) -> String {
21    remote_identity_relative_to(dest, None)
22}
23
24/// As [`remote_identity`], but resolves a non-existent relative local
25/// path against `base` instead of the process's current directory when
26/// `base` is `Some`. Exposed (test-only) so the relative-path test can
27/// absolutize deterministically instead of mutating the shared process
28/// cwd via `set_current_dir` — a `set_current_dir` call races every
29/// other test in the binary running in parallel (#505 PR 5/5).
30fn remote_identity_relative_to(dest: &str, base: Option<&Path>) -> String {
31    // file:// URLs → local path handling.
32    if let Some(rest) = dest.strip_prefix("file://") {
33        let path = rest.strip_prefix("localhost").unwrap_or(rest);
34        return canonical_local(path, base);
35    }
36
37    // Scheme URLs.
38    if let Some((scheme, rest)) = dest.split_once("://") {
39        let scheme = scheme.to_ascii_lowercase();
40        let (authority, path) = match rest.find('/') {
41            Some(i) => (&rest[..i], &rest[i..]),
42            None => (rest, ""),
43        };
44        // Drop userinfo.
45        let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
46        let (host, port) = split_host_port(host_port);
47        let host = host.to_ascii_lowercase();
48        let default_port = match scheme.as_str() {
49            "ssh" => Some("22"),
50            "https" => Some("443"),
51            "http" => Some("80"),
52            "git" => Some("9418"),
53            _ => None,
54        };
55        let port_part = match port {
56            Some(p) if Some(p) != default_port => format!(":{p}"),
57            _ => String::new(),
58        };
59        return format!("{scheme}://{host}{port_part}{}", strip_path(path));
60    }
61
62    // scp-style `[user@]host:path` — a colon before the first slash.
63    let first_seg = dest.split('/').next().unwrap_or(dest);
64    if first_seg.contains(':') && !looks_like_dos_drive(dest) {
65        // Bracket-aware split: `[::1]:path` keeps the literal intact.
66        let after_user = dest.rsplit_once('@').map_or(dest, |(_, rest)| rest);
67        let (host, path) = if after_user.starts_with('[') {
68            match after_user.find(']') {
69                Some(end) => {
70                    let host = &after_user[..=end];
71                    let path = after_user[end + 1..].strip_prefix(':').unwrap_or("");
72                    (host, path)
73                }
74                None => after_user.split_once(':').unwrap_or((after_user, "")),
75            }
76        } else {
77            after_user.split_once(':').unwrap_or((after_user, ""))
78        };
79        let host = host.to_ascii_lowercase();
80        return format!("ssh://{host}/{}", strip_path(path).trim_start_matches('/'));
81    }
82
83    // Local path.
84    canonical_local(dest, base)
85}
86
87/// Split `host[:port]`, leaving IPv6 bracket literals intact.
88fn split_host_port(hp: &str) -> (&str, Option<&str>) {
89    if hp.starts_with('[') {
90        // `[::1]` or `[::1]:2222`
91        match hp.find(']') {
92            Some(end) => {
93                let host = &hp[..=end];
94                let port = hp[end + 1..].strip_prefix(':');
95                (host, port)
96            }
97            None => (hp, None),
98        }
99    } else {
100        match hp.rsplit_once(':') {
101            Some((h, p)) if p.bytes().all(|b| b.is_ascii_digit()) && !p.is_empty() => (h, Some(p)),
102            _ => (hp, None),
103        }
104    }
105}
106
107fn looks_like_dos_drive(dest: &str) -> bool {
108    // Strip Windows' `\\?\` extended-length-path prefix first: `Path::
109    // canonicalize` emits it on Windows (e.g. `\\?\C:\Users\...`), so a
110    // canonicalized path doesn't start with a bare drive letter. Without
111    // this strip, `remote_identity` misclassified such paths as an
112    // scp-style `[user@]host:path` remote (the literal `\\?\C` segment
113    // read as a "host"), corrupting local-path identity comparisons on
114    // Windows — caught by the `windows-smoke` CI job added in this PR.
115    let dest = dest.strip_prefix(r"\\?\").unwrap_or(dest);
116    dest.len() >= 2
117        && dest.as_bytes()[0].is_ascii_alphabetic()
118        && dest.as_bytes()[1] == b':'
119        && matches!(dest.as_bytes().get(2), None | Some(b'/' | b'\\'))
120}
121
122/// Strip exactly one trailing `/` then one trailing `.git`.
123fn strip_path(path: &str) -> String {
124    let p = path.strip_suffix('/').unwrap_or(path);
125    let p = p.strip_suffix(".git").unwrap_or(p);
126    p.to_owned()
127}
128
129fn canonical_local(path: &str, base: Option<&Path>) -> String {
130    let p = strip_path(path);
131    let pb = Path::new(&p);
132    let abs = pb.canonicalize().unwrap_or_else(|_| {
133        // Lexical fallback for paths that don't exist (yet, or after
134        // the `.git` strip): absolutize against `base` (or cwd, when
135        // `base` is `None`) and normalize `.`/`..` components, so
136        // `/a/b` + `../up` and `/a/up` agree.
137        let joined = if pb.is_absolute() {
138            pb.to_path_buf()
139        } else {
140            match base {
141                Some(b) => b.join(pb),
142                None => std::env::current_dir().map_or_else(|_| pb.to_path_buf(), |c| c.join(pb)),
143            }
144        };
145        lexical_normalize(&joined)
146    });
147    abs.to_string_lossy().into_owned()
148}
149
150/// Resolve `.` and `..` components lexically (no filesystem access).
151fn lexical_normalize(p: &Path) -> std::path::PathBuf {
152    use std::path::Component;
153    let mut out = std::path::PathBuf::new();
154    for c in p.components() {
155        match c {
156            Component::CurDir => {}
157            Component::ParentDir => {
158                if !out.pop() {
159                    out.push("..");
160                }
161            }
162            other => out.push(other.as_os_str()),
163        }
164    }
165    out
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn url_equivalence_table() {
174        // SPEC-GIT-IMPORT §8's worked example.
175        let canonical = remote_identity("ssh://github.com/org/repo");
176        for spelling in [
177            "git@github.com:org/repo.git",
178            "ssh://GIT@GITHUB.COM:22/org/repo/",
179            "ssh://github.com/org/repo.git",
180        ] {
181            assert_eq!(remote_identity(spelling), canonical, "{spelling}");
182        }
183        // https default port + case + .git
184        assert_eq!(
185            remote_identity("HTTPS://GitHub.com:443/Org/Repo.git"),
186            "https://github.com/Org/Repo",
187            "host lowercases; path case is significant"
188        );
189        // Non-default port survives.
190        assert_ne!(remote_identity("ssh://github.com:2222/org/repo"), canonical);
191        // http port 80.
192        assert_eq!(
193            remote_identity("http://host:80/r"),
194            remote_identity("http://HOST/r")
195        );
196    }
197
198    #[test]
199    fn ipv6_and_dos_paths() {
200        assert_eq!(remote_identity("[::1]:path/repo"), "ssh://[::1]/path/repo");
201        assert_eq!(
202            remote_identity("ssh://[::A]:22/r"),
203            remote_identity("ssh://[::a]/r")
204        );
205        // DOS drives are paths, not scp remotes.
206        assert!(!remote_identity("C:/repos/x").starts_with("ssh://"));
207        // Windows' `\\?\` extended-length-path prefix (what
208        // `Path::canonicalize` emits on Windows) must not defeat DOS-drive
209        // detection: a canonicalized Windows path is a local path, not an
210        // scp-style `host:path` remote. Platform-independent: this only
211        // exercises the string classifier, not real filesystem
212        // canonicalization, so it runs (and must pass) on every OS.
213        assert!(looks_like_dos_drive(r"C:\Users\x"));
214        assert!(looks_like_dos_drive(r"\\?\C:\Users\x"));
215        assert!(!remote_identity(r"\\?\C:\Users\x\repo").starts_with("ssh://"));
216    }
217
218    #[test]
219    fn relative_dotdot_paths_normalize_lexically() {
220        let td = tempfile::tempdir().unwrap();
221        // canonicalize: macOS tempdirs live behind the /var symlink,
222        // and cwd always reports the resolved spelling.
223        let a = td.path().canonicalize().unwrap().join("a");
224        let base = a.join("b");
225        std::fs::create_dir_all(&base).unwrap();
226        // `../up.git` doesn't exist: the `.git`-stripped fallback must
227        // still agree with the identity seen from the absolutized
228        // clone URL (`<td>/a/up.git` → `<td>/a/up`). Absolutize against
229        // an explicit `base` instead of `set_current_dir` (#505 PR 5/5):
230        // mutating the process cwd races every other test in this binary
231        // running in parallel.
232        let from_rel = remote_identity_relative_to("../up.git", Some(&base));
233        let from_abs = remote_identity(&format!("{}/up.git", a.display()));
234        assert_eq!(from_rel, from_abs);
235    }
236
237    #[test]
238    fn local_paths_collapse_through_symlinks() {
239        let td = tempfile::tempdir().unwrap();
240        let real = td.path().join("real");
241        std::fs::create_dir(&real).unwrap();
242        let link = td.path().join("link");
243        #[cfg(unix)]
244        std::os::unix::fs::symlink(&real, &link).unwrap();
245        #[cfg(unix)]
246        assert_eq!(
247            remote_identity(link.to_str().unwrap()),
248            remote_identity(real.to_str().unwrap())
249        );
250        // file:// collapses to the same identity.
251        assert_eq!(
252            remote_identity(&format!("file://{}", real.display())),
253            remote_identity(real.to_str().unwrap())
254        );
255    }
256}