use std::net::Ipv6Addr;
use crate::address::HostPort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Scheme {
Ssh,
Git,
Http,
Https,
}
impl Scheme {
pub fn default_port(self) -> u16 {
match self {
Scheme::Ssh => 22,
Scheme::Git => 9418,
Scheme::Http => 80,
Scheme::Https => 443,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteUrl {
pub scheme: Scheme,
pub host: String,
pub port: Option<u16>,
}
impl RemoteUrl {
pub fn effective_port(&self) -> u16 {
self.port.unwrap_or(self.scheme.default_port())
}
}
pub fn parse(url: &str) -> Option<RemoteUrl> {
match url.split_once("://") {
Some((scheme, rest)) => parse_url_form(scheme, rest),
None => parse_scp_form(url),
}
}
fn parse_url_form(scheme: &str, rest: &str) -> Option<RemoteUrl> {
let scheme = scheme_from_name(scheme)?;
let authority = rest.split('/').next().unwrap_or_default();
let HostPort { host, port } = strip_userinfo(authority).parse().ok()?;
Some(RemoteUrl { scheme, host, port })
}
fn parse_scp_form(url: &str) -> Option<RemoteUrl> {
if is_windows_drive_path(url) {
return None;
}
let host_end = scp_host_end(url)?;
if url[host_end..].starts_with("::") {
return None;
}
let authority = strip_userinfo_outside_brackets(&url[..host_end]);
let address: HostPort = match authority.strip_prefix('[') {
Some(bracketed) => {
let inner = strip_userinfo(bracketed.strip_suffix(']')?);
if inner.parse::<Ipv6Addr>().is_ok() {
format!("[{inner}]").parse().ok()?
} else {
inner.parse().ok()?
}
}
None => authority.parse().ok()?,
};
let HostPort { host, port } = address;
Some(RemoteUrl {
scheme: Scheme::Ssh,
host,
port,
})
}
fn scp_host_end(url: &str) -> Option<usize> {
let search_from = match bracket_start(url) {
Some(open) => url[open..].find(']')? + open,
None => 0,
};
let colon = url[search_from..].find(':')? + search_from;
if url[..colon].contains('/') {
return None;
}
Some(colon)
}
fn bracket_start(url: &str) -> Option<usize> {
if url.starts_with('[') {
return Some(0);
}
let at = url.find('@')?;
let user = &url[..at];
let is_user = !user.contains([':', '/', '[']);
(is_user && url[at + 1..].starts_with('[')).then_some(at + 1)
}
fn strip_userinfo_outside_brackets(authority: &str) -> &str {
match bracket_start(authority) {
Some(open) => &authority[open..],
None => strip_userinfo(authority),
}
}
fn scheme_from_name(name: &str) -> Option<Scheme> {
match name.to_ascii_lowercase().as_str() {
"ssh" | "git+ssh" | "ssh+git" => Some(Scheme::Ssh),
"git" => Some(Scheme::Git),
"http" => Some(Scheme::Http),
"https" => Some(Scheme::Https),
_ => None,
}
}
fn strip_userinfo(authority: &str) -> &str {
authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host)
}
fn is_windows_drive_path(url: &str) -> bool {
let bytes = url.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
}
#[cfg(test)]
mod tests {
use super::*;
fn remote(scheme: Scheme, host: &str, port: Option<u16>) -> Option<RemoteUrl> {
Some(RemoteUrl {
scheme,
host: host.to_owned(),
port,
})
}
#[test]
fn url_forms() {
let cases = [
(
"ssh://192.0.2.10/app.git",
remote(Scheme::Ssh, "192.0.2.10", None),
),
(
"ssh://git@192.0.2.10:2222/a/app.git",
remote(Scheme::Ssh, "192.0.2.10", Some(2222)),
),
(
"git+ssh://example.com/app",
remote(Scheme::Ssh, "example.com", None),
),
(
"ssh+git://example.com/app",
remote(Scheme::Ssh, "example.com", None),
),
(
"git://example.com:9419/app",
remote(Scheme::Git, "example.com", Some(9419)),
),
(
"http://192.0.2.10:3000/a/app.git",
remote(Scheme::Http, "192.0.2.10", Some(3000)),
),
(
"https://example.com/a/app.git",
remote(Scheme::Https, "example.com", None),
),
(
"https://example.com",
remote(Scheme::Https, "example.com", None),
),
(
"HTTPS://Example.COM/app",
remote(Scheme::Https, "example.com", None),
),
];
for (input, expected) in cases {
assert_eq!(parse(input), expected, "{input}");
}
}
#[test]
fn scp_like_forms() {
let cases = [
(
"git@192.0.2.10:alice/app.git",
remote(Scheme::Ssh, "192.0.2.10", None),
),
(
"example.com:app.git",
remote(Scheme::Ssh, "example.com", None),
),
("git@nas:app.git", remote(Scheme::Ssh, "nas", None)),
(
"[git@example.com:2222]:app.git",
remote(Scheme::Ssh, "example.com", Some(2222)),
),
(
"git@[example.com:2222]:app.git",
remote(Scheme::Ssh, "example.com", Some(2222)),
),
(
"[2001:db8::1]:app.git",
remote(Scheme::Ssh, "2001:db8::1", None),
),
(
"git@example.com:/srv/app.git",
remote(Scheme::Ssh, "example.com", None),
),
(
"git@example.com:dir[1]/app.git",
remote(Scheme::Ssh, "example.com", None),
),
];
for (input, expected) in cases {
assert_eq!(parse(input), expected, "{input}");
}
}
#[test]
fn ipv6_in_url_form() {
assert_eq!(
parse("ssh://git@[2001:DB8::1]:2222/app.git"),
remote(Scheme::Ssh, "2001:db8::1", Some(2222))
);
assert_eq!(
parse("https://[2001:db8::1]/app.git"),
remote(Scheme::Https, "2001:db8::1", None)
);
}
#[test]
fn credentials_are_not_kept() {
let url = parse("https://alice:hunter2@example.com/app.git").unwrap();
assert_eq!(url.host, "example.com");
assert!(!format!("{url:?}").contains("hunter2"));
}
#[test]
fn local_paths_and_other_transports_are_none() {
for input in [
"/srv/git/app.git",
"./app",
"../app.git",
"app",
"dir/with:colon",
"file:///srv/git/app.git",
"ext::ssh -i key example.com %S app",
"s3://bucket/app",
"C:\\repos\\app",
"C:/repos/app",
"",
] {
assert_eq!(parse(input), None, "{input}");
}
}
#[test]
fn malformed_hosts_and_ports_are_none() {
for input in [
"ssh://:2222/app",
"ssh://example.com:/app",
"ssh://example.com:0/app",
"ssh://example.com:65536/app",
"ssh://example.com:22x/app",
"ssh://[not-ipv6]/app",
"ssh://[2001:db8::1]x/app",
"http.proxy=http://example.com",
":app.git",
"-oProxyCommand=evil:app",
"[example.com:app.git",
] {
assert_eq!(parse(input), None, "{input}");
}
}
#[test]
fn effective_port_uses_scheme_default() {
assert_eq!(parse("git@example.com:app").unwrap().effective_port(), 22);
assert_eq!(
parse("git://example.com/app").unwrap().effective_port(),
9418
);
assert_eq!(
parse("http://example.com/app").unwrap().effective_port(),
80
);
assert_eq!(
parse("https://example.com/app").unwrap().effective_port(),
443
);
assert_eq!(
parse("https://example.com:8443/a")
.unwrap()
.effective_port(),
8443
);
}
}