1use 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
16const 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#[must_use]
49pub fn is_ci() -> bool {
50 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 CI_VARS.iter().any(|var| std::env::var(var).is_ok())
60}
61
62#[derive(Debug, Clone)]
64pub struct ApprovalManager {
65 approval_file: PathBuf,
66 approvals: HashMap<String, ApprovalRecord>,
67}
68
69impl ApprovalManager {
70 #[must_use]
72 pub fn new(approval_file: PathBuf) -> Self {
73 Self {
74 approval_file,
75 approvals: HashMap::new(),
76 }
77 }
78
79 pub fn default_approval_file() -> Result<PathBuf> {
88 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 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 pub fn with_default_file() -> Result<Self> {
105 Ok(Self::new(Self::default_approval_file()?))
106 }
107
108 #[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 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 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 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 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 pub async fn save_approvals(&self) -> Result<()> {
163 let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
165
166 if let Some(parent) = canonical_path.parent()
168 && !parent.exists()
169 {
170 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 let temp_path = canonical_path.with_extension("tmp");
186
187 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 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 drop(file);
224
225 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 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 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 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, 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 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 #[must_use]
302 pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
303 self.approvals.values().collect()
304 }
305
306 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 }
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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct ApprovalRecord {
339 pub directory_path: PathBuf,
341 pub config_hash: String,
343 pub approved_at: DateTime<Utc>,
345 pub expires_at: Option<DateTime<Utc>>,
347 pub note: Option<String>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum ApprovalStatus {
354 Approved,
356 RequiresApproval {
358 current_hash: String,
360 },
361 NotApproved {
363 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
400pub fn check_approval_status(
405 manager: &ApprovalManager,
406 directory_path: &Path,
407 hooks: Option<&Hooks>,
408) -> Result<ApprovalStatus> {
409 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
421fn 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, ¤t_hash)? {
433 Ok(ApprovalStatus::Approved)
434 } else {
435 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#[must_use]
449pub fn compute_approval_hash(hooks: Option<&Hooks>) -> String {
450 let mut hasher = Sha256::new();
451
452 let hooks_for_hash = hooks.and_then(|h| {
455 let hfh = HooksForHash::from_hooks(h);
456 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#[must_use]
474pub fn compute_directory_key(path: &Path) -> String {
475 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
484fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
486 for component in path.components() {
489 match component {
490 Component::Normal(_)
496 | Component::RootDir
497 | Component::CurDir
498 | Component::ParentDir
499 | Component::Prefix(_) => {}
500 }
501 }
502
503 if path.exists() {
505 std::fs::canonicalize(path)
506 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
507 } else {
508 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 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
531fn validate_directory_path(path: &Path) -> Result<PathBuf> {
533 validate_path_structure(path)?;
535
536 Ok(path.to_path_buf())
538}
539
540fn validate_path_structure(path: &Path) -> Result<()> {
542 let path_str = path.to_string_lossy();
543
544 if path_str.contains('\0') {
546 return Err(Error::configuration("Path contains null bytes"));
547 }
548
549 let suspicious_patterns = [
551 "../../../", "..\\..\\..\\", "%2e%2e", "..;/", ];
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#[derive(Debug, Clone)]
571pub struct ConfigSummary {
572 pub has_hooks: bool,
574 pub hook_count: usize,
576}
577
578impl ConfigSummary {
579 #[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 #[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 assert!(!manager.is_approved(directory, &config_hash).unwrap());
639
640 manager
642 .approve_config(
643 directory,
644 config_hash.clone(),
645 Some("Test approval".to_string()),
646 )
647 .await
648 .unwrap();
649
650 assert!(manager.is_approved(directory, &config_hash).unwrap());
652
653 assert!(!manager.is_approved(directory, "different_hash").unwrap());
655
656 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 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 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 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 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 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 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 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 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 let path_with_null = PathBuf::from("/test\0/path");
796 assert!(validate_path_structure(&path_with_null).is_err());
797
798 assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
800 assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
801
802 assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
804
805 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 let result = validate_and_canonicalize_path(&test_file).unwrap();
817 assert!(result.is_absolute());
818 assert!(result.exists());
819
820 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 let nested_new = temp_dir.path().join("subdir/newfile.txt");
827 let result = validate_and_canonicalize_path(&nested_new);
828 assert!(result.is_ok()); }
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 std::fs::write(&approval_file, "{invalid json}").unwrap();
838
839 let mut manager = ApprovalManager::new(approval_file.clone());
840
841 let result = manager.load_approvals().await;
843 assert!(
844 result.is_err(),
845 "Expected error when loading corrupted JSON"
846 );
847
848 assert_eq!(manager.approvals.len(), 0);
850
851 let directory = Path::new("/test/dir");
853 manager
854 .approve_config(directory, "test_hash".to_string(), None)
855 .await
856 .unwrap();
857
858 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 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 assert!(!manager.is_approved(directory, &config_hash).unwrap());
888
889 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 temp_env::with_var("CI", Some("true"), || {
899 assert!(is_ci());
900 });
901
902 temp_env::with_var("CI", Some("1"), || {
904 assert!(is_ci());
905 });
906
907 temp_env::with_var("CI", Some("yes"), || {
909 assert!(is_ci());
910 });
911
912 temp_env::with_var("CI", Some("false"), || {
914 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 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 temp_env::with_var_unset("CI", || {
962 temp_env::with_var("GITHUB_ACTIONS", Some("true"), || {
963 assert!(is_ci());
964 });
965 });
966
967 temp_env::with_var_unset("CI", || {
969 temp_env::with_var("GITLAB_CI", Some("true"), || {
970 assert!(is_ci());
971 });
972 });
973
974 temp_env::with_var_unset("CI", || {
976 temp_env::with_var("BUILDKITE", Some("true"), || {
977 assert!(is_ci());
978 });
979 });
980
981 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 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 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 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 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}