Skip to main content

cuenv_hooks/
approval.rs

1//! Configuration approval management for secure hook execution
2
3use crate::types::{Hook, Hooks};
4use crate::{Error, Result};
5use chrono::{DateTime, Utc};
6use fs4::tokio::AsyncFileExt;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::{BTreeMap, 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/// CI provider environment variables to check when detecting CI environments
17const CI_VARS: &[&str] = &[
18    "GITHUB_ACTIONS",
19    "GITLAB_CI",
20    "BUILDKITE",
21    "JENKINS_URL",
22    "CIRCLECI",
23    "TRAVIS",
24    "BITBUCKET_PIPELINES",
25    "AZURE_PIPELINES",
26    "TF_BUILD",
27    "DRONE",
28    "TEAMCITY_VERSION",
29];
30
31/// Check if the current process is running in a CI environment.
32///
33/// This checks for common CI environment variables used by popular CI/CD systems:
34/// - `CI` - Generic CI indicator (GitHub Actions, GitLab CI, CircleCI, Travis CI, etc.)
35/// - `GITHUB_ACTIONS` - GitHub Actions
36/// - `GITLAB_CI` - GitLab CI
37/// - `BUILDKITE` - Buildkite
38/// - `JENKINS_URL` - Jenkins
39/// - `CIRCLECI` - CircleCI
40/// - `TRAVIS` - Travis CI
41/// - `BITBUCKET_PIPELINES` - Bitbucket Pipelines
42/// - `AZURE_PIPELINES` - Azure Pipelines
43/// - `TF_BUILD` - Azure DevOps / Team Foundation Build
44/// - `DRONE` - Drone CI
45/// - `TEAMCITY_VERSION` - TeamCity
46///
47/// Returns `true` if any of these environment variables are set to a truthy value.
48#[must_use]
49pub fn is_ci() -> bool {
50    // Check for the generic CI variable first (most CI systems set this)
51    if std::env::var("CI")
52        .map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false")
53        .unwrap_or(false)
54    {
55        return true;
56    }
57
58    // Check for specific CI provider variables
59    CI_VARS.iter().any(|var| std::env::var(var).is_ok())
60}
61
62/// Manages approval of configurations before hook execution
63#[derive(Debug, Clone)]
64pub struct ApprovalManager {
65    approval_file: PathBuf,
66    approvals: HashMap<String, ApprovalRecord>,
67}
68
69impl ApprovalManager {
70    /// Create a new approval manager with specified approval file
71    #[must_use]
72    pub fn new(approval_file: PathBuf) -> Self {
73        Self {
74            approval_file,
75            approvals: HashMap::new(),
76        }
77    }
78
79    /// Get the default approval file path.
80    ///
81    /// Uses platform-appropriate paths:
82    /// - Linux: `~/.local/state/cuenv/approved.json`
83    /// - macOS: `~/Library/Application Support/cuenv/approved.json`
84    /// - Windows: `%APPDATA%\cuenv\approved.json`
85    ///
86    /// Can be overridden with `CUENV_APPROVAL_FILE` environment variable.
87    pub fn default_approval_file() -> Result<PathBuf> {
88        // Check for CUENV_APPROVAL_FILE environment variable first
89        if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE")
90            && !approval_file.is_empty()
91        {
92            return Ok(PathBuf::from(approval_file));
93        }
94
95        // Use platform-appropriate paths via dirs crate
96        let base = dirs::state_dir()
97            .or_else(dirs::data_dir)
98            .ok_or_else(|| Error::configuration("Could not determine state directory"))?;
99
100        Ok(base.join("cuenv").join("approved.json"))
101    }
102
103    /// Create an approval manager using the default approval file
104    pub fn with_default_file() -> Result<Self> {
105        Ok(Self::new(Self::default_approval_file()?))
106    }
107
108    /// Get approval for a specific directory
109    #[must_use]
110    pub fn get_approval(&self, directory: &str) -> Option<&ApprovalRecord> {
111        let path = PathBuf::from(directory);
112        let dir_key = compute_directory_key(&path);
113        self.approvals.get(&dir_key)
114    }
115
116    /// Load approvals from disk with file locking
117    pub async fn load_approvals(&mut self) -> Result<()> {
118        if !self.approval_file.exists() {
119            debug!("No approval file found at {}", self.approval_file.display());
120            return Ok(());
121        }
122
123        // Open file with shared lock for reading
124        let mut file = OpenOptions::new()
125            .read(true)
126            .open(&self.approval_file)
127            .await
128            .map_err(|e| Error::Io {
129                source: e,
130                path: Some(self.approval_file.clone().into_boxed_path()),
131                operation: "open".to_string(),
132            })?;
133
134        // Acquire shared lock (multiple readers allowed)
135        file.lock_shared().map_err(|e| {
136            Error::configuration(format!(
137                "Failed to acquire shared lock on approval file: {}",
138                e
139            ))
140        })?;
141
142        let mut contents = String::new();
143        file.read_to_string(&mut contents)
144            .await
145            .map_err(|e| Error::Io {
146                source: e,
147                path: Some(self.approval_file.clone().into_boxed_path()),
148                operation: "read_to_string".to_string(),
149            })?;
150
151        // Unlock happens automatically when file is dropped
152        drop(file);
153
154        self.approvals = serde_json::from_str(&contents)
155            .map_err(|e| Error::serialization(format!("Failed to parse approval file: {e}")))?;
156
157        info!("Loaded {} approvals from file", self.approvals.len());
158        Ok(())
159    }
160
161    /// Save approvals to disk with file locking
162    pub async fn save_approvals(&self) -> Result<()> {
163        // Validate and canonicalize the approval file path to prevent path traversal
164        let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
165
166        // Ensure parent directory exists
167        if let Some(parent) = canonical_path.parent()
168            && !parent.exists()
169        {
170            // Validate the parent directory path as well
171            let parent_path = validate_directory_path(parent)?;
172            fs::create_dir_all(&parent_path)
173                .await
174                .map_err(|e| Error::Io {
175                    source: e,
176                    path: Some(parent_path.into()),
177                    operation: "create_dir_all".to_string(),
178                })?;
179        }
180
181        let contents = serde_json::to_string_pretty(&self.approvals)
182            .map_err(|e| Error::serialization(format!("Failed to serialize approvals: {e}")))?;
183
184        // Write to a temporary file first, then rename atomically
185        let temp_path = canonical_path.with_extension("tmp");
186
187        // Open temp file with exclusive lock for writing
188        let mut file = OpenOptions::new()
189            .write(true)
190            .create(true)
191            .truncate(true)
192            .open(&temp_path)
193            .await
194            .map_err(|e| Error::Io {
195                source: e,
196                path: Some(temp_path.clone().into_boxed_path()),
197                operation: "open".to_string(),
198            })?;
199
200        // Acquire exclusive lock (only one writer allowed)
201        file.lock_exclusive().map_err(|e| {
202            Error::configuration(format!(
203                "Failed to acquire exclusive lock on temp file: {}",
204                e
205            ))
206        })?;
207
208        file.write_all(contents.as_bytes())
209            .await
210            .map_err(|e| Error::Io {
211                source: e,
212                path: Some(temp_path.clone().into_boxed_path()),
213                operation: "write_all".to_string(),
214            })?;
215
216        file.sync_all().await.map_err(|e| Error::Io {
217            source: e,
218            path: Some(temp_path.clone().into_boxed_path()),
219            operation: "sync_all".to_string(),
220        })?;
221
222        // Unlock happens automatically when file is dropped
223        drop(file);
224
225        // Atomically rename temp file to final location
226        fs::rename(&temp_path, &canonical_path)
227            .await
228            .map_err(|e| Error::Io {
229                source: e,
230                path: Some(canonical_path.clone().into_boxed_path()),
231                operation: "rename".to_string(),
232            })?;
233
234        debug!("Saved {} approvals to file", self.approvals.len());
235        Ok(())
236    }
237
238    /// Check if a configuration is approved for a specific directory
239    pub fn is_approved(&self, directory_path: &Path, config_hash: &str) -> Result<bool> {
240        let dir_key = compute_directory_key(directory_path);
241
242        if let Some(approval) = self.approvals.get(&dir_key)
243            && approval.config_hash == config_hash
244        {
245            // Check if approval hasn't expired
246            if let Some(expires_at) = approval.expires_at
247                && Utc::now() > expires_at
248            {
249                warn!("Approval for {} has expired", directory_path.display());
250                return Ok(false);
251            }
252            return Ok(true);
253        }
254
255        Ok(false)
256    }
257
258    /// Approve a configuration for a specific directory
259    pub async fn approve_config(
260        &mut self,
261        directory_path: &Path,
262        config_hash: String,
263        note: Option<String>,
264    ) -> Result<()> {
265        let dir_key = compute_directory_key(directory_path);
266        let approval = ApprovalRecord {
267            directory_path: directory_path.to_path_buf(),
268            config_hash,
269            approved_at: Utc::now(),
270            expires_at: None, // No expiration by default
271            note,
272        };
273
274        self.approvals.insert(dir_key, approval);
275        self.save_approvals().await?;
276
277        info!(
278            "Approved configuration for directory: {}",
279            directory_path.display()
280        );
281        Ok(())
282    }
283
284    /// Revoke approval for a directory
285    pub async fn revoke_approval(&mut self, directory_path: &Path) -> Result<bool> {
286        let dir_key = compute_directory_key(directory_path);
287
288        if self.approvals.remove(&dir_key).is_some() {
289            self.save_approvals().await?;
290            info!(
291                "Revoked approval for directory: {}",
292                directory_path.display()
293            );
294            Ok(true)
295        } else {
296            Ok(false)
297        }
298    }
299
300    /// List all approved directories
301    #[must_use]
302    pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
303        self.approvals.values().collect()
304    }
305
306    /// Clean up expired approvals
307    pub async fn cleanup_expired(&mut self) -> Result<usize> {
308        let now = Utc::now();
309        let initial_count = self.approvals.len();
310
311        self.approvals.retain(|_, approval| {
312            if let Some(expires_at) = approval.expires_at {
313                expires_at > now
314            } else {
315                true // Keep approvals without expiration
316            }
317        });
318
319        let removed_count = initial_count - self.approvals.len();
320        if removed_count > 0 {
321            self.save_approvals().await?;
322            info!("Cleaned up {} expired approvals", removed_count);
323        }
324
325        Ok(removed_count)
326    }
327
328    /// Check if the approvals map contains a specific directory key
329    #[must_use]
330    pub fn contains_key(&self, directory_path: &Path) -> bool {
331        let dir_key = compute_directory_key(directory_path);
332        self.approvals.contains_key(&dir_key)
333    }
334}
335
336/// Record of an approved configuration
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct ApprovalRecord {
339    /// Path to the directory
340    pub directory_path: PathBuf,
341    /// Hash of the approved configuration
342    pub config_hash: String,
343    /// When this approval was granted
344    pub approved_at: DateTime<Utc>,
345    /// Optional expiration time
346    pub expires_at: Option<DateTime<Utc>>,
347    /// Optional note about this approval
348    pub note: Option<String>,
349}
350
351/// Status of approval check for a configuration
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum ApprovalStatus {
354    /// Configuration is approved and can be executed
355    Approved,
356    /// Configuration has changed and requires new approval
357    RequiresApproval {
358        /// The hash of the current configuration that needs approval
359        current_hash: String,
360    },
361    /// Configuration is not approved
362    NotApproved {
363        /// The hash of the current configuration that is not approved
364        current_hash: String,
365    },
366}
367
368#[derive(Debug, Serialize)]
369struct ApprovalHashInput {
370    #[serde(skip_serializing_if = "Option::is_none")]
371    hooks: Option<HooksForHash>,
372}
373
374#[derive(Debug, Serialize)]
375struct HooksForHash {
376    #[serde(skip_serializing_if = "Option::is_none", rename = "onEnter")]
377    on_enter: Option<BTreeMap<String, Hook>>,
378    #[serde(skip_serializing_if = "Option::is_none", rename = "onExit")]
379    on_exit: Option<BTreeMap<String, Hook>>,
380    #[serde(skip_serializing_if = "Option::is_none", rename = "prePush")]
381    pre_push: Option<BTreeMap<String, Hook>>,
382}
383
384impl HooksForHash {
385    fn from_hooks(hooks: &Hooks) -> Self {
386        Self {
387            on_enter: hooks.on_enter.as_ref().map(sorted_hooks_map),
388            on_exit: hooks.on_exit.as_ref().map(sorted_hooks_map),
389            pre_push: hooks.pre_push.as_ref().map(sorted_hooks_map),
390        }
391    }
392}
393
394fn sorted_hooks_map(map: &HashMap<String, Hook>) -> BTreeMap<String, Hook> {
395    map.iter()
396        .map(|(name, hook)| (name.clone(), hook.clone()))
397        .collect()
398}
399
400/// Check the approval status for a configuration.
401///
402/// In CI environments (detected via [`is_ci`]), hooks are always auto-approved
403/// since CI environments are typically non-interactive and already secured.
404pub fn check_approval_status(
405    manager: &ApprovalManager,
406    directory_path: &Path,
407    hooks: Option<&Hooks>,
408) -> Result<ApprovalStatus> {
409    // Auto-approve in CI environments - they are non-interactive and already secured
410    if is_ci() {
411        debug!(
412            "Auto-approving hooks in CI environment for {}",
413            directory_path.display()
414        );
415        return Ok(ApprovalStatus::Approved);
416    }
417
418    check_approval_status_core(manager, directory_path, hooks)
419}
420
421/// Core approval logic without CI bypass.
422///
423/// This function contains the actual approval checking logic and is used by tests
424/// to verify behavior without CI environment interference.
425fn check_approval_status_core(
426    manager: &ApprovalManager,
427    directory_path: &Path,
428    hooks: Option<&Hooks>,
429) -> Result<ApprovalStatus> {
430    let current_hash = compute_approval_hash(hooks);
431
432    if manager.is_approved(directory_path, &current_hash)? {
433        Ok(ApprovalStatus::Approved)
434    } else {
435        // Check if there's an existing approval with a different hash
436        if manager.contains_key(directory_path) {
437            Ok(ApprovalStatus::RequiresApproval { current_hash })
438        } else {
439            Ok(ApprovalStatus::NotApproved { current_hash })
440        }
441    }
442}
443
444/// Compute a hash for approval based only on security-sensitive hooks.
445///
446/// Only onEnter, onExit, and prePush hooks are included since they execute arbitrary commands.
447/// Changes to env vars, tasks, config settings do NOT require re-approval.
448#[must_use]
449pub fn compute_approval_hash(hooks: Option<&Hooks>) -> String {
450    let mut hasher = Sha256::new();
451
452    // Extract only the hooks portion for hashing
453    // Treat empty hooks the same as no hooks for consistent hashing
454    let hooks_for_hash = hooks.and_then(|h| {
455        let hfh = HooksForHash::from_hooks(h);
456        // If all fields are None, treat as no hooks
457        if hfh.on_enter.is_none() && hfh.on_exit.is_none() && hfh.pre_push.is_none() {
458            None
459        } else {
460            Some(hfh)
461        }
462    });
463    let hooks_only = ApprovalHashInput {
464        hooks: hooks_for_hash,
465    };
466    let canonical = serde_json::to_string(&hooks_only).unwrap_or_default();
467    hasher.update(canonical.as_bytes());
468
469    format!("{:x}", hasher.finalize())[..16].to_string()
470}
471
472/// Compute a directory key for the approvals map
473#[must_use]
474pub fn compute_directory_key(path: &Path) -> String {
475    // Try to canonicalize the path for consistency
476    // If canonicalization fails (e.g., path doesn't exist), use the path as-is
477    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
478
479    let mut hasher = Sha256::new();
480    hasher.update(canonical_path.to_string_lossy().as_bytes());
481    format!("{:x}", hasher.finalize())[..16].to_string()
482}
483
484/// Validate and canonicalize a path to prevent path traversal attacks
485fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
486    // All path components are allowed - parent directory references and prefixes
487    // are resolved through canonicalization, and we validate the final result
488    for component in path.components() {
489        match component {
490            // All component types are valid for input paths:
491            // - Normal: regular path segments
492            // - RootDir/CurDir: explicit root or current directory
493            // - ParentDir: resolved through canonicalization
494            // - Prefix: Windows drive prefixes
495            Component::Normal(_)
496            | Component::RootDir
497            | Component::CurDir
498            | Component::ParentDir
499            | Component::Prefix(_) => {}
500        }
501    }
502
503    // If the path exists, canonicalize it
504    if path.exists() {
505        std::fs::canonicalize(path)
506            .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
507    } else {
508        // For non-existent paths, validate the parent and construct the canonical path
509        if let Some(parent) = path.parent() {
510            if parent.exists() {
511                let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
512                    Error::configuration(format!("Failed to canonicalize parent path: {}", e))
513                })?;
514                if let Some(file_name) = path.file_name() {
515                    Ok(canonical_parent.join(file_name))
516                } else {
517                    Err(Error::configuration("Invalid file path"))
518                }
519            } else {
520                // Parent doesn't exist, but we can still validate the path structure
521                validate_path_structure(path)?;
522                Ok(path.to_path_buf())
523            }
524        } else {
525            validate_path_structure(path)?;
526            Ok(path.to_path_buf())
527        }
528    }
529}
530
531/// Validate directory path for creation
532fn validate_directory_path(path: &Path) -> Result<PathBuf> {
533    // Check for suspicious patterns
534    validate_path_structure(path)?;
535
536    // Return the path as-is if validation passes
537    Ok(path.to_path_buf())
538}
539
540/// Validate path structure for security
541fn validate_path_structure(path: &Path) -> Result<()> {
542    let path_str = path.to_string_lossy();
543
544    // Check for null bytes
545    if path_str.contains('\0') {
546        return Err(Error::configuration("Path contains null bytes"));
547    }
548
549    // Check for suspicious patterns that might indicate path traversal attempts
550    let suspicious_patterns = [
551        "../../../",    // Multiple parent directory traversals
552        "..\\..\\..\\", // Windows-style traversals
553        "%2e%2e",       // URL-encoded parent directory
554        "..;/",         // Semicolon injection
555    ];
556
557    for pattern in &suspicious_patterns {
558        if path_str.contains(pattern) {
559            return Err(Error::configuration(format!(
560                "Path contains suspicious pattern: {}",
561                pattern
562            )));
563        }
564    }
565
566    Ok(())
567}
568
569/// Summary of hook counts for display
570#[derive(Debug, Clone)]
571pub struct ConfigSummary {
572    /// Whether any hooks are defined
573    pub has_hooks: bool,
574    /// Total number of hooks across all hook types
575    pub hook_count: usize,
576}
577
578impl ConfigSummary {
579    /// Create a summary from hooks
580    #[must_use]
581    pub fn from_hooks(hooks: Option<&Hooks>) -> Self {
582        let mut summary = Self {
583            has_hooks: false,
584            hook_count: 0,
585        };
586
587        if let Some(hooks) = hooks {
588            let on_enter_count = hooks.on_enter.as_ref().map_or(0, |map| map.len());
589            let on_exit_count = hooks.on_exit.as_ref().map_or(0, |map| map.len());
590            let pre_push_count = hooks.pre_push.as_ref().map_or(0, |map| map.len());
591            summary.hook_count = on_enter_count + on_exit_count + pre_push_count;
592            summary.has_hooks = summary.hook_count > 0;
593        }
594
595        summary
596    }
597
598    /// Get a human-readable description of the hooks
599    #[must_use]
600    pub fn description(&self) -> String {
601        if !self.has_hooks {
602            "no hooks".to_string()
603        } else if self.hook_count == 1 {
604            "1 hook".to_string()
605        } else {
606            format!("{} hooks", self.hook_count)
607        }
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use tempfile::TempDir;
615
616    fn make_hook(command: &str, args: &[&str]) -> Hook {
617        Hook {
618            order: 100,
619            propagate: false,
620            command: command.to_string(),
621            args: args.iter().map(|arg| (*arg).to_string()).collect(),
622            dir: None,
623            inputs: vec![],
624            source: None,
625        }
626    }
627
628    #[tokio::test]
629    async fn test_approval_manager_operations() {
630        let temp_dir = TempDir::new().unwrap();
631        let approval_file = temp_dir.path().join("approvals.json");
632        let mut manager = ApprovalManager::new(approval_file);
633
634        let directory = Path::new("/test/directory");
635        let config_hash = "test_hash_123".to_string();
636
637        // Initially not approved
638        assert!(!manager.is_approved(directory, &config_hash).unwrap());
639
640        // Approve configuration
641        manager
642            .approve_config(
643                directory,
644                config_hash.clone(),
645                Some("Test approval".to_string()),
646            )
647            .await
648            .unwrap();
649
650        // Should now be approved
651        assert!(manager.is_approved(directory, &config_hash).unwrap());
652
653        // Different hash should not be approved
654        assert!(!manager.is_approved(directory, "different_hash").unwrap());
655
656        // Test persistence
657        let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
658        manager2.load_approvals().await.unwrap();
659        assert!(manager2.is_approved(directory, &config_hash).unwrap());
660
661        // Revoke approval
662        let revoked = manager2.revoke_approval(directory).await.unwrap();
663        assert!(revoked);
664        assert!(!manager2.is_approved(directory, &config_hash).unwrap());
665    }
666
667    #[test]
668    fn test_approval_hash_consistency() {
669        // Same hooks should produce same hash
670        let mut hooks_map = HashMap::new();
671        hooks_map.insert("setup".to_string(), make_hook("echo", &["hello"]));
672        let hooks = Hooks {
673            on_enter: Some(hooks_map.clone()),
674            on_exit: None,
675            pre_push: None,
676        };
677
678        let hash1 = compute_approval_hash(Some(&hooks));
679        let hash2 = compute_approval_hash(Some(&hooks));
680        assert_eq!(hash1, hash2, "Same hooks should produce same hash");
681
682        // Different hooks should produce different hash
683        let mut hooks_map2 = HashMap::new();
684        hooks_map2.insert("setup".to_string(), make_hook("echo", &["world"]));
685        let hooks2 = Hooks {
686            on_enter: Some(hooks_map2),
687            on_exit: None,
688            pre_push: None,
689        };
690
691        let hash3 = compute_approval_hash(Some(&hooks2));
692        assert_ne!(
693            hash1, hash3,
694            "Different hooks should produce different hash"
695        );
696    }
697
698    #[test]
699    fn test_approval_hash_no_hooks() {
700        // Configs without hooks should produce consistent hash
701        let hash1 = compute_approval_hash(None);
702        let hash2 = compute_approval_hash(None);
703        assert_eq!(hash1, hash2, "No hooks should produce consistent hash");
704
705        // Empty hooks should be same as no hooks
706        let empty_hooks = Hooks {
707            on_enter: None,
708            on_exit: None,
709            pre_push: None,
710        };
711        let hash3 = compute_approval_hash(Some(&empty_hooks));
712        assert_eq!(hash1, hash3, "Empty hooks should be same as no hooks");
713    }
714
715    #[test]
716    fn test_config_summary() {
717        let mut on_enter = HashMap::new();
718        on_enter.insert("npm".to_string(), make_hook("npm", &["install"]));
719        on_enter.insert(
720            "docker".to_string(),
721            make_hook("docker-compose", &["up", "-d"]),
722        );
723
724        let mut on_exit = HashMap::new();
725        on_exit.insert("docker".to_string(), make_hook("docker-compose", &["down"]));
726
727        let hooks = Hooks {
728            on_enter: Some(on_enter),
729            on_exit: Some(on_exit),
730            pre_push: None,
731        };
732
733        let summary = ConfigSummary::from_hooks(Some(&hooks));
734        assert!(summary.has_hooks);
735        assert_eq!(summary.hook_count, 3);
736
737        let description = summary.description();
738        assert!(description.contains("3 hooks"));
739    }
740
741    #[test]
742    fn test_approval_status() {
743        let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
744        let directory = Path::new("/test/dir");
745        let hooks = Hooks {
746            on_enter: None,
747            on_exit: None,
748            pre_push: None,
749        };
750
751        let status = check_approval_status_core(&manager, directory, Some(&hooks)).unwrap();
752        assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
753
754        // Add an approval with a different hash
755        let different_hash = "different_hash".to_string();
756        manager.approvals.insert(
757            compute_directory_key(directory),
758            ApprovalRecord {
759                directory_path: directory.to_path_buf(),
760                config_hash: different_hash,
761                approved_at: Utc::now(),
762                expires_at: None,
763                note: None,
764            },
765        );
766
767        let status = check_approval_status_core(&manager, directory, Some(&hooks)).unwrap();
768        assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
769
770        // Add approval with correct hash
771        let correct_hash = compute_approval_hash(Some(&hooks));
772        manager.approvals.insert(
773            compute_directory_key(directory),
774            ApprovalRecord {
775                directory_path: directory.to_path_buf(),
776                config_hash: correct_hash,
777                approved_at: Utc::now(),
778                expires_at: None,
779                note: None,
780            },
781        );
782
783        let status = check_approval_status_core(&manager, directory, Some(&hooks)).unwrap();
784        assert!(matches!(status, ApprovalStatus::Approved));
785    }
786
787    #[test]
788    fn test_path_validation() {
789        // Test valid paths
790        assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
791        assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
792        assert!(validate_path_structure(Path::new("file.txt")).is_ok());
793
794        // Test paths with null bytes (should fail)
795        let path_with_null = PathBuf::from("/test\0/path");
796        assert!(validate_path_structure(&path_with_null).is_err());
797
798        // Test paths with multiple parent directory traversals (should fail)
799        assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
800        assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
801
802        // Test URL-encoded traversals (should fail)
803        assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
804
805        // Test semicolon injection (should fail)
806        assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
807    }
808
809    #[test]
810    fn test_validate_and_canonicalize_path() {
811        let temp_dir = TempDir::new().unwrap();
812        let test_file = temp_dir.path().join("test.txt");
813        std::fs::write(&test_file, "test").unwrap();
814
815        // Test existing file canonicalization
816        let result = validate_and_canonicalize_path(&test_file).unwrap();
817        assert!(result.is_absolute());
818        assert!(result.exists());
819
820        // Test non-existent file in existing directory
821        let new_file = temp_dir.path().join("new_file.txt");
822        let result = validate_and_canonicalize_path(&new_file).unwrap();
823        assert!(result.ends_with("new_file.txt"));
824
825        // Test validation with parent directory that exists
826        let nested_new = temp_dir.path().join("subdir/newfile.txt");
827        let result = validate_and_canonicalize_path(&nested_new);
828        assert!(result.is_ok()); // Should succeed even though parent doesn't exist yet
829    }
830
831    #[tokio::test]
832    async fn test_approval_file_corruption_recovery() {
833        let temp_dir = TempDir::new().unwrap();
834        let approval_file = temp_dir.path().join("approvals.json");
835
836        // Write corrupted JSON to the approval file
837        std::fs::write(&approval_file, "{invalid json}").unwrap();
838
839        let mut manager = ApprovalManager::new(approval_file.clone());
840
841        // Loading should fail due to corrupted JSON
842        let result = manager.load_approvals().await;
843        assert!(
844            result.is_err(),
845            "Expected error when loading corrupted JSON"
846        );
847
848        // Manager should still be usable with empty approvals
849        assert_eq!(manager.approvals.len(), 0);
850
851        // Should be able to save new approvals
852        let directory = Path::new("/test/dir");
853        manager
854            .approve_config(directory, "test_hash".to_string(), None)
855            .await
856            .unwrap();
857
858        // New manager should be able to load the fixed file
859        let mut manager2 = ApprovalManager::new(approval_file);
860        manager2.load_approvals().await.unwrap();
861        assert_eq!(manager2.approvals.len(), 1);
862    }
863
864    #[tokio::test]
865    async fn test_approval_expiration() {
866        let temp_dir = TempDir::new().unwrap();
867        let approval_file = temp_dir.path().join("approvals.json");
868        let mut manager = ApprovalManager::new(approval_file);
869
870        let directory = Path::new("/test/expire");
871        let config_hash = "expire_hash".to_string();
872
873        // Add an expired approval
874        let expired_approval = ApprovalRecord {
875            directory_path: directory.to_path_buf(),
876            config_hash: config_hash.clone(),
877            approved_at: Utc::now() - chrono::Duration::hours(2),
878            expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
879            note: Some("Expired approval".to_string()),
880        };
881
882        manager
883            .approvals
884            .insert(compute_directory_key(directory), expired_approval);
885
886        // Should not be approved due to expiration
887        assert!(!manager.is_approved(directory, &config_hash).unwrap());
888
889        // Cleanup should remove expired approval
890        let removed = manager.cleanup_expired().await.unwrap();
891        assert_eq!(removed, 1);
892        assert_eq!(manager.approvals.len(), 0);
893    }
894
895    #[test]
896    fn test_is_ci_with_ci_env_var() {
897        // Test with CI=true
898        temp_env::with_var("CI", Some("true"), || {
899            assert!(is_ci());
900        });
901
902        // Test with CI=1
903        temp_env::with_var("CI", Some("1"), || {
904            assert!(is_ci());
905        });
906
907        // Test with CI=yes (any non-empty, non-false value)
908        temp_env::with_var("CI", Some("yes"), || {
909            assert!(is_ci());
910        });
911
912        // Test with CI=false (should NOT be detected as CI)
913        temp_env::with_var("CI", Some("false"), || {
914            // Clear other CI vars to isolate the test
915            temp_env::with_vars_unset(
916                vec![
917                    "GITHUB_ACTIONS",
918                    "GITLAB_CI",
919                    "BUILDKITE",
920                    "JENKINS_URL",
921                    "CIRCLECI",
922                    "TRAVIS",
923                    "BITBUCKET_PIPELINES",
924                    "AZURE_PIPELINES",
925                    "TF_BUILD",
926                    "DRONE",
927                    "TEAMCITY_VERSION",
928                ],
929                || {
930                    assert!(!is_ci());
931                },
932            );
933        });
934
935        // Test with CI=0 (should NOT be detected as CI)
936        temp_env::with_var("CI", Some("0"), || {
937            temp_env::with_vars_unset(
938                vec![
939                    "GITHUB_ACTIONS",
940                    "GITLAB_CI",
941                    "BUILDKITE",
942                    "JENKINS_URL",
943                    "CIRCLECI",
944                    "TRAVIS",
945                    "BITBUCKET_PIPELINES",
946                    "AZURE_PIPELINES",
947                    "TF_BUILD",
948                    "DRONE",
949                    "TEAMCITY_VERSION",
950                ],
951                || {
952                    assert!(!is_ci());
953                },
954            );
955        });
956    }
957
958    #[test]
959    fn test_is_ci_with_provider_specific_vars() {
960        // Test GitHub Actions
961        temp_env::with_var_unset("CI", || {
962            temp_env::with_var("GITHUB_ACTIONS", Some("true"), || {
963                assert!(is_ci());
964            });
965        });
966
967        // Test GitLab CI
968        temp_env::with_var_unset("CI", || {
969            temp_env::with_var("GITLAB_CI", Some("true"), || {
970                assert!(is_ci());
971            });
972        });
973
974        // Test Buildkite
975        temp_env::with_var_unset("CI", || {
976            temp_env::with_var("BUILDKITE", Some("true"), || {
977                assert!(is_ci());
978            });
979        });
980
981        // Test Jenkins
982        temp_env::with_var_unset("CI", || {
983            temp_env::with_var("JENKINS_URL", Some("http://jenkins.example.com"), || {
984                assert!(is_ci());
985            });
986        });
987    }
988
989    #[test]
990    fn test_is_ci_not_detected() {
991        // Clear all CI-related environment variables
992        temp_env::with_vars_unset(
993            vec![
994                "CI",
995                "GITHUB_ACTIONS",
996                "GITLAB_CI",
997                "BUILDKITE",
998                "JENKINS_URL",
999                "CIRCLECI",
1000                "TRAVIS",
1001                "BITBUCKET_PIPELINES",
1002                "AZURE_PIPELINES",
1003                "TF_BUILD",
1004                "DRONE",
1005                "TEAMCITY_VERSION",
1006            ],
1007            || {
1008                assert!(!is_ci());
1009            },
1010        );
1011    }
1012
1013    #[test]
1014    fn test_approval_status_auto_approved_in_ci() {
1015        let manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
1016        let directory = Path::new("/test/ci_dir");
1017
1018        // Create hooks that would normally require approval
1019        let mut hooks_map = HashMap::new();
1020        hooks_map.insert("setup".to_string(), make_hook("echo", &["hello"]));
1021
1022        let hooks = Hooks {
1023            on_enter: Some(hooks_map),
1024            on_exit: None,
1025            pre_push: None,
1026        };
1027
1028        // In CI environment, should be auto-approved
1029        temp_env::with_var("CI", Some("true"), || {
1030            let status = check_approval_status(&manager, directory, Some(&hooks)).unwrap();
1031            assert!(
1032                matches!(status, ApprovalStatus::Approved),
1033                "Hooks should be auto-approved in CI"
1034            );
1035        });
1036
1037        // Outside CI environment, should require approval
1038        temp_env::with_vars_unset(
1039            vec![
1040                "CI",
1041                "GITHUB_ACTIONS",
1042                "GITLAB_CI",
1043                "BUILDKITE",
1044                "JENKINS_URL",
1045                "CIRCLECI",
1046                "TRAVIS",
1047                "BITBUCKET_PIPELINES",
1048                "AZURE_PIPELINES",
1049                "TF_BUILD",
1050                "DRONE",
1051                "TEAMCITY_VERSION",
1052            ],
1053            || {
1054                let status = check_approval_status(&manager, directory, Some(&hooks)).unwrap();
1055                assert!(
1056                    matches!(status, ApprovalStatus::NotApproved { .. }),
1057                    "Hooks should require approval outside CI"
1058                );
1059            },
1060        );
1061    }
1062}