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_approval_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 for approval based only on security-sensitive hooks.
324/// Only onEnter and onExit hooks are included since they execute arbitrary commands.
325/// Changes to env vars, tasks, config settings do NOT require re-approval.
326pub fn compute_approval_hash(config: &Value) -> String {
327    let mut hasher = Sha256::new();
328
329    // Extract only the hooks portion for hashing
330    let hooks_only = extract_hooks_for_hash(config);
331    let canonical = serde_json::to_string(&hooks_only).unwrap_or_default();
332    hasher.update(canonical.as_bytes());
333
334    format!("{:x}", hasher.finalize())[..16].to_string()
335}
336
337/// Extract only hooks (onEnter/onExit) from config for approval hashing
338fn extract_hooks_for_hash(config: &Value) -> Value {
339    if let Some(obj) = config.as_object()
340        && let Some(hooks) = obj.get("hooks")
341    {
342        // Return a normalized structure with only hooks
343        return serde_json::json!({ "hooks": hooks });
344    }
345    // No hooks = empty object (will hash to consistent value)
346    serde_json::json!({})
347}
348
349/// Compute a directory key for the approvals map
350pub fn compute_directory_key(path: &Path) -> String {
351    // Try to canonicalize the path for consistency
352    // If canonicalization fails (e.g., path doesn't exist), use the path as-is
353    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
354
355    let mut hasher = Sha256::new();
356    hasher.update(canonical_path.to_string_lossy().as_bytes());
357    format!("{:x}", hasher.finalize())[..16].to_string()
358}
359
360/// Validate and canonicalize a path to prevent path traversal attacks
361fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
362    // Check for suspicious path components
363    for component in path.components() {
364        match component {
365            Component::Normal(_) | Component::RootDir | Component::CurDir => {}
366            Component::ParentDir => {
367                // Allow parent directory references only if they don't escape the base directory
368                // We'll resolve them through canonicalization
369            }
370            Component::Prefix(_) => {
371                // Windows drive prefixes are okay
372            }
373        }
374    }
375
376    // If the path exists, canonicalize it
377    if path.exists() {
378        std::fs::canonicalize(path)
379            .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
380    } else {
381        // For non-existent paths, validate the parent and construct the canonical path
382        if let Some(parent) = path.parent() {
383            if parent.exists() {
384                let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
385                    Error::configuration(format!("Failed to canonicalize parent path: {}", e))
386                })?;
387                if let Some(file_name) = path.file_name() {
388                    Ok(canonical_parent.join(file_name))
389                } else {
390                    Err(Error::configuration("Invalid file path"))
391                }
392            } else {
393                // Parent doesn't exist, but we can still validate the path structure
394                validate_path_structure(path)?;
395                Ok(path.to_path_buf())
396            }
397        } else {
398            validate_path_structure(path)?;
399            Ok(path.to_path_buf())
400        }
401    }
402}
403
404/// Validate directory path for creation
405fn validate_directory_path(path: &Path) -> Result<PathBuf> {
406    // Check for suspicious patterns
407    validate_path_structure(path)?;
408
409    // Return the path as-is if validation passes
410    Ok(path.to_path_buf())
411}
412
413/// Validate path structure for security
414fn validate_path_structure(path: &Path) -> Result<()> {
415    let path_str = path.to_string_lossy();
416
417    // Check for null bytes
418    if path_str.contains('\0') {
419        return Err(Error::configuration("Path contains null bytes"));
420    }
421
422    // Check for suspicious patterns that might indicate path traversal attempts
423    let suspicious_patterns = [
424        "../../../",    // Multiple parent directory traversals
425        "..\\..\\..\\", // Windows-style traversals
426        "%2e%2e",       // URL-encoded parent directory
427        "..;/",         // Semicolon injection
428    ];
429
430    for pattern in &suspicious_patterns {
431        if path_str.contains(pattern) {
432            return Err(Error::configuration(format!(
433                "Path contains suspicious pattern: {}",
434                pattern
435            )));
436        }
437    }
438
439    Ok(())
440}
441
442/// Generate a summary of a configuration for display to users
443#[derive(Debug, Clone)]
444pub struct ConfigSummary {
445    pub has_hooks: bool,
446    pub hook_count: usize,
447    pub has_env_vars: bool,
448    pub env_var_count: usize,
449    pub has_tasks: bool,
450    pub task_count: usize,
451}
452
453impl ConfigSummary {
454    /// Create a summary from a JSON configuration
455    pub fn from_json(config: &Value) -> Self {
456        let mut summary = Self {
457            has_hooks: false,
458            hook_count: 0,
459            has_env_vars: false,
460            env_var_count: 0,
461            has_tasks: false,
462            task_count: 0,
463        };
464
465        if let Some(obj) = config.as_object() {
466            // Check for hooks
467            if let Some(hooks) = obj.get("hooks")
468                && let Some(hooks_obj) = hooks.as_object()
469            {
470                summary.has_hooks = true;
471
472                // Count onEnter hooks
473                if let Some(on_enter) = hooks_obj.get("onEnter") {
474                    if let Some(arr) = on_enter.as_array() {
475                        summary.hook_count += arr.len();
476                    } else if on_enter.is_object() {
477                        summary.hook_count += 1;
478                    }
479                }
480
481                // Count onExit hooks
482                if let Some(on_exit) = hooks_obj.get("onExit") {
483                    if let Some(arr) = on_exit.as_array() {
484                        summary.hook_count += arr.len();
485                    } else if on_exit.is_object() {
486                        summary.hook_count += 1;
487                    }
488                }
489            }
490
491            // Check for environment variables
492            if let Some(env) = obj.get("env")
493                && let Some(env_obj) = env.as_object()
494            {
495                summary.has_env_vars = true;
496                summary.env_var_count = env_obj.len();
497            }
498
499            // Check for tasks
500            if let Some(tasks) = obj.get("tasks") {
501                if let Some(tasks_obj) = tasks.as_object() {
502                    summary.has_tasks = true;
503                    summary.task_count = tasks_obj.len();
504                } else if let Some(tasks_arr) = tasks.as_array() {
505                    summary.has_tasks = true;
506                    summary.task_count = tasks_arr.len();
507                }
508            }
509        }
510
511        summary
512    }
513
514    /// Get a human-readable description of the configuration
515    pub fn description(&self) -> String {
516        let mut parts = Vec::new();
517
518        if self.has_hooks {
519            if self.hook_count == 1 {
520                parts.push("1 hook".to_string());
521            } else {
522                parts.push(format!("{} hooks", self.hook_count));
523            }
524        }
525
526        if self.has_env_vars {
527            if self.env_var_count == 1 {
528                parts.push("1 environment variable".to_string());
529            } else {
530                parts.push(format!("{} environment variables", self.env_var_count));
531            }
532        }
533
534        if self.has_tasks {
535            if self.task_count == 1 {
536                parts.push("1 task".to_string());
537            } else {
538                parts.push(format!("{} tasks", self.task_count));
539            }
540        }
541
542        if parts.is_empty() {
543            "empty configuration".to_string()
544        } else {
545            parts.join(", ")
546        }
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use serde_json::json;
554    use tempfile::TempDir;
555
556    #[tokio::test]
557    async fn test_approval_manager_operations() {
558        let temp_dir = TempDir::new().unwrap();
559        let approval_file = temp_dir.path().join("approvals.json");
560        let mut manager = ApprovalManager::new(approval_file);
561
562        let directory = Path::new("/test/directory");
563        let config_hash = "test_hash_123".to_string();
564
565        // Initially not approved
566        assert!(!manager.is_approved(directory, &config_hash).unwrap());
567
568        // Approve configuration
569        manager
570            .approve_config(
571                directory,
572                config_hash.clone(),
573                Some("Test approval".to_string()),
574            )
575            .await
576            .unwrap();
577
578        // Should now be approved
579        assert!(manager.is_approved(directory, &config_hash).unwrap());
580
581        // Different hash should not be approved
582        assert!(!manager.is_approved(directory, "different_hash").unwrap());
583
584        // Test persistence
585        let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
586        manager2.load_approvals().await.unwrap();
587        assert!(manager2.is_approved(directory, &config_hash).unwrap());
588
589        // Revoke approval
590        let revoked = manager2.revoke_approval(directory).await.unwrap();
591        assert!(revoked);
592        assert!(!manager2.is_approved(directory, &config_hash).unwrap());
593    }
594
595    #[test]
596    fn test_approval_hash_only_includes_hooks() {
597        // Same hooks with different env vars should produce same hash
598        let config1 = json!({
599            "env": {"TEST": "value1"},
600            "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["hello"]}}}
601        });
602
603        let config2 = json!({
604            "env": {"TEST": "value2", "NEW_VAR": "new"},
605            "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["hello"]}}}
606        });
607
608        let hash1 = compute_approval_hash(&config1);
609        let hash2 = compute_approval_hash(&config2);
610        assert_eq!(hash1, hash2, "Env changes should not affect approval hash");
611
612        // Different hooks should produce different hash
613        let config3 = json!({
614            "env": {"TEST": "value1"},
615            "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["world"]}}}
616        });
617
618        let hash3 = compute_approval_hash(&config3);
619        assert_ne!(hash1, hash3, "Hook changes should affect approval hash");
620    }
621
622    #[test]
623    fn test_approval_hash_ignores_tasks() {
624        let config1 = json!({
625            "tasks": {"build": {"command": "npm", "args": ["run", "build"]}},
626            "hooks": {"onEnter": {"setup": {"command": "echo"}}}
627        });
628
629        let config2 = json!({
630            "tasks": {},
631            "hooks": {"onEnter": {"setup": {"command": "echo"}}}
632        });
633
634        let hash1 = compute_approval_hash(&config1);
635        let hash2 = compute_approval_hash(&config2);
636        assert_eq!(hash1, hash2, "Task changes should not affect approval hash");
637    }
638
639    #[test]
640    fn test_approval_hash_no_hooks() {
641        // Configs without hooks should produce same consistent hash
642        let config1 = json!({
643            "env": {"TEST": "value"}
644        });
645
646        let config2 = json!({
647            "env": {"OTHER": "different"},
648            "tasks": {"test": {}}
649        });
650
651        let hash1 = compute_approval_hash(&config1);
652        let hash2 = compute_approval_hash(&config2);
653        assert_eq!(hash1, hash2, "Configs without hooks should have same hash");
654    }
655
656    #[test]
657    fn test_config_summary() {
658        let config = json!({
659            "env": {
660                "NODE_ENV": "development",
661                "API_URL": "http://localhost:3000"
662            },
663            "hooks": {
664                "onEnter": [
665                    {"command": "npm", "args": ["install"]},
666                    {"command": "docker-compose", "args": ["up", "-d"]}
667                ],
668                "onExit": [
669                    {"command": "docker-compose", "args": ["down"]}
670                ]
671            },
672            "tasks": {
673                "build": {"command": "npm", "args": ["run", "build"]},
674                "test": {"command": "npm", "args": ["test"]}
675            }
676        });
677
678        let summary = ConfigSummary::from_json(&config);
679        assert!(summary.has_hooks);
680        assert_eq!(summary.hook_count, 3);
681        assert!(summary.has_env_vars);
682        assert_eq!(summary.env_var_count, 2);
683        assert!(summary.has_tasks);
684        assert_eq!(summary.task_count, 2);
685
686        let description = summary.description();
687        assert!(description.contains("3 hooks"));
688        assert!(description.contains("2 environment variables"));
689        assert!(description.contains("2 tasks"));
690    }
691
692    #[test]
693    fn test_approval_status() {
694        let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
695        let directory = Path::new("/test/dir");
696        let config = json!({"env": {"TEST": "value"}});
697
698        let status = check_approval_status(&manager, directory, &config).unwrap();
699        assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
700
701        // Add an approval with a different hash
702        let different_hash = "different_hash".to_string();
703        manager.approvals.insert(
704            compute_directory_key(directory),
705            ApprovalRecord {
706                directory_path: directory.to_path_buf(),
707                config_hash: different_hash,
708                approved_at: Utc::now(),
709                expires_at: None,
710                note: None,
711            },
712        );
713
714        let status = check_approval_status(&manager, directory, &config).unwrap();
715        assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
716
717        // Add approval with correct hash
718        let correct_hash = compute_approval_hash(&config);
719        manager.approvals.insert(
720            compute_directory_key(directory),
721            ApprovalRecord {
722                directory_path: directory.to_path_buf(),
723                config_hash: correct_hash,
724                approved_at: Utc::now(),
725                expires_at: None,
726                note: None,
727            },
728        );
729
730        let status = check_approval_status(&manager, directory, &config).unwrap();
731        assert!(matches!(status, ApprovalStatus::Approved));
732    }
733
734    #[test]
735    fn test_path_validation() {
736        // Test valid paths
737        assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
738        assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
739        assert!(validate_path_structure(Path::new("file.txt")).is_ok());
740
741        // Test paths with null bytes (should fail)
742        let path_with_null = PathBuf::from("/test\0/path");
743        assert!(validate_path_structure(&path_with_null).is_err());
744
745        // Test paths with multiple parent directory traversals (should fail)
746        assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
747        assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
748
749        // Test URL-encoded traversals (should fail)
750        assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
751
752        // Test semicolon injection (should fail)
753        assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
754    }
755
756    #[test]
757    fn test_validate_and_canonicalize_path() {
758        let temp_dir = TempDir::new().unwrap();
759        let test_file = temp_dir.path().join("test.txt");
760        std::fs::write(&test_file, "test").unwrap();
761
762        // Test existing file canonicalization
763        let result = validate_and_canonicalize_path(&test_file).unwrap();
764        assert!(result.is_absolute());
765        assert!(result.exists());
766
767        // Test non-existent file in existing directory
768        let new_file = temp_dir.path().join("new_file.txt");
769        let result = validate_and_canonicalize_path(&new_file).unwrap();
770        assert!(result.ends_with("new_file.txt"));
771
772        // Test validation with parent directory that exists
773        let nested_new = temp_dir.path().join("subdir/newfile.txt");
774        let result = validate_and_canonicalize_path(&nested_new);
775        assert!(result.is_ok()); // Should succeed even though parent doesn't exist yet
776    }
777
778    #[tokio::test]
779    async fn test_approval_file_corruption_recovery() {
780        let temp_dir = TempDir::new().unwrap();
781        let approval_file = temp_dir.path().join("approvals.json");
782
783        // Write corrupted JSON to the approval file
784        std::fs::write(&approval_file, "{invalid json}").unwrap();
785
786        let mut manager = ApprovalManager::new(approval_file.clone());
787
788        // Loading should fail due to corrupted JSON
789        let result = manager.load_approvals().await;
790        assert!(
791            result.is_err(),
792            "Expected error when loading corrupted JSON"
793        );
794
795        // Manager should still be usable with empty approvals
796        assert_eq!(manager.approvals.len(), 0);
797
798        // Should be able to save new approvals
799        let directory = Path::new("/test/dir");
800        manager
801            .approve_config(directory, "test_hash".to_string(), None)
802            .await
803            .unwrap();
804
805        // New manager should be able to load the fixed file
806        let mut manager2 = ApprovalManager::new(approval_file);
807        manager2.load_approvals().await.unwrap();
808        assert_eq!(manager2.approvals.len(), 1);
809    }
810
811    #[tokio::test]
812    async fn test_concurrent_approval_access() {
813        let temp_dir = TempDir::new().unwrap();
814        let approval_file = temp_dir.path().join("approvals.json");
815
816        // Create multiple managers accessing the same file
817        let mut manager1 = ApprovalManager::new(approval_file.clone());
818        let mut manager2 = ApprovalManager::new(approval_file.clone());
819
820        // Approve from first manager
821        manager1
822            .approve_config(
823                Path::new("/test/dir1"),
824                "hash1".to_string(),
825                Some("Manager 1".to_string()),
826            )
827            .await
828            .unwrap();
829
830        // Approve from second manager
831        manager2
832            .approve_config(
833                Path::new("/test/dir2"),
834                "hash2".to_string(),
835                Some("Manager 2".to_string()),
836            )
837            .await
838            .unwrap();
839
840        // Load in a third manager to verify both approvals
841        let mut manager3 = ApprovalManager::new(approval_file);
842        manager3.load_approvals().await.unwrap();
843
844        // Should have the approval from manager1 (manager2's might have overwritten)
845        // Due to file locking, one of them should succeed
846        assert!(!manager3.approvals.is_empty());
847    }
848
849    #[tokio::test]
850    async fn test_approval_expiration() {
851        let temp_dir = TempDir::new().unwrap();
852        let approval_file = temp_dir.path().join("approvals.json");
853        let mut manager = ApprovalManager::new(approval_file);
854
855        let directory = Path::new("/test/expire");
856        let config_hash = "expire_hash".to_string();
857
858        // Add an expired approval
859        let expired_approval = ApprovalRecord {
860            directory_path: directory.to_path_buf(),
861            config_hash: config_hash.clone(),
862            approved_at: Utc::now() - chrono::Duration::hours(2),
863            expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
864            note: Some("Expired approval".to_string()),
865        };
866
867        manager
868            .approvals
869            .insert(compute_directory_key(directory), expired_approval);
870
871        // Should not be approved due to expiration
872        assert!(!manager.is_approved(directory, &config_hash).unwrap());
873
874        // Cleanup should remove expired approval
875        let removed = manager.cleanup_expired().await.unwrap();
876        assert_eq!(removed, 1);
877        assert_eq!(manager.approvals.len(), 0);
878    }
879}