use std::path::{Path, PathBuf};
use khive_runtime::engine_config::GitWriteSectionConfig;
#[derive(Debug, Clone)]
pub struct GitWritePolicyEntry {
pub repo_path: PathBuf,
pub branch_patterns: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GitWritePolicy {
pub allowed: Vec<GitWritePolicyEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitWritePolicyError {
NotConfigured,
RepoNotAllowlisted(String),
BranchNotAllowed { repo: String, branch: String },
}
impl std::fmt::Display for GitWritePolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotConfigured => write!(
f,
"this verb is unavailable until a git-write policy is configured \
([git_write] in khive config, ADR-108 Amendment)"
),
Self::RepoNotAllowlisted(repo) => write!(
f,
"repo {repo:?} is not in the configured [git_write] allowlist"
),
Self::BranchNotAllowed { repo, branch } => write!(
f,
"branch {branch:?} in repo {repo:?} does not match any allowed \
branch pattern for that repo's [git_write] entry"
),
}
}
}
impl std::error::Error for GitWritePolicyError {}
impl GitWritePolicy {
pub fn check(&self, repo: &Path, branch: &str) -> Result<PathBuf, GitWritePolicyError> {
if self.allowed.is_empty() {
return Err(GitWritePolicyError::NotConfigured);
}
let canonical_repo = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
let entry = self.allowed.iter().find(|e| {
std::fs::canonicalize(&e.repo_path)
.map(|c| c == canonical_repo)
.unwrap_or(false)
});
let Some(entry) = entry else {
return Err(GitWritePolicyError::RepoNotAllowlisted(
repo.display().to_string(),
));
};
if entry
.branch_patterns
.iter()
.any(|pattern| glob_match(pattern, branch))
{
Ok(canonical_repo)
} else {
Err(GitWritePolicyError::BranchNotAllowed {
repo: repo.display().to_string(),
branch: branch.to_string(),
})
}
}
}
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern.matches('*').count() > 1 {
return false;
}
if !pattern.contains('*') {
return pattern == value;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut rest = value;
let last = parts.len() - 1;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !rest.starts_with(part) {
return false;
}
rest = &rest[part.len()..];
} else if i == last {
if !rest.ends_with(part) {
return false;
}
} else {
match rest.find(part) {
Some(pos) => rest = &rest[pos + part.len()..],
None => return false,
}
}
}
true
}
impl GitWritePolicy {
pub fn from_config(section: &GitWriteSectionConfig) -> Self {
let allowed = section
.allowed
.iter()
.map(|entry| GitWritePolicyEntry {
repo_path: PathBuf::from(&entry.repo),
branch_patterns: entry.branches.clone(),
})
.collect();
GitWritePolicy { allowed }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(repo: &Path, branches: &[&str]) -> GitWritePolicyEntry {
GitWritePolicyEntry {
repo_path: repo.to_path_buf(),
branch_patterns: branches.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn empty_policy_denies_not_configured() {
let policy = GitWritePolicy::default();
let dir = tempfile::tempdir().unwrap();
assert_eq!(
policy.check(dir.path(), "main"),
Err(GitWritePolicyError::NotConfigured)
);
}
#[test]
fn allowlisted_repo_and_branch_succeeds() {
let dir = tempfile::tempdir().unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(dir.path(), &["main", "release-*"])],
};
assert!(policy.check(dir.path(), "main").is_ok());
assert!(policy.check(dir.path(), "release-1.2.3").is_ok());
}
#[test]
fn non_allowlisted_repo_denied() {
let dir = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(dir.path(), &["main"])],
};
let err = policy.check(other.path(), "main").unwrap_err();
assert!(matches!(err, GitWritePolicyError::RepoNotAllowlisted(_)));
}
#[test]
fn branch_outside_patterns_denied() {
let dir = tempfile::tempdir().unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(dir.path(), &["main"])],
};
let err = policy.check(dir.path(), "feat/x").unwrap_err();
assert!(matches!(err, GitWritePolicyError::BranchNotAllowed { .. }));
}
#[test]
fn glob_match_exact_no_wildcard() {
assert!(glob_match("main", "main"));
assert!(!glob_match("main", "mainx"));
}
#[test]
fn glob_match_prefix_and_suffix_and_contains() {
assert!(glob_match("feat/*", "feat/x"));
assert!(!glob_match("feat/*", "fix/x"));
assert!(glob_match("*-stable", "v1-stable"));
assert!(glob_match("*", "anything"));
assert!(glob_match("rel-*-final", "rel-1.2-final"));
assert!(!glob_match("rel-*-final", "rel-1.2"));
}
#[test]
fn glob_match_rejects_multi_star_patterns() {
assert!(!glob_match("**", "anything"));
assert!(!glob_match("**", ""));
assert!(!glob_match("rel-*-*-final", "rel-1.2-final"));
}
#[test]
fn glob_match_single_star_boundary() {
assert!(glob_match("a*b", "a-mid-b"));
assert!(!glob_match("a*b*c", "a-x-b-y-c"));
}
#[test]
fn check_returns_canonical_repo_path_on_success() {
let dir = tempfile::tempdir().unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(dir.path(), &["main"])],
};
let canonical = policy.check(dir.path(), "main").expect("allowed");
assert_eq!(canonical, std::fs::canonicalize(dir.path()).unwrap());
}
#[test]
fn from_config_converts_section() {
use khive_runtime::engine_config::GitWriteEntryConfig;
let section = GitWriteSectionConfig {
allowed: vec![GitWriteEntryConfig {
repo: "/abs/path".to_string(),
branches: vec!["main".to_string()],
}],
};
let policy = GitWritePolicy::from_config(§ion);
assert_eq!(policy.allowed.len(), 1);
assert_eq!(policy.allowed[0].repo_path, PathBuf::from("/abs/path"));
assert_eq!(policy.allowed[0].branch_patterns, vec!["main".to_string()]);
}
#[cfg(unix)]
#[test]
fn symlink_resolving_to_allowlisted_repo_succeeds() {
let real = tempfile::tempdir().unwrap();
let parent = tempfile::tempdir().unwrap();
let link = parent.path().join("link-to-real");
std::os::unix::fs::symlink(real.path(), &link).unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(real.path(), &["main"])],
};
assert!(
policy.check(&link, "main").is_ok(),
"a symlink that canonicalizes to an allowlisted repo must be accepted"
);
}
#[cfg(unix)]
#[test]
fn symlink_resolving_elsewhere_denied() {
let real = tempfile::tempdir().unwrap();
let decoy = tempfile::tempdir().unwrap();
let parent = tempfile::tempdir().unwrap();
let link = parent.path().join("link-to-decoy");
std::os::unix::fs::symlink(decoy.path(), &link).unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(real.path(), &["main"])],
};
let err = policy.check(&link, "main").unwrap_err();
assert!(
matches!(err, GitWritePolicyError::RepoNotAllowlisted(_)),
"a symlink resolving outside the allowlist must not be usable as a bypass"
);
}
#[cfg(unix)]
#[test]
fn canonical_path_returned_at_check_time_is_immune_to_later_retarget() {
let real = tempfile::tempdir().unwrap();
let decoy = tempfile::tempdir().unwrap();
let parent = tempfile::tempdir().unwrap();
let link = parent.path().join("link");
std::os::unix::fs::symlink(real.path(), &link).unwrap();
let policy = GitWritePolicy {
allowed: vec![entry(real.path(), &["main"])],
};
let canonical_at_check = policy.check(&link, "main").expect("allowed at check time");
assert_eq!(
canonical_at_check,
std::fs::canonicalize(real.path()).unwrap()
);
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(decoy.path(), &link).unwrap();
assert_eq!(
canonical_at_check,
std::fs::canonicalize(real.path()).unwrap()
);
assert_ne!(
canonical_at_check,
std::fs::canonicalize(decoy.path()).unwrap()
);
}
}