use std::fmt;
use std::str::FromStr;
use strum::{Display, EnumString, VariantNames};
use thiserror::Error;
use url::Url;
#[cfg(feature = "tracing")]
use tracing::debug;
#[derive(Debug, PartialEq, Eq, EnumString, VariantNames, Clone, Display, Copy)]
#[strum(serialize_all = "kebab_case")]
pub enum Scheme {
File,
Ftp,
Ftps,
Git,
#[strum(serialize = "git+ssh")]
GitSsh,
Http,
Https,
Ssh,
Unspecified,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct GitUrl {
pub host: Option<String>,
pub name: String,
pub owner: Option<String>,
pub organization: Option<String>,
pub fullname: String,
pub scheme: Scheme,
pub user: Option<String>,
pub token: Option<String>,
pub port: Option<u16>,
pub path: String,
pub git_suffix: bool,
pub scheme_prefix: bool,
}
impl fmt::Display for GitUrl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let scheme_prefix = match self.scheme_prefix {
true => format!("{}://", self.scheme),
false => String::new(),
};
let auth_info = match self.scheme {
Scheme::Ssh | Scheme::Git | Scheme::GitSsh => {
if let Some(user) = &self.user {
format!("{}@", user)
} else {
String::new()
}
}
Scheme::Http | Scheme::Https => match (&self.user, &self.token) {
(Some(user), Some(token)) => format!("{}:{}@", user, token),
(Some(user), None) => format!("{}@", user),
(None, Some(token)) => format!("{}@", token),
(None, None) => String::new(),
},
_ => String::new(),
};
let host = match &self.host {
Some(host) => host.to_string(),
None => String::new(),
};
let port = match &self.port {
Some(p) => format!(":{}", p),
None => String::new(),
};
let path = match &self.scheme {
Scheme::Ssh => {
if self.port.is_some() {
format!("/{}", &self.path)
} else {
format!(":{}", &self.path)
}
}
_ => self.path.to_string(),
};
let git_url_str = format!("{}{}{}{}{}", scheme_prefix, auth_info, host, port, path);
write!(f, "{}", git_url_str)
}
}
impl Default for GitUrl {
fn default() -> Self {
GitUrl {
host: None,
name: "".to_string(),
owner: None,
organization: None,
fullname: "".to_string(),
scheme: Scheme::Unspecified,
user: None,
token: None,
port: None,
path: "".to_string(),
git_suffix: false,
scheme_prefix: false,
}
}
}
impl FromStr for GitUrl {
type Err = GitUrlParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
GitUrl::parse(s)
}
}
impl GitUrl {
pub fn trim_auth(&self) -> GitUrl {
let mut new_giturl = self.clone();
new_giturl.user = None;
new_giturl.token = None;
new_giturl
}
pub fn parse(url: &str) -> Result<GitUrl, GitUrlParseError> {
let normalized = normalize_url(url)?;
let scheme = if let Ok(scheme) = Scheme::from_str(normalized.scheme()) {
scheme
} else {
return Err(GitUrlParseError::UnsupportedScheme(
normalized.scheme().to_string(),
));
};
if normalized.path().is_empty() {
return Err(GitUrlParseError::EmptyPath);
}
let urlpath = match &scheme {
Scheme::Ssh => {
normalized.path()[1..].to_string()
}
_ => normalized.path().to_string(),
};
let git_suffix_check = &urlpath.ends_with(".git");
#[cfg(feature = "tracing")]
debug!("The urlpath: {:?}", &urlpath);
let splitpath = &urlpath.rsplit_terminator('/').collect::<Vec<&str>>();
#[cfg(feature = "tracing")]
debug!("rsplit results for metadata: {:?}", splitpath);
let name = splitpath[0].trim_end_matches(".git").to_string();
let (owner, organization, fullname) = match &scheme {
Scheme::File => (None::<String>, None::<String>, name.clone()),
_ => {
let mut fullname: Vec<&str> = Vec::new();
let hosts_w_organization_in_path = ["dev.azure.com", "ssh.dev.azure.com"];
let host_str = if let Some(host) = normalized.host_str() {
host
} else {
return Err(GitUrlParseError::UnsupportedUrlHostFormat);
};
match hosts_w_organization_in_path.contains(&host_str) {
true => {
#[cfg(feature = "tracing")]
debug!("Found a git provider with an org");
match &scheme {
Scheme::Ssh => {
fullname.push(splitpath[2]);
fullname.push(splitpath[1]);
fullname.push(splitpath[0]);
(
Some(splitpath[1].to_string()),
Some(splitpath[2].to_string()),
fullname.join("/"),
)
}
Scheme::Https => {
fullname.push(splitpath[3]);
fullname.push(splitpath[2]);
fullname.push(splitpath[0]);
(
Some(splitpath[2].to_string()),
Some(splitpath[3].to_string()),
fullname.join("/"),
)
}
_ => return Err(GitUrlParseError::UnexpectedScheme),
}
}
false => {
if !url.starts_with("ssh") && splitpath.len() < 2 {
return Err(GitUrlParseError::UnexpectedFormat);
}
let position = match splitpath.len() {
0 => return Err(GitUrlParseError::UnexpectedFormat),
1 => 0,
_ => 1,
};
fullname.push(splitpath[position]);
fullname.push(name.as_str());
(
Some(splitpath[position].to_string()),
None::<String>,
fullname.join("/"),
)
}
}
}
};
let final_host = match scheme {
Scheme::File => None,
_ => normalized.host_str().map(|h| h.to_string()),
};
let final_path = match scheme {
Scheme::File => {
if let Some(host) = normalized.host_str() {
format!("{}{}", host, urlpath)
} else {
urlpath
}
}
_ => urlpath,
};
Ok(GitUrl {
host: final_host,
name,
owner,
organization,
fullname,
scheme,
user: match normalized.username().to_string().len() {
0 => None,
_ => Some(normalized.username().to_string()),
},
token: normalized.password().map(|p| p.to_string()),
port: normalized.port(),
path: final_path,
git_suffix: *git_suffix_check,
scheme_prefix: url.contains("://") || url.starts_with("git:"),
})
}
}
fn normalize_ssh_url(url: &str) -> Result<Url, GitUrlParseError> {
let u = url.split(':').collect::<Vec<&str>>();
match u.len() {
2 => {
#[cfg(feature = "tracing")]
debug!("Normalizing ssh url: {:?}", u);
normalize_url(&format!("ssh://{}/{}", u[0], u[1]))
}
3 => {
#[cfg(feature = "tracing")]
debug!("Normalizing ssh url with ports: {:?}", u);
normalize_url(&format!("ssh://{}:{}/{}", u[0], u[1], u[2]))
}
_default => Err(GitUrlParseError::UnsupportedSshUrlFormat),
}
}
#[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
fn normalize_file_path(filepath: &str) -> Result<Url, GitUrlParseError> {
let fp = Url::from_file_path(filepath);
match fp {
Ok(path) => Ok(path),
Err(_e) => {
if let Ok(file_url) = normalize_url(&format!("file://{}", filepath)) {
Ok(file_url)
} else {
Err(GitUrlParseError::FileUrlNormalizeFailedSchemeAdded)
}
}
}
}
#[cfg(target_arch = "wasm32")]
fn normalize_file_path(_filepath: &str) -> Result<Url, GitUrlParseError> {
unreachable!()
}
pub fn normalize_url(url: &str) -> Result<Url, GitUrlParseError> {
#[cfg(feature = "tracing")]
debug!("Processing: {:?}", &url);
if url.contains('\0') {
return Err(GitUrlParseError::FoundNullBytes);
}
let trim_url = url.trim_end_matches('/');
let url_to_parse = if trim_url.starts_with("git:") && !trim_url.starts_with("git://") {
trim_url.replace("git:", "git://")
} else {
trim_url.to_string()
};
let url_parse = Url::parse(&url_to_parse);
Ok(match url_parse {
Ok(u) => {
match Scheme::from_str(u.scheme()) {
Ok(_p) => u,
Err(_e) => {
#[cfg(feature = "tracing")]
debug!("Scheme parse fail. Assuming a userless ssh url");
if let Ok(ssh_url) = normalize_ssh_url(trim_url) {
ssh_url
} else {
return Err(GitUrlParseError::SshUrlNormalizeFailedNoScheme);
}
}
}
}
Err(url::ParseError::RelativeUrlWithoutBase) => {
match is_ssh_url(trim_url) {
true => {
#[cfg(feature = "tracing")]
debug!("Scheme::SSH match for normalization");
normalize_ssh_url(trim_url)?
}
false => {
#[cfg(feature = "tracing")]
debug!("Scheme::File match for normalization");
normalize_file_path(trim_url)?
}
}
}
Err(err) => {
return Err(GitUrlParseError::from(err));
}
})
}
fn is_ssh_url(url: &str) -> bool {
if !url.contains(':') {
return false;
}
if let (Some(at_pos), Some(colon_pos)) = (url.find('@'), url.find(':')) {
if colon_pos < at_pos {
return false;
}
let parts: Vec<&str> = url.split('@').collect();
return parts.len() == 2 || parts[0].is_empty();
}
let parts: Vec<&str> = url.split(':').collect();
parts.len() == 2 && parts[0].is_empty() && parts[1].is_empty()
}
#[derive(Error, Debug, PartialEq, Eq)]
pub enum GitUrlParseError {
#[error("Error from Url crate: {0}")]
UrlParseError(#[from] url::ParseError),
#[error("No url scheme was found, then failed to normalize as ssh url.")]
SshUrlNormalizeFailedNoScheme,
#[error("No url scheme was found, then failed to normalize as ssh url after adding 'ssh://'")]
SshUrlNormalizeFailedSchemeAdded,
#[error("Failed to normalize as ssh url after adding 'ssh://'")]
SshUrlNormalizeFailedSchemeAddedWithPorts,
#[error("No url scheme was found, then failed to normalize as file url.")]
FileUrlNormalizeFailedNoScheme,
#[error(
"No url scheme was found, then failed to normalize as file url after adding 'file://'"
)]
FileUrlNormalizeFailedSchemeAdded,
#[error("Git Url not in expected format")]
UnexpectedFormat,
#[error("Git Url for host using unexpected scheme")]
UnexpectedScheme,
#[error("Scheme unsupported: {0}")]
UnsupportedScheme(String),
#[error("Host from Url cannot be str or does not exist")]
UnsupportedUrlHostFormat,
#[error("Git Url not in expected format for SSH")]
UnsupportedSshUrlFormat,
#[error("Normalized URL has no path")]
EmptyPath,
#[error("Found null bytes within input url before parsing")]
FoundNullBytes,
}