1use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum FilePermission {
13 Read,
15 Write,
17 Deny,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct PathRule {
24 pub pattern: String,
26 pub permission: FilePermission,
28}
29
30impl PathRule {
31 #[must_use]
33 pub fn new(pattern: impl Into<String>, permission: FilePermission) -> Self {
34 Self {
35 pattern: pattern.into(),
36 permission,
37 }
38 }
39
40 #[must_use]
42 pub fn read(pattern: impl Into<String>) -> Self {
43 Self::new(pattern, FilePermission::Read)
44 }
45
46 #[must_use]
48 pub fn write(pattern: impl Into<String>) -> Self {
49 Self::new(pattern, FilePermission::Write)
50 }
51
52 #[must_use]
54 pub fn deny(pattern: impl Into<String>) -> Self {
55 Self::new(pattern, FilePermission::Deny)
56 }
57
58 #[must_use]
60 pub fn matches(&self, path: &str) -> bool {
61 if self.pattern.contains('*') {
62 glob_match(&self.pattern, path)
63 } else {
64 path.starts_with(&self.pattern)
65 }
66 }
67}
68
69fn glob_match(pattern: &str, path: &str) -> bool {
71 if pattern == "**" || pattern == "*" {
72 return true;
73 }
74 if pattern.contains("**") {
75 let parts: Vec<&str> = pattern.split("**").collect();
76 if parts.len() == 2 {
77 let prefix = parts[0].trim_end_matches('/');
78 let suffix = parts[1].trim_start_matches('/');
79 if !prefix.is_empty() && !path.starts_with(prefix) {
81 return false;
82 }
83 if !suffix.is_empty() {
85 if let Some(ext) = suffix.strip_prefix("*.") {
86 return path.ends_with(&format!(".{ext}"));
87 }
88 return path.ends_with(suffix);
89 }
90 return true;
91 }
92 }
93 if let Some(prefix) = pattern.strip_suffix("/*") {
94 return path.starts_with(prefix);
95 }
96 if let Some(suffix) = pattern.strip_prefix("*/") {
97 return path.ends_with(suffix);
98 }
99 pattern == path
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FilesystemScope {
105 pub rules: Vec<PathRule>,
107}
108
109impl FilesystemScope {
110 #[must_use]
112 pub fn new() -> Self {
113 Self::default()
114 }
115
116 #[must_use]
118 pub fn with_rule(mut self, rule: PathRule) -> Self {
119 self.rules.push(rule);
120 self
121 }
122
123 #[must_use]
125 pub fn permission_for(&self, path: &str) -> Option<FilePermission> {
126 let mut has_read = false;
127 let mut has_write = false;
128
129 for rule in &self.rules {
130 if !rule.matches(path) {
131 continue;
132 }
133
134 match rule.permission {
135 FilePermission::Deny => return Some(FilePermission::Deny),
136 FilePermission::Write => has_write = true,
137 FilePermission::Read => has_read = true,
138 }
139 }
140
141 if has_write {
142 Some(FilePermission::Write)
143 } else if has_read {
144 Some(FilePermission::Read)
145 } else {
146 None
147 }
148 }
149
150 #[must_use]
152 pub fn can_write(&self, path: &str) -> bool {
153 matches!(self.permission_for(path), Some(FilePermission::Write))
154 }
155
156 #[must_use]
158 pub fn can_read(&self, path: &str) -> bool {
159 matches!(
160 self.permission_for(path),
161 Some(FilePermission::Read | FilePermission::Write)
162 )
163 }
164}
165
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum RepoAccessMode {
170 ReadOnly,
172 #[default]
174 ReadWrite,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct RepositoryScope {
180 pub repo: String,
182 pub mode: RepoAccessMode,
184}
185
186impl RepositoryScope {
187 #[must_use]
189 pub fn new(repo: impl Into<String>, mode: RepoAccessMode) -> Self {
190 Self {
191 repo: repo.into(),
192 mode,
193 }
194 }
195
196 #[must_use]
198 pub fn read_only(repo: impl Into<String>) -> Self {
199 Self::new(repo, RepoAccessMode::ReadOnly)
200 }
201
202 #[must_use]
204 pub fn read_write(repo: impl Into<String>) -> Self {
205 Self::new(repo, RepoAccessMode::ReadWrite)
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum GitPermission {
213 Commit,
215 Branch,
217 Push,
219}
220
221#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223pub struct GitScope {
224 pub permissions: HashSet<GitPermission>,
226}
227
228impl GitScope {
229 #[must_use]
231 pub fn new() -> Self {
232 Self::default()
233 }
234
235 #[must_use]
237 pub fn with_commit() -> Self {
238 let mut scope = Self::new();
239 scope.permissions.insert(GitPermission::Commit);
240 scope
241 }
242
243 #[must_use]
245 pub fn with_branch() -> Self {
246 let mut scope = Self::with_commit();
247 scope.permissions.insert(GitPermission::Branch);
248 scope
249 }
250
251 #[must_use]
253 pub fn can_commit(&self) -> bool {
254 self.permissions.contains(&GitPermission::Commit)
255 }
256
257 #[must_use]
259 pub fn can_branch(&self) -> bool {
260 self.permissions.contains(&GitPermission::Branch)
261 }
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ExecutionScope {
267 pub allowed: Vec<String>,
269 pub denied: Vec<String>,
271}
272
273impl ExecutionScope {
274 #[must_use]
276 pub fn new() -> Self {
277 Self::default()
278 }
279
280 #[must_use]
282 pub fn allow(mut self, pattern: impl Into<String>) -> Self {
283 self.allowed.push(pattern.into());
284 self
285 }
286
287 #[must_use]
289 pub fn deny(mut self, pattern: impl Into<String>) -> Self {
290 self.denied.push(pattern.into());
291 self
292 }
293
294 #[must_use]
296 pub fn is_allowed(&self, command: &str) -> bool {
297 for pattern in &self.denied {
299 if command.starts_with(pattern) || pattern == "*" {
300 return false;
301 }
302 }
303 if self.allowed.is_empty() {
305 return true; }
307 for pattern in &self.allowed {
308 if command.starts_with(pattern) || pattern == "*" {
309 return true;
310 }
311 }
312 false
313 }
314}
315
316#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct Scope {
319 pub filesystem: FilesystemScope,
321 pub repositories: Vec<RepositoryScope>,
323 pub git: GitScope,
325 pub execution: ExecutionScope,
327}
328
329impl Scope {
330 #[must_use]
332 pub fn new() -> Self {
333 Self::default()
334 }
335
336 #[must_use]
338 pub fn with_filesystem(mut self, fs: FilesystemScope) -> Self {
339 self.filesystem = fs;
340 self
341 }
342
343 #[must_use]
345 pub fn with_repository(mut self, repo: RepositoryScope) -> Self {
346 self.repositories.push(repo);
347 self
348 }
349
350 #[must_use]
352 pub fn with_git(mut self, git: GitScope) -> Self {
353 self.git = git;
354 self
355 }
356
357 #[must_use]
359 pub fn with_execution(mut self, exec: ExecutionScope) -> Self {
360 self.execution = exec;
361 self
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum ScopeCompatibility {
368 Compatible,
370 SoftConflict,
372 HardConflict,
374}
375
376impl ScopeCompatibility {
377 #[must_use]
379 pub fn combine(self, other: Self) -> Self {
380 match (self, other) {
381 (Self::HardConflict, _) | (_, Self::HardConflict) => Self::HardConflict,
382 (Self::SoftConflict, _) | (_, Self::SoftConflict) => Self::SoftConflict,
383 _ => Self::Compatible,
384 }
385 }
386}
387
388#[must_use]
390pub fn check_compatibility(a: &Scope, b: &Scope) -> ScopeCompatibility {
391 let fs_compat = check_filesystem_compatibility(&a.filesystem, &b.filesystem);
392 let repo_compat = check_repository_compatibility(&a.repositories, &b.repositories);
393 let git_compat = check_git_compatibility(&a.git, &b.git);
394 let exec_compat = check_execution_compatibility(&a.execution, &b.execution);
395
396 fs_compat
397 .combine(repo_compat)
398 .combine(git_compat)
399 .combine(exec_compat)
400}
401
402fn check_filesystem_compatibility(a: &FilesystemScope, b: &FilesystemScope) -> ScopeCompatibility {
403 for rule_a in &a.rules {
405 for rule_b in &b.rules {
406 if patterns_might_overlap(&rule_a.pattern, &rule_b.pattern) {
408 if rule_a.permission == FilePermission::Deny
410 || rule_b.permission == FilePermission::Deny
411 {
412 return ScopeCompatibility::HardConflict;
413 }
414 match (rule_a.permission, rule_b.permission) {
415 (FilePermission::Write, FilePermission::Write) => {
416 return ScopeCompatibility::HardConflict;
417 }
418 (FilePermission::Read, FilePermission::Write)
419 | (FilePermission::Write, FilePermission::Read) => {
420 return ScopeCompatibility::SoftConflict;
421 }
422 _ => {}
423 }
424 }
425 }
426 }
427 ScopeCompatibility::Compatible
428}
429
430fn patterns_might_overlap(a: &str, b: &str) -> bool {
431 if a.contains('*') || b.contains('*') {
433 return true;
434 }
435 a.starts_with(b) || b.starts_with(a)
437}
438
439fn check_repository_compatibility(
440 a: &[RepositoryScope],
441 b: &[RepositoryScope],
442) -> ScopeCompatibility {
443 for repo_a in a {
444 for repo_b in b {
445 if repo_a.repo == repo_b.repo {
446 match (repo_a.mode, repo_b.mode) {
447 (RepoAccessMode::ReadWrite, RepoAccessMode::ReadWrite) => {
448 return ScopeCompatibility::HardConflict;
449 }
450 (RepoAccessMode::ReadOnly, RepoAccessMode::ReadWrite)
451 | (RepoAccessMode::ReadWrite, RepoAccessMode::ReadOnly) => {
452 return ScopeCompatibility::SoftConflict;
453 }
454 _ => {}
455 }
456 }
457 }
458 }
459 ScopeCompatibility::Compatible
460}
461
462fn check_git_compatibility(a: &GitScope, b: &GitScope) -> ScopeCompatibility {
463 if a.can_commit() && b.can_commit() {
465 return ScopeCompatibility::HardConflict;
466 }
467 ScopeCompatibility::Compatible
468}
469
470fn check_execution_compatibility(a: &ExecutionScope, b: &ExecutionScope) -> ScopeCompatibility {
471 let a_allows_all = a.allowed.iter().any(|p| p == "*") && a.denied.is_empty();
475 let b_allows_all = b.allowed.iter().any(|p| p == "*") && b.denied.is_empty();
476
477 if a_allows_all && b_allows_all {
478 return ScopeCompatibility::HardConflict;
479 }
480 if a_allows_all || b_allows_all {
481 return ScopeCompatibility::SoftConflict;
482 }
483
484 ScopeCompatibility::Compatible
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[test]
492 fn path_rule_matching() {
493 let rule = PathRule::write("src/");
494 assert!(rule.matches("src/main.rs"));
495 assert!(rule.matches("src/lib.rs"));
496 assert!(!rule.matches("tests/test.rs"));
497 }
498
499 #[test]
500 fn glob_matching() {
501 let rule = PathRule::write("src/**/*.rs");
502 assert!(rule.matches("src/main.rs"));
503 assert!(rule.matches("src/core/mod.rs"));
504 }
505
506 #[test]
507 fn filesystem_scope_permissions() {
508 let scope = FilesystemScope::new()
509 .with_rule(PathRule::write("src/"))
510 .with_rule(PathRule::read("docs/"))
511 .with_rule(PathRule::deny("src/secret/"));
512
513 assert!(scope.can_write("src/main.rs"));
514 assert!(scope.can_read("docs/README.md"));
515 assert!(!scope.can_write("docs/README.md"));
516 assert!(!scope.can_read("src/secret/key.txt"));
517 }
518
519 #[test]
520 fn repository_scope_creation() {
521 let ro = RepositoryScope::read_only("repo1");
522 let rw = RepositoryScope::read_write("repo2");
523
524 assert_eq!(ro.mode, RepoAccessMode::ReadOnly);
525 assert_eq!(rw.mode, RepoAccessMode::ReadWrite);
526 }
527
528 #[test]
529 fn git_scope_permissions() {
530 let scope = GitScope::with_branch();
531 assert!(scope.can_commit());
532 assert!(scope.can_branch());
533
534 let empty = GitScope::new();
535 assert!(!empty.can_commit());
536 }
537
538 #[test]
539 fn execution_scope_allowed() {
540 let scope = ExecutionScope::new().allow("cargo").allow("npm").deny("rm");
541
542 assert!(scope.is_allowed("cargo build"));
543 assert!(scope.is_allowed("npm install"));
544 assert!(!scope.is_allowed("rm -rf /"));
545 }
546
547 #[test]
548 fn compatible_scopes() {
549 let a = Scope::new()
550 .with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/a/")));
551 let b = Scope::new()
552 .with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/b/")));
553
554 assert_eq!(check_compatibility(&a, &b), ScopeCompatibility::Compatible);
555 }
556
557 #[test]
558 fn hard_conflict_overlapping_writes() {
559 let a =
560 Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")));
561 let b =
562 Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")));
563
564 assert_eq!(
565 check_compatibility(&a, &b),
566 ScopeCompatibility::HardConflict
567 );
568 }
569
570 #[test]
571 fn deny_rules_produce_hard_conflict_on_overlap() {
572 let a =
573 Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::deny("src/")));
574 let b =
575 Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::read("src/")));
576
577 assert_eq!(
578 check_compatibility(&a, &b),
579 ScopeCompatibility::HardConflict
580 );
581 }
582
583 #[test]
584 fn soft_conflict_read_write() {
585 let a =
586 Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::read("src/")));
587 let b =
588 Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")));
589
590 assert_eq!(
591 check_compatibility(&a, &b),
592 ScopeCompatibility::SoftConflict
593 );
594 }
595
596 #[test]
597 fn repository_conflict() {
598 let a = Scope::new().with_repository(RepositoryScope::read_write("repo"));
599 let b = Scope::new().with_repository(RepositoryScope::read_write("repo"));
600
601 assert_eq!(
602 check_compatibility(&a, &b),
603 ScopeCompatibility::HardConflict
604 );
605 }
606
607 #[test]
608 fn git_commit_conflict() {
609 let a = Scope::new().with_git(GitScope::with_commit());
610 let b = Scope::new().with_git(GitScope::with_commit());
611
612 assert_eq!(
613 check_compatibility(&a, &b),
614 ScopeCompatibility::HardConflict
615 );
616 }
617
618 #[test]
619 fn scope_serialization() {
620 let scope = Scope::new()
621 .with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")))
622 .with_repository(RepositoryScope::read_write("my-repo"))
623 .with_git(GitScope::with_commit());
624
625 let json = serde_json::to_string(&scope).unwrap();
626 let restored: Scope = serde_json::from_str(&json).unwrap();
627
628 assert_eq!(scope, restored);
629 }
630}