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