use std::collections::HashSet;
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> {
if !url.starts_with("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 {
if url.starts_with(pattern) {
if url.len() == pattern.len() || pattern.ends_with('/') {
return Ok(());
}
let next = url.as_bytes()[pattern.len()];
if matches!(next, b'/' | b'?' | b'#' | b'.') {
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()
);
}
}