mkit_git_bridge/
remoteid.rs1use std::path::Path;
10
11#[must_use]
20pub fn remote_identity(dest: &str) -> String {
21 remote_identity_relative_to(dest, None)
22}
23
24fn remote_identity_relative_to(dest: &str, base: Option<&Path>) -> String {
31 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 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 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 let first_seg = dest.split('/').next().unwrap_or(dest);
64 if first_seg.contains(':') && !looks_like_dos_drive(dest) {
65 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 canonical_local(dest, base)
85}
86
87fn split_host_port(hp: &str) -> (&str, Option<&str>) {
89 if hp.starts_with('[') {
90 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 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
122fn 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 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
150fn 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 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 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 assert_ne!(remote_identity("ssh://github.com:2222/org/repo"), canonical);
191 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 assert!(!remote_identity("C:/repos/x").starts_with("ssh://"));
207 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 let a = td.path().canonicalize().unwrap().join("a");
224 let base = a.join("b");
225 std::fs::create_dir_all(&base).unwrap();
226 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 assert_eq!(
252 remote_identity(&format!("file://{}", real.display())),
253 remote_identity(real.to_str().unwrap())
254 );
255 }
256}