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> {
34 if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE") {
36 return Ok(PathBuf::from(approval_file));
37 }
38
39 if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
41 let state_path = PathBuf::from(state_dir);
42 if let Some(parent) = state_path.parent() {
43 return Ok(parent.join("approved.json"));
44 }
45 }
46
47 let home = dirs::home_dir()
48 .ok_or_else(|| Error::configuration("Could not determine home directory"))?;
49 Ok(home.join(".cuenv").join("approved.json"))
50 }
51
52 pub fn with_default_file() -> Result<Self> {
54 Ok(Self::new(Self::default_approval_file()?))
55 }
56
57 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 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 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 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 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 pub async fn save_approvals(&self) -> Result<()> {
111 let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
113
114 if let Some(parent) = canonical_path.parent()
116 && !parent.exists()
117 {
118 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 let temp_path = canonical_path.with_extension("tmp");
134
135 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 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 drop(file);
172
173 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 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 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 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, 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 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 pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
250 self.approvals.values().collect()
251 }
252
253 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 }
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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
278pub struct ApprovalRecord {
279 pub directory_path: PathBuf,
281 pub config_hash: String,
283 pub approved_at: DateTime<Utc>,
285 pub expires_at: Option<DateTime<Utc>>,
287 pub note: Option<String>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum ApprovalStatus {
294 Approved,
296 RequiresApproval { current_hash: String },
298 NotApproved { current_hash: String },
300}
301
302pub fn check_approval_status(
304 manager: &ApprovalManager,
305 directory_path: &Path,
306 config: &Value,
307) -> Result<ApprovalStatus> {
308 let current_hash = compute_approval_hash(config);
309
310 if manager.is_approved(directory_path, ¤t_hash)? {
311 Ok(ApprovalStatus::Approved)
312 } else {
313 let dir_key = compute_directory_key(directory_path);
315 if manager.approvals.contains_key(&dir_key) {
316 Ok(ApprovalStatus::RequiresApproval { current_hash })
317 } else {
318 Ok(ApprovalStatus::NotApproved { current_hash })
319 }
320 }
321}
322
323pub fn compute_approval_hash(config: &Value) -> String {
327 let mut hasher = Sha256::new();
328
329 let hooks_only = extract_hooks_for_hash(config);
331 let canonical = serde_json::to_string(&hooks_only).unwrap_or_default();
332 hasher.update(canonical.as_bytes());
333
334 format!("{:x}", hasher.finalize())[..16].to_string()
335}
336
337fn extract_hooks_for_hash(config: &Value) -> Value {
339 if let Some(obj) = config.as_object()
340 && let Some(hooks) = obj.get("hooks")
341 {
342 return serde_json::json!({ "hooks": hooks });
344 }
345 serde_json::json!({})
347}
348
349pub fn compute_directory_key(path: &Path) -> String {
351 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
354
355 let mut hasher = Sha256::new();
356 hasher.update(canonical_path.to_string_lossy().as_bytes());
357 format!("{:x}", hasher.finalize())[..16].to_string()
358}
359
360fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
362 for component in path.components() {
364 match component {
365 Component::Normal(_) | Component::RootDir | Component::CurDir => {}
366 Component::ParentDir => {
367 }
370 Component::Prefix(_) => {
371 }
373 }
374 }
375
376 if path.exists() {
378 std::fs::canonicalize(path)
379 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
380 } else {
381 if let Some(parent) = path.parent() {
383 if parent.exists() {
384 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
385 Error::configuration(format!("Failed to canonicalize parent path: {}", e))
386 })?;
387 if let Some(file_name) = path.file_name() {
388 Ok(canonical_parent.join(file_name))
389 } else {
390 Err(Error::configuration("Invalid file path"))
391 }
392 } else {
393 validate_path_structure(path)?;
395 Ok(path.to_path_buf())
396 }
397 } else {
398 validate_path_structure(path)?;
399 Ok(path.to_path_buf())
400 }
401 }
402}
403
404fn validate_directory_path(path: &Path) -> Result<PathBuf> {
406 validate_path_structure(path)?;
408
409 Ok(path.to_path_buf())
411}
412
413fn validate_path_structure(path: &Path) -> Result<()> {
415 let path_str = path.to_string_lossy();
416
417 if path_str.contains('\0') {
419 return Err(Error::configuration("Path contains null bytes"));
420 }
421
422 let suspicious_patterns = [
424 "../../../", "..\\..\\..\\", "%2e%2e", "..;/", ];
429
430 for pattern in &suspicious_patterns {
431 if path_str.contains(pattern) {
432 return Err(Error::configuration(format!(
433 "Path contains suspicious pattern: {}",
434 pattern
435 )));
436 }
437 }
438
439 Ok(())
440}
441
442#[derive(Debug, Clone)]
444pub struct ConfigSummary {
445 pub has_hooks: bool,
446 pub hook_count: usize,
447 pub has_env_vars: bool,
448 pub env_var_count: usize,
449 pub has_tasks: bool,
450 pub task_count: usize,
451}
452
453impl ConfigSummary {
454 pub fn from_json(config: &Value) -> Self {
456 let mut summary = Self {
457 has_hooks: false,
458 hook_count: 0,
459 has_env_vars: false,
460 env_var_count: 0,
461 has_tasks: false,
462 task_count: 0,
463 };
464
465 if let Some(obj) = config.as_object() {
466 if let Some(hooks) = obj.get("hooks")
468 && let Some(hooks_obj) = hooks.as_object()
469 {
470 summary.has_hooks = true;
471
472 if let Some(on_enter) = hooks_obj.get("onEnter") {
474 if let Some(arr) = on_enter.as_array() {
475 summary.hook_count += arr.len();
476 } else if on_enter.is_object() {
477 summary.hook_count += 1;
478 }
479 }
480
481 if let Some(on_exit) = hooks_obj.get("onExit") {
483 if let Some(arr) = on_exit.as_array() {
484 summary.hook_count += arr.len();
485 } else if on_exit.is_object() {
486 summary.hook_count += 1;
487 }
488 }
489 }
490
491 if let Some(env) = obj.get("env")
493 && let Some(env_obj) = env.as_object()
494 {
495 summary.has_env_vars = true;
496 summary.env_var_count = env_obj.len();
497 }
498
499 if let Some(tasks) = obj.get("tasks") {
501 if let Some(tasks_obj) = tasks.as_object() {
502 summary.has_tasks = true;
503 summary.task_count = tasks_obj.len();
504 } else if let Some(tasks_arr) = tasks.as_array() {
505 summary.has_tasks = true;
506 summary.task_count = tasks_arr.len();
507 }
508 }
509 }
510
511 summary
512 }
513
514 pub fn description(&self) -> String {
516 let mut parts = Vec::new();
517
518 if self.has_hooks {
519 if self.hook_count == 1 {
520 parts.push("1 hook".to_string());
521 } else {
522 parts.push(format!("{} hooks", self.hook_count));
523 }
524 }
525
526 if self.has_env_vars {
527 if self.env_var_count == 1 {
528 parts.push("1 environment variable".to_string());
529 } else {
530 parts.push(format!("{} environment variables", self.env_var_count));
531 }
532 }
533
534 if self.has_tasks {
535 if self.task_count == 1 {
536 parts.push("1 task".to_string());
537 } else {
538 parts.push(format!("{} tasks", self.task_count));
539 }
540 }
541
542 if parts.is_empty() {
543 "empty configuration".to_string()
544 } else {
545 parts.join(", ")
546 }
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use serde_json::json;
554 use tempfile::TempDir;
555
556 #[tokio::test]
557 async fn test_approval_manager_operations() {
558 let temp_dir = TempDir::new().unwrap();
559 let approval_file = temp_dir.path().join("approvals.json");
560 let mut manager = ApprovalManager::new(approval_file);
561
562 let directory = Path::new("/test/directory");
563 let config_hash = "test_hash_123".to_string();
564
565 assert!(!manager.is_approved(directory, &config_hash).unwrap());
567
568 manager
570 .approve_config(
571 directory,
572 config_hash.clone(),
573 Some("Test approval".to_string()),
574 )
575 .await
576 .unwrap();
577
578 assert!(manager.is_approved(directory, &config_hash).unwrap());
580
581 assert!(!manager.is_approved(directory, "different_hash").unwrap());
583
584 let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
586 manager2.load_approvals().await.unwrap();
587 assert!(manager2.is_approved(directory, &config_hash).unwrap());
588
589 let revoked = manager2.revoke_approval(directory).await.unwrap();
591 assert!(revoked);
592 assert!(!manager2.is_approved(directory, &config_hash).unwrap());
593 }
594
595 #[test]
596 fn test_approval_hash_only_includes_hooks() {
597 let config1 = json!({
599 "env": {"TEST": "value1"},
600 "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["hello"]}}}
601 });
602
603 let config2 = json!({
604 "env": {"TEST": "value2", "NEW_VAR": "new"},
605 "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["hello"]}}}
606 });
607
608 let hash1 = compute_approval_hash(&config1);
609 let hash2 = compute_approval_hash(&config2);
610 assert_eq!(hash1, hash2, "Env changes should not affect approval hash");
611
612 let config3 = json!({
614 "env": {"TEST": "value1"},
615 "hooks": {"onEnter": {"setup": {"command": "echo", "args": ["world"]}}}
616 });
617
618 let hash3 = compute_approval_hash(&config3);
619 assert_ne!(hash1, hash3, "Hook changes should affect approval hash");
620 }
621
622 #[test]
623 fn test_approval_hash_ignores_tasks() {
624 let config1 = json!({
625 "tasks": {"build": {"command": "npm", "args": ["run", "build"]}},
626 "hooks": {"onEnter": {"setup": {"command": "echo"}}}
627 });
628
629 let config2 = json!({
630 "tasks": {},
631 "hooks": {"onEnter": {"setup": {"command": "echo"}}}
632 });
633
634 let hash1 = compute_approval_hash(&config1);
635 let hash2 = compute_approval_hash(&config2);
636 assert_eq!(hash1, hash2, "Task changes should not affect approval hash");
637 }
638
639 #[test]
640 fn test_approval_hash_no_hooks() {
641 let config1 = json!({
643 "env": {"TEST": "value"}
644 });
645
646 let config2 = json!({
647 "env": {"OTHER": "different"},
648 "tasks": {"test": {}}
649 });
650
651 let hash1 = compute_approval_hash(&config1);
652 let hash2 = compute_approval_hash(&config2);
653 assert_eq!(hash1, hash2, "Configs without hooks should have same hash");
654 }
655
656 #[test]
657 fn test_config_summary() {
658 let config = json!({
659 "env": {
660 "NODE_ENV": "development",
661 "API_URL": "http://localhost:3000"
662 },
663 "hooks": {
664 "onEnter": [
665 {"command": "npm", "args": ["install"]},
666 {"command": "docker-compose", "args": ["up", "-d"]}
667 ],
668 "onExit": [
669 {"command": "docker-compose", "args": ["down"]}
670 ]
671 },
672 "tasks": {
673 "build": {"command": "npm", "args": ["run", "build"]},
674 "test": {"command": "npm", "args": ["test"]}
675 }
676 });
677
678 let summary = ConfigSummary::from_json(&config);
679 assert!(summary.has_hooks);
680 assert_eq!(summary.hook_count, 3);
681 assert!(summary.has_env_vars);
682 assert_eq!(summary.env_var_count, 2);
683 assert!(summary.has_tasks);
684 assert_eq!(summary.task_count, 2);
685
686 let description = summary.description();
687 assert!(description.contains("3 hooks"));
688 assert!(description.contains("2 environment variables"));
689 assert!(description.contains("2 tasks"));
690 }
691
692 #[test]
693 fn test_approval_status() {
694 let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
695 let directory = Path::new("/test/dir");
696 let config = json!({"env": {"TEST": "value"}});
697
698 let status = check_approval_status(&manager, directory, &config).unwrap();
699 assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
700
701 let different_hash = "different_hash".to_string();
703 manager.approvals.insert(
704 compute_directory_key(directory),
705 ApprovalRecord {
706 directory_path: directory.to_path_buf(),
707 config_hash: different_hash,
708 approved_at: Utc::now(),
709 expires_at: None,
710 note: None,
711 },
712 );
713
714 let status = check_approval_status(&manager, directory, &config).unwrap();
715 assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
716
717 let correct_hash = compute_approval_hash(&config);
719 manager.approvals.insert(
720 compute_directory_key(directory),
721 ApprovalRecord {
722 directory_path: directory.to_path_buf(),
723 config_hash: correct_hash,
724 approved_at: Utc::now(),
725 expires_at: None,
726 note: None,
727 },
728 );
729
730 let status = check_approval_status(&manager, directory, &config).unwrap();
731 assert!(matches!(status, ApprovalStatus::Approved));
732 }
733
734 #[test]
735 fn test_path_validation() {
736 assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
738 assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
739 assert!(validate_path_structure(Path::new("file.txt")).is_ok());
740
741 let path_with_null = PathBuf::from("/test\0/path");
743 assert!(validate_path_structure(&path_with_null).is_err());
744
745 assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
747 assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
748
749 assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
751
752 assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
754 }
755
756 #[test]
757 fn test_validate_and_canonicalize_path() {
758 let temp_dir = TempDir::new().unwrap();
759 let test_file = temp_dir.path().join("test.txt");
760 std::fs::write(&test_file, "test").unwrap();
761
762 let result = validate_and_canonicalize_path(&test_file).unwrap();
764 assert!(result.is_absolute());
765 assert!(result.exists());
766
767 let new_file = temp_dir.path().join("new_file.txt");
769 let result = validate_and_canonicalize_path(&new_file).unwrap();
770 assert!(result.ends_with("new_file.txt"));
771
772 let nested_new = temp_dir.path().join("subdir/newfile.txt");
774 let result = validate_and_canonicalize_path(&nested_new);
775 assert!(result.is_ok()); }
777
778 #[tokio::test]
779 async fn test_approval_file_corruption_recovery() {
780 let temp_dir = TempDir::new().unwrap();
781 let approval_file = temp_dir.path().join("approvals.json");
782
783 std::fs::write(&approval_file, "{invalid json}").unwrap();
785
786 let mut manager = ApprovalManager::new(approval_file.clone());
787
788 let result = manager.load_approvals().await;
790 assert!(
791 result.is_err(),
792 "Expected error when loading corrupted JSON"
793 );
794
795 assert_eq!(manager.approvals.len(), 0);
797
798 let directory = Path::new("/test/dir");
800 manager
801 .approve_config(directory, "test_hash".to_string(), None)
802 .await
803 .unwrap();
804
805 let mut manager2 = ApprovalManager::new(approval_file);
807 manager2.load_approvals().await.unwrap();
808 assert_eq!(manager2.approvals.len(), 1);
809 }
810
811 #[tokio::test]
812 async fn test_concurrent_approval_access() {
813 let temp_dir = TempDir::new().unwrap();
814 let approval_file = temp_dir.path().join("approvals.json");
815
816 let mut manager1 = ApprovalManager::new(approval_file.clone());
818 let mut manager2 = ApprovalManager::new(approval_file.clone());
819
820 manager1
822 .approve_config(
823 Path::new("/test/dir1"),
824 "hash1".to_string(),
825 Some("Manager 1".to_string()),
826 )
827 .await
828 .unwrap();
829
830 manager2
832 .approve_config(
833 Path::new("/test/dir2"),
834 "hash2".to_string(),
835 Some("Manager 2".to_string()),
836 )
837 .await
838 .unwrap();
839
840 let mut manager3 = ApprovalManager::new(approval_file);
842 manager3.load_approvals().await.unwrap();
843
844 assert!(!manager3.approvals.is_empty());
847 }
848
849 #[tokio::test]
850 async fn test_approval_expiration() {
851 let temp_dir = TempDir::new().unwrap();
852 let approval_file = temp_dir.path().join("approvals.json");
853 let mut manager = ApprovalManager::new(approval_file);
854
855 let directory = Path::new("/test/expire");
856 let config_hash = "expire_hash".to_string();
857
858 let expired_approval = ApprovalRecord {
860 directory_path: directory.to_path_buf(),
861 config_hash: config_hash.clone(),
862 approved_at: Utc::now() - chrono::Duration::hours(2),
863 expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
864 note: Some("Expired approval".to_string()),
865 };
866
867 manager
868 .approvals
869 .insert(compute_directory_key(directory), expired_approval);
870
871 assert!(!manager.is_approved(directory, &config_hash).unwrap());
873
874 let removed = manager.cleanup_expired().await.unwrap();
876 assert_eq!(removed, 1);
877 assert_eq!(manager.approvals.len(), 0);
878 }
879}