1use std::fmt;
2use std::str::FromStr;
3use strum::{Display, EnumString, VariantNames};
4use thiserror::Error;
5use url::Url;
6
7#[cfg(feature = "tracing")]
8use tracing::debug;
9
10#[derive(Debug, PartialEq, Eq, EnumString, VariantNames, Clone, Display, Copy)]
12#[strum(serialize_all = "kebab_case")]
13pub enum Scheme {
14 File,
16 Ftp,
18 Ftps,
20 Git,
22 #[strum(serialize = "git+ssh")]
24 GitSsh,
25 Http,
27 Https,
29 Ssh,
31 Unspecified,
33}
34
35#[derive(Debug, PartialEq, Eq, Clone)]
40pub struct GitUrl {
41 pub host: Option<String>,
43 pub name: String,
45 pub owner: Option<String>,
47 pub organization: Option<String>,
49 pub fullname: String,
51 pub scheme: Scheme,
53 pub user: Option<String>,
55 pub token: Option<String>,
57 pub port: Option<u16>,
59 pub path: String,
61 pub git_suffix: bool,
63 pub scheme_prefix: bool,
65}
66
67impl fmt::Display for GitUrl {
69 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
70 let scheme_prefix = match self.scheme_prefix {
71 true => format!("{}://", self.scheme),
72 false => String::new(),
73 };
74
75 let auth_info = match self.scheme {
76 Scheme::Ssh | Scheme::Git | Scheme::GitSsh => {
77 if let Some(user) = &self.user {
78 format!("{}@", user)
79 } else {
80 String::new()
81 }
82 }
83 Scheme::Http | Scheme::Https => match (&self.user, &self.token) {
84 (Some(user), Some(token)) => format!("{}:{}@", user, token),
85 (Some(user), None) => format!("{}@", user),
86 (None, Some(token)) => format!("{}@", token),
87 (None, None) => String::new(),
88 },
89 _ => String::new(),
90 };
91
92 let host = match &self.host {
93 Some(host) => host.to_string(),
94 None => String::new(),
95 };
96
97 let port = match &self.port {
98 Some(p) => format!(":{}", p),
99 None => String::new(),
100 };
101
102 let path = match &self.scheme {
103 Scheme::Ssh => {
104 if self.port.is_some() {
105 format!("/{}", &self.path)
106 } else {
107 format!(":{}", &self.path)
108 }
109 }
110 _ => (&self.path).to_string(),
111 };
112
113 let git_url_str = format!("{}{}{}{}{}", scheme_prefix, auth_info, host, port, path);
114
115 write!(f, "{}", git_url_str)
116 }
117}
118
119impl Default for GitUrl {
120 fn default() -> Self {
121 GitUrl {
122 host: None,
123 name: "".to_string(),
124 owner: None,
125 organization: None,
126 fullname: "".to_string(),
127 scheme: Scheme::Unspecified,
128 user: None,
129 token: None,
130 port: None,
131 path: "".to_string(),
132 git_suffix: false,
133 scheme_prefix: false,
134 }
135 }
136}
137
138impl FromStr for GitUrl {
139 type Err = GitUrlParseError;
140
141 fn from_str(s: &str) -> Result<Self, Self::Err> {
142 GitUrl::parse(s)
143 }
144}
145
146impl GitUrl {
147 pub fn trim_auth(&self) -> GitUrl {
150 let mut new_giturl = self.clone();
151 new_giturl.user = None;
152 new_giturl.token = None;
153 new_giturl
154 }
155
156 pub fn parse(url: &str) -> Result<GitUrl, GitUrlParseError> {
158 let normalized = if let Ok(url) = normalize_url(url) {
160 url
161 } else {
162 return Err(GitUrlParseError::UrlNormalizeFailed);
163 };
164
165 let scheme = if let Ok(scheme) = Scheme::from_str(normalized.scheme()) {
167 scheme
168 } else {
169 return Err(GitUrlParseError::UnsupportedScheme(
170 normalized.scheme().to_string(),
171 ));
172 };
173
174 let urlpath = match &scheme {
176 Scheme::Ssh => {
177 normalized.path()[1..].to_string()
180 }
181 _ => normalized.path().to_string(),
182 };
183
184 let git_suffix_check = &urlpath.ends_with(".git");
185
186 #[cfg(feature = "tracing")]
189 debug!("The urlpath: {:?}", &urlpath);
190
191 let splitpath = &urlpath.rsplit_terminator('/').collect::<Vec<&str>>();
199
200 #[cfg(feature = "tracing")]
201 debug!("rsplit results for metadata: {:?}", splitpath);
202
203 let name = splitpath[0].trim_end_matches(".git").to_string();
204
205 let (owner, organization, fullname) = match &scheme {
208 Scheme::File => (None::<String>, None::<String>, name.clone()),
210 _ => {
211 let mut fullname: Vec<&str> = Vec::new();
212
213 let hosts_w_organization_in_path = vec!["dev.azure.com", "ssh.dev.azure.com"];
215 let host_str = if let Some(host) = normalized.host_str() {
218 host
219 } else {
220 return Err(GitUrlParseError::UnsupportedUrlHostFormat);
221 };
222
223 match hosts_w_organization_in_path.contains(&host_str) {
224 true => {
225 #[cfg(feature = "tracing")]
226 debug!("Found a git provider with an org");
227
228 match &scheme {
231 Scheme::Ssh => {
233 fullname.push(splitpath[2]);
235 fullname.push(splitpath[1]);
237 fullname.push(splitpath[0]);
239
240 (
241 Some(splitpath[1].to_string()),
242 Some(splitpath[2].to_string()),
243 fullname.join("/"),
244 )
245 }
246 Scheme::Https => {
248 fullname.push(splitpath[3]);
250 fullname.push(splitpath[2]);
252 fullname.push(splitpath[0]);
254
255 (
256 Some(splitpath[2].to_string()),
257 Some(splitpath[3].to_string()),
258 fullname.join("/"),
259 )
260 }
261
262 _ => return Err(GitUrlParseError::UnexpectedScheme),
264 }
265 }
266 false => {
267 if !url.starts_with("ssh") && splitpath.len() < 2 {
268 return Err(GitUrlParseError::UnexpectedFormat);
269 }
270
271 let position = match splitpath.len() {
272 0 => return Err(GitUrlParseError::UnexpectedFormat),
273 1 => 0,
274 _ => 1,
275 };
276
277 fullname.push(splitpath[position]);
279 fullname.push(name.as_str());
281
282 (
283 Some(splitpath[position].to_string()),
284 None::<String>,
285 fullname.join("/"),
286 )
287 }
288 }
289 }
290 };
291
292 let final_host = match scheme {
293 Scheme::File => None,
294 _ => normalized.host_str().map(|h| h.to_string()),
295 };
296
297 let final_path = match scheme {
298 Scheme::File => {
299 if let Some(host) = normalized.host_str() {
300 format!("{}{}", host, urlpath)
301 } else {
302 urlpath
303 }
304 }
305 _ => urlpath,
306 };
307
308 Ok(GitUrl {
309 host: final_host,
310 name,
311 owner,
312 organization,
313 fullname,
314 scheme,
315 user: match normalized.username().to_string().len() {
316 0 => None,
317 _ => Some(normalized.username().to_string()),
318 },
319 token: normalized.password().map(|p| p.to_string()),
320 port: normalized.port(),
321 path: final_path,
322 git_suffix: *git_suffix_check,
323 scheme_prefix: url.contains("://") || url.starts_with("git:"),
324 })
325 }
326}
327
328fn normalize_ssh_url(url: &str) -> Result<Url, GitUrlParseError> {
335 let u = url.split(':').collect::<Vec<&str>>();
336
337 match u.len() {
338 2 => {
339 #[cfg(feature = "tracing")]
340 debug!("Normalizing ssh url: {:?}", u);
341 normalize_url(&format!("ssh://{}/{}", u[0], u[1]))
342 }
343 3 => {
344 #[cfg(feature = "tracing")]
345 debug!("Normalizing ssh url with ports: {:?}", u);
346 normalize_url(&format!("ssh://{}:{}/{}", u[0], u[1], u[2]))
347 }
348 _default => Err(GitUrlParseError::UnsupportedSshUrlFormat),
349 }
350}
351
352#[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
356fn normalize_file_path(filepath: &str) -> Result<Url, GitUrlParseError> {
357 let fp = Url::from_file_path(filepath);
358
359 match fp {
360 Ok(path) => Ok(path),
361 Err(_e) => {
362 if let Ok(file_url) = normalize_url(&format!("file://{}", filepath)) {
363 Ok(file_url)
364 } else {
365 return Err(GitUrlParseError::FileUrlNormalizeFailedSchemeAdded);
366 }
367 }
368 }
369}
370
371#[cfg(target_arch = "wasm32")]
372fn normalize_file_path(_filepath: &str) -> Result<Url, GitUrlParseError> {
373 unreachable!()
374}
375
376pub fn normalize_url(url: &str) -> Result<Url, GitUrlParseError> {
380 #[cfg(feature = "tracing")]
381 debug!("Processing: {:?}", &url);
382
383 if url.contains('\0') {
387 return Err(GitUrlParseError::FoundNullBytes);
388 }
389
390 let trim_url = url.trim_end_matches('/');
392
393 let url_to_parse = if trim_url.starts_with("git:") && !trim_url.starts_with("git://") {
397 trim_url.replace("git:", "git://")
398 } else {
399 trim_url.to_string()
400 };
401
402 let url_parse = Url::parse(&url_to_parse);
403
404 Ok(match url_parse {
405 Ok(u) => {
406 match Scheme::from_str(u.scheme()) {
407 Ok(_p) => u,
408 Err(_e) => {
409 #[cfg(feature = "tracing")]
411 debug!("Scheme parse fail. Assuming a userless ssh url");
412 if let Ok(ssh_url) = normalize_ssh_url(trim_url) {
413 ssh_url
414 } else {
415 return Err(GitUrlParseError::SshUrlNormalizeFailedNoScheme);
416 }
417 }
418 }
419 }
420
421 Err(url::ParseError::RelativeUrlWithoutBase) => {
424 match is_ssh_url(trim_url) {
431 true => {
432 #[cfg(feature = "tracing")]
433 debug!("Scheme::SSH match for normalization");
434 normalize_ssh_url(trim_url)?
435 }
436 false => {
437 #[cfg(feature = "tracing")]
438 debug!("Scheme::File match for normalization");
439 normalize_file_path(trim_url)?
440 }
441 }
442 }
443 Err(err) => {
444 return Err(GitUrlParseError::from(err));
445 }
446 })
447}
448
449fn is_ssh_url(url: &str) -> bool {
453 if !url.contains(':') {
455 return false;
456 }
457
458 if let (Some(at_pos), Some(colon_pos)) = (url.find('@'), url.find(':')) {
460 if colon_pos < at_pos {
461 return false;
462 }
463
464 let parts: Vec<&str> = url.split('@').collect();
466 if parts.len() != 2 && !parts[0].is_empty() {
467 return false;
468 } else {
469 return true;
470 }
471 }
472
473 let parts: Vec<&str> = url.split(':').collect();
475
476 if parts.len() != 2 && !parts[0].is_empty() && !parts[1].is_empty() {
485 return false;
486 } else {
487 return true;
488 }
489}
490
491#[derive(Error, Debug, PartialEq, Eq)]
492pub enum GitUrlParseError {
493 #[error("Error from Url crate")]
494 UrlParseError(#[from] url::ParseError),
495
496 #[error("Url normalization into url::Url failed")]
497 UrlNormalizeFailed,
498
499 #[error("No url scheme was found, then failed to normalize as ssh url.")]
500 SshUrlNormalizeFailedNoScheme,
501
502 #[error("No url scheme was found, then failed to normalize as ssh url after adding 'ssh://'")]
503 SshUrlNormalizeFailedSchemeAdded,
504
505 #[error("Failed to normalize as ssh url after adding 'ssh://'")]
506 SshUrlNormalizeFailedSchemeAddedWithPorts,
507
508 #[error("No url scheme was found, then failed to normalize as file url.")]
509 FileUrlNormalizeFailedNoScheme,
510
511 #[error(
512 "No url scheme was found, then failed to normalize as file url after adding 'file://'"
513 )]
514 FileUrlNormalizeFailedSchemeAdded,
515
516 #[error("Git Url not in expected format")]
517 UnexpectedFormat,
518
519 #[error("Git Url for host using unexpected scheme")]
521 UnexpectedScheme,
522
523 #[error("Scheme unsupported: {0}")]
524 UnsupportedScheme(String),
525 #[error("Host from Url cannot be str or does not exist")]
526 UnsupportedUrlHostFormat,
527 #[error("Git Url not in expected format for SSH")]
528 UnsupportedSshUrlFormat,
529
530 #[error("Found null bytes within input url before parsing")]
531 FoundNullBytes,
532}