| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- //! Self-contained trust resolution for repository and worktree paths.
- //!
- //! Evaluates a `(repo_path, worktree_path)` pair against a [`TrustConfig`]
- //! of allowlisted and denied paths, returning a [`TrustDecision`] with the
- //! chosen [`TrustPolicy`] and a log of [`TrustEvent`]s.
- use std::path::{Path, PathBuf};
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum TrustPolicy {
- AutoTrust,
- RequireApproval,
- Deny,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum TrustEvent {
- TrustRequired { repo: String, worktree: String },
- TrustResolved { repo: String, policy: TrustPolicy },
- TrustDenied { repo: String, reason: String },
- }
- #[derive(Debug, Clone, Default)]
- pub struct TrustConfig {
- allowlisted: Vec<PathBuf>,
- denied: Vec<PathBuf>,
- }
- impl TrustConfig {
- #[must_use]
- pub fn new() -> Self {
- Self::default()
- }
- #[must_use]
- pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
- self.allowlisted.push(path.into());
- self
- }
- #[must_use]
- pub fn with_denied(mut self, path: impl Into<PathBuf>) -> Self {
- self.denied.push(path.into());
- self
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct TrustDecision {
- pub policy: TrustPolicy,
- pub events: Vec<TrustEvent>,
- }
- #[derive(Debug, Clone)]
- pub struct TrustResolver {
- config: TrustConfig,
- }
- impl TrustResolver {
- #[must_use]
- pub fn new(config: TrustConfig) -> Self {
- Self { config }
- }
- #[must_use]
- pub fn resolve_trust(&self, repo_path: &str, worktree_path: &str) -> TrustDecision {
- let mut events = Vec::new();
- events.push(TrustEvent::TrustRequired {
- repo: repo_path.to_owned(),
- worktree: worktree_path.to_owned(),
- });
- if self
- .config
- .denied
- .iter()
- .any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root))
- {
- let reason = format!("repository path matches deny list: {repo_path}");
- events.push(TrustEvent::TrustDenied {
- repo: repo_path.to_owned(),
- reason,
- });
- return TrustDecision {
- policy: TrustPolicy::Deny,
- events,
- };
- }
- if self
- .config
- .allowlisted
- .iter()
- .any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root))
- {
- events.push(TrustEvent::TrustResolved {
- repo: repo_path.to_owned(),
- policy: TrustPolicy::AutoTrust,
- });
- return TrustDecision {
- policy: TrustPolicy::AutoTrust,
- events,
- };
- }
- TrustDecision {
- policy: TrustPolicy::RequireApproval,
- events,
- }
- }
- }
- fn normalize_path(path: &Path) -> PathBuf {
- std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
- }
- fn path_matches(candidate: &str, root: &Path) -> bool {
- let candidate = normalize_path(Path::new(candidate));
- let root = normalize_path(root);
- candidate == root || candidate.starts_with(&root)
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- #[test]
- fn allowlisted_repo_auto_trusts_and_records_events() {
- // Given: a resolver whose allowlist contains /tmp/trusted
- let config = TrustConfig::new().with_allowlisted("/tmp/trusted");
- let resolver = TrustResolver::new(config);
- // When: we resolve trust for a repo under the allowlisted root
- let decision =
- resolver.resolve_trust("/tmp/trusted/repo-a", "/tmp/trusted/repo-a/worktree");
- // Then: the policy is AutoTrust
- assert_eq!(decision.policy, TrustPolicy::AutoTrust);
- // And: both TrustRequired and TrustResolved events are recorded
- assert!(decision.events.iter().any(|e| matches!(
- e,
- TrustEvent::TrustRequired { repo, worktree }
- if repo == "/tmp/trusted/repo-a"
- && worktree == "/tmp/trusted/repo-a/worktree"
- )));
- assert!(decision.events.iter().any(|e| matches!(
- e,
- TrustEvent::TrustResolved { policy, .. }
- if *policy == TrustPolicy::AutoTrust
- )));
- }
- #[test]
- fn unknown_repo_requires_approval_and_remains_gated() {
- // Given: a resolver with no matching paths for the tested repo
- let config = TrustConfig::new().with_allowlisted("/tmp/other");
- let resolver = TrustResolver::new(config);
- // When: we resolve trust for an unknown repo
- let decision =
- resolver.resolve_trust("/tmp/unknown/repo-b", "/tmp/unknown/repo-b/worktree");
- // Then: the policy is RequireApproval
- assert_eq!(decision.policy, TrustPolicy::RequireApproval);
- // And: only the TrustRequired event is recorded (no resolution)
- assert_eq!(decision.events.len(), 1);
- assert!(matches!(
- &decision.events[0],
- TrustEvent::TrustRequired { .. }
- ));
- }
- #[test]
- fn denied_repo_blocks_and_records_denial_events() {
- // Given: a resolver whose deny list contains /tmp/blocked
- let config = TrustConfig::new().with_denied("/tmp/blocked");
- let resolver = TrustResolver::new(config);
- // When: we resolve trust for a repo under the denied root
- let decision =
- resolver.resolve_trust("/tmp/blocked/repo-c", "/tmp/blocked/repo-c/worktree");
- // Then: the policy is Deny
- assert_eq!(decision.policy, TrustPolicy::Deny);
- // And: both TrustRequired and TrustDenied events are recorded
- assert!(decision
- .events
- .iter()
- .any(|e| matches!(e, TrustEvent::TrustRequired { .. })));
- assert!(decision.events.iter().any(|e| matches!(
- e,
- TrustEvent::TrustDenied { reason, .. }
- if reason.contains("deny list")
- )));
- }
- #[test]
- fn denied_takes_precedence_over_allowlisted() {
- // Given: a resolver where the same root appears in both lists
- let config = TrustConfig::new()
- .with_allowlisted("/tmp/contested")
- .with_denied("/tmp/contested");
- let resolver = TrustResolver::new(config);
- // When: we resolve trust for a repo under the contested root
- let decision =
- resolver.resolve_trust("/tmp/contested/repo-d", "/tmp/contested/repo-d/worktree");
- // Then: deny takes precedence — policy is Deny
- assert_eq!(decision.policy, TrustPolicy::Deny);
- // And: TrustDenied is recorded, but TrustResolved is not
- assert!(decision
- .events
- .iter()
- .any(|e| matches!(e, TrustEvent::TrustDenied { .. })));
- assert!(!decision
- .events
- .iter()
- .any(|e| matches!(e, TrustEvent::TrustResolved { .. })));
- }
- }
|