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_config_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_config_hash(config: &Value) -> String {
325 let mut hasher = Sha256::new();
326
327 let canonical = serde_json::to_string(config).unwrap_or_default();
329 hasher.update(canonical.as_bytes());
330
331 format!("{:x}", hasher.finalize())[..16].to_string()
332}
333
334pub fn compute_directory_key(path: &Path) -> String {
336 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
339
340 let mut hasher = Sha256::new();
341 hasher.update(canonical_path.to_string_lossy().as_bytes());
342 format!("{:x}", hasher.finalize())[..16].to_string()
343}
344
345fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
347 for component in path.components() {
349 match component {
350 Component::Normal(_) | Component::RootDir | Component::CurDir => {}
351 Component::ParentDir => {
352 }
355 Component::Prefix(_) => {
356 }
358 }
359 }
360
361 if path.exists() {
363 std::fs::canonicalize(path)
364 .map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
365 } else {
366 if let Some(parent) = path.parent() {
368 if parent.exists() {
369 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
370 Error::configuration(format!("Failed to canonicalize parent path: {}", e))
371 })?;
372 if let Some(file_name) = path.file_name() {
373 Ok(canonical_parent.join(file_name))
374 } else {
375 Err(Error::configuration("Invalid file path"))
376 }
377 } else {
378 validate_path_structure(path)?;
380 Ok(path.to_path_buf())
381 }
382 } else {
383 validate_path_structure(path)?;
384 Ok(path.to_path_buf())
385 }
386 }
387}
388
389fn validate_directory_path(path: &Path) -> Result<PathBuf> {
391 validate_path_structure(path)?;
393
394 Ok(path.to_path_buf())
396}
397
398fn validate_path_structure(path: &Path) -> Result<()> {
400 let path_str = path.to_string_lossy();
401
402 if path_str.contains('\0') {
404 return Err(Error::configuration("Path contains null bytes"));
405 }
406
407 let suspicious_patterns = [
409 "../../../", "..\\..\\..\\", "%2e%2e", "..;/", ];
414
415 for pattern in &suspicious_patterns {
416 if path_str.contains(pattern) {
417 return Err(Error::configuration(format!(
418 "Path contains suspicious pattern: {}",
419 pattern
420 )));
421 }
422 }
423
424 Ok(())
425}
426
427#[derive(Debug, Clone)]
429pub struct ConfigSummary {
430 pub has_hooks: bool,
431 pub hook_count: usize,
432 pub has_env_vars: bool,
433 pub env_var_count: usize,
434 pub has_tasks: bool,
435 pub task_count: usize,
436}
437
438impl ConfigSummary {
439 pub fn from_json(config: &Value) -> Self {
441 let mut summary = Self {
442 has_hooks: false,
443 hook_count: 0,
444 has_env_vars: false,
445 env_var_count: 0,
446 has_tasks: false,
447 task_count: 0,
448 };
449
450 if let Some(obj) = config.as_object() {
451 if let Some(hooks) = obj.get("hooks")
453 && let Some(hooks_obj) = hooks.as_object()
454 {
455 summary.has_hooks = true;
456
457 if let Some(on_enter) = hooks_obj.get("onEnter") {
459 if let Some(arr) = on_enter.as_array() {
460 summary.hook_count += arr.len();
461 } else if on_enter.is_object() {
462 summary.hook_count += 1;
463 }
464 }
465
466 if let Some(on_exit) = hooks_obj.get("onExit") {
468 if let Some(arr) = on_exit.as_array() {
469 summary.hook_count += arr.len();
470 } else if on_exit.is_object() {
471 summary.hook_count += 1;
472 }
473 }
474 }
475
476 if let Some(env) = obj.get("env")
478 && let Some(env_obj) = env.as_object()
479 {
480 summary.has_env_vars = true;
481 summary.env_var_count = env_obj.len();
482 }
483
484 if let Some(tasks) = obj.get("tasks") {
486 if let Some(tasks_obj) = tasks.as_object() {
487 summary.has_tasks = true;
488 summary.task_count = tasks_obj.len();
489 } else if let Some(tasks_arr) = tasks.as_array() {
490 summary.has_tasks = true;
491 summary.task_count = tasks_arr.len();
492 }
493 }
494 }
495
496 summary
497 }
498
499 pub fn description(&self) -> String {
501 let mut parts = Vec::new();
502
503 if self.has_hooks {
504 if self.hook_count == 1 {
505 parts.push("1 hook".to_string());
506 } else {
507 parts.push(format!("{} hooks", self.hook_count));
508 }
509 }
510
511 if self.has_env_vars {
512 if self.env_var_count == 1 {
513 parts.push("1 environment variable".to_string());
514 } else {
515 parts.push(format!("{} environment variables", self.env_var_count));
516 }
517 }
518
519 if self.has_tasks {
520 if self.task_count == 1 {
521 parts.push("1 task".to_string());
522 } else {
523 parts.push(format!("{} tasks", self.task_count));
524 }
525 }
526
527 if parts.is_empty() {
528 "empty configuration".to_string()
529 } else {
530 parts.join(", ")
531 }
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use serde_json::json;
539 use tempfile::TempDir;
540
541 #[tokio::test]
542 async fn test_approval_manager_operations() {
543 let temp_dir = TempDir::new().unwrap();
544 let approval_file = temp_dir.path().join("approvals.json");
545 let mut manager = ApprovalManager::new(approval_file);
546
547 let directory = Path::new("/test/directory");
548 let config_hash = "test_hash_123".to_string();
549
550 assert!(!manager.is_approved(directory, &config_hash).unwrap());
552
553 manager
555 .approve_config(
556 directory,
557 config_hash.clone(),
558 Some("Test approval".to_string()),
559 )
560 .await
561 .unwrap();
562
563 assert!(manager.is_approved(directory, &config_hash).unwrap());
565
566 assert!(!manager.is_approved(directory, "different_hash").unwrap());
568
569 let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
571 manager2.load_approvals().await.unwrap();
572 assert!(manager2.is_approved(directory, &config_hash).unwrap());
573
574 let revoked = manager2.revoke_approval(directory).await.unwrap();
576 assert!(revoked);
577 assert!(!manager2.is_approved(directory, &config_hash).unwrap());
578 }
579
580 #[test]
581 fn test_compute_config_hash() {
582 let config1 = json!({
583 "env": {"TEST": "value"},
584 "hooks": {"onEnter": [{"command": "echo", "args": ["hello"]}]}
585 });
586
587 let config2 = json!({
588 "hooks": {"onEnter": [{"command": "echo", "args": ["hello"]}]},
589 "env": {"TEST": "value"}
590 });
591
592 let hash1 = compute_config_hash(&config1);
594 let hash2 = compute_config_hash(&config2);
595 assert_eq!(hash1, hash2);
596
597 let config3 = json!({
598 "env": {"TEST": "different_value"},
599 "hooks": {"onEnter": [{"command": "echo", "args": ["hello"]}]}
600 });
601
602 let hash3 = compute_config_hash(&config3);
603 assert_ne!(hash1, hash3);
604 }
605
606 #[test]
607 fn test_config_summary() {
608 let config = json!({
609 "env": {
610 "NODE_ENV": "development",
611 "API_URL": "http://localhost:3000"
612 },
613 "hooks": {
614 "onEnter": [
615 {"command": "npm", "args": ["install"]},
616 {"command": "docker-compose", "args": ["up", "-d"]}
617 ],
618 "onExit": [
619 {"command": "docker-compose", "args": ["down"]}
620 ]
621 },
622 "tasks": {
623 "build": {"command": "npm", "args": ["run", "build"]},
624 "test": {"command": "npm", "args": ["test"]}
625 }
626 });
627
628 let summary = ConfigSummary::from_json(&config);
629 assert!(summary.has_hooks);
630 assert_eq!(summary.hook_count, 3);
631 assert!(summary.has_env_vars);
632 assert_eq!(summary.env_var_count, 2);
633 assert!(summary.has_tasks);
634 assert_eq!(summary.task_count, 2);
635
636 let description = summary.description();
637 assert!(description.contains("3 hooks"));
638 assert!(description.contains("2 environment variables"));
639 assert!(description.contains("2 tasks"));
640 }
641
642 #[test]
643 fn test_approval_status() {
644 let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
645 let directory = Path::new("/test/dir");
646 let config = json!({"env": {"TEST": "value"}});
647
648 let status = check_approval_status(&manager, directory, &config).unwrap();
649 assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
650
651 let different_hash = "different_hash".to_string();
653 manager.approvals.insert(
654 compute_directory_key(directory),
655 ApprovalRecord {
656 directory_path: directory.to_path_buf(),
657 config_hash: different_hash,
658 approved_at: Utc::now(),
659 expires_at: None,
660 note: None,
661 },
662 );
663
664 let status = check_approval_status(&manager, directory, &config).unwrap();
665 assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
666
667 let correct_hash = compute_config_hash(&config);
669 manager.approvals.insert(
670 compute_directory_key(directory),
671 ApprovalRecord {
672 directory_path: directory.to_path_buf(),
673 config_hash: correct_hash,
674 approved_at: Utc::now(),
675 expires_at: None,
676 note: None,
677 },
678 );
679
680 let status = check_approval_status(&manager, directory, &config).unwrap();
681 assert!(matches!(status, ApprovalStatus::Approved));
682 }
683
684 #[test]
685 fn test_path_validation() {
686 assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
688 assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
689 assert!(validate_path_structure(Path::new("file.txt")).is_ok());
690
691 let path_with_null = PathBuf::from("/test\0/path");
693 assert!(validate_path_structure(&path_with_null).is_err());
694
695 assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
697 assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
698
699 assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
701
702 assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
704 }
705
706 #[test]
707 fn test_validate_and_canonicalize_path() {
708 let temp_dir = TempDir::new().unwrap();
709 let test_file = temp_dir.path().join("test.txt");
710 std::fs::write(&test_file, "test").unwrap();
711
712 let result = validate_and_canonicalize_path(&test_file).unwrap();
714 assert!(result.is_absolute());
715 assert!(result.exists());
716
717 let new_file = temp_dir.path().join("new_file.txt");
719 let result = validate_and_canonicalize_path(&new_file).unwrap();
720 assert!(result.ends_with("new_file.txt"));
721
722 let nested_new = temp_dir.path().join("subdir/newfile.txt");
724 let result = validate_and_canonicalize_path(&nested_new);
725 assert!(result.is_ok()); }
727
728 #[tokio::test]
729 async fn test_approval_file_corruption_recovery() {
730 let temp_dir = TempDir::new().unwrap();
731 let approval_file = temp_dir.path().join("approvals.json");
732
733 std::fs::write(&approval_file, "{invalid json}").unwrap();
735
736 let mut manager = ApprovalManager::new(approval_file.clone());
737
738 let result = manager.load_approvals().await;
740 assert!(
741 result.is_err(),
742 "Expected error when loading corrupted JSON"
743 );
744
745 assert_eq!(manager.approvals.len(), 0);
747
748 let directory = Path::new("/test/dir");
750 manager
751 .approve_config(directory, "test_hash".to_string(), None)
752 .await
753 .unwrap();
754
755 let mut manager2 = ApprovalManager::new(approval_file);
757 manager2.load_approvals().await.unwrap();
758 assert_eq!(manager2.approvals.len(), 1);
759 }
760
761 #[tokio::test]
762 async fn test_concurrent_approval_access() {
763 let temp_dir = TempDir::new().unwrap();
764 let approval_file = temp_dir.path().join("approvals.json");
765
766 let mut manager1 = ApprovalManager::new(approval_file.clone());
768 let mut manager2 = ApprovalManager::new(approval_file.clone());
769
770 manager1
772 .approve_config(
773 Path::new("/test/dir1"),
774 "hash1".to_string(),
775 Some("Manager 1".to_string()),
776 )
777 .await
778 .unwrap();
779
780 manager2
782 .approve_config(
783 Path::new("/test/dir2"),
784 "hash2".to_string(),
785 Some("Manager 2".to_string()),
786 )
787 .await
788 .unwrap();
789
790 let mut manager3 = ApprovalManager::new(approval_file);
792 manager3.load_approvals().await.unwrap();
793
794 assert!(!manager3.approvals.is_empty());
797 }
798
799 #[tokio::test]
800 async fn test_approval_expiration() {
801 let temp_dir = TempDir::new().unwrap();
802 let approval_file = temp_dir.path().join("approvals.json");
803 let mut manager = ApprovalManager::new(approval_file);
804
805 let directory = Path::new("/test/expire");
806 let config_hash = "expire_hash".to_string();
807
808 let expired_approval = ApprovalRecord {
810 directory_path: directory.to_path_buf(),
811 config_hash: config_hash.clone(),
812 approved_at: Utc::now() - chrono::Duration::hours(2),
813 expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
814 note: Some("Expired approval".to_string()),
815 };
816
817 manager
818 .approvals
819 .insert(compute_directory_key(directory), expired_approval);
820
821 assert!(!manager.is_approved(directory, &config_hash).unwrap());
823
824 let removed = manager.cleanup_expired().await.unwrap();
826 assert_eq!(removed, 1);
827 assert_eq!(manager.approvals.len(), 0);
828 }
829}