cuenv_core/hooks/
approval.rs

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