1use super::diff::{ChangeType, Diff, FileChange};
7use super::scope::{FilePermission, Scope};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ScopeViolation {
15 pub violation_type: ViolationType,
17 pub path: Option<String>,
19 pub description: String,
21 pub fatal: bool,
23}
24
25impl ScopeViolation {
26 pub fn filesystem(path: impl Into<String>, description: impl Into<String>) -> Self {
28 Self {
29 violation_type: ViolationType::Filesystem,
30 path: Some(path.into()),
31 description: description.into(),
32 fatal: true,
33 }
34 }
35
36 pub fn git(description: impl Into<String>) -> Self {
38 Self {
39 violation_type: ViolationType::Git,
40 path: None,
41 description: description.into(),
42 fatal: true,
43 }
44 }
45
46 pub fn execution(description: impl Into<String>) -> Self {
48 Self {
49 violation_type: ViolationType::Execution,
50 path: None,
51 description: description.into(),
52 fatal: true,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ViolationType {
61 Filesystem,
63 Git,
65 Execution,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct VerificationResult {
72 pub id: Uuid,
74 pub task_id: Uuid,
76 pub attempt_id: Uuid,
78 pub passed: bool,
80 pub violations: Vec<ScopeViolation>,
82 pub verified_at: chrono::DateTime<chrono::Utc>,
84}
85
86impl VerificationResult {
87 pub fn pass(task_id: Uuid, attempt_id: Uuid) -> Self {
89 Self {
90 id: Uuid::new_v4(),
91 task_id,
92 attempt_id,
93 passed: true,
94 violations: Vec::new(),
95 verified_at: chrono::Utc::now(),
96 }
97 }
98
99 pub fn fail(task_id: Uuid, attempt_id: Uuid, violations: Vec<ScopeViolation>) -> Self {
101 Self {
102 id: Uuid::new_v4(),
103 task_id,
104 attempt_id,
105 passed: false,
106 violations,
107 verified_at: chrono::Utc::now(),
108 }
109 }
110
111 pub fn has_fatal_violations(&self) -> bool {
113 self.violations.iter().any(|v| v.fatal)
114 }
115}
116
117pub struct ScopeEnforcer {
119 scope: Scope,
120}
121
122impl ScopeEnforcer {
123 pub fn new(scope: Scope) -> Self {
125 Self { scope }
126 }
127
128 pub fn verify_diff(&self, diff: &Diff, task_id: Uuid, attempt_id: Uuid) -> VerificationResult {
130 let mut violations = Vec::new();
131
132 for change in &diff.changes {
133 if let Some(violation) = self.check_file_change(change) {
134 violations.push(violation);
135 }
136 }
137
138 if violations.is_empty() {
139 VerificationResult::pass(task_id, attempt_id)
140 } else {
141 VerificationResult::fail(task_id, attempt_id, violations)
142 }
143 }
144
145 fn check_file_change(&self, change: &FileChange) -> Option<ScopeViolation> {
147 let path_str = change.path.to_string_lossy();
148
149 match change.change_type {
150 ChangeType::Created | ChangeType::Modified => {
151 if !self.scope.filesystem.can_write(&path_str) {
153 return Some(ScopeViolation::filesystem(
154 path_str.to_string(),
155 format!(
156 "Write to '{}' not allowed by scope (change type: {:?})",
157 path_str, change.change_type
158 ),
159 ));
160 }
161 }
162 ChangeType::Deleted => {
163 if !self.scope.filesystem.can_write(&path_str) {
165 return Some(ScopeViolation::filesystem(
166 path_str.to_string(),
167 format!("Delete of '{path_str}' not allowed by scope"),
168 ));
169 }
170 }
171 }
172
173 if self.scope.filesystem.permission_for(&path_str) == Some(FilePermission::Deny) {
175 return Some(ScopeViolation::filesystem(
176 path_str.to_string(),
177 format!("Access to '{path_str}' is explicitly denied"),
178 ));
179 }
180
181 None
182 }
183
184 pub fn verify_git_operations(
186 &self,
187 commits_created: bool,
188 branches_created: bool,
189 task_id: Uuid,
190 attempt_id: Uuid,
191 ) -> VerificationResult {
192 let mut violations = Vec::new();
193
194 if commits_created && !self.scope.git.can_commit() {
195 violations.push(ScopeViolation::git(
196 "Commits were created but git commit permission not granted",
197 ));
198 }
199
200 if branches_created && !self.scope.git.can_branch() {
201 violations.push(ScopeViolation::git(
202 "Branches were created but git branch permission not granted",
203 ));
204 }
205
206 if violations.is_empty() {
207 VerificationResult::pass(task_id, attempt_id)
208 } else {
209 VerificationResult::fail(task_id, attempt_id, violations)
210 }
211 }
212
213 pub fn verify_all(
215 &self,
216 diff: &Diff,
217 commits_created: bool,
218 branches_created: bool,
219 task_id: Uuid,
220 attempt_id: Uuid,
221 ) -> VerificationResult {
222 let mut all_violations = Vec::new();
223
224 let diff_result = self.verify_diff(diff, task_id, attempt_id);
226 all_violations.extend(diff_result.violations);
227
228 let git_result =
230 self.verify_git_operations(commits_created, branches_created, task_id, attempt_id);
231 all_violations.extend(git_result.violations);
232
233 if all_violations.is_empty() {
234 VerificationResult::pass(task_id, attempt_id)
235 } else {
236 VerificationResult::fail(task_id, attempt_id, all_violations)
237 }
238 }
239
240 pub fn scope(&self) -> &Scope {
242 &self.scope
243 }
244}
245
246pub fn path_matches_any(path: &Path, patterns: &[String]) -> bool {
248 let path_str = path.to_string_lossy();
249 for pattern in patterns {
250 if pattern.contains('*') {
251 if pattern == "*" {
253 return true;
254 }
255 if let Some(prefix) = pattern.strip_suffix("/*") {
256 if path_str.starts_with(prefix) {
257 return true;
258 }
259 }
260 if let Some(suffix) = pattern.strip_prefix("*/") {
261 if path_str.ends_with(suffix) {
262 return true;
263 }
264 }
265 } else if path_str.starts_with(pattern) {
266 return true;
267 }
268 }
269 false
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::core::diff::{ChangeType, Diff, FileChange};
276 use crate::core::scope::{FilesystemScope, GitScope, PathRule, Scope};
277 use std::path::PathBuf;
278
279 fn test_scope() -> Scope {
280 Scope::new()
281 .with_filesystem(
282 FilesystemScope::new()
283 .with_rule(PathRule::write("src/"))
284 .with_rule(PathRule::read("docs/"))
285 .with_rule(PathRule::deny("secrets/")),
286 )
287 .with_git(GitScope::with_commit())
288 }
289
290 fn test_diff_with_changes(changes: Vec<FileChange>) -> Diff {
291 Diff {
292 id: Uuid::new_v4(),
293 task_id: None,
294 attempt_id: None,
295 baseline_id: Uuid::new_v4(),
296 changes,
297 computed_at: chrono::Utc::now(),
298 }
299 }
300
301 #[test]
302 fn allowed_write_passes() {
303 let enforcer = ScopeEnforcer::new(test_scope());
304 let task_id = Uuid::new_v4();
305 let attempt_id = Uuid::new_v4();
306
307 let diff = test_diff_with_changes(vec![FileChange {
308 path: PathBuf::from("src/main.rs"),
309 change_type: ChangeType::Modified,
310 old_hash: Some("old".to_string()),
311 new_hash: Some("new".to_string()),
312 }]);
313
314 let result = enforcer.verify_diff(&diff, task_id, attempt_id);
315 assert!(result.passed);
316 assert!(result.violations.is_empty());
317 }
318
319 #[test]
320 fn disallowed_write_fails() {
321 let enforcer = ScopeEnforcer::new(test_scope());
322 let task_id = Uuid::new_v4();
323 let attempt_id = Uuid::new_v4();
324
325 let diff = test_diff_with_changes(vec![FileChange {
326 path: PathBuf::from("docs/README.md"),
327 change_type: ChangeType::Modified,
328 old_hash: Some("old".to_string()),
329 new_hash: Some("new".to_string()),
330 }]);
331
332 let result = enforcer.verify_diff(&diff, task_id, attempt_id);
333 assert!(!result.passed);
334 assert_eq!(result.violations.len(), 1);
335 assert_eq!(
336 result.violations[0].violation_type,
337 ViolationType::Filesystem
338 );
339 }
340
341 #[test]
342 fn denied_path_fails() {
343 let enforcer = ScopeEnforcer::new(test_scope());
344 let task_id = Uuid::new_v4();
345 let attempt_id = Uuid::new_v4();
346
347 let diff = test_diff_with_changes(vec![FileChange {
348 path: PathBuf::from("secrets/api_key.txt"),
349 change_type: ChangeType::Created,
350 old_hash: None,
351 new_hash: Some("new".to_string()),
352 }]);
353
354 let result = enforcer.verify_diff(&diff, task_id, attempt_id);
355 assert!(!result.passed);
356 }
357
358 #[test]
359 fn git_commit_allowed() {
360 let enforcer = ScopeEnforcer::new(test_scope());
361 let task_id = Uuid::new_v4();
362 let attempt_id = Uuid::new_v4();
363
364 let result = enforcer.verify_git_operations(true, false, task_id, attempt_id);
365 assert!(result.passed);
366 }
367
368 #[test]
369 fn git_commit_disallowed() {
370 let scope = Scope::new().with_git(GitScope::new()); let enforcer = ScopeEnforcer::new(scope);
372 let task_id = Uuid::new_v4();
373 let attempt_id = Uuid::new_v4();
374
375 let result = enforcer.verify_git_operations(true, false, task_id, attempt_id);
376 assert!(!result.passed);
377 assert_eq!(result.violations[0].violation_type, ViolationType::Git);
378 }
379
380 #[test]
381 fn git_branch_disallowed() {
382 let scope = Scope::new().with_git(GitScope::with_commit()); let enforcer = ScopeEnforcer::new(scope);
384 let task_id = Uuid::new_v4();
385 let attempt_id = Uuid::new_v4();
386
387 let result = enforcer.verify_git_operations(false, true, task_id, attempt_id);
388 assert!(!result.passed);
389 }
390
391 #[test]
392 fn full_verification() {
393 let enforcer = ScopeEnforcer::new(test_scope());
394 let task_id = Uuid::new_v4();
395 let attempt_id = Uuid::new_v4();
396
397 let diff = test_diff_with_changes(vec![FileChange {
398 path: PathBuf::from("src/lib.rs"),
399 change_type: ChangeType::Created,
400 old_hash: None,
401 new_hash: Some("new".to_string()),
402 }]);
403
404 let result = enforcer.verify_all(&diff, true, false, task_id, attempt_id);
405 assert!(result.passed);
406 }
407
408 #[test]
409 fn full_verification_with_violations() {
410 let enforcer = ScopeEnforcer::new(test_scope());
411 let task_id = Uuid::new_v4();
412 let attempt_id = Uuid::new_v4();
413
414 let diff = test_diff_with_changes(vec![
415 FileChange {
416 path: PathBuf::from("src/lib.rs"),
417 change_type: ChangeType::Created,
418 old_hash: None,
419 new_hash: Some("new".to_string()),
420 },
421 FileChange {
422 path: PathBuf::from("config/settings.json"),
423 change_type: ChangeType::Modified,
424 old_hash: Some("old".to_string()),
425 new_hash: Some("new".to_string()),
426 },
427 ]);
428
429 let result = enforcer.verify_all(&diff, true, true, task_id, attempt_id);
430 assert!(!result.passed);
431 assert!(result.violations.len() >= 2);
433 }
434
435 #[test]
436 fn violation_serialization() {
437 let violation = ScopeViolation::filesystem("test.txt", "Write not allowed");
438 let json = serde_json::to_string(&violation).unwrap();
439 let restored: ScopeViolation = serde_json::from_str(&json).unwrap();
440
441 assert_eq!(violation, restored);
442 }
443
444 #[test]
445 fn verification_result_serialization() {
446 let result = VerificationResult::pass(Uuid::new_v4(), Uuid::new_v4());
447 let json = serde_json::to_string(&result).unwrap();
448 assert!(json.contains("\"passed\":true"));
449 }
450
451 #[test]
452 fn path_matching() {
453 let patterns = vec!["src/".to_string(), "tests/*".to_string()];
454
455 assert!(path_matches_any(Path::new("src/main.rs"), &patterns));
456 assert!(path_matches_any(Path::new("tests/test1.rs"), &patterns));
457 assert!(!path_matches_any(Path::new("docs/README.md"), &patterns));
458 }
459}