trust_resolver.rs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. //! Self-contained trust resolution for repository and worktree paths.
  2. //!
  3. //! Evaluates a `(repo_path, worktree_path)` pair against a [`TrustConfig`]
  4. //! of allowlisted and denied paths, returning a [`TrustDecision`] with the
  5. //! chosen [`TrustPolicy`] and a log of [`TrustEvent`]s.
  6. use std::path::{Path, PathBuf};
  7. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  8. pub enum TrustPolicy {
  9. AutoTrust,
  10. RequireApproval,
  11. Deny,
  12. }
  13. #[derive(Debug, Clone, PartialEq, Eq)]
  14. pub enum TrustEvent {
  15. TrustRequired { repo: String, worktree: String },
  16. TrustResolved { repo: String, policy: TrustPolicy },
  17. TrustDenied { repo: String, reason: String },
  18. }
  19. #[derive(Debug, Clone, Default)]
  20. pub struct TrustConfig {
  21. allowlisted: Vec<PathBuf>,
  22. denied: Vec<PathBuf>,
  23. }
  24. impl TrustConfig {
  25. #[must_use]
  26. pub fn new() -> Self {
  27. Self::default()
  28. }
  29. #[must_use]
  30. pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
  31. self.allowlisted.push(path.into());
  32. self
  33. }
  34. #[must_use]
  35. pub fn with_denied(mut self, path: impl Into<PathBuf>) -> Self {
  36. self.denied.push(path.into());
  37. self
  38. }
  39. }
  40. #[derive(Debug, Clone, PartialEq, Eq)]
  41. pub struct TrustDecision {
  42. pub policy: TrustPolicy,
  43. pub events: Vec<TrustEvent>,
  44. }
  45. #[derive(Debug, Clone)]
  46. pub struct TrustResolver {
  47. config: TrustConfig,
  48. }
  49. impl TrustResolver {
  50. #[must_use]
  51. pub fn new(config: TrustConfig) -> Self {
  52. Self { config }
  53. }
  54. #[must_use]
  55. pub fn resolve_trust(&self, repo_path: &str, worktree_path: &str) -> TrustDecision {
  56. let mut events = Vec::new();
  57. events.push(TrustEvent::TrustRequired {
  58. repo: repo_path.to_owned(),
  59. worktree: worktree_path.to_owned(),
  60. });
  61. if self
  62. .config
  63. .denied
  64. .iter()
  65. .any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root))
  66. {
  67. let reason = format!("repository path matches deny list: {repo_path}");
  68. events.push(TrustEvent::TrustDenied {
  69. repo: repo_path.to_owned(),
  70. reason,
  71. });
  72. return TrustDecision {
  73. policy: TrustPolicy::Deny,
  74. events,
  75. };
  76. }
  77. if self
  78. .config
  79. .allowlisted
  80. .iter()
  81. .any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root))
  82. {
  83. events.push(TrustEvent::TrustResolved {
  84. repo: repo_path.to_owned(),
  85. policy: TrustPolicy::AutoTrust,
  86. });
  87. return TrustDecision {
  88. policy: TrustPolicy::AutoTrust,
  89. events,
  90. };
  91. }
  92. TrustDecision {
  93. policy: TrustPolicy::RequireApproval,
  94. events,
  95. }
  96. }
  97. }
  98. fn normalize_path(path: &Path) -> PathBuf {
  99. std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
  100. }
  101. fn path_matches(candidate: &str, root: &Path) -> bool {
  102. let candidate = normalize_path(Path::new(candidate));
  103. let root = normalize_path(root);
  104. candidate == root || candidate.starts_with(&root)
  105. }
  106. #[cfg(test)]
  107. mod tests {
  108. use super::*;
  109. #[test]
  110. fn allowlisted_repo_auto_trusts_and_records_events() {
  111. // Given: a resolver whose allowlist contains /tmp/trusted
  112. let config = TrustConfig::new().with_allowlisted("/tmp/trusted");
  113. let resolver = TrustResolver::new(config);
  114. // When: we resolve trust for a repo under the allowlisted root
  115. let decision =
  116. resolver.resolve_trust("/tmp/trusted/repo-a", "/tmp/trusted/repo-a/worktree");
  117. // Then: the policy is AutoTrust
  118. assert_eq!(decision.policy, TrustPolicy::AutoTrust);
  119. // And: both TrustRequired and TrustResolved events are recorded
  120. assert!(decision.events.iter().any(|e| matches!(
  121. e,
  122. TrustEvent::TrustRequired { repo, worktree }
  123. if repo == "/tmp/trusted/repo-a"
  124. && worktree == "/tmp/trusted/repo-a/worktree"
  125. )));
  126. assert!(decision.events.iter().any(|e| matches!(
  127. e,
  128. TrustEvent::TrustResolved { policy, .. }
  129. if *policy == TrustPolicy::AutoTrust
  130. )));
  131. }
  132. #[test]
  133. fn unknown_repo_requires_approval_and_remains_gated() {
  134. // Given: a resolver with no matching paths for the tested repo
  135. let config = TrustConfig::new().with_allowlisted("/tmp/other");
  136. let resolver = TrustResolver::new(config);
  137. // When: we resolve trust for an unknown repo
  138. let decision =
  139. resolver.resolve_trust("/tmp/unknown/repo-b", "/tmp/unknown/repo-b/worktree");
  140. // Then: the policy is RequireApproval
  141. assert_eq!(decision.policy, TrustPolicy::RequireApproval);
  142. // And: only the TrustRequired event is recorded (no resolution)
  143. assert_eq!(decision.events.len(), 1);
  144. assert!(matches!(
  145. &decision.events[0],
  146. TrustEvent::TrustRequired { .. }
  147. ));
  148. }
  149. #[test]
  150. fn denied_repo_blocks_and_records_denial_events() {
  151. // Given: a resolver whose deny list contains /tmp/blocked
  152. let config = TrustConfig::new().with_denied("/tmp/blocked");
  153. let resolver = TrustResolver::new(config);
  154. // When: we resolve trust for a repo under the denied root
  155. let decision =
  156. resolver.resolve_trust("/tmp/blocked/repo-c", "/tmp/blocked/repo-c/worktree");
  157. // Then: the policy is Deny
  158. assert_eq!(decision.policy, TrustPolicy::Deny);
  159. // And: both TrustRequired and TrustDenied events are recorded
  160. assert!(decision
  161. .events
  162. .iter()
  163. .any(|e| matches!(e, TrustEvent::TrustRequired { .. })));
  164. assert!(decision.events.iter().any(|e| matches!(
  165. e,
  166. TrustEvent::TrustDenied { reason, .. }
  167. if reason.contains("deny list")
  168. )));
  169. }
  170. #[test]
  171. fn denied_takes_precedence_over_allowlisted() {
  172. // Given: a resolver where the same root appears in both lists
  173. let config = TrustConfig::new()
  174. .with_allowlisted("/tmp/contested")
  175. .with_denied("/tmp/contested");
  176. let resolver = TrustResolver::new(config);
  177. // When: we resolve trust for a repo under the contested root
  178. let decision =
  179. resolver.resolve_trust("/tmp/contested/repo-d", "/tmp/contested/repo-d/worktree");
  180. // Then: deny takes precedence — policy is Deny
  181. assert_eq!(decision.policy, TrustPolicy::Deny);
  182. // And: TrustDenied is recorded, but TrustResolved is not
  183. assert!(decision
  184. .events
  185. .iter()
  186. .any(|e| matches!(e, TrustEvent::TrustDenied { .. })));
  187. assert!(!decision
  188. .events
  189. .iter()
  190. .any(|e| matches!(e, TrustEvent::TrustResolved { .. })));
  191. }
  192. }