use std::borrow::Cow;
use std::ffi::OsStr;
use std::path::{Component, Path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebRemote {
pub base: String,
pub repo: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AliasRemote {
pub alias: String,
pub repo: String,
pub(crate) user: Option<String>,
pub(crate) port: Option<std::num::NonZeroU16>,
}
impl AliasRemote {
pub fn host(&self) -> &str {
&self.alias
}
pub fn resolved(&self, hostname: &str) -> Option<WebRemote> {
if !is_safe_host(hostname) || (hostname == self.alias && !hostname.contains('.')) {
return None;
}
Some(WebRemote {
base: format!("https://{hostname}"),
repo: self.repo.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectedRemote {
Web(WebRemote),
Alias(AliasRemote),
}
pub fn parse_remote(url: &str) -> Option<SelectedRemote> {
let url = url.trim();
if let Some(rest) = url.strip_prefix("ssh://") {
let (authority, path) = rest.split_once('/')?;
return selected(authority, uri_repo(path)?);
}
if let Some((scheme, rest)) = url
.split_once("://")
.filter(|(scheme, _)| matches!(*scheme, "http" | "https"))
{
let (authority, path) = rest.split_once('/')?;
let authority = strip_userinfo(authority);
if !safe_authority(authority) {
return None;
}
return Some(SelectedRemote::Web(WebRemote {
base: format!("{scheme}://{authority}"),
repo: uri_repo(path)?,
}));
}
if url.contains("://") {
return None;
}
let (owner, path) = url.split_once(':')?;
selected(owner, repo_path(path)?)
}
pub fn pick_remote(remotes: &[(String, String)]) -> Option<SelectedRemote> {
for name in ["upstream", "origin"] {
if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
if let Some(remote) = parse_remote(url) {
return Some(remote);
}
}
}
remotes.iter().find_map(|(_, url)| parse_remote(url))
}
pub fn permalink(remote: &WebRemote, sha: &str, path: &Path, lines: (usize, usize)) -> String {
let frag = if lines.0 == lines.1 {
format!("#L{}", lines.0)
} else {
format!("#L{}-L{}", lines.0, lines.1)
};
format!(
"{}/{}/blob/{}/{}{frag}",
remote.base,
encode_repo_path(&remote.repo),
sha,
encode_path(path),
)
}
pub(crate) fn is_safe_host(host: &str) -> bool {
!host.is_empty()
&& !host.starts_with('-')
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
fn strip_userinfo(authority: &str) -> &str {
authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host)
}
fn safe_authority(authority: &str) -> bool {
let mut parts = authority.split(':');
let host = parts.next().unwrap_or_default();
match (parts.next(), parts.next()) {
(None, _) => is_safe_host(host),
(Some(port), None) => is_safe_host(host) && port.parse::<std::num::NonZeroU16>().is_ok(),
(Some(_), Some(_)) => false,
}
}
fn ssh_host(authority: &str) -> Option<&str> {
let host = match authority.split_once(':') {
None => authority,
Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
Some(_) => return None,
};
is_safe_host(host).then_some(host)
}
fn selected(authority: &str, repo: String) -> Option<SelectedRemote> {
let (user, authority) = match authority.rsplit_once('@') {
Some((user, authority)) if is_safe_host(user) => (Some(user.to_owned()), authority),
Some(_) => return None,
None => (None, authority),
};
let host = ssh_host(authority)?;
let port = match authority.split_once(':') {
Some((_, port)) => Some(port.parse::<std::num::NonZeroU16>().ok()?),
None => None,
};
Some(SelectedRemote::Alias(AliasRemote {
alias: host.to_owned(),
repo,
user,
port,
}))
}
fn repo_path(path: &str) -> Option<String> {
let path = path.trim_start_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
(!path.is_empty()).then(|| path.to_string())
}
fn uri_repo(path: &str) -> Option<String> {
if path.contains(['?', '#']) {
return None;
}
let source = path.trim_start_matches('/').as_bytes();
let mut decoded = Vec::with_capacity(source.len());
let mut offset = 0;
while offset < source.len() {
if source[offset] == b'%' {
let digits = std::str::from_utf8(source.get(offset + 1..offset + 3)?).ok()?;
decoded.push(u8::from_str_radix(digits, 16).ok()?);
offset += 3;
} else {
decoded.push(source[offset]);
offset += 1;
}
}
let mut path = String::from_utf8(decoded).ok()?;
if path.ends_with(".git") {
path.truncate(path.len() - 4);
}
(!path.is_empty() && !path.contains('\0')).then_some(path)
}
const HEX: &[u8; 16] = b"0123456789ABCDEF";
fn encode_segment(segment: &[u8]) -> String {
let mut out = String::with_capacity(segment.len());
for &byte in segment {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
_ => {
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
}
}
out
}
fn segment_bytes(segment: &OsStr) -> Cow<'_, [u8]> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
Cow::Borrowed(segment.as_bytes())
}
#[cfg(not(unix))]
{
Cow::Owned(segment.to_string_lossy().into_owned().into_bytes())
}
}
fn encode_path(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(segment) => Some(encode_segment(&segment_bytes(segment))),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn encode_repo_path(repo: &str) -> String {
repo.split('/')
.filter(|segment| !segment.is_empty())
.map(|segment| encode_segment(segment.as_bytes()))
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
fn web(url: &str) -> (String, String) {
match parse_remote(url) {
Some(SelectedRemote::Web(remote)) => (remote.base, remote.repo),
Some(SelectedRemote::Alias(remote)) => {
let web = remote
.resolved(remote.host())
.expect("literal FQDN in fixture");
(web.base, web.repo)
}
other => panic!("{url} did not parse to a web remote: {other:?}"),
}
}
fn alias(url: &str) -> (String, String) {
match parse_remote(url) {
Some(SelectedRemote::Alias(remote)) => (remote.alias, remote.repo),
other => panic!("{url} did not parse to an alias remote: {other:?}"),
}
}
#[test]
fn https_authority_and_nested_repo_path_survive() {
assert_eq!(
web("https://bbgithub.dev.bloomberg.com/acme/demo.git"),
(
"https://bbgithub.dev.bloomberg.com".to_string(),
"acme/demo".to_string()
)
);
assert_eq!(
web("https://bbgithub.dev.bloomberg.com/acme/nested/demo.git"),
(
"https://bbgithub.dev.bloomberg.com".to_string(),
"acme/nested/demo".to_string()
)
);
assert_eq!(
web("https://gitea.internal:3000/team/sub/project.git"),
(
"https://gitea.internal:3000".to_string(),
"team/sub/project".to_string()
)
);
assert_eq!(
web("http://gitea.internal/team/proj"),
("http://gitea.internal".to_string(), "team/proj".to_string())
);
assert_eq!(
web("https://oauth2:tok@gitlab.example.com/group/proj.git"),
(
"https://gitlab.example.com".to_string(),
"group/proj".to_string()
)
);
}
#[test]
fn reviewer_table_identities() {
assert_eq!(
web("https://github.com/acme/demo.git"),
("https://github.com".to_string(), "acme/demo".to_string())
);
assert_eq!(
web("ssh://git@github.com/acme/demo.git"),
("https://github.com".to_string(), "acme/demo".to_string())
);
assert_eq!(
web("git@github.com:acme/demo"),
("https://github.com".to_string(), "acme/demo".to_string())
);
assert_eq!(
web("git@bbgithub.dev.bloomberg.com:acme/demo.git"),
(
"https://bbgithub.dev.bloomberg.com".to_string(),
"acme/demo".to_string()
)
);
assert_eq!(
web("https://bbgithub.dev.bloomberg.com/acme/demo.git"),
(
"https://bbgithub.dev.bloomberg.com".to_string(),
"acme/demo".to_string()
)
);
assert_eq!(
web("ssh://git@gitlab.example.com:2222/team/repo.git"),
(
"https://gitlab.example.com".to_string(),
"team/repo".to_string()
)
);
}
#[test]
fn dotless_ssh_hosts_are_unresolved_aliases() {
assert_eq!(
alias("bbgithub:acme/demo.git"),
("bbgithub".to_string(), "acme/demo".to_string())
);
assert_eq!(
alias("git@bbgithub:acme/demo.git"),
("bbgithub".to_string(), "acme/demo".to_string())
);
assert_eq!(
alias("ssh://git@bbgithub/acme/demo.git"),
("bbgithub".to_string(), "acme/demo".to_string())
);
assert_eq!(
alias("ssh://bb:2222/team/repo.git"),
("bb".to_string(), "team/repo".to_string())
);
let resolved = AliasRemote {
alias: "bbgithub".to_string(),
repo: "acme/demo".to_string(),
user: None,
port: None,
}
.resolved("bbgithub.dev.bloomberg.com")
.unwrap();
assert_eq!(resolved.base, "https://bbgithub.dev.bloomberg.com");
assert_eq!(resolved.repo, "acme/demo");
}
#[test]
fn unsupported_urls_refuse_instead_of_guessing() {
for url in [
"not a url",
"/srv/git/repo.git",
"../repo",
"file:///srv/repo.git",
"git://github.com/acme/demo.git",
"svn+ssh://host/team/repo",
"https://host/",
"https://host",
"git@host:",
"-oProxyCommand=evil:org/repo",
"git@-flag:org/repo",
"ssh://git@[::1]/repo",
"ssh://git@host:notaport/repo",
"ssh://git@host",
] {
assert!(parse_remote(url).is_none(), "should refuse: {url}");
}
}
#[test]
fn git_suffix_strips_once() {
assert_eq!(
web("https://host/acme/demo.git.git"),
("https://host".to_string(), "acme/demo.git".to_string())
);
}
#[test]
fn pick_remote_prefers_upstream_then_origin() {
let remote = |name: &str, url: &str| (name.to_string(), url.to_string());
let picked = |remotes: &[(String, String)]| pick_remote(remotes);
assert_eq!(
picked(&[
remote("origin", "https://gitlab.com/o/r.git"),
remote("upstream", "https://github.com/a/b.git"),
]),
Some(SelectedRemote::Web(WebRemote {
base: "https://github.com".to_string(),
repo: "a/b".to_string(),
}))
);
assert!(matches!(
picked(&[
remote("upstream", "/local/x"),
remote("origin", "git@github.com:o/r.git"),
]),
Some(SelectedRemote::Alias(_))
));
assert!(matches!(
picked(&[
remote("origin", "https://gitlab.com/o/r.git"),
remote("upstream", "git@bb:acme/demo.git"),
]),
Some(SelectedRemote::Alias(_))
));
assert!(picked(&[remote("origin", "/local/x")]).is_none());
assert!(matches!(
picked(&[remote("other", "https://gitlab.com/o/r.git")]),
Some(SelectedRemote::Web(_))
));
}
#[test]
fn permalink_pins_sha_and_encodes_segments() {
let github = WebRemote {
base: "https://github.com".to_string(),
repo: "stropdev/strop".to_string(),
};
assert_eq!(
permalink(&github, "abc123", Path::new("f.rs"), (2, 2)),
"https://github.com/stropdev/strop/blob/abc123/f.rs#L2"
);
assert_eq!(
permalink(&github, "abc123", Path::new("src/lib.rs"), (1, 3)),
"https://github.com/stropdev/strop/blob/abc123/src/lib.rs#L1-L3"
);
assert_eq!(
permalink(&github, "abc123", Path::new("src/sp ace/日本語.rs"), (1, 1)),
"https://github.com/stropdev/strop/blob/abc123/src/sp%20ace/%E6%97%A5%E6%9C%AC%E8%AA%9E.rs#L1"
);
let spaced = WebRemote {
base: "https://host".to_string(),
repo: "my repo/x".to_string(),
};
assert_eq!(
permalink(&spaced, "abc", Path::new("f.rs"), (1, 1)),
"https://host/my%20repo/x/blob/abc/f.rs#L1"
);
}
#[cfg(unix)]
#[test]
fn permalink_percent_encodes_non_utf8_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let github = WebRemote {
base: "https://github.com".to_string(),
repo: "stropdev/strop".to_string(),
};
let path = Path::new(std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs"));
assert_eq!(
permalink(&github, "abc", path, (1, 1)),
"https://github.com/stropdev/strop/blob/abc/src/%FF%FE.rs#L1"
);
}
}