use std::collections::HashSet;
#[cfg(feature = "git")]
use url::Url;
pub const DEFAULT_AUTHOR_NAME: &str = "sandbox";
pub const DEFAULT_AUTHOR_EMAIL: &str = "sandbox@bashkit.local";
#[derive(Debug, Clone)]
pub struct GitConfig {
pub(crate) author_name: String,
pub(crate) author_email: String,
pub(crate) remote_allowlist: HashSet<String>,
pub(crate) allow_all_remotes: bool,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
author_name: DEFAULT_AUTHOR_NAME.to_string(),
author_email: DEFAULT_AUTHOR_EMAIL.to_string(),
remote_allowlist: HashSet::new(),
allow_all_remotes: false,
}
}
}
impl GitConfig {
pub fn new() -> Self {
Self::default()
}
pub fn author(mut self, name: impl Into<String>, email: impl Into<String>) -> Self {
self.author_name = name.into();
self.author_email = email.into();
self
}
pub fn allow_remote(mut self, pattern: impl Into<String>) -> Self {
self.remote_allowlist.insert(pattern.into());
self
}
pub fn allow_remotes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
for pattern in patterns {
self.remote_allowlist.insert(pattern.into());
}
self
}
pub fn allow_all_remotes(mut self) -> Self {
self.allow_all_remotes = true;
self
}
pub fn author_name(&self) -> &str {
&self.author_name
}
pub fn author_email(&self) -> &str {
&self.author_email
}
#[allow(dead_code)]
pub(crate) fn has_remote_access(&self) -> bool {
self.allow_all_remotes || !self.remote_allowlist.is_empty()
}
#[cfg(feature = "git")]
pub(crate) fn is_url_allowed(&self, url: &str) -> Result<(), String> {
let parsed_url = Url::parse(url).map_err(|err| {
format!(
"error: invalid remote URL '{}': {}\n\
hint: remote URLs must be valid absolute HTTPS URLs",
url, err
)
})?;
if parsed_url.scheme() != "https" {
return Err(format!(
"error: only HTTPS URLs are allowed (got '{}')\n\
hint: SSH and git:// protocols are disabled for security",
url
));
}
if self.allow_all_remotes {
return Ok(());
}
if self.remote_allowlist.is_empty() {
return Err("error: no remote URLs are allowed\n\
hint: configure allowed remotes with GitConfig::allow_remote()"
.to_string());
}
for pattern in &self.remote_allowlist {
let Ok(pattern_url) = Url::parse(pattern) else {
continue;
};
if pattern_url.scheme() != "https" {
continue;
}
if parsed_url.host_str() != pattern_url.host_str() {
continue;
}
if parsed_url.port_or_known_default() != pattern_url.port_or_known_default() {
continue;
}
let pattern_path = pattern_url.path();
let url_path = parsed_url.path();
if pattern_path == "/" || pattern_path.is_empty() {
return Ok(());
}
if !url_path.starts_with(pattern_path) {
continue;
}
if !pattern_path.ends_with('/') && url_path.len() > pattern_path.len() {
let Some(&next) = url_path.as_bytes().get(pattern_path.len()) else {
continue;
};
if next != b'/' {
continue;
}
}
return Ok(());
}
Err(format!(
"error: remote URL '{}' is not in allowlist\n\
hint: configure allowed remotes with GitConfig::allow_remote()",
url
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = GitConfig::new();
assert_eq!(config.author_name(), DEFAULT_AUTHOR_NAME);
assert_eq!(config.author_email(), DEFAULT_AUTHOR_EMAIL);
assert!(!config.has_remote_access());
}
#[test]
fn test_custom_author() {
let config = GitConfig::new().author("Test User", "test@example.com");
assert_eq!(config.author_name(), "Test User");
assert_eq!(config.author_email(), "test@example.com");
}
#[test]
fn test_remote_allowlist() {
let config = GitConfig::new()
.allow_remote("https://github.com/org1/")
.allow_remote("https://github.com/org2/");
assert!(config.has_remote_access());
assert_eq!(config.remote_allowlist.len(), 2);
}
#[test]
fn test_allow_all_remotes() {
let config = GitConfig::new().allow_all_remotes();
assert!(config.has_remote_access());
}
#[test]
#[cfg(feature = "git")]
fn test_url_validation_https_allowed() {
let config = GitConfig::new().allow_remote("https://github.com/org/");
assert!(
config
.is_url_allowed("https://github.com/org/repo.git")
.is_ok()
);
assert!(
config
.is_url_allowed("https://github.com/other/repo.git")
.is_err()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_validation_ssh_blocked() {
let config = GitConfig::new().allow_all_remotes();
assert!(
config
.is_url_allowed("git@github.com:org/repo.git")
.is_err()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_validation_git_protocol_blocked() {
let config = GitConfig::new().allow_all_remotes();
assert!(
config
.is_url_allowed("git://github.com/org/repo.git")
.is_err()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_validation_empty_allowlist() {
let config = GitConfig::new();
assert!(
config
.is_url_allowed("https://github.com/org/repo.git")
.is_err()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_validation_allow_all() {
let config = GitConfig::new().allow_all_remotes();
assert!(
config
.is_url_allowed("https://github.com/any/repo.git")
.is_ok()
);
assert!(
config
.is_url_allowed("https://gitlab.com/any/repo.git")
.is_ok()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_boundary_prevents_prefix_confusion() {
let config = GitConfig::new().allow_remote("https://github.com/myorg");
assert!(
config
.is_url_allowed("https://github.com/myorg/repo.git")
.is_ok()
);
assert!(
config
.is_url_allowed("https://github.com/myorg-evil/malicious.git")
.is_err()
);
assert!(
config
.is_url_allowed("https://github.com/myorg-phishing/repo.git")
.is_err()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_boundary_exact_match() {
let config = GitConfig::new().allow_remote("https://github.com/myorg/repo.git");
assert!(
config
.is_url_allowed("https://github.com/myorg/repo.git")
.is_ok()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_boundary_with_trailing_slash() {
let config = GitConfig::new().allow_remote("https://github.com/myorg/");
assert!(
config
.is_url_allowed("https://github.com/myorg/repo.git")
.is_ok()
);
assert!(
config
.is_url_allowed("https://github.com/myorg-evil/repo.git")
.is_err()
);
}
#[test]
#[cfg(feature = "git")]
fn test_url_allowlist_rejects_host_confusion_vectors() {
let config = GitConfig::new().allow_remote("https://github.com");
assert!(
config
.is_url_allowed("https://github.com@evil.com/org/repo.git")
.is_err()
);
assert!(
config
.is_url_allowed("https://github.com.evil.com/org/repo.git")
.is_err()
);
}
}