use schemars::JsonSchema;
use serde::Serialize;
use super::ci_platform::CiPlatform;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct GitRepoInfo {
pub url: String,
pub provider: GitRepoProvider,
pub host: String,
pub owner: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
pub enum GitRepoProvider {
#[serde(rename = "github")]
GitHub,
#[serde(rename = "gitlab")]
GitLab,
#[serde(rename = "gitea")]
Gitea,
#[serde(rename = "azure-devops")]
AzureDevOps,
#[serde(rename = "unknown")]
Unknown,
}
impl GitRepoProvider {
pub fn from_platform(value: Option<&str>) -> Option<Self> {
value?.parse::<CiPlatform>().ok().map(Into::into)
}
fn from_remote_host(url: &GitRemoteUrl) -> Option<Self> {
if url.is_github() {
Some(Self::GitHub)
} else if url.is_gitlab() {
Some(Self::GitLab)
} else if url.is_gitea() {
Some(Self::Gitea)
} else if url.is_azure_devops() {
Some(Self::AzureDevOps)
} else {
None
}
}
}
impl From<CiPlatform> for GitRepoProvider {
fn from(platform: CiPlatform) -> Self {
match platform {
CiPlatform::GitHub => Self::GitHub,
CiPlatform::GitLab => Self::GitLab,
CiPlatform::Gitea => Self::Gitea,
CiPlatform::AzureDevOps => Self::AzureDevOps,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRemoteUrl {
host: String,
owner: String,
repo: String,
}
fn split_namespace_repo(path: &str) -> Option<(String, String)> {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 2 {
return None;
}
let repo_with_suffix = segments.last()?;
let repo = repo_with_suffix
.strip_suffix(".git")
.unwrap_or(repo_with_suffix);
let namespace = segments[..segments.len() - 1].join("/");
if namespace.is_empty() || repo.is_empty() {
return None;
}
Some((namespace, repo.to_string()))
}
impl GitRemoteUrl {
pub fn parse(url: &str) -> Option<Self> {
let url = url.trim();
let (host, namespace, repo) = if let Some(rest) = url.strip_prefix("https://") {
let (host, path) = rest.split_once('/')?;
let (namespace, repo) = split_namespace_repo(path)?;
(host, namespace, repo)
} else if let Some(rest) = url.strip_prefix("http://") {
let (host, path) = rest.split_once('/')?;
let (namespace, repo) = split_namespace_repo(path)?;
(host, namespace, repo)
} else if let Some(rest) = url.strip_prefix("git://") {
let (host, path) = rest.split_once('/')?;
let (namespace, repo) = split_namespace_repo(path)?;
(host, namespace, repo)
} else if let Some(rest) = url.strip_prefix("ssh://") {
let without_user = rest.split('@').next_back()?;
let (host_with_port, path) = without_user.split_once('/')?;
let host = host_with_port.split(':').next().unwrap_or(host_with_port);
let (namespace, repo) = split_namespace_repo(path)?;
(host, namespace, repo)
} else if let Some(rest) = url.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
let (namespace, repo) = split_namespace_repo(path)?;
(host, namespace, repo)
} else {
return None;
};
if host.is_empty() {
return None;
}
Some(Self {
host: host.to_string(),
owner: namespace,
repo,
})
}
pub fn host(&self) -> &str {
&self.host
}
pub fn owner(&self) -> &str {
&self.owner
}
pub fn repo(&self) -> &str {
&self.repo
}
pub fn project_identifier(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.repo)
}
pub fn is_github(&self) -> bool {
self.host.to_ascii_lowercase().contains("github")
}
pub fn is_gitlab(&self) -> bool {
self.host.to_ascii_lowercase().contains("gitlab")
}
pub fn is_gitea(&self) -> bool {
self.host.to_ascii_lowercase().contains("gitea")
}
pub fn is_azure_devops(&self) -> bool {
let host = self.host.to_ascii_lowercase();
host.contains("dev.azure.com") || host.contains("visualstudio.com")
}
pub fn azure_organization(&self) -> Option<&str> {
if !self.is_azure_devops() {
return None;
}
let parts: Vec<&str> = self.owner.split('/').collect();
let host = self.host.to_ascii_lowercase();
if host.contains("ssh.dev.azure.com") {
parts.get(1).copied()
} else if host.contains("dev.azure.com") {
parts.first().copied()
} else {
self.host.split('.').next()
}
}
pub fn azure_project(&self) -> Option<&str> {
if !self.is_azure_devops() {
return None;
}
let parts: Vec<&str> = self.owner.split('/').collect();
let host = self.host.to_ascii_lowercase();
if host.contains("ssh.dev.azure.com") {
parts.get(2).copied()
} else if host.contains("dev.azure.com") {
parts.get(1).copied()
} else {
parts.first().copied()
}
}
pub fn web_url(&self) -> Option<String> {
if self.is_azure_devops() {
let organization = self.azure_organization()?;
let project = self.azure_project()?;
let host = if self
.host
.to_ascii_lowercase()
.ends_with(".visualstudio.com")
{
self.host.as_str()
} else {
"dev.azure.com"
};
return Some(crate::git::remote_ref::azure::fork_remote_url(
host,
organization,
project,
&self.repo,
));
}
Some(format!(
"https://{}/{}/{}",
self.host, self.owner, self.repo
))
}
pub fn repo_info(&self, provider_override: Option<&str>) -> Option<GitRepoInfo> {
let provider_override = GitRepoProvider::from_platform(provider_override);
let provider_from_host = GitRepoProvider::from_remote_host(self);
let provider = provider_override
.or(provider_from_host)
.unwrap_or(GitRepoProvider::Unknown);
if provider == GitRepoProvider::AzureDevOps {
if let Some((host, organization, project)) = self.azure_repo_info_parts() {
return Some(GitRepoInfo {
url: crate::git::remote_ref::azure::fork_remote_url(
&host,
&organization,
&project,
&self.repo,
),
provider,
host,
owner: organization,
name: self.repo.clone(),
project: Some(project),
remote: None,
});
}
if provider_override == Some(GitRepoProvider::AzureDevOps)
&& provider_from_host != Some(GitRepoProvider::AzureDevOps)
{
return Some(GitRepoInfo {
url: self.web_url()?,
provider: GitRepoProvider::Unknown,
host: self.host.clone(),
owner: self.owner.clone(),
name: self.repo.clone(),
project: None,
remote: None,
});
}
}
Some(GitRepoInfo {
url: self.web_url()?,
provider,
host: self.host.clone(),
owner: self.owner.clone(),
name: self.repo.clone(),
project: None,
remote: None,
})
}
fn azure_repo_info_parts(&self) -> Option<(String, String, String)> {
if let (Some(organization), Some(project)) =
(self.azure_organization(), self.azure_project())
{
let host = if self
.host
.to_ascii_lowercase()
.ends_with(".visualstudio.com")
{
self.host.clone()
} else {
"dev.azure.com".to_string()
};
return Some((host, organization.to_string(), project.to_string()));
}
let parts: Vec<&str> = self.owner.split('/').collect();
if parts.len() >= 3 && parts[2] == "_git" {
return Some((
self.host.clone(),
parts[0].to_string(),
parts[1].to_string(),
));
}
if parts.len() >= 3 && parts[0] == "v3" {
return Some((
self.host.clone(),
parts[1].to_string(),
parts[2].to_string(),
));
}
None
}
}
pub fn parse_owner_repo(url: &str) -> Option<(String, String)> {
GitRemoteUrl::parse(url).map(|u| (u.owner().to_string(), u.repo().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_https_urls() {
let url = GitRemoteUrl::parse("https://github.com/owner/repo.git").unwrap();
assert_eq!(url.host(), "github.com");
assert_eq!(url.owner(), "owner");
assert_eq!(url.repo(), "repo");
assert_eq!(url.project_identifier(), "github.com/owner/repo");
let url = GitRemoteUrl::parse("https://github.com/owner/repo").unwrap();
assert_eq!(url.repo(), "repo");
let url = GitRemoteUrl::parse(" https://github.com/owner/repo.git\n").unwrap();
assert_eq!(url.owner(), "owner");
}
#[test]
fn test_http_urls() {
let url = GitRemoteUrl::parse("http://gitlab.internal.company.com/owner/repo.git").unwrap();
assert_eq!(
url.project_identifier(),
"gitlab.internal.company.com/owner/repo"
);
}
#[test]
fn test_git_at_urls() {
let url = GitRemoteUrl::parse("git@github.com:owner/repo.git").unwrap();
assert_eq!(url.project_identifier(), "github.com/owner/repo");
let url = GitRemoteUrl::parse("git@github.com:owner/repo").unwrap();
assert_eq!(url.repo(), "repo");
let url = GitRemoteUrl::parse("git@gitlab.example.com:owner/repo.git").unwrap();
assert!(url.project_identifier().starts_with("gitlab.example.com/"));
let url = GitRemoteUrl::parse("git@bitbucket.org:owner/repo.git").unwrap();
assert!(url.project_identifier().starts_with("bitbucket.org/"));
}
#[test]
fn test_ssh_urls() {
let url = GitRemoteUrl::parse("ssh://git@github.com/owner/repo.git").unwrap();
assert_eq!(url.project_identifier(), "github.com/owner/repo");
let url = GitRemoteUrl::parse("ssh://github.com/owner/repo.git").unwrap();
assert!(url.project_identifier().starts_with("github.com/"));
assert_eq!(url.owner(), "owner");
}
#[test]
fn test_ssh_urls_with_ports() {
let url = GitRemoteUrl::parse("ssh://git@host:22/owner/repo.git").unwrap();
assert_eq!(url.host(), "host");
assert_eq!(url.owner(), "owner");
assert_eq!(url.repo(), "repo");
assert_eq!(url.project_identifier(), "host/owner/repo");
let url = GitRemoteUrl::parse("ssh://host:2222/owner/repo.git").unwrap();
assert_eq!(url.host(), "host");
assert_eq!(url.owner(), "owner");
assert_eq!(url.repo(), "repo");
let url =
GitRemoteUrl::parse("ssh://git@gitlab.internal:2222/group/subgroup/repo.git").unwrap();
assert_eq!(url.host(), "gitlab.internal");
assert_eq!(url.owner(), "group/subgroup");
assert_eq!(url.repo(), "repo");
assert_eq!(
url.project_identifier(),
"gitlab.internal/group/subgroup/repo"
);
let with_port = GitRemoteUrl::parse("ssh://git@host:2222/owner/repo.git").unwrap();
let without_port = GitRemoteUrl::parse("ssh://git@host/owner/repo.git").unwrap();
assert_eq!(
with_port.project_identifier(),
without_port.project_identifier(),
"Port is a transport detail — same project identity"
);
}
#[test]
fn test_git_protocol_urls() {
let url = GitRemoteUrl::parse("git://github.com/owner/repo.git").unwrap();
assert_eq!(url.project_identifier(), "github.com/owner/repo");
assert!(url.is_github());
let url = GitRemoteUrl::parse("git://gitlab.example.com/owner/repo.git").unwrap();
assert!(url.is_gitlab());
}
#[test]
fn test_malformed_urls() {
assert!(GitRemoteUrl::parse("").is_none());
assert!(GitRemoteUrl::parse("https://github.com/").is_none());
assert!(GitRemoteUrl::parse("https://github.com/owner/").is_none());
assert!(GitRemoteUrl::parse("git@github.com:").is_none());
assert!(GitRemoteUrl::parse("git@github.com:owner/").is_none());
assert!(GitRemoteUrl::parse("ftp://github.com/owner/repo.git").is_none());
}
#[test]
fn test_org_repos() {
let url = GitRemoteUrl::parse("https://github.com/company-org/project.git").unwrap();
assert_eq!(url.owner(), "company-org");
assert_eq!(url.repo(), "project");
}
#[test]
fn test_parse_owner_repo() {
assert_eq!(
parse_owner_repo("https://github.com/owner/repo.git"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo("https://github.com/owner/repo"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo(" https://github.com/owner/repo.git\n"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo("git@github.com:owner/repo.git"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo("git@github.com:owner/repo"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo("ssh://git@github.com/owner/repo.git"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo("https://gitlab.com/owner/repo.git"),
Some(("owner".to_string(), "repo".to_string()))
);
assert_eq!(parse_owner_repo("https://github.com/owner/"), None);
assert_eq!(parse_owner_repo("git@github.com:owner/"), None);
assert_eq!(parse_owner_repo(""), None);
}
#[test]
fn test_project_identifier() {
let cases = [
(
"https://github.com/max-sixty/worktrunk.git",
"github.com/max-sixty/worktrunk",
),
("git@github.com:owner/repo.git", "github.com/owner/repo"),
(
"ssh://git@gitlab.example.com/org/project.git",
"gitlab.example.com/org/project",
),
];
for (input, expected) in cases {
let url = GitRemoteUrl::parse(input).unwrap();
assert_eq!(url.project_identifier(), expected, "input: {input}");
}
}
#[test]
fn test_is_github() {
assert!(
GitRemoteUrl::parse("https://github.com/owner/repo.git")
.unwrap()
.is_github()
);
assert!(
GitRemoteUrl::parse("git@github.com:owner/repo.git")
.unwrap()
.is_github()
);
assert!(
GitRemoteUrl::parse("ssh://git@github.com/owner/repo.git")
.unwrap()
.is_github()
);
assert!(
GitRemoteUrl::parse("https://github.mycompany.com/owner/repo.git")
.unwrap()
.is_github()
);
assert!(
!GitRemoteUrl::parse("https://gitlab.com/owner/repo.git")
.unwrap()
.is_github()
);
assert!(
!GitRemoteUrl::parse("https://bitbucket.org/owner/repo.git")
.unwrap()
.is_github()
);
}
#[test]
fn test_is_gitlab() {
assert!(
GitRemoteUrl::parse("https://gitlab.com/owner/repo.git")
.unwrap()
.is_gitlab()
);
assert!(
GitRemoteUrl::parse("git@gitlab.com:owner/repo.git")
.unwrap()
.is_gitlab()
);
assert!(
GitRemoteUrl::parse("https://gitlab.example.com/owner/repo.git")
.unwrap()
.is_gitlab()
);
assert!(
!GitRemoteUrl::parse("https://github.com/owner/repo.git")
.unwrap()
.is_gitlab()
);
assert!(
!GitRemoteUrl::parse("https://bitbucket.org/owner/repo.git")
.unwrap()
.is_gitlab()
);
}
#[test]
fn test_nested_gitlab_groups_https() {
let url = GitRemoteUrl::parse("https://gitlab.com/group/subgroup/repo.git").unwrap();
assert_eq!(url.host(), "gitlab.com");
assert_eq!(url.owner(), "group/subgroup");
assert_eq!(url.repo(), "repo");
assert_eq!(
url.project_identifier(),
"gitlab.com/group/subgroup/repo",
"Security: nested group must be fully preserved in identifier"
);
let url =
GitRemoteUrl::parse("https://gitlab.com/org/team/project/subproject/repo.git").unwrap();
assert_eq!(url.host(), "gitlab.com");
assert_eq!(url.owner(), "org/team/project/subproject");
assert_eq!(url.repo(), "repo");
assert_eq!(
url.project_identifier(),
"gitlab.com/org/team/project/subproject/repo"
);
let url = GitRemoteUrl::parse("https://gitlab.com/group/subgroup/repo").unwrap();
assert_eq!(url.repo(), "repo");
assert_eq!(url.owner(), "group/subgroup");
}
#[test]
fn test_nested_gitlab_groups_ssh() {
let url = GitRemoteUrl::parse("git@gitlab.com:group/subgroup/repo.git").unwrap();
assert_eq!(url.host(), "gitlab.com");
assert_eq!(url.owner(), "group/subgroup");
assert_eq!(url.repo(), "repo");
assert_eq!(
url.project_identifier(),
"gitlab.com/group/subgroup/repo",
"Security: SSH URLs must handle nested groups identically to HTTPS"
);
let url = GitRemoteUrl::parse("ssh://git@gitlab.com/group/subgroup/repo.git").unwrap();
assert_eq!(url.owner(), "group/subgroup");
assert_eq!(url.repo(), "repo");
let url = GitRemoteUrl::parse("git@gitlab.com:a/b/c/d/repo.git").unwrap();
assert_eq!(url.owner(), "a/b/c/d");
assert_eq!(url.repo(), "repo");
}
#[test]
fn test_nested_groups_self_hosted() {
let url =
GitRemoteUrl::parse("https://gitlab.mycompany.com/team/frontend/repo.git").unwrap();
assert_eq!(url.host(), "gitlab.mycompany.com");
assert_eq!(url.owner(), "team/frontend");
assert_eq!(url.repo(), "repo");
let url = GitRemoteUrl::parse("git@gitlab.internal:org/dept/project/repo.git").unwrap();
assert_eq!(url.owner(), "org/dept/project");
assert_eq!(url.repo(), "repo");
}
#[test]
fn test_nested_groups_security_uniqueness() {
let repo1 = GitRemoteUrl::parse("https://gitlab.com/company/team/repo-a.git").unwrap();
let repo2 = GitRemoteUrl::parse("https://gitlab.com/company/team/repo-b.git").unwrap();
assert_ne!(
repo1.project_identifier(),
repo2.project_identifier(),
"Security: Different repos MUST have different project identifiers"
);
assert_eq!(repo1.owner(), "company/team");
assert_eq!(repo2.owner(), "company/team");
assert_ne!(repo1.repo(), repo2.repo());
}
#[test]
fn test_parse_owner_repo_nested() {
assert_eq!(
parse_owner_repo("https://gitlab.com/group/subgroup/repo.git"),
Some(("group/subgroup".to_string(), "repo".to_string()))
);
assert_eq!(
parse_owner_repo("git@gitlab.com:a/b/c/repo.git"),
Some(("a/b/c".to_string(), "repo".to_string()))
);
}
#[test]
fn test_nested_groups_edge_cases() {
let url = GitRemoteUrl::parse("https://gitlab.com/a/b/c/d/e/f/g/repo.git").unwrap();
assert_eq!(url.owner(), "a/b/c/d/e/f/g");
assert_eq!(url.repo(), "repo");
assert_eq!(url.project_identifier(), "gitlab.com/a/b/c/d/e/f/g/repo");
let url = GitRemoteUrl::parse("https://gitlab.com/group/repo.name.git").unwrap();
assert_eq!(url.owner(), "group");
assert_eq!(url.repo(), "repo.name");
let url =
GitRemoteUrl::parse("https://gitlab.com/my-group/sub_group/my-repo_v2.git").unwrap();
assert_eq!(url.owner(), "my-group/sub_group");
assert_eq!(url.repo(), "my-repo_v2");
}
#[test]
fn test_nested_groups_similar_paths_are_distinct() {
let cases = [
(
"https://gitlab.com/org/team/repo-a.git",
"gitlab.com/org/team/repo-a",
),
(
"https://gitlab.com/org/team/repo-b.git",
"gitlab.com/org/team/repo-b",
),
("https://gitlab.com/org/repo.git", "gitlab.com/org/repo"),
(
"https://gitlab.com/org/team/repo.git",
"gitlab.com/org/team/repo",
),
(
"https://gitlab.com/org/team/sub/repo.git",
"gitlab.com/org/team/sub/repo",
),
(
"https://gitlab.com/project/repo.git",
"gitlab.com/project/repo",
),
(
"https://gitlab.com/repo/project.git",
"gitlab.com/repo/project",
),
];
let identifiers: Vec<_> = cases
.iter()
.map(|(url, _)| GitRemoteUrl::parse(url).unwrap().project_identifier())
.collect();
for (i, id) in identifiers.iter().enumerate() {
assert_eq!(
id, cases[i].1,
"URL {} should produce identifier {}",
cases[i].0, cases[i].1
);
}
let mut sorted = identifiers.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
identifiers.len(),
sorted.len(),
"All project identifiers must be unique"
);
}
#[test]
fn test_nested_groups_malformed_paths() {
assert!(GitRemoteUrl::parse("https://gitlab.com/group/").is_none());
assert!(GitRemoteUrl::parse("git@gitlab.com:group/").is_none());
assert!(GitRemoteUrl::parse("https://gitlab.com/").is_none());
assert!(GitRemoteUrl::parse("git@gitlab.com:").is_none());
let url = GitRemoteUrl::parse("https://gitlab.com/group//subgroup/repo.git");
if let Some(parsed) = url {
assert!(!parsed.owner().contains("//"));
assert!(!parsed.owner().is_empty());
}
assert!(GitRemoteUrl::parse("https://gitlab.com/group/.git").is_none());
let url = GitRemoteUrl::parse("https://gitlab.com/group/.git.git").unwrap();
assert_eq!(url.repo(), ".git");
}
#[test]
fn test_all_url_formats_handle_nested_groups_identically() {
let formats = [
"https://gitlab.com/group/subgroup/repo.git",
"https://gitlab.com/group/subgroup/repo",
"git@gitlab.com:group/subgroup/repo.git",
"git@gitlab.com:group/subgroup/repo",
"ssh://git@gitlab.com/group/subgroup/repo.git",
"ssh://gitlab.com/group/subgroup/repo.git",
"git://gitlab.com/group/subgroup/repo.git",
"http://gitlab.com/group/subgroup/repo.git",
];
let expected_identifier = "gitlab.com/group/subgroup/repo";
for url in formats {
let parsed =
GitRemoteUrl::parse(url).unwrap_or_else(|| panic!("Failed to parse URL: {url}"));
assert_eq!(
parsed.project_identifier(),
expected_identifier,
"URL format '{url}' must produce consistent identifier"
);
assert_eq!(parsed.owner(), "group/subgroup");
assert_eq!(parsed.repo(), "repo");
}
}
#[test]
fn test_adversarial_different_nesting_levels_no_collision() {
let attacker = GitRemoteUrl::parse("https://gitlab.com/a-b/c/repo.git").unwrap();
let victim = GitRemoteUrl::parse("https://gitlab.com/a/b/c/repo.git").unwrap();
assert_ne!(
attacker.project_identifier(),
victim.project_identifier(),
"CRITICAL: Different group structures must have different identifiers"
);
assert_eq!(attacker.project_identifier(), "gitlab.com/a-b/c/repo");
assert_eq!(victim.project_identifier(), "gitlab.com/a/b/c/repo");
}
#[test]
fn test_adversarial_host_spoofing_no_collision() {
let evil_host = GitRemoteUrl::parse("https://gitlab.com.evil.com/owner/repo.git").unwrap();
let real_host = GitRemoteUrl::parse("https://gitlab.com/owner/repo.git").unwrap();
assert_ne!(
evil_host.project_identifier(),
real_host.project_identifier(),
"Different hosts must produce different identifiers"
);
assert_eq!(evil_host.host(), "gitlab.com.evil.com");
assert_eq!(real_host.host(), "gitlab.com");
}
#[test]
fn test_adversarial_case_sensitivity() {
let uppercase = GitRemoteUrl::parse("https://gitlab.com/Owner/Repo.git").unwrap();
let lowercase = GitRemoteUrl::parse("https://gitlab.com/owner/repo.git").unwrap();
assert_ne!(
uppercase.project_identifier(),
lowercase.project_identifier(),
"Case differences must produce different identifiers"
);
}
#[test]
fn test_adversarial_git_suffix_manipulation() {
let double_git = GitRemoteUrl::parse("https://gitlab.com/owner/repo.git.git").unwrap();
let single_git = GitRemoteUrl::parse("https://gitlab.com/owner/repo.git").unwrap();
let no_git = GitRemoteUrl::parse("https://gitlab.com/owner/repo").unwrap();
assert_eq!(double_git.repo(), "repo.git");
assert_eq!(single_git.repo(), "repo");
assert_eq!(no_git.repo(), "repo");
assert_eq!(single_git.project_identifier(), no_git.project_identifier());
assert_ne!(
double_git.project_identifier(),
single_git.project_identifier()
);
}
#[test]
fn test_adversarial_ssh_user_injection() {
let parsed =
GitRemoteUrl::parse("ssh://git@legitimate.com@attacker.com/owner/repo.git").unwrap();
assert_eq!(
parsed.host(),
"attacker.com",
"SSH URLs with multiple @ signs: last @ determines host"
);
assert!(parsed.project_identifier().starts_with("attacker.com/"));
}
#[test]
fn test_adversarial_ssh_at_in_path() {
assert!(
GitRemoteUrl::parse("ssh://git@host.com/org@company/repo.git").is_none(),
"SSH URLs with @ in path after host are rejected (ambiguous parsing)"
);
let https_with_at = GitRemoteUrl::parse("https://host.com/org@company/repo.git").unwrap();
assert_eq!(https_with_at.owner(), "org@company");
assert_eq!(https_with_at.repo(), "repo");
}
#[test]
fn test_adversarial_empty_user_ssh() {
assert!(
GitRemoteUrl::parse("ssh://user@/owner/repo.git").is_none(),
"Empty host should be rejected"
);
let parsed = GitRemoteUrl::parse("ssh://@host.com/owner/repo.git").unwrap();
assert_eq!(parsed.host(), "host.com");
assert_eq!(parsed.owner(), "owner");
assert_eq!(parsed.repo(), "repo");
}
#[test]
fn test_adversarial_empty_segment_normalization() {
let with_double_slash = GitRemoteUrl::parse("https://gitlab.com/a//b/repo.git").unwrap();
let normal = GitRemoteUrl::parse("https://gitlab.com/a/b/repo.git").unwrap();
assert_eq!(
with_double_slash.project_identifier(),
normal.project_identifier(),
"Empty segment normalization should produce consistent identifiers"
);
assert!(!with_double_slash.owner().contains("//"));
}
#[test]
fn test_adversarial_dot_segments() {
let with_dot = GitRemoteUrl::parse("https://gitlab.com/owner/./repo.git").unwrap();
let normal = GitRemoteUrl::parse("https://gitlab.com/owner/repo.git").unwrap();
assert_eq!(with_dot.owner(), "owner/.");
assert_eq!(with_dot.repo(), "repo");
assert_ne!(
with_dot.project_identifier(),
normal.project_identifier(),
"Literal . segment produces different identifier (no collision)"
);
}
#[test]
fn test_adversarial_parent_traversal() {
let with_dotdot =
GitRemoteUrl::parse("https://gitlab.com/owner/../victim/repo.git").unwrap();
let victim = GitRemoteUrl::parse("https://gitlab.com/victim/repo.git").unwrap();
assert_eq!(with_dotdot.owner(), "owner/../victim");
assert!(
with_dotdot.project_identifier().contains(".."),
"Parent traversal (..) must be treated literally"
);
assert_ne!(
with_dotdot.project_identifier(),
victim.project_identifier(),
"Path traversal attack must not collide with target"
);
}
#[test]
fn test_adversarial_unicode_lookalikes() {
let normal = GitRemoteUrl::parse("https://gitlab.com/owner/repo.git").unwrap();
let with_greek_o = GitRemoteUrl::parse("https://gitlab.com/\u{03BF}wner/repo.git").unwrap();
assert_ne!(
normal.project_identifier(),
with_greek_o.project_identifier(),
"Unicode lookalikes must produce different identifiers"
);
}
#[test]
fn test_adversarial_url_encoded_slash() {
let parsed = GitRemoteUrl::parse("https://gitlab.com/attacker/evil%2Frepo.git").unwrap();
assert_eq!(parsed.owner(), "attacker");
assert_eq!(parsed.repo(), "evil%2Frepo");
let target = GitRemoteUrl::parse("https://gitlab.com/attacker/evil/repo.git").unwrap();
assert_ne!(
parsed.project_identifier(),
target.project_identifier(),
"URL-encoded slash must not collide with actual nested path"
);
}
#[test]
fn test_adversarial_comprehensive_uniqueness() {
let urls = [
"https://gitlab.com/a/repo.git",
"https://gitlab.com/a/b/repo.git",
"https://gitlab.com/a/b/c/repo.git",
"https://gitlab.com/a-b/repo.git",
"https://gitlab.com/a/b-repo.git",
"https://gitlab.com/A/repo.git", "https://gitlab.com/a/Repo.git", "https://github.com/a/repo.git", "https://gitlab.example.com/a/repo.git", ];
let identifiers: Vec<String> = urls
.iter()
.filter_map(|u| GitRemoteUrl::parse(u).map(|p| p.project_identifier()))
.collect();
let mut unique = identifiers.clone();
unique.sort();
unique.dedup();
assert_eq!(
identifiers.len(),
unique.len(),
"All URLs must produce unique identifiers. Got duplicates in: {:?}",
identifiers
);
}
#[test]
fn test_is_azure_devops() {
let url = GitRemoteUrl::parse("https://dev.azure.com/myorg/myproject/_git/myrepo").unwrap();
assert!(url.is_azure_devops());
assert!(!url.is_github());
assert!(!url.is_gitlab());
let url = GitRemoteUrl::parse("git@ssh.dev.azure.com:v3/myorg/myproject/myrepo").unwrap();
assert!(url.is_azure_devops());
let url =
GitRemoteUrl::parse("https://myorg.visualstudio.com/myproject/_git/myrepo").unwrap();
assert!(url.is_azure_devops());
let url = GitRemoteUrl::parse("https://github.com/owner/repo").unwrap();
assert!(!url.is_azure_devops());
let url = GitRemoteUrl::parse("https://gitlab.com/owner/repo").unwrap();
assert!(!url.is_azure_devops());
}
#[test]
fn test_azure_organization_and_project() {
let url = GitRemoteUrl::parse("https://dev.azure.com/myorg/myproject/_git/myrepo").unwrap();
assert_eq!(url.azure_organization(), Some("myorg"));
assert_eq!(url.azure_project(), Some("myproject"));
let url = GitRemoteUrl::parse("git@ssh.dev.azure.com:v3/myorg/myproject/myrepo").unwrap();
assert_eq!(url.azure_organization(), Some("myorg"));
assert_eq!(url.azure_project(), Some("myproject"));
let url =
GitRemoteUrl::parse("https://myorg.visualstudio.com/myproject/_git/myrepo").unwrap();
assert_eq!(url.azure_organization(), Some("myorg"));
assert_eq!(url.azure_project(), Some("myproject"));
let url = GitRemoteUrl::parse("https://github.com/owner/repo").unwrap();
assert_eq!(url.azure_organization(), None);
assert_eq!(url.azure_project(), None);
}
#[test]
fn git_repo_provider_serializes_json_values() {
let cases = [
(GitRepoProvider::GitHub, "\"github\""),
(GitRepoProvider::GitLab, "\"gitlab\""),
(GitRepoProvider::Gitea, "\"gitea\""),
(GitRepoProvider::AzureDevOps, "\"azure-devops\""),
(GitRepoProvider::Unknown, "\"unknown\""),
];
for (provider, expected) in cases {
assert_eq!(serde_json::to_string(&provider).unwrap(), expected);
}
}
#[test]
fn repo_info_from_remote_github_https_and_ssh() {
for input in [
"https://github.com/owner/repo.git",
"git@github.com:owner/repo.git",
] {
let info = GitRemoteUrl::parse(input).unwrap().repo_info(None).unwrap();
assert_eq!(info.url, "https://github.com/owner/repo");
assert_eq!(info.provider, GitRepoProvider::GitHub);
assert_eq!(info.host, "github.com");
assert_eq!(info.owner, "owner");
assert_eq!(info.name, "repo");
assert_eq!(info.project, None);
}
}
#[test]
fn repo_info_from_remote_gitlab_nested_namespace() {
let info = GitRemoteUrl::parse("git@gitlab.com:group/subgroup/repo.git")
.unwrap()
.repo_info(None)
.unwrap();
assert_eq!(info.url, "https://gitlab.com/group/subgroup/repo");
assert_eq!(info.provider, GitRepoProvider::GitLab);
assert_eq!(info.host, "gitlab.com");
assert_eq!(info.owner, "group/subgroup");
assert_eq!(info.name, "repo");
assert_eq!(info.project, None);
}
#[test]
fn repo_info_from_remote_gitea_host_and_configured_host() {
let info = GitRemoteUrl::parse("https://gitea.example.com/owner/repo.git")
.unwrap()
.repo_info(None)
.unwrap();
assert_eq!(info.url, "https://gitea.example.com/owner/repo");
assert_eq!(info.provider, GitRepoProvider::Gitea);
assert_eq!(info.host, "gitea.example.com");
assert_eq!(info.owner, "owner");
assert_eq!(info.name, "repo");
let info = GitRemoteUrl::parse("https://codeberg.org/owner/repo.git")
.unwrap()
.repo_info(Some("gitea"))
.unwrap();
assert_eq!(info.url, "https://codeberg.org/owner/repo");
assert_eq!(info.provider, GitRepoProvider::Gitea);
assert_eq!(info.host, "codeberg.org");
assert_eq!(info.owner, "owner");
assert_eq!(info.name, "repo");
}
#[test]
fn repo_info_from_remote_unknown_parseable_host() {
let info = GitRemoteUrl::parse("https://git.example.com/team/repo.git")
.unwrap()
.repo_info(None)
.unwrap();
assert_eq!(info.url, "https://git.example.com/team/repo");
assert_eq!(info.provider, GitRepoProvider::Unknown);
assert_eq!(info.host, "git.example.com");
assert_eq!(info.owner, "team");
assert_eq!(info.name, "repo");
assert_eq!(info.project, None);
}
#[test]
fn repo_info_from_remote_configured_azure_generic_host_is_unknown() {
let info = GitRemoteUrl::parse("https://git.example.com/myorg/myrepo.git")
.unwrap()
.repo_info(Some("azure-devops"))
.unwrap();
assert_eq!(info.url, "https://git.example.com/myorg/myrepo");
assert_eq!(info.provider, GitRepoProvider::Unknown);
assert_eq!(info.host, "git.example.com");
assert_eq!(info.owner, "myorg");
assert_eq!(info.name, "myrepo");
assert_eq!(info.project, None);
}
#[test]
fn repo_info_from_remote_platform_override_uses_ci_platform_parser() {
let info = GitRemoteUrl::parse("https://git.example.com/owner/repo.git")
.unwrap()
.repo_info(Some(" github "))
.unwrap();
assert_eq!(info.url, "https://git.example.com/owner/repo");
assert_eq!(info.provider, GitRepoProvider::Unknown);
assert_eq!(info.owner, "owner");
assert_eq!(info.name, "repo");
assert_eq!(
GitRepoProvider::from_platform(Some("gitlab")),
Some(GitRepoProvider::GitLab)
);
}
#[test]
fn repo_info_from_remote_azure_devops_urls() {
let cases = [
(
"https://dev.azure.com/myorg/myproject/_git/myrepo",
"dev.azure.com",
"https://dev.azure.com/myorg/myproject/_git/myrepo",
),
(
"git@ssh.dev.azure.com:v3/myorg/myproject/myrepo",
"dev.azure.com",
"https://dev.azure.com/myorg/myproject/_git/myrepo",
),
(
"https://myorg.visualstudio.com/myproject/_git/myrepo",
"myorg.visualstudio.com",
"https://myorg.visualstudio.com/myproject/_git/myrepo",
),
];
for (input, expected_host, expected_url) in cases {
let info = GitRemoteUrl::parse(input).unwrap().repo_info(None).unwrap();
assert_eq!(info.url, expected_url, "input: {input}");
assert_eq!(info.provider, GitRepoProvider::AzureDevOps);
assert_eq!(info.host, expected_host, "input: {input}");
assert_eq!(info.owner, "myorg", "input: {input}");
assert_eq!(info.name, "myrepo", "input: {input}");
assert_eq!(info.project.as_deref(), Some("myproject"), "input: {input}");
}
}
#[test]
fn repo_info_from_remote_configured_azure_noncanonical_host() {
let info = GitRemoteUrl::parse("https://git.example.com/myorg/myproject/_git/myrepo")
.unwrap()
.repo_info(Some("azure-devops"))
.unwrap();
assert_eq!(
info.url,
"https://git.example.com/myorg/myproject/_git/myrepo"
);
assert_eq!(info.provider, GitRepoProvider::AzureDevOps);
assert_eq!(info.host, "git.example.com");
assert_eq!(info.owner, "myorg");
assert_eq!(info.name, "myrepo");
assert_eq!(info.project.as_deref(), Some("myproject"));
let info = GitRemoteUrl::parse("https://git.example.com/myorg/myproject/_git/myrepo")
.unwrap()
.repo_info(Some("azuredevops"))
.unwrap();
assert_eq!(
info.url,
"https://git.example.com/myorg/myproject/_git/myrepo"
);
assert_eq!(info.provider, GitRepoProvider::AzureDevOps);
assert_eq!(info.host, "git.example.com");
assert_eq!(info.owner, "myorg");
assert_eq!(info.name, "myrepo");
assert_eq!(info.project.as_deref(), Some("myproject"));
let info = GitRemoteUrl::parse("git@git.example.com:v3/myorg/myproject/myrepo")
.unwrap()
.repo_info(Some("azure-devops"))
.unwrap();
assert_eq!(
info.url,
"https://git.example.com/myorg/myproject/_git/myrepo"
);
assert_eq!(info.provider, GitRepoProvider::AzureDevOps);
assert_eq!(info.host, "git.example.com");
assert_eq!(info.owner, "myorg");
assert_eq!(info.name, "myrepo");
assert_eq!(info.project.as_deref(), Some("myproject"));
}
#[test]
fn test_web_url() {
let web_url = |input: &str| GitRemoteUrl::parse(input).unwrap().web_url();
assert_eq!(
web_url("git@github.com:owner/repo.git"),
Some("https://github.com/owner/repo".to_string())
);
assert_eq!(
web_url("https://github.com/owner/repo.git"),
Some("https://github.com/owner/repo".to_string())
);
assert_eq!(
web_url("git@github.mycompany.com:owner/repo.git"),
Some("https://github.mycompany.com/owner/repo".to_string())
);
assert_eq!(
web_url("git@gitlab.com:group/subgroup/repo.git"),
Some("https://gitlab.com/group/subgroup/repo".to_string())
);
assert_eq!(
web_url("git@gitea.example.com:owner/repo.git"),
Some("https://gitea.example.com/owner/repo".to_string())
);
assert_eq!(
web_url("https://dev.azure.com/myorg/myproject/_git/myrepo"),
Some("https://dev.azure.com/myorg/myproject/_git/myrepo".to_string())
);
assert_eq!(
web_url("git@ssh.dev.azure.com:v3/myorg/myproject/myrepo"),
Some("https://dev.azure.com/myorg/myproject/_git/myrepo".to_string())
);
assert_eq!(
web_url("https://myorg.visualstudio.com/myproject/_git/myrepo"),
Some("https://myorg.visualstudio.com/myproject/_git/myrepo".to_string())
);
}
}