use std::fmt::Display;
use std::str::FromStr;
use std::{error::Error, fmt};
use tracing::debug;
use url::Url;
mod scheme;
pub use crate::scheme::Scheme;
#[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,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct FromStrError {
url: String,
kind: FromStrErrorKind,
}
impl Display for FromStrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
FromStrErrorKind::NormalizeUrl(_) => {
write!(f, "unable to normalize URL `{}`", self.url)
}
FromStrErrorKind::UrlHost => {
write!(f, "could not isolate host from URL `{}`", self.url)
}
FromStrErrorKind::UnsupportedScheme => {
write!(f, "unsupported scheme`",)
}
FromStrErrorKind::MalformedGitUrl => {
write!(f, "unknown format of git URL `{}`", self.url)
}
}
}
}
impl Error for FromStrError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match &self.kind {
FromStrErrorKind::NormalizeUrl(err) => Some(err),
FromStrErrorKind::UrlHost => None,
FromStrErrorKind::UnsupportedScheme => None,
FromStrErrorKind::MalformedGitUrl => None,
}
}
}
#[derive(Debug)]
pub enum FromStrErrorKind {
#[non_exhaustive]
NormalizeUrl(NormalizeUrlError),
#[non_exhaustive]
UrlHost,
#[non_exhaustive]
UnsupportedScheme,
#[non_exhaustive]
MalformedGitUrl,
}
impl FromStr for GitUrl {
type Err = FromStrError;
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, FromStrError> {
let normalized = normalize_url(url).map_err(|err| FromStrError {
url: url.to_owned(),
kind: FromStrErrorKind::NormalizeUrl(err),
})?;
let scheme = Scheme::from_str(normalized.scheme()).map_err(|_err| FromStrError {
url: url.to_owned(),
kind: FromStrErrorKind::UnsupportedScheme,
})?;
let urlpath = match &scheme {
Scheme::Ssh => {
normalized.path()[1..].to_string()
}
_ => normalized.path().to_string(),
};
let git_suffix_check = &urlpath.ends_with(".git");
debug!("The urlpath: {:?}", &urlpath);
let splitpath = &urlpath.rsplit_terminator('/').collect::<Vec<&str>>();
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 = normalized.host_str().ok_or_else(|| FromStrError {
url: url.to_owned(),
kind: FromStrErrorKind::UrlHost,
})?;
match hosts_w_organization_in_path.contains(&host_str) {
true => {
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(FromStrError {
url: url.to_owned(),
kind: FromStrErrorKind::UnsupportedScheme,
});
}
}
}
false => {
if !url.starts_with("ssh") && splitpath.len() < 2 {
return Err(FromStrError {
url: url.to_owned(),
kind: FromStrErrorKind::MalformedGitUrl,
});
}
let position = match splitpath.len() {
0 => {
return Err(FromStrError {
url: url.to_owned(),
kind: FromStrErrorKind::MalformedGitUrl,
})
}
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, NormalizeUrlError> {
let u = url.split(':').collect::<Vec<&str>>();
match u.len() {
2 => {
debug!("Normalizing ssh url: {:?}", u);
normalize_url(&format!("ssh://{}/{}", u[0], u[1]))
}
3 => {
debug!("Normalizing ssh url with ports: {:?}", u);
normalize_url(&format!("ssh://{}:{}/{}", u[0], u[1], u[2]))
}
_default => Err(NormalizeUrlError {
kind: NormalizeUrlErrorKind::UnsupportedSshPattern {
url: url.to_owned(),
},
}),
}
}
#[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
fn normalize_file_path(filepath: &str) -> Result<Url, NormalizeUrlError> {
let fp = Url::from_file_path(filepath);
match fp {
Ok(path) => Ok(path),
Err(_e) => normalize_url(&format!("file://{}", filepath)),
}
}
#[cfg(target_arch = "wasm32")]
fn normalize_file_path(_filepath: &str) -> Result<Url> {
unreachable!()
}
#[derive(Debug)]
#[non_exhaustive]
pub struct NormalizeUrlError {
kind: NormalizeUrlErrorKind,
}
impl Display for NormalizeUrlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
NormalizeUrlErrorKind::NullBytes => write!(f, "input URL contains null bytes"),
NormalizeUrlErrorKind::UrlParse(_) => write!(f, "unable to parse URL"),
NormalizeUrlErrorKind::UnsupportedSshPattern { url } => {
write!(f, "unsupported SSH pattern `{}`", url)
}
NormalizeUrlErrorKind::UnsupportedWindowsPath { path } => {
write!(f, "unsupported absolute Windows path `{}`", path)
}
NormalizeUrlErrorKind::UnsupportedScheme => write!(f, "unsupported URL scheme"),
}
}
}
impl Error for NormalizeUrlError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match &self.kind {
NormalizeUrlErrorKind::NullBytes => None,
NormalizeUrlErrorKind::UrlParse(err) => Some(err),
NormalizeUrlErrorKind::UnsupportedSshPattern { url: _ } => None,
NormalizeUrlErrorKind::UnsupportedWindowsPath { path: _ } => None,
NormalizeUrlErrorKind::UnsupportedScheme => None,
}
}
}
#[derive(Debug)]
pub enum NormalizeUrlErrorKind {
#[non_exhaustive]
NullBytes,
#[non_exhaustive]
UrlParse(url::ParseError),
#[non_exhaustive]
UnsupportedSshPattern { url: String },
#[non_exhaustive]
UnsupportedWindowsPath { path: String },
#[non_exhaustive]
UnsupportedScheme,
}
pub fn normalize_url(url: &str) -> Result<Url, NormalizeUrlError> {
debug!("Processing: {:?}", &url);
if url.contains('\0') {
return Err(NormalizeUrlError {
kind: NormalizeUrlErrorKind::NullBytes,
});
}
let url = url.trim_end_matches('/');
if is_absolute_windows_path(url) {
return Err(NormalizeUrlError {
kind: NormalizeUrlErrorKind::UnsupportedWindowsPath {
path: url.to_owned(),
},
});
}
let url_starts_with_git_but_no_slash = url.starts_with("git:") && url.get(4..5) != Some("/");
let url_to_parse = if url_starts_with_git_but_no_slash {
url.replace("git:", "git://")
} else {
url.to_string()
};
let url_parse = Url::parse(&url_to_parse);
Ok(match url_parse {
Ok(u) => match Scheme::from_str(u.scheme()) {
Ok(_) => u,
Err(_) => normalize_ssh_url(url)?,
},
Err(url::ParseError::RelativeUrlWithoutBase) => {
match string_contains_asperand_before_colon(url) {
true => {
debug!("Scheme::SSH match for normalization");
normalize_ssh_url(url)?
}
false => {
debug!("Scheme::File match for normalization");
normalize_file_path(url)?
}
}
}
Err(err) => {
return Err(NormalizeUrlError {
kind: NormalizeUrlErrorKind::UrlParse(err),
});
}
})
}
fn string_contains_asperand_before_colon(str: &str) -> bool {
let index_of_asperand = str.find('@');
let index_of_colon = str.find(':');
match (index_of_asperand, index_of_colon) {
(Some(index_of_asperand), Some(index_of_colon)) => index_of_asperand < index_of_colon,
_ => false,
}
}
fn is_absolute_windows_path(url: &str) -> bool {
if let Some(path) = url.strip_prefix("file://") {
return is_windows_drive_path(path.trim_start_matches('/'));
}
is_windows_drive_path(url)
}
fn is_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
}