1use std::borrow::Cow;
8use std::ffi::OsStr;
9use std::path::{Component, Path};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WebRemote {
15 pub base: String,
20 pub repo: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AliasRemote {
29 pub alias: String,
32 pub repo: String,
33 pub(crate) user: Option<String>,
34 pub(crate) port: Option<std::num::NonZeroU16>,
35}
36
37impl AliasRemote {
38 pub fn host(&self) -> &str {
39 &self.alias
40 }
41 pub fn resolved(&self, hostname: &str) -> Option<WebRemote> {
43 if !is_safe_host(hostname) || (hostname == self.alias && !hostname.contains('.')) {
44 return None;
45 }
46 Some(WebRemote {
47 base: format!("https://{hostname}"),
48 repo: self.repo.clone(),
49 })
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum SelectedRemote {
56 Web(WebRemote),
57 Alias(AliasRemote),
58}
59
60pub fn parse_remote(url: &str) -> Option<SelectedRemote> {
70 let url = url.trim();
71 if let Some(rest) = url.strip_prefix("ssh://") {
72 let (authority, path) = rest.split_once('/')?;
73 return selected(authority, uri_repo(path)?);
74 }
75 if let Some((scheme, rest)) = url
76 .split_once("://")
77 .filter(|(scheme, _)| matches!(*scheme, "http" | "https"))
78 {
79 let (authority, path) = rest.split_once('/')?;
80 let authority = strip_userinfo(authority);
81 if !safe_authority(authority) {
82 return None;
83 }
84 return Some(SelectedRemote::Web(WebRemote {
85 base: format!("{scheme}://{authority}"),
86 repo: uri_repo(path)?,
87 }));
88 }
89 if url.contains("://") {
90 return None;
92 }
93 let (owner, path) = url.split_once(':')?;
95 selected(owner, repo_path(path)?)
96}
97
98pub fn pick_remote(remotes: &[(String, String)]) -> Option<SelectedRemote> {
102 for name in ["upstream", "origin"] {
103 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
104 if let Some(remote) = parse_remote(url) {
105 return Some(remote);
106 }
107 }
108 }
109 remotes.iter().find_map(|(_, url)| parse_remote(url))
110}
111
112pub fn permalink(remote: &WebRemote, sha: &str, path: &Path, lines: (usize, usize)) -> String {
117 let frag = if lines.0 == lines.1 {
118 format!("#L{}", lines.0)
119 } else {
120 format!("#L{}-L{}", lines.0, lines.1)
121 };
122 format!(
123 "{}/{}/blob/{}/{}{frag}",
124 remote.base,
125 encode_repo_path(&remote.repo),
126 sha,
127 encode_path(path),
128 )
129}
130
131pub(crate) fn is_safe_host(host: &str) -> bool {
135 !host.is_empty()
136 && !host.starts_with('-')
137 && host
138 .bytes()
139 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
140}
141
142fn strip_userinfo(authority: &str) -> &str {
145 authority
146 .rsplit_once('@')
147 .map_or(authority, |(_, host)| host)
148}
149
150fn safe_authority(authority: &str) -> bool {
152 let mut parts = authority.split(':');
153 let host = parts.next().unwrap_or_default();
154 match (parts.next(), parts.next()) {
155 (None, _) => is_safe_host(host),
156 (Some(port), None) => is_safe_host(host) && port.parse::<std::num::NonZeroU16>().is_ok(),
157 (Some(_), Some(_)) => false,
159 }
160}
161
162fn ssh_host(authority: &str) -> Option<&str> {
167 let host = match authority.split_once(':') {
168 None => authority,
169 Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
170 Some(_) => return None,
171 };
172 is_safe_host(host).then_some(host)
173}
174
175fn selected(authority: &str, repo: String) -> Option<SelectedRemote> {
177 let (user, authority) = match authority.rsplit_once('@') {
178 Some((user, authority)) if is_safe_host(user) => (Some(user.to_owned()), authority),
179 Some(_) => return None,
180 None => (None, authority),
181 };
182 let host = ssh_host(authority)?;
183 let port = match authority.split_once(':') {
184 Some((_, port)) => Some(port.parse::<std::num::NonZeroU16>().ok()?),
185 None => None,
186 };
187 Some(SelectedRemote::Alias(AliasRemote {
188 alias: host.to_owned(),
189 repo,
190 user,
191 port,
192 }))
193}
194
195fn repo_path(path: &str) -> Option<String> {
200 let path = path.trim_start_matches('/');
201 let path = path.strip_suffix(".git").unwrap_or(path);
202 (!path.is_empty()).then(|| path.to_string())
203}
204
205fn uri_repo(path: &str) -> Option<String> {
208 if path.contains(['?', '#']) {
209 return None;
210 }
211 let source = path.trim_start_matches('/').as_bytes();
212 let mut decoded = Vec::with_capacity(source.len());
213 let mut offset = 0;
214 while offset < source.len() {
215 if source[offset] == b'%' {
216 let digits = std::str::from_utf8(source.get(offset + 1..offset + 3)?).ok()?;
217 decoded.push(u8::from_str_radix(digits, 16).ok()?);
218 offset += 3;
219 } else {
220 decoded.push(source[offset]);
221 offset += 1;
222 }
223 }
224 let mut path = String::from_utf8(decoded).ok()?;
225 if path.ends_with(".git") {
226 path.truncate(path.len() - 4);
227 }
228 (!path.is_empty() && !path.contains('\0')).then_some(path)
229}
230
231const HEX: &[u8; 16] = b"0123456789ABCDEF";
232
233fn encode_segment(segment: &[u8]) -> String {
238 let mut out = String::with_capacity(segment.len());
239 for &byte in segment {
240 match byte {
241 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
242 out.push(byte as char)
243 }
244 _ => {
245 out.push('%');
246 out.push(HEX[(byte >> 4) as usize] as char);
247 out.push(HEX[(byte & 0x0f) as usize] as char);
248 }
249 }
250 }
251 out
252}
253
254fn segment_bytes(segment: &OsStr) -> Cow<'_, [u8]> {
255 #[cfg(unix)]
256 {
257 use std::os::unix::ffi::OsStrExt;
258 Cow::Borrowed(segment.as_bytes())
259 }
260 #[cfg(not(unix))]
261 {
262 Cow::Owned(segment.to_string_lossy().into_owned().into_bytes())
263 }
264}
265
266fn encode_path(path: &Path) -> String {
269 path.components()
270 .filter_map(|component| match component {
271 Component::Normal(segment) => Some(encode_segment(&segment_bytes(segment))),
272 _ => None,
273 })
274 .collect::<Vec<_>>()
275 .join("/")
276}
277
278fn encode_repo_path(repo: &str) -> String {
281 repo.split('/')
282 .filter(|segment| !segment.is_empty())
283 .map(|segment| encode_segment(segment.as_bytes()))
284 .collect::<Vec<_>>()
285 .join("/")
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn web(url: &str) -> (String, String) {
293 match parse_remote(url) {
294 Some(SelectedRemote::Web(remote)) => (remote.base, remote.repo),
295 Some(SelectedRemote::Alias(remote)) => {
296 let web = remote
297 .resolved(remote.host())
298 .expect("literal FQDN in fixture");
299 (web.base, web.repo)
300 }
301 other => panic!("{url} did not parse to a web remote: {other:?}"),
302 }
303 }
304
305 fn alias(url: &str) -> (String, String) {
306 match parse_remote(url) {
307 Some(SelectedRemote::Alias(remote)) => (remote.alias, remote.repo),
308 other => panic!("{url} did not parse to an alias remote: {other:?}"),
309 }
310 }
311
312 #[test]
315 fn https_authority_and_nested_repo_path_survive() {
316 assert_eq!(
317 web("https://bbgithub.dev.bloomberg.com/acme/demo.git"),
318 (
319 "https://bbgithub.dev.bloomberg.com".to_string(),
320 "acme/demo".to_string()
321 )
322 );
323 assert_eq!(
324 web("https://bbgithub.dev.bloomberg.com/acme/nested/demo.git"),
325 (
326 "https://bbgithub.dev.bloomberg.com".to_string(),
327 "acme/nested/demo".to_string()
328 )
329 );
330 assert_eq!(
332 web("https://gitea.internal:3000/team/sub/project.git"),
333 (
334 "https://gitea.internal:3000".to_string(),
335 "team/sub/project".to_string()
336 )
337 );
338 assert_eq!(
339 web("http://gitea.internal/team/proj"),
340 ("http://gitea.internal".to_string(), "team/proj".to_string())
341 );
342 assert_eq!(
344 web("https://oauth2:tok@gitlab.example.com/group/proj.git"),
345 (
346 "https://gitlab.example.com".to_string(),
347 "group/proj".to_string()
348 )
349 );
350 }
351
352 #[test]
355 fn reviewer_table_identities() {
356 assert_eq!(
357 web("https://github.com/acme/demo.git"),
358 ("https://github.com".to_string(), "acme/demo".to_string())
359 );
360 assert_eq!(
361 web("ssh://git@github.com/acme/demo.git"),
362 ("https://github.com".to_string(), "acme/demo".to_string())
363 );
364 assert_eq!(
365 web("git@github.com:acme/demo"),
366 ("https://github.com".to_string(), "acme/demo".to_string())
367 );
368 assert_eq!(
369 web("git@bbgithub.dev.bloomberg.com:acme/demo.git"),
370 (
371 "https://bbgithub.dev.bloomberg.com".to_string(),
372 "acme/demo".to_string()
373 )
374 );
375 assert_eq!(
376 web("https://bbgithub.dev.bloomberg.com/acme/demo.git"),
377 (
378 "https://bbgithub.dev.bloomberg.com".to_string(),
379 "acme/demo".to_string()
380 )
381 );
382 assert_eq!(
384 web("ssh://git@gitlab.example.com:2222/team/repo.git"),
385 (
386 "https://gitlab.example.com".to_string(),
387 "team/repo".to_string()
388 )
389 );
390 }
391
392 #[test]
395 fn dotless_ssh_hosts_are_unresolved_aliases() {
396 assert_eq!(
397 alias("bbgithub:acme/demo.git"),
398 ("bbgithub".to_string(), "acme/demo".to_string())
399 );
400 assert_eq!(
401 alias("git@bbgithub:acme/demo.git"),
402 ("bbgithub".to_string(), "acme/demo".to_string())
403 );
404 assert_eq!(
405 alias("ssh://git@bbgithub/acme/demo.git"),
406 ("bbgithub".to_string(), "acme/demo".to_string())
407 );
408 assert_eq!(
409 alias("ssh://bb:2222/team/repo.git"),
410 ("bb".to_string(), "team/repo".to_string())
411 );
412 let resolved = AliasRemote {
414 alias: "bbgithub".to_string(),
415 repo: "acme/demo".to_string(),
416 user: None,
417 port: None,
418 }
419 .resolved("bbgithub.dev.bloomberg.com")
420 .unwrap();
421 assert_eq!(resolved.base, "https://bbgithub.dev.bloomberg.com");
422 assert_eq!(resolved.repo, "acme/demo");
423 }
424
425 #[test]
428 fn unsupported_urls_refuse_instead_of_guessing() {
429 for url in [
430 "not a url",
431 "/srv/git/repo.git",
432 "../repo",
433 "file:///srv/repo.git",
434 "git://github.com/acme/demo.git",
435 "svn+ssh://host/team/repo",
436 "https://host/",
437 "https://host",
438 "git@host:",
439 "-oProxyCommand=evil:org/repo",
440 "git@-flag:org/repo",
441 "ssh://git@[::1]/repo",
442 "ssh://git@host:notaport/repo",
443 "ssh://git@host",
444 ] {
445 assert!(parse_remote(url).is_none(), "should refuse: {url}");
446 }
447 }
448
449 #[test]
452 fn git_suffix_strips_once() {
453 assert_eq!(
454 web("https://host/acme/demo.git.git"),
455 ("https://host".to_string(), "acme/demo.git".to_string())
456 );
457 }
458
459 #[test]
462 fn pick_remote_prefers_upstream_then_origin() {
463 let remote = |name: &str, url: &str| (name.to_string(), url.to_string());
464 let picked = |remotes: &[(String, String)]| pick_remote(remotes);
465 assert_eq!(
466 picked(&[
467 remote("origin", "https://gitlab.com/o/r.git"),
468 remote("upstream", "https://github.com/a/b.git"),
469 ]),
470 Some(SelectedRemote::Web(WebRemote {
471 base: "https://github.com".to_string(),
472 repo: "a/b".to_string(),
473 }))
474 );
475 assert!(matches!(
477 picked(&[
478 remote("upstream", "/local/x"),
479 remote("origin", "git@github.com:o/r.git"),
480 ]),
481 Some(SelectedRemote::Alias(_))
482 ));
483 assert!(matches!(
486 picked(&[
487 remote("origin", "https://gitlab.com/o/r.git"),
488 remote("upstream", "git@bb:acme/demo.git"),
489 ]),
490 Some(SelectedRemote::Alias(_))
491 ));
492 assert!(picked(&[remote("origin", "/local/x")]).is_none());
493 assert!(matches!(
494 picked(&[remote("other", "https://gitlab.com/o/r.git")]),
495 Some(SelectedRemote::Web(_))
496 ));
497 }
498
499 #[test]
502 fn permalink_pins_sha_and_encodes_segments() {
503 let github = WebRemote {
504 base: "https://github.com".to_string(),
505 repo: "stropdev/strop".to_string(),
506 };
507 assert_eq!(
508 permalink(&github, "abc123", Path::new("f.rs"), (2, 2)),
509 "https://github.com/stropdev/strop/blob/abc123/f.rs#L2"
510 );
511 assert_eq!(
512 permalink(&github, "abc123", Path::new("src/lib.rs"), (1, 3)),
513 "https://github.com/stropdev/strop/blob/abc123/src/lib.rs#L1-L3"
514 );
515 assert_eq!(
517 permalink(&github, "abc123", Path::new("src/sp ace/日本語.rs"), (1, 1)),
518 "https://github.com/stropdev/strop/blob/abc123/src/sp%20ace/%E6%97%A5%E6%9C%AC%E8%AA%9E.rs#L1"
519 );
520 let spaced = WebRemote {
521 base: "https://host".to_string(),
522 repo: "my repo/x".to_string(),
523 };
524 assert_eq!(
525 permalink(&spaced, "abc", Path::new("f.rs"), (1, 1)),
526 "https://host/my%20repo/x/blob/abc/f.rs#L1"
527 );
528 }
529
530 #[cfg(unix)]
533 #[test]
534 fn permalink_percent_encodes_non_utf8_path_bytes() {
535 use std::os::unix::ffi::OsStrExt;
536 let github = WebRemote {
537 base: "https://github.com".to_string(),
538 repo: "stropdev/strop".to_string(),
539 };
540 let path = Path::new(std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs"));
541 assert_eq!(
542 permalink(&github, "abc", path, (1, 1)),
543 "https://github.com/stropdev/strop/blob/abc/src/%FF%FE.rs#L1"
544 );
545 }
546}