cuenv_core/hooks/
approval.rs

1//! Configuration approval management for secure hook execution
2
3use crate::{Error, Result};
4use chrono::{DateTime, Utc};
5use fs4::tokio::AsyncFileExt;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::path::{Component, Path, PathBuf};
11use tokio::fs;
12use tokio::fs::OpenOptions;
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tracing::{debug, info, warn};
15
16/// Manages approval of configurations before hook execution
17#[derive(Debug, Clone)]
18pub struct ApprovalManager {
19    approval_file: PathBuf,
20    approvals: HashMap<String, ApprovalRecord>,
21}
22
23impl ApprovalManager {
24    /// Create a new approval manager with specified approval file
25    pub fn new(approval_file: PathBuf) -> Self {
26        Self {
27            approval_file,
28            approvals: HashMap::new(),
29        }
30    }
31
32    /// Get the default approval file path (~/.cuenv/approved.json)
33    pub fn default_approval_file() -> Result<PathBuf> {
34        // Check for CUENV_APPROVAL_FILE environment variable first
35        if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE") {
36            return Ok(PathBuf::from(approval_file));
37        }
38
39        // Check for CUENV_STATE_DIR to keep consistency with state directory
40        if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
41            let state_path = PathBuf::from(state_dir);
42            if let Some(parent) = state_path.parent() {
43                return Ok(parent.join("approved.json"));
44            }
45        }
46
47        let home = dirs::home_dir()
48            .ok_or_else(|| Error::configuration("Could not determine home directory"))?;
49        Ok(home.join(".cuenv").join("approved.json"))
50    }
51
52    /// Create an approval manager using the default approval file
53    pub fn with_default_file() -> Result<Self> {
54        Ok(Self::new(Self::default_approval_file()?))
55    }
56
57    /// Get approval for a specific directory
58    pub fn get_approval(&self, directory: &str) -> Option<&ApprovalRecord> {
59        let path = PathBuf::from(directory);
60        let dir_key = compute_directory_key(&path);
61        self.approvals.get(&dir_key)
62    }
63
64    /// Load approvals from disk with file locking
65    pub async fn load_approvals(&mut self) -> Result<()> {
66        if !self.approval_file.exists() {
67            debug!("No approval file found at {}", self.approval_file.display());
68            return Ok(());
69        }
70
71        // Open file with shared lock for reading
72        let mut file = OpenOptions::new()
73            .read(true)
74            .open(&self.approval_file)
75            .await
76            .map_err(|e| Error::Io {
77                source: e,
78                path: Some(self.approval_file.clone().into_boxed_path()),
79                operation: "open".to_string(),
80            })?;
81
82        // Acquire shared lock (multiple readers allowed)
83        file.lock_shared().map_err(|e| {
84            Error::configuration(format!(
85                "Failed to acquire shared lock on approval file: {}",
86                e
87            ))
88        })?;
89
90        let mut contents = String::new();
91        file.read_to_string(&mut contents)
92            .await
93            .map_err(|e| Error::Io {
94                source: e,
95                path: Some(self.approval_file.clone().into_boxed_path()),
96                operation: "read_to_string".to_string(),
97            })?;
98
99        // Unlock happens automatically when file is dropped
100        drop(file);
101
102        self.approvals = serde_json::from_str(&contents)
103            .map_err(|e| Error::configuration(format!("Failed to parse approval file: {e}")))?;
104
105        info!("Loaded {} approvals from file", self.approvals.len());
106        Ok(())
107    }
108
109    /// Save approvals to disk with file locking
110    pub async fn save_approvals(&self) -> Result<()> {
111        // Validate and canonicalize the approval file path to prevent path traversal
112        let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
113
114        // Ensure parent directory exists
115        if let Some(parent) = canonical_path.parent()
116            && !parent.exists()
117        {
118            // Validate the parent directory path as well
119            let parent_path = validate_directory_path(parent)?;
120            fs::create_dir_all(&parent_path)
121                .await
122                .map_err(|e| Error::Io {
123                    source: e,
124                    path: Some(parent_path.into()),
125                    operation: "create_dir_all".to_string(),
126                })?;
127        }
128
129        let contents = serde_json::to_string_pretty(&self.approvals)
130            .map_err(|e| Error::configuration(format!("Failed to serialize approvals: {e}")))?;
131
132        // Write to a temporary file first, then rename atomically
133        let temp_path = canonical_path.with_extension("tmp");
134
135        // Open temp file with exclusive lock for writing
136        let mut file = OpenOptions::new()
137            .write(true)
138            .create(true)
139            .truncate(true)
140            .open(&temp_path)
141            .await
142            .map_err(|e| Error::Io {
143                source: e,
144                path: Some(temp_path.clone().into_boxed_path()),
145                operation: "open".to_string(),
146            })?;
147
148        // Acquire exclusive lock (only one writer allowed)
149        file.lock_exclusive().map_err(|e| {
150            Error::configuration(format!(
151                "Failed to acquire exclusive lock on temp file: {}",
152                e
153            ))
154        })?;
155
156        file.write_all(contents.as_bytes())
157            .await
158            .map_err(|e| Error::Io {
159                source: e,
160                path: Some(temp_path.clone().into_boxed_path()),
161                operation: "write_all".to_string(),
162            })?;
163
164        file.sync_all().await.map_err(|e| Error::Io {
165            source: e,
166            path: Some(temp_path.clone().into_boxed_path()),
167            operation: "sync_all".to_string(),
168        })?;
169
170        // Unlock happens automatically when file is dropped
171        drop(file);
172
173        // Atomically rename temp file to final location
174        fs::rename(&temp_path, &canonical_path)
175            .await
176            .map_err(|e| Error::Io {
177                source: e,
178                path: Some(canonical_path.clone().into_boxed_path()),
179                operation: "rename".to_string(),
180            })?;
181
182        debug!("Saved {} approvals to file", self.approvals.len());
183        Ok(())
184    }
185
186    /// Check if a configuration is approved for a specific directory
187    pub fn is_approved(&self, directory_path: &Path, config_hash: &str) -> Result<bool> {
188        let dir_key = compute_directory_key(directory_path);
189
190        if let Some(approval) = self.approvals.get(&dir_key)
191            && approval.config_hash == config_hash
192        {
193            // Check if approval hasn't expired
194            if let Some(expires_at) = approval.expires_at
195                && Utc::now() > expires_at
196            {
197                warn!("Approval for {} has expired", directory_path.display());
198                return Ok(false);
199            }
200            return Ok(true);
201        }
202
203        Ok(false)
204    }
205
206    /// Approve a configuration for a specific directory
207    pub async fn approve_config(
208        &mut self,
209        directory_path: &Path,
210        config_hash: String,
211        note: Option<String>,
212    ) -> Result<()> {
213        let dir_key = compute_directory_key(directory_path);
214        let approval = ApprovalRecord {
215            directory_path: directory_path.to_path_buf(),
216            config_hash,
217            approved_at: Utc::now(),
218            expires_at: None, // No expiration by default
219            note,
220        };
221
222        self.approvals.insert(dir_key, approval);
223        self.save_approvals().await?;
224
225        info!(
226            "Approved configuration for directory: {}",
227            directory_path.display()
228        );
229        Ok(())
230    }
231
232    /// Revoke approval for a directory
233    pub async fn revoke_approval(&mut self, directory_path: &Path) -> Result<bool> {
234        let dir_key = compute_directory_key(directory_path);
235
236        if self.approvals.remove(&dir_key).is_some() {
237            self.save_approvals().await?;
238            info!(
239                "Revoked approval for directory: {}",
240                directory_path.display()
241            );
242            Ok(true)
243        } else {
244            Ok(false)
245        }
246    }
247
248    /// List all approved directories
249    pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
250        self.approvals.values().collect()
251    }
252
253    /// Clean up expired approvals
254    pub async fn cleanup_expired(&mut self) -> Result<usize> {
255        let now = Utc::now();
256        let initial_count = self.approvals.len();
257
258        self.approvals.retain(|_, approval| {
259            if let Some(expires_at) = approval.expires_at {
260                expires_at > now
261            } else {
262                true // Keep approvals without expiration
263            }
264        });
265
266        let removed_count = initial_count - self.approvals.len();
267        if removed_count > 0 {
268            self.save_approvals().await?;
269            info!("Cleaned up {} expired approvals", removed_count);
270        }
271
272        Ok(removed_count)
273    }
274}
275
276/// Record of an approved configuration
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
278pub struct ApprovalRecord {
279    /// Path to the directory
280    pub directory_path: PathBuf,
281    /// Hash of the approved configuration
282    pub config_hash: String,
283    /// When this approval was granted
284    pub approved_at: DateTime<Utc>,
285    /// Optional expiration time
286    pub expires_at: Option<DateTime<Utc>>,
287    /// Optional note about this approval
288    pub note: Option<String>,
289}
290
291/// Status of approval check for a configuration
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum ApprovalStatus {
294    /// Configuration is approved and can be executed
295    Approved,
296    /// Configuration has changed and requires new approval
297    RequiresApproval { current_hash: String },
298    /// Configuration is not approved
299    NotApproved { current_hash: String },
300}
301
302/// Check the approval status for a configuration
303pub fn check_approval_status(
304    manager: &ApprovalManager,
305    directory_path: &Path,
306    config: &Value,
307) -> Result<ApprovalStatus> {
308    let current_hash = compute_config_hash(config);
309
310    if manager.is_approved(directory_path, &current_hash)? {
311        Ok(ApprovalStatus::Approved)
312    } else {
313        // Check if there's an existing approval with a different hash
314        let dir_key = compute_directory_key(directory_path);
315        if manager.approvals.contains_key(&dir_key) {
316            Ok(ApprovalStatus::RequiresApproval { current_hash })
317        } else {
318            Ok(ApprovalStatus::NotApproved { current_hash })
319        }
320    }
321}
322
323/// Compute a hash of a configuration
324pub fn compute_config_hash(config: &Value) -> String {
325    let mut hasher = Sha256::new();
326
327    // Convert to canonical JSON string for consistent hashing
328    let canonical = serde_json::to_string(config).unwrap_or_default();
329    hasher.update(canonical.as_bytes());
330
331    format!("{:x}", hasher.finalize())[..16].to_string()
332}
333
334/// Compute a directory key for the approvals map
335pub fn compute_directory_key(path: &Path) -> String {
336    // Try to canonicalize the path for consistency
337    // If canonicalization fails (e.g., path doesn't exist), use the path as-is
338    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
339
340    let mut hasher = Sha256::new();
341    hasher.update(canonical_path.to_string_lossy().as_bytes());
342    format!("{:x}", hasher.finalize())[..16].to_string()
343}
344
345/// Validate and canonicalize a path to prevent path traversal attacks
346fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
347    // Check for suspicious path components
348    for component in path.components() {
349        match component {
350            Component::Normal(_) | Component::RootDir | Component::CurDir => {}
351            Component::ParentDir => {
352                // Allow parent directory references only if they don't escape the base directory
353                // We'll resolve them through canonicalization
354            }
355            Component::Prefix(_) => {
356                // Windows drive prefixes are okay
357            }
358        }
359    }
360
361    // If the path exists, canonicalize it
362    if path.exists() {
363        std::fs::canonicalize(path)
364            .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
365    } else {
366        // For non-existent paths, validate the parent and construct the canonical path
367        if let Some(parent) = path.parent() {
368            if parent.exists() {
369                let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
370                    Error::configuration(format!("Failed to canonicalize parent path: {}", e))
371                })?;
372                if let Some(file_name) = path.file_name() {
373                    Ok(canonical_parent.join(file_name))
374                } else {
375                    Err(Error::configuration("Invalid file path"))
376                }
377            } else {
378                // Parent doesn't exist, but we can still validate the path structure
379                validate_path_structure(path)?;
380                Ok(path.to_path_buf())
381            }
382        } else {
383            validate_path_structure(path)?;
384            Ok(path.to_path_buf())
385        }
386    }
387}
388
389/// Validate directory path for creation
390fn validate_directory_path(path: &Path) -> Result<PathBuf> {
391    // Check for suspicious patterns
392    validate_path_structure(path)?;
393
394    // Return the path as-is if validation passes
395    Ok(path.to_path_buf())
396}
397
398/// Validate path structure for security
399fn validate_path_structure(path: &Path) -> Result<()> {
400    let path_str = path.to_string_lossy();
401
402    // Check for null bytes
403    if path_str.contains('\0') {
404        return Err(Error::configuration("Path contains null bytes"));
405    }
406
407    // Check for suspicious patterns that might indicate path traversal attempts
408    let suspicious_patterns = [
409        "../../../",    // Multiple parent directory traversals
410        "..\\..\\..\\", // Windows-style traversals
411        "%2e%2e",       // URL-encoded parent directory
412        "..;/",         // Semicolon injection
413    ];
414
415    for pattern in &suspicious_patterns {
416        if path_str.contains(pattern) {
417            return Err(Error::configuration(format!(
418                "Path contains suspicious pattern: {}",
419                pattern
420            )));
421        }
422    }
423
424    Ok(())
425}
426
427/// Generate a summary of a configuration for display to users
428#[derive(Debug, Clone)]
429pub struct ConfigSummary {
430    pub has_hooks: bool,
431    pub hook_count: usize,
432    pub has_env_vars: bool,
433    pub env_var_count: usize,
434    pub has_tasks: bool,
435    pub task_count: usize,
436}
437
438impl ConfigSummary {
439    /// Create a summary from a JSON configuration
440    pub fn from_json(config: &Value) -> Self {
441        let mut summary = Self {
442            has_hooks: false,
443            hook_count: 0,
444            has_env_vars: false,
445            env_var_count: 0,
446            has_tasks: false,
447            task_count: 0,
448        };
449
450        if let Some(obj) = config.as_object() {
451            // Check for hooks
452            if let Some(hooks) = obj.get("hooks")
453                && let Some(hooks_obj) = hooks.as_object()
454            {
455                summary.has_hooks = true;
456
457                // Count onEnter hooks
458                if let Some(on_enter) = hooks_obj.get("onEnter") {
459                    if let Some(arr) = on_enter.as_array() {
460                        summary.hook_count += arr.len();
461                    } else if on_enter.is_object() {
462                        summary.hook_count += 1;
463                    }
464                }
465
466                // Count onExit hooks
467                if let Some(on_exit) = hooks_obj.get("onExit") {
468                    if let Some(arr) = on_exit.as_array() {
469                        summary.hook_count += arr.len();
470                    } else if on_exit.is_object() {
471                        summary.hook_count += 1;
472                    }
473                }
474            }
475
476            // Check for environment variables
477            if let Some(env) = obj.get("env")
478                && let Some(env_obj) = env.as_object()
479            {
480                summary.has_env_vars = true;
481                summary.env_var_count = env_obj.len();
482            }
483
484            // Check for tasks
485            if let Some(tasks) = obj.get("tasks") {
486                if let Some(tasks_obj) = tasks.as_object() {
487                    summary.has_tasks = true;
488                    summary.task_count = tasks_obj.len();
489                } else if let Some(tasks_arr) = tasks.as_array() {
490                    summary.has_tasks = true;
491                    summary.task_count = tasks_arr.len();
492                }
493            }
494        }
495
496        summary
497    }
498
499    /// Get a human-readable description of the configuration
500    pub fn description(&self) -> String {
501        let mut parts = Vec::new();
502
503        if self.has_hooks {
504            if self.hook_count == 1 {
505                parts.push("1 hook".to_string());
506            } else {
507                parts.push(format!("{} hooks", self.hook_count));
508            }
509        }
510
511        if self.has_env_vars {
512            if self.env_var_count == 1 {
513                parts.push("1 environment variable".to_string());
514            } else {
515                parts.push(format!("{} environment variables", self.env_var_count));
516            }
517        }
518
519        if self.has_tasks {
520            if self.task_count == 1 {
521                parts.push("1 task".to_string());
522            } else {
523                parts.push(format!("{} tasks", self.task_count));
524            }
525        }
526
527        if parts.is_empty() {
528            "empty configuration".to_string()
529        } else {
530            parts.join(", ")
531        }
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use serde_json::json;
539    use tempfile::TempDir;
540
541    #[tokio::test]
542    async fn test_approval_manager_operations() {
543        let temp_dir = TempDir::new().unwrap();
544        let approval_file = temp_dir.path().join("approvals.json");
545        let mut manager = ApprovalManager::new(approval_file);
546
547        let directory = Path::new("/test/directory");
548        let config_hash = "test_hash_123".to_string();
549
550        // Initially not approved
551        assert!(!manager.is_approved(directory, &config_hash).unwrap());
552
553        // Approve configuration
554        manager
555            .approve_config(
556                directory,
557                config_hash.clone(),
558                Some("Test approval".to_string()),
559            )
560            .await
561            .unwrap();
562
563        // Should now be approved
564        assert!(manager.is_approved(directory, &config_hash).unwrap());
565
566        // Different hash should not be approved
567        assert!(!manager.is_approved(directory, "different_hash").unwrap());
568
569        // Test persistence
570        let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
571        manager2.load_approvals().await.unwrap();
572        assert!(manager2.is_approved(directory, &config_hash).unwrap());
573
574        // Revoke approval
575        let revoked = manager2.revoke_approval(directory).await.unwrap();
576        assert!(revoked);
577        assert!(!manager2.is_approved(directory, &config_hash).unwrap());
578    }
579
580    #[test]
581    fn test_compute_config_hash() {
582        let config1 = json!({
583            "env": {"TEST": "value"},
584            "hooks": {"onEnter": [{"command": "echo", "args": ["hello"]}]}
585        });
586
587        let config2 = json!({
588            "hooks": {"onEnter": [{"command": "echo", "args": ["hello"]}]},
589            "env": {"TEST": "value"}
590        });
591
592        // Different key order should produce same hash due to canonical JSON
593        let hash1 = compute_config_hash(&config1);
594        let hash2 = compute_config_hash(&config2);
595        assert_eq!(hash1, hash2);
596
597        let config3 = json!({
598            "env": {"TEST": "different_value"},
599            "hooks": {"onEnter": [{"command": "echo", "args": ["hello"]}]}
600        });
601
602        let hash3 = compute_config_hash(&config3);
603        assert_ne!(hash1, hash3);
604    }
605
606    #[test]
607    fn test_config_summary() {
608        let config = json!({
609            "env": {
610                "NODE_ENV": "development",
611                "API_URL": "http://localhost:3000"
612            },
613            "hooks": {
614                "onEnter": [
615                    {"command": "npm", "args": ["install"]},
616                    {"command": "docker-compose", "args": ["up", "-d"]}
617                ],
618                "onExit": [
619                    {"command": "docker-compose", "args": ["down"]}
620                ]
621            },
622            "tasks": {
623                "build": {"command": "npm", "args": ["run", "build"]},
624                "test": {"command": "npm", "args": ["test"]}
625            }
626        });
627
628        let summary = ConfigSummary::from_json(&config);
629        assert!(summary.has_hooks);
630        assert_eq!(summary.hook_count, 3);
631        assert!(summary.has_env_vars);
632        assert_eq!(summary.env_var_count, 2);
633        assert!(summary.has_tasks);
634        assert_eq!(summary.task_count, 2);
635
636        let description = summary.description();
637        assert!(description.contains("3 hooks"));
638        assert!(description.contains("2 environment variables"));
639        assert!(description.contains("2 tasks"));
640    }
641
642    #[test]
643    fn test_approval_status() {
644        let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
645        let directory = Path::new("/test/dir");
646        let config = json!({"env": {"TEST": "value"}});
647
648        let status = check_approval_status(&manager, directory, &config).unwrap();
649        assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
650
651        // Add an approval with a different hash
652        let different_hash = "different_hash".to_string();
653        manager.approvals.insert(
654            compute_directory_key(directory),
655            ApprovalRecord {
656                directory_path: directory.to_path_buf(),
657                config_hash: different_hash,
658                approved_at: Utc::now(),
659                expires_at: None,
660                note: None,
661            },
662        );
663
664        let status = check_approval_status(&manager, directory, &config).unwrap();
665        assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
666
667        // Add approval with correct hash
668        let correct_hash = compute_config_hash(&config);
669        manager.approvals.insert(
670            compute_directory_key(directory),
671            ApprovalRecord {
672                directory_path: directory.to_path_buf(),
673                config_hash: correct_hash,
674                approved_at: Utc::now(),
675                expires_at: None,
676                note: None,
677            },
678        );
679
680        let status = check_approval_status(&manager, directory, &config).unwrap();
681        assert!(matches!(status, ApprovalStatus::Approved));
682    }
683
684    #[test]
685    fn test_path_validation() {
686        // Test valid paths
687        assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
688        assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
689        assert!(validate_path_structure(Path::new("file.txt")).is_ok());
690
691        // Test paths with null bytes (should fail)
692        let path_with_null = PathBuf::from("/test\0/path");
693        assert!(validate_path_structure(&path_with_null).is_err());
694
695        // Test paths with multiple parent directory traversals (should fail)
696        assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
697        assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
698
699        // Test URL-encoded traversals (should fail)
700        assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
701
702        // Test semicolon injection (should fail)
703        assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
704    }
705
706    #[test]
707    fn test_validate_and_canonicalize_path() {
708        let temp_dir = TempDir::new().unwrap();
709        let test_file = temp_dir.path().join("test.txt");
710        std::fs::write(&test_file, "test").unwrap();
711
712        // Test existing file canonicalization
713        let result = validate_and_canonicalize_path(&test_file).unwrap();
714        assert!(result.is_absolute());
715        assert!(result.exists());
716
717        // Test non-existent file in existing directory
718        let new_file = temp_dir.path().join("new_file.txt");
719        let result = validate_and_canonicalize_path(&new_file).unwrap();
720        assert!(result.ends_with("new_file.txt"));
721
722        // Test validation with parent directory that exists
723        let nested_new = temp_dir.path().join("subdir/newfile.txt");
724        let result = validate_and_canonicalize_path(&nested_new);
725        assert!(result.is_ok()); // Should succeed even though parent doesn't exist yet
726    }
727
728    #[tokio::test]
729    async fn test_approval_file_corruption_recovery() {
730        let temp_dir = TempDir::new().unwrap();
731        let approval_file = temp_dir.path().join("approvals.json");
732
733        // Write corrupted JSON to the approval file
734        std::fs::write(&approval_file, "{invalid json}").unwrap();
735
736        let mut manager = ApprovalManager::new(approval_file.clone());
737
738        // Loading should fail due to corrupted JSON
739        let result = manager.load_approvals().await;
740        assert!(
741            result.is_err(),
742            "Expected error when loading corrupted JSON"
743        );
744
745        // Manager should still be usable with empty approvals
746        assert_eq!(manager.approvals.len(), 0);
747
748        // Should be able to save new approvals
749        let directory = Path::new("/test/dir");
750        manager
751            .approve_config(directory, "test_hash".to_string(), None)
752            .await
753            .unwrap();
754
755        // New manager should be able to load the fixed file
756        let mut manager2 = ApprovalManager::new(approval_file);
757        manager2.load_approvals().await.unwrap();
758        assert_eq!(manager2.approvals.len(), 1);
759    }
760
761    #[tokio::test]
762    async fn test_concurrent_approval_access() {
763        let temp_dir = TempDir::new().unwrap();
764        let approval_file = temp_dir.path().join("approvals.json");
765
766        // Create multiple managers accessing the same file
767        let mut manager1 = ApprovalManager::new(approval_file.clone());
768        let mut manager2 = ApprovalManager::new(approval_file.clone());
769
770        // Approve from first manager
771        manager1
772            .approve_config(
773                Path::new("/test/dir1"),
774                "hash1".to_string(),
775                Some("Manager 1".to_string()),
776            )
777            .await
778            .unwrap();
779
780        // Approve from second manager
781        manager2
782            .approve_config(
783                Path::new("/test/dir2"),
784                "hash2".to_string(),
785                Some("Manager 2".to_string()),
786            )
787            .await
788            .unwrap();
789
790        // Load in a third manager to verify both approvals
791        let mut manager3 = ApprovalManager::new(approval_file);
792        manager3.load_approvals().await.unwrap();
793
794        // Should have the approval from manager1 (manager2's might have overwritten)
795        // Due to file locking, one of them should succeed
796        assert!(!manager3.approvals.is_empty());
797    }
798
799    #[tokio::test]
800    async fn test_approval_expiration() {
801        let temp_dir = TempDir::new().unwrap();
802        let approval_file = temp_dir.path().join("approvals.json");
803        let mut manager = ApprovalManager::new(approval_file);
804
805        let directory = Path::new("/test/expire");
806        let config_hash = "expire_hash".to_string();
807
808        // Add an expired approval
809        let expired_approval = ApprovalRecord {
810            directory_path: directory.to_path_buf(),
811            config_hash: config_hash.clone(),
812            approved_at: Utc::now() - chrono::Duration::hours(2),
813            expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
814            note: Some("Expired approval".to_string()),
815        };
816
817        manager
818            .approvals
819            .insert(compute_directory_key(directory), expired_approval);
820
821        // Should not be approved due to expiration
822        assert!(!manager.is_approved(directory, &config_hash).unwrap());
823
824        // Cleanup should remove expired approval
825        let removed = manager.cleanup_expired().await.unwrap();
826        assert_eq!(removed, 1);
827        assert_eq!(manager.approvals.len(), 0);
828    }
829}