1use std::path::{Path, PathBuf};
30
31use khive_runtime::engine_config::GitWriteSectionConfig;
32
33#[derive(Debug, Clone)]
35pub struct GitWritePolicyEntry {
36 pub repo_path: PathBuf,
40 pub branch_patterns: Vec<String>,
43}
44
45#[derive(Debug, Clone, Default)]
52pub struct GitWritePolicy {
53 pub allowed: Vec<GitWritePolicyEntry>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum GitWritePolicyError {
59 NotConfigured,
61 RepoNotAllowlisted(String),
64 BranchNotAllowed { repo: String, branch: String },
67}
68
69impl std::fmt::Display for GitWritePolicyError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::NotConfigured => write!(
73 f,
74 "this verb is unavailable until a git-write policy is configured \
75 ([git_write] in khive config, ADR-108 Amendment)"
76 ),
77 Self::RepoNotAllowlisted(repo) => write!(
78 f,
79 "repo {repo:?} is not in the configured [git_write] allowlist"
80 ),
81 Self::BranchNotAllowed { repo, branch } => write!(
82 f,
83 "branch {branch:?} in repo {repo:?} does not match any allowed \
84 branch pattern for that repo's [git_write] entry"
85 ),
86 }
87 }
88}
89
90impl std::error::Error for GitWritePolicyError {}
91
92impl GitWritePolicy {
93 pub fn check(&self, repo: &Path, branch: &str) -> Result<PathBuf, GitWritePolicyError> {
115 if self.allowed.is_empty() {
116 return Err(GitWritePolicyError::NotConfigured);
117 }
118 let canonical_repo = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
119 let entry = self.allowed.iter().find(|e| {
120 std::fs::canonicalize(&e.repo_path)
121 .map(|c| c == canonical_repo)
122 .unwrap_or(false)
123 });
124 let Some(entry) = entry else {
125 return Err(GitWritePolicyError::RepoNotAllowlisted(
126 repo.display().to_string(),
127 ));
128 };
129 if entry
130 .branch_patterns
131 .iter()
132 .any(|pattern| glob_match(pattern, branch))
133 {
134 Ok(canonical_repo)
135 } else {
136 Err(GitWritePolicyError::BranchNotAllowed {
137 repo: repo.display().to_string(),
138 branch: branch.to_string(),
139 })
140 }
141 }
142}
143
144fn glob_match(pattern: &str, value: &str) -> bool {
156 if pattern.matches('*').count() > 1 {
157 return false;
158 }
159 if !pattern.contains('*') {
160 return pattern == value;
161 }
162 let parts: Vec<&str> = pattern.split('*').collect();
163 let mut rest = value;
164 let last = parts.len() - 1;
165 for (i, part) in parts.iter().enumerate() {
166 if part.is_empty() {
167 continue;
168 }
169 if i == 0 {
170 if !rest.starts_with(part) {
171 return false;
172 }
173 rest = &rest[part.len()..];
174 } else if i == last {
175 if !rest.ends_with(part) {
176 return false;
177 }
178 } else {
179 match rest.find(part) {
180 Some(pos) => rest = &rest[pos + part.len()..],
181 None => return false,
182 }
183 }
184 }
185 true
186}
187
188impl GitWritePolicy {
189 pub fn from_config(section: &GitWriteSectionConfig) -> Self {
195 let allowed = section
196 .allowed
197 .iter()
198 .map(|entry| GitWritePolicyEntry {
199 repo_path: PathBuf::from(&entry.repo),
200 branch_patterns: entry.branches.clone(),
201 })
202 .collect();
203 GitWritePolicy { allowed }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn entry(repo: &Path, branches: &[&str]) -> GitWritePolicyEntry {
212 GitWritePolicyEntry {
213 repo_path: repo.to_path_buf(),
214 branch_patterns: branches.iter().map(|s| s.to_string()).collect(),
215 }
216 }
217
218 #[test]
219 fn empty_policy_denies_not_configured() {
220 let policy = GitWritePolicy::default();
221 let dir = tempfile::tempdir().unwrap();
222 assert_eq!(
223 policy.check(dir.path(), "main"),
224 Err(GitWritePolicyError::NotConfigured)
225 );
226 }
227
228 #[test]
229 fn allowlisted_repo_and_branch_succeeds() {
230 let dir = tempfile::tempdir().unwrap();
231 let policy = GitWritePolicy {
232 allowed: vec![entry(dir.path(), &["main", "release-*"])],
233 };
234 assert!(policy.check(dir.path(), "main").is_ok());
235 assert!(policy.check(dir.path(), "release-1.2.3").is_ok());
236 }
237
238 #[test]
239 fn non_allowlisted_repo_denied() {
240 let dir = tempfile::tempdir().unwrap();
241 let other = tempfile::tempdir().unwrap();
242 let policy = GitWritePolicy {
243 allowed: vec![entry(dir.path(), &["main"])],
244 };
245 let err = policy.check(other.path(), "main").unwrap_err();
246 assert!(matches!(err, GitWritePolicyError::RepoNotAllowlisted(_)));
247 }
248
249 #[test]
250 fn branch_outside_patterns_denied() {
251 let dir = tempfile::tempdir().unwrap();
252 let policy = GitWritePolicy {
253 allowed: vec![entry(dir.path(), &["main"])],
254 };
255 let err = policy.check(dir.path(), "feat/x").unwrap_err();
256 assert!(matches!(err, GitWritePolicyError::BranchNotAllowed { .. }));
257 }
258
259 #[test]
260 fn glob_match_exact_no_wildcard() {
261 assert!(glob_match("main", "main"));
262 assert!(!glob_match("main", "mainx"));
263 }
264
265 #[test]
266 fn glob_match_prefix_and_suffix_and_contains() {
267 assert!(glob_match("feat/*", "feat/x"));
268 assert!(!glob_match("feat/*", "fix/x"));
269 assert!(glob_match("*-stable", "v1-stable"));
270 assert!(glob_match("*", "anything"));
271 assert!(glob_match("rel-*-final", "rel-1.2-final"));
272 assert!(!glob_match("rel-*-final", "rel-1.2"));
273 }
274
275 #[test]
278 fn glob_match_rejects_multi_star_patterns() {
279 assert!(!glob_match("**", "anything"));
280 assert!(!glob_match("**", ""));
281 assert!(!glob_match("rel-*-*-final", "rel-1.2-final"));
282 }
283
284 #[test]
285 fn glob_match_single_star_boundary() {
286 assert!(glob_match("a*b", "a-mid-b"));
287 assert!(!glob_match("a*b*c", "a-x-b-y-c"));
288 }
289
290 #[test]
291 fn check_returns_canonical_repo_path_on_success() {
292 let dir = tempfile::tempdir().unwrap();
293 let policy = GitWritePolicy {
294 allowed: vec![entry(dir.path(), &["main"])],
295 };
296 let canonical = policy.check(dir.path(), "main").expect("allowed");
297 assert_eq!(canonical, std::fs::canonicalize(dir.path()).unwrap());
298 }
299
300 #[test]
301 fn from_config_converts_section() {
302 use khive_runtime::engine_config::GitWriteEntryConfig;
303 let section = GitWriteSectionConfig {
304 allowed: vec![GitWriteEntryConfig {
305 repo: "/abs/path".to_string(),
306 branches: vec!["main".to_string()],
307 }],
308 };
309 let policy = GitWritePolicy::from_config(§ion);
310 assert_eq!(policy.allowed.len(), 1);
311 assert_eq!(policy.allowed[0].repo_path, PathBuf::from("/abs/path"));
312 assert_eq!(policy.allowed[0].branch_patterns, vec!["main".to_string()]);
313 }
314
315 #[cfg(unix)]
316 #[test]
317 fn symlink_resolving_to_allowlisted_repo_succeeds() {
318 let real = tempfile::tempdir().unwrap();
319 let parent = tempfile::tempdir().unwrap();
320 let link = parent.path().join("link-to-real");
321 std::os::unix::fs::symlink(real.path(), &link).unwrap();
322
323 let policy = GitWritePolicy {
324 allowed: vec![entry(real.path(), &["main"])],
325 };
326 assert!(
327 policy.check(&link, "main").is_ok(),
328 "a symlink that canonicalizes to an allowlisted repo must be accepted"
329 );
330 }
331
332 #[cfg(unix)]
333 #[test]
334 fn symlink_resolving_elsewhere_denied() {
335 let real = tempfile::tempdir().unwrap();
336 let decoy = tempfile::tempdir().unwrap();
337 let parent = tempfile::tempdir().unwrap();
338 let link = parent.path().join("link-to-decoy");
339 std::os::unix::fs::symlink(decoy.path(), &link).unwrap();
340
341 let policy = GitWritePolicy {
342 allowed: vec![entry(real.path(), &["main"])],
343 };
344 let err = policy.check(&link, "main").unwrap_err();
345 assert!(
346 matches!(err, GitWritePolicyError::RepoNotAllowlisted(_)),
347 "a symlink resolving outside the allowlist must not be usable as a bypass"
348 );
349 }
350
351 #[cfg(unix)]
361 #[test]
362 fn canonical_path_returned_at_check_time_is_immune_to_later_retarget() {
363 let real = tempfile::tempdir().unwrap();
364 let decoy = tempfile::tempdir().unwrap();
365 let parent = tempfile::tempdir().unwrap();
366 let link = parent.path().join("link");
367 std::os::unix::fs::symlink(real.path(), &link).unwrap();
368
369 let policy = GitWritePolicy {
370 allowed: vec![entry(real.path(), &["main"])],
371 };
372 let canonical_at_check = policy.check(&link, "main").expect("allowed at check time");
373 assert_eq!(
374 canonical_at_check,
375 std::fs::canonicalize(real.path()).unwrap()
376 );
377
378 std::fs::remove_file(&link).unwrap();
381 std::os::unix::fs::symlink(decoy.path(), &link).unwrap();
382
383 assert_eq!(
387 canonical_at_check,
388 std::fs::canonicalize(real.path()).unwrap()
389 );
390 assert_ne!(
391 canonical_at_check,
392 std::fs::canonicalize(decoy.path()).unwrap()
393 );
394 }
395}