1use crate::{Error, Result};
4use chrono::{DateTime, Utc};
5use fs4::tokio::AsyncFileExt;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::path::{Component, Path, PathBuf};
11use tokio::fs;
12use tokio::fs::OpenOptions;
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tracing::{debug, info, warn};
15
16#[derive(Debug, Clone)]
18pub struct ApprovalManager {
19 approval_file: PathBuf,
20 approvals: HashMap<String, ApprovalRecord>,
21}
22
23impl ApprovalManager {
24 pub fn new(approval_file: PathBuf) -> Self {
26 Self {
27 approval_file,
28 approvals: HashMap::new(),
29 }
30 }
31
32 pub fn default_approval_file() -> Result<PathBuf> {
41 if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE")
43 && !approval_file.is_empty()
44 {
45 return Ok(PathBuf::from(approval_file));
46 }
47
48 crate::paths::approvals_file()
49 }
50
51 pub fn with_default_file() -> Result<Self> {
53 Ok(Self::new(Self::default_approval_file()?))
54 }
55
56 pub fn get_approval(&self, directory: &str) -> Option<&ApprovalRecord> {
58 let path = PathBuf::from(directory);
59 let dir_key = compute_directory_key(&path);
60 self.approvals.get(&dir_key)
61 }
62
63 pub async fn load_approvals(&mut self) -> Result<()> {
65 if !self.approval_file.exists() {
66 debug!("No approval file found at {}", self.approval_file.display());
67 return Ok(());
68 }
69
70 let mut file = OpenOptions::new()
72 .read(true)
73 .open(&self.approval_file)
74 .await
75 .map_err(|e| Error::Io {
76 source: e,
77 path: Some(self.approval_file.clone().into_boxed_path()),
78 operation: "open".to_string(),
79 })?;
80
81 file.lock_shared().map_err(|e| {
83 Error::configuration(format!(
84 "Failed to acquire shared lock on approval file: {}",
85 e
86 ))
87 })?;
88
89 let mut contents = String::new();
90 file.read_to_string(&mut contents)
91 .await
92 .map_err(|e| Error::Io {
93 source: e,
94 path: Some(self.approval_file.clone().into_boxed_path()),
95 operation: "read_to_string".to_string(),
96 })?;
97
98 drop(file);
100
101 self.approvals = serde_json::from_str(&contents)
102 .map_err(|e| Error::configuration(format!("Failed to parse approval file: {e}")))?;
103
104 info!("Loaded {} approvals from file", self.approvals.len());
105 Ok(())
106 }
107
108 pub async fn save_approvals(&self) -> Result<()> {
110 let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
112
113 if let Some(parent) = canonical_path.parent()
115 && !parent.exists()
116 {
117 let parent_path = validate_directory_path(parent)?;
119 fs::create_dir_all(&parent_path)
120 .await
121 .map_err(|e| Error::Io {
122 source: e,
123 path: Some(parent_path.into()),
124 operation: "create_dir_all".to_string(),
125 })?;
126 }
127
128 let contents = serde_json::to_string_pretty(&self.approvals)
129 .map_err(|e| Error::configuration(format!("Failed to serialize approvals: {e}")))?;
130
131 let temp_path = canonical_path.with_extension("tmp");
133
134 let mut file = OpenOptions::new()
136 .write(true)
137 .create(true)
138 .truncate(true)
139 .open(&temp_path)
140 .await
141 .map_err(|e| Error::Io {
142 source: e,
143 path: Some(temp_path.clone().into_boxed_path()),
144 operation: "open".to_string(),
145 })?;
146
147 file.lock_exclusive().map_err(|e| {
149 Error::configuration(format!(
150 "Failed to acquire exclusive lock on temp file: {}",
151 e
152 ))
153 })?;
154
155 file.write_all(contents.as_bytes())
156 .await
157 .map_err(|e| Error::Io {
158 source: e,
159 path: Some(temp_path.clone().into_boxed_path()),
160 operation: "write_all".to_string(),
161 })?;
162
163 file.sync_all().await.map_err(|e| Error::Io {
164 source: e,
165 path: Some(temp_path.clone().into_boxed_path()),
166 operation: "sync_all".to_string(),
167 })?;
168
169 drop(file);
171
172 fs::rename(&temp_path, &canonical_path)
174 .await
175 .map_err(|e| Error::Io {
176 source: e,
177 path: Some(canonical_path.clone().into_boxed_path()),
178 operation: "rename".to_string(),
179 })?;
180
181 debug!("Saved {} approvals to file", self.approvals.len());
182 Ok(())
183 }
184
185 pub fn is_approved(&self, directory_path: &Path, config_hash: &str) -> Result<bool> {
187 let dir_key = compute_directory_key(directory_path);
188
189 if let Some(approval) = self.approvals.get(&dir_key)
190 && approval.config_hash == config_hash
191 {
192 if let Some(expires_at) = approval.expires_at
194 && Utc::now() > expires_at
195 {
196 warn!("Approval for {} has expired", directory_path.display());
197 return Ok(false);
198 }
199 return Ok(true);
200 }
201
202 Ok(false)
203 }
204
205 pub async fn approve_config(
207 &mut self,
208 directory_path: &Path,
209 config_hash: String,
210 note: Option<String>,
211 ) -> Result<()> {
212 let dir_key = compute_directory_key(directory_path);
213 let approval = ApprovalRecord {
214 directory_path: directory_path.to_path_buf(),
215 config_hash,
216 approved_at: Utc::now(),
217 expires_at: None, note,
219 };
220
221 self.approvals.insert(dir_key, approval);
222 self.save_approvals().await?;
223
224 info!(
225 "Approved configuration for directory: {}",
226 directory_path.display()
227 );
228 Ok(())
229 }
230
231 pub async fn revoke_approval(&mut self, directory_path: &Path) -> Result<bool> {
233 let dir_key = compute_directory_key(directory_path);
234
235 if self.approvals.remove(&dir_key).is_some() {
236 self.save_approvals().await?;
237 info!(
238 "Revoked approval for directory: {}",
239 directory_path.display()
240 );
241 Ok(true)
242 } else {
243 Ok(false)
244 }
245 }
246
247 pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
249 self.approvals.values().collect()
250 }
251
252 pub async fn cleanup_expired(&mut self) -> Result<usize> {
254 let now = Utc::now();
255 let initial_count = self.approvals.len();
256
257 self.approvals.retain(|_, approval| {
258 if let Some(expires_at) = approval.expires_at {
259 expires_at > now
260 } else {
261 true }
263 });
264
265 let removed_count = initial_count - self.approvals.len();
266 if removed_count > 0 {
267 self.save_approvals().await?;
268 info!("Cleaned up {} expired approvals", removed_count);
269 }
270
271 Ok(removed_count)
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
277pub struct ApprovalRecord {
278 pub directory_path: PathBuf,
280 pub config_hash: String,
282 pub approved_at: DateTime<Utc>,
284 pub expires_at: Option<DateTime<Utc>>,
286 pub note: Option<String>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum ApprovalStatus {
293 Approved,
295 RequiresApproval { current_hash: String },
297 NotApproved { current_hash: String },
299}
300
301pub fn check_approval_status(
303 manager: &ApprovalManager,
304 directory_path: &Path,
305 config: &Value,
306) -> Result<ApprovalStatus> {
307 let current_hash = compute_approval_hash(config);
308
309 if manager.is_approved(directory_path, ¤t_hash)? {
310 Ok(ApprovalStatus::Approved)
311 } else {
312 let dir_key = compute_directory_key(directory_path);
314 if manager.approvals.contains_key(&dir_key) {
315 Ok(ApprovalStatus::RequiresApproval { current_hash })
316 } else {
317 Ok(ApprovalStatus::NotApproved { current_hash })
318 }
319 }
320}
321
322pub fn compute_approval_hash(config: &Value) -> String {
326 let mut hasher = Sha256::new();
327
328 let hooks_only = extract_hooks_for_hash(config);
330 let canonical = serde_json::to_string(&hooks_only).unwrap_or_default();
331 hasher.update(canonical.as_bytes());
332
333 format!("{:x}", hasher.finalize())[..16].to_string()
334}
335
336fn extract_hooks_for_hash(config: &Value) -> Value {
338 if let Some(obj) = config.as_object()
339 && let Some(hooks) = obj.get("hooks")
340 {
341 return serde_json::json!({ "hooks": hooks });
343 }
344 serde_json::json!({})
346}
347
348pub fn compute_directory_key(path: &Path) -> String {
350 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
353
354 let mut hasher = Sha256::new();
355 hasher.update(canonical_path.to_string_lossy().as_bytes());
356 format!("{:x}", hasher.finalize())[..16].to_string()
357}
358
359fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
361 for component in path.components() {
363 match component {
364 Component::Normal(_) | Component::RootDir | Component::CurDir => {}
365 Component::ParentDir => {
366 }
369 Component::Prefix(_) => {
370 }
372 }
373 }
374
375 if path.exists() {
377 std::fs::canonicalize(path)
378 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
379 } else {
380 if let Some(parent) = path.parent() {
382 if parent.exists() {
383 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
384 Error::configuration(format!("Failed to canonicalize parent path: {}", e))
385 })?;
386 if let Some(file_name) = path.file_name() {
387 Ok(canonical_parent.join(file_name))
388 } else {
389 Err(Error::configuration("Invalid file path"))
390 }
391 } else {
392 validate_path_structure(path)?;
394 Ok(path.to_path_buf())
395 }
396 } else {
397 validate_path_structure(path)?;
398 Ok(path.to_path_buf())
399 }
400 }
401}
402
403fn validate_directory_path(path: &Path) -> Result<PathBuf> {
405 validate_path_structure(path)?;
407
408 Ok(path.to_path_buf())
410}
411
412fn validate_path_structure(path: &Path) -> Result<()> {
414 let path_str = path.to_string_lossy();
415
416 if path_str.contains('\0') {
418 return Err(Error::configuration("Path contains null bytes"));
419 }
420
421 let suspicious_patterns = [
423 "../../../", "..\\..\\..\\", "%2e%2e", "..;/", ];
428
429 for pattern in &suspicious_patterns {
430 if path_str.contains(pattern) {
431 return Err(Error::configuration(format!(
432 "Path contains suspicious pattern: {}",
433 pattern
434 )));
435 }
436 }
437
438 Ok(())
439}
440
441#[derive(Debug, Clone)]
443pub struct ConfigSummary {
444 pub has_hooks: bool,
445 pub hook_count: usize,
446 pub has_env_vars: bool,
447 pub env_var_count: usize,
448 pub has_tasks: bool,
449 pub task_count: usize,
450}
451
452impl ConfigSummary {
453 pub fn from_json(config: &Value) -> Self {
455 let mut summary = Self {
456 has_hooks: false,
457 hook_count: 0,
458 has_env_vars: false,
459 env_var_count: 0,
460 has_tasks: false,
461 task_count: 0,
462 };
463
464 if let Some(obj) = config.as_object() {
465 if let Some(hooks) = obj.get("hooks")
467 && let Some(hooks_obj) = hooks.as_object()
468 {
469 summary.has_hooks = true;
470
471 if let Some(on_enter) = hooks_obj.get("onEnter") {
473 if let Some(arr) = on_enter.as_array() {
474 summary.hook_count += arr.len();
475 } else if on_enter.is_object() {
476 summary.hook_count += 1;
477 }
478 }
479
480 if let Some(on_exit) = hooks_obj.get("onExit") {
482 if let Some(arr) = on_exit.as_array() {
483 summary.hook_count += arr.len();
484 } else if on_exit.is_object() {
485 summary.hook_count += 1;
486 }
487 }
488 }
489
490 if let Some(env) = obj.get("env")
492 && let Some(env_obj) = env.as_object()
493 {
494 summary.has_env_vars = true;
495 summary.env_var_count = env_obj.len();
496 }
497
498 if let Some(tasks) = obj.get("tasks") {
500 if let Some(tasks_obj) = tasks.as_object() {
501 summary.has_tasks = true;
502 summary.task_count = tasks_obj.len();
503 } else if let Some(tasks_arr) = tasks.as_array() {
504 summary.has_tasks = true;
505 summary.task_count = tasks_arr.len();
506 }
507 }
508 }
509
510 summary
511 }
512
513 pub fn description(&self) -> String {
515 let mut parts = Vec::new();
516
517 if self.has_hooks {
518 if self.hook_count == 1 {
519 parts.push("1 hook".to_string());
520 } else {
521 parts.push(format!("{} hooks", self.hook_count));
522 }
523 }
524
525 if self.has_env_vars {
526 if self.env_var_count == 1 {
527 parts.push("1 environment variable".to_string());
528 } else {
529 parts.push(format!("{} environment variables", self.env_var_count));
530 }
531 }
532
533 if self.has_tasks {
534 if self.task_count == 1 {
535 parts.push("1 task".to_string());
536 } else {
537 parts.push(format!("{} tasks", self.task_count));
538 }
539 }
540
541 if parts.is_empty() {
542 "empty configuration".to_string()
543 } else {
544 parts.join(", ")
545 }
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use serde_json::json;
553 use tempfile::TempDir;
554
555 #[tokio::test]
556 async fn test_approval_manager_operations() {
557 let temp_dir = TempDir::new().unwrap();
558 let approval_file = temp_dir.path().join("approvals.json");
559 let mut manager = ApprovalManager::new(approval_file);
560
561 let directory = Path::new("/test/directory");
562 let config_hash = "test_hash_123".to_string();
563
564 assert!(!manager.is_approved(directory, &config_hash).unwrap());
566
567 manager
569 .approve_config(
570 directory,
571 config_hash.clone(),
572 Some("Test approval".to_string()),
573 )
574 .await
575 .unwrap();
576
577 assert!(manager.is_approved(directory, &config_hash).unwrap());
579
580 assert!(!manager.is_approved(directory, "different_hash").unwrap());
582
583 let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
585 manager2.load_approvals().await.unwrap();
586 assert!(manager2.is_approved(directory, &config_hash).unwrap());
587
588 let revoked = manager2.revoke_approval(directory).await.unwrap();
590 assert!(revoked);
591 assert!(!manager2.is_approved(directory, &config_hash).unwrap());
592 }
593
594 #[test]
595 fn test_approval_hash_only_includes_hooks() {
596 let config1 = json!({
598 "env": {"TEST": "value1"},
599 "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["hello"]}}}
600 });
601
602 let config2 = json!({
603 "env": {"TEST": "value2", "NEW_VAR": "new"},
604 "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["hello"]}}}
605 });
606
607 let hash1 = compute_approval_hash(&config1);
608 let hash2 = compute_approval_hash(&config2);
609 assert_eq!(hash1, hash2, "Env changes should not affect approval hash");
610
611 let config3 = json!({
613 "env": {"TEST": "value1"},
614 "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["world"]}}}
615 });
616
617 let hash3 = compute_approval_hash(&config3);
618 assert_ne!(hash1, hash3, "Hook changes should affect approval hash");
619 }
620
621 #[test]
622 fn test_approval_hash_ignores_tasks() {
623 let config1 = json!({
624 "tasks": {"build": {"command": "npm", "args": ["run", "build"]}},
625 "hooks": {"onEnter": {"setup": {"command": "echo"}}}
626 });
627
628 let config2 = json!({
629 "tasks": {},
630 "hooks": {"onEnter": {"setup": {"command": "echo"}}}
631 });
632
633 let hash1 = compute_approval_hash(&config1);
634 let hash2 = compute_approval_hash(&config2);
635 assert_eq!(hash1, hash2, "Task changes should not affect approval hash");
636 }
637
638 #[test]
639 fn test_approval_hash_no_hooks() {
640 let config1 = json!({
642 "env": {"TEST": "value"}
643 });
644
645 let config2 = json!({
646 "env": {"OTHER": "different"},
647 "tasks": {"test": {}}
648 });
649
650 let hash1 = compute_approval_hash(&config1);
651 let hash2 = compute_approval_hash(&config2);
652 assert_eq!(hash1, hash2, "Configs without hooks should have same hash");
653 }
654
655 #[test]
656 fn test_config_summary() {
657 let config = json!({
658 "env": {
659 "NODE_ENV": "development",
660 "API_URL": "http://localhost:3000"
661 },
662 "hooks": {
663 "onEnter": [
664 {"command": "npm", "args": ["install"]},
665 {"command": "docker-compose", "args": ["up", "-d"]}
666 ],
667 "onExit": [
668 {"command": "docker-compose", "args": ["down"]}
669 ]
670 },
671 "tasks": {
672 "build": {"command": "npm", "args": ["run", "build"]},
673 "test": {"command": "npm", "args": ["test"]}
674 }
675 });
676
677 let summary = ConfigSummary::from_json(&config);
678 assert!(summary.has_hooks);
679 assert_eq!(summary.hook_count, 3);
680 assert!(summary.has_env_vars);
681 assert_eq!(summary.env_var_count, 2);
682 assert!(summary.has_tasks);
683 assert_eq!(summary.task_count, 2);
684
685 let description = summary.description();
686 assert!(description.contains("3 hooks"));
687 assert!(description.contains("2 environment variables"));
688 assert!(description.contains("2 tasks"));
689 }
690
691 #[test]
692 fn test_approval_status() {
693 let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
694 let directory = Path::new("/test/dir");
695 let config = json!({"env": {"TEST": "value"}});
696
697 let status = check_approval_status(&manager, directory, &config).unwrap();
698 assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
699
700 let different_hash = "different_hash".to_string();
702 manager.approvals.insert(
703 compute_directory_key(directory),
704 ApprovalRecord {
705 directory_path: directory.to_path_buf(),
706 config_hash: different_hash,
707 approved_at: Utc::now(),
708 expires_at: None,
709 note: None,
710 },
711 );
712
713 let status = check_approval_status(&manager, directory, &config).unwrap();
714 assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
715
716 let correct_hash = compute_approval_hash(&config);
718 manager.approvals.insert(
719 compute_directory_key(directory),
720 ApprovalRecord {
721 directory_path: directory.to_path_buf(),
722 config_hash: correct_hash,
723 approved_at: Utc::now(),
724 expires_at: None,
725 note: None,
726 },
727 );
728
729 let status = check_approval_status(&manager, directory, &config).unwrap();
730 assert!(matches!(status, ApprovalStatus::Approved));
731 }
732
733 #[test]
734 fn test_path_validation() {
735 assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
737 assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
738 assert!(validate_path_structure(Path::new("file.txt")).is_ok());
739
740 let path_with_null = PathBuf::from("/test\0/path");
742 assert!(validate_path_structure(&path_with_null).is_err());
743
744 assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
746 assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
747
748 assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
750
751 assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
753 }
754
755 #[test]
756 fn test_validate_and_canonicalize_path() {
757 let temp_dir = TempDir::new().unwrap();
758 let test_file = temp_dir.path().join("test.txt");
759 std::fs::write(&test_file, "test").unwrap();
760
761 let result = validate_and_canonicalize_path(&test_file).unwrap();
763 assert!(result.is_absolute());
764 assert!(result.exists());
765
766 let new_file = temp_dir.path().join("new_file.txt");
768 let result = validate_and_canonicalize_path(&new_file).unwrap();
769 assert!(result.ends_with("new_file.txt"));
770
771 let nested_new = temp_dir.path().join("subdir/newfile.txt");
773 let result = validate_and_canonicalize_path(&nested_new);
774 assert!(result.is_ok()); }
776
777 #[tokio::test]
778 async fn test_approval_file_corruption_recovery() {
779 let temp_dir = TempDir::new().unwrap();
780 let approval_file = temp_dir.path().join("approvals.json");
781
782 std::fs::write(&approval_file, "{invalid json}").unwrap();
784
785 let mut manager = ApprovalManager::new(approval_file.clone());
786
787 let result = manager.load_approvals().await;
789 assert!(
790 result.is_err(),
791 "Expected error when loading corrupted JSON"
792 );
793
794 assert_eq!(manager.approvals.len(), 0);
796
797 let directory = Path::new("/test/dir");
799 manager
800 .approve_config(directory, "test_hash".to_string(), None)
801 .await
802 .unwrap();
803
804 let mut manager2 = ApprovalManager::new(approval_file);
806 manager2.load_approvals().await.unwrap();
807 assert_eq!(manager2.approvals.len(), 1);
808 }
809
810 #[tokio::test]
811 async fn test_concurrent_approval_access() {
812 let temp_dir = TempDir::new().unwrap();
813 let approval_file = temp_dir.path().join("approvals.json");
814
815 let mut manager1 = ApprovalManager::new(approval_file.clone());
817 let mut manager2 = ApprovalManager::new(approval_file.clone());
818
819 manager1
821 .approve_config(
822 Path::new("/test/dir1"),
823 "hash1".to_string(),
824 Some("Manager 1".to_string()),
825 )
826 .await
827 .unwrap();
828
829 manager2
831 .approve_config(
832 Path::new("/test/dir2"),
833 "hash2".to_string(),
834 Some("Manager 2".to_string()),
835 )
836 .await
837 .unwrap();
838
839 let mut manager3 = ApprovalManager::new(approval_file);
841 manager3.load_approvals().await.unwrap();
842
843 assert!(!manager3.approvals.is_empty());
846 }
847
848 #[tokio::test]
849 async fn test_approval_expiration() {
850 let temp_dir = TempDir::new().unwrap();
851 let approval_file = temp_dir.path().join("approvals.json");
852 let mut manager = ApprovalManager::new(approval_file);
853
854 let directory = Path::new("/test/expire");
855 let config_hash = "expire_hash".to_string();
856
857 let expired_approval = ApprovalRecord {
859 directory_path: directory.to_path_buf(),
860 config_hash: config_hash.clone(),
861 approved_at: Utc::now() - chrono::Duration::hours(2),
862 expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
863 note: Some("Expired approval".to_string()),
864 };
865
866 manager
867 .approvals
868 .insert(compute_directory_key(directory), expired_approval);
869
870 assert!(!manager.is_approved(directory, &config_hash).unwrap());
872
873 let removed = manager.cleanup_expired().await.unwrap();
875 assert_eq!(removed, 1);
876 assert_eq!(manager.approvals.len(), 0);
877 }
878}