1use std::fs::{self, Metadata};
16use std::path::{Component, Path, PathBuf};
17use thiserror::Error;
18
19const MAX_PATH_DEPTH: usize = 64;
21
22const MAX_COMPONENT_LENGTH: usize = 255;
24
25#[derive(Debug, Error)]
27pub enum WorkspaceError {
28 #[error("Failed to create workspace directory: {0}")]
29 CreateFailed(#[from] std::io::Error),
30
31 #[error("Path '{path}' is outside workspace '{workspace}'")]
32 PathOutsideWorkspace { path: PathBuf, workspace: PathBuf },
33
34 #[error("Workspace directory does not exist: {0}")]
35 WorkspaceNotFound(PathBuf),
36
37 #[error("Invalid workspace path: {0}")]
38 InvalidPath(String),
39
40 #[error("Symlink '{symlink}' points outside workspace to '{target}'")]
41 SymlinkEscape { symlink: PathBuf, target: PathBuf },
42
43 #[error("Path contains dangerous characters or patterns: {0}")]
44 DangerousPath(String),
45
46 #[error("Path exceeds maximum depth of {MAX_PATH_DEPTH} components")]
47 PathTooDeep,
48
49 #[error("Path component exceeds maximum length of {MAX_COMPONENT_LENGTH} characters")]
50 ComponentTooLong,
51
52 #[error("Access denied: {0}")]
53 AccessDenied(String),
54}
55
56pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
58
59#[derive(Debug, Clone)]
61pub struct SessionWorkspace {
62 pub root: PathBuf,
64 pub session_id: String,
66 pub is_sandbox: bool,
68}
69
70impl SessionWorkspace {
71 pub fn create_sandbox(session_id: &str) -> WorkspaceResult<Self> {
75 validate_session_id(session_id)?;
77
78 let base_dir = get_sessions_base_dir();
79 let workspace_dir = base_dir.join(session_id);
80
81 fs::create_dir_all(&workspace_dir)?;
82
83 let canonical = workspace_dir.canonicalize().map_err(|e| {
85 WorkspaceError::InvalidPath(format!("{}: {}", workspace_dir.display(), e))
86 })?;
87
88 tracing::info!(
89 session_id = %session_id,
90 workspace = ?canonical,
91 "Created sandbox workspace for session"
92 );
93
94 Ok(Self {
95 root: canonical,
96 session_id: session_id.to_string(),
97 is_sandbox: true,
98 })
99 }
100
101 pub fn from_directory(session_id: &str, directory: PathBuf) -> WorkspaceResult<Self> {
105 validate_session_id(session_id)?;
107
108 if !directory.exists() {
110 return Err(WorkspaceError::WorkspaceNotFound(directory));
111 }
112
113 let metadata = fs::symlink_metadata(&directory)
115 .map_err(|e| WorkspaceError::InvalidPath(format!("{}: {}", directory.display(), e)))?;
116
117 if !metadata.is_dir() && !metadata.file_type().is_symlink() {
118 return Err(WorkspaceError::InvalidPath(format!(
119 "{} is not a directory",
120 directory.display()
121 )));
122 }
123
124 let canonical = directory
126 .canonicalize()
127 .map_err(|e| WorkspaceError::InvalidPath(format!("{}: {}", directory.display(), e)))?;
128
129 if !canonical.is_dir() {
131 return Err(WorkspaceError::InvalidPath(format!(
132 "{} does not resolve to a directory",
133 directory.display()
134 )));
135 }
136
137 tracing::info!(
138 session_id = %session_id,
139 workspace = ?canonical,
140 "Using existing directory as workspace"
141 );
142
143 Ok(Self {
144 root: canonical,
145 session_id: session_id.to_string(),
146 is_sandbox: false,
147 })
148 }
149
150 pub fn resolve_path(&self, path: &Path) -> WorkspaceResult<PathBuf> {
160 validate_path_safety(path)?;
162
163 let resolved = if path.is_absolute() {
164 path.to_path_buf()
165 } else {
166 self.root.join(path)
167 };
168
169 validate_path_depth(&resolved)?;
171
172 if resolved.exists() {
174 self.validate_symlinks_in_path(&resolved)?;
175 }
176
177 let canonical = if resolved.exists() {
180 resolved.canonicalize().map_err(|e| {
181 WorkspaceError::InvalidPath(format!("{}: {}", resolved.display(), e))
182 })?
183 } else {
184 normalize_path(&resolved, &self.root)?
186 };
187
188 if !canonical.starts_with(&self.root) {
190 return Err(WorkspaceError::PathOutsideWorkspace {
191 path: path.to_path_buf(),
192 workspace: self.root.clone(),
193 });
194 }
195
196 Ok(canonical)
197 }
198
199 pub fn resolve_path_for_read(&self, path: &Path) -> WorkspaceResult<PathBuf> {
203 let resolved = self.resolve_path(path)?;
204
205 if !resolved.exists() {
206 return Err(WorkspaceError::InvalidPath(format!(
207 "Path does not exist: {}",
208 path.display()
209 )));
210 }
211
212 self.validate_symlinks_in_path(&resolved)?;
214
215 Ok(resolved)
216 }
217
218 pub fn resolve_path_for_write(&self, path: &Path) -> WorkspaceResult<PathBuf> {
222 let resolved = self.resolve_path(path)?;
223
224 if let Some(parent) = resolved.parent() {
226 if !parent.exists() {
227 return Err(WorkspaceError::InvalidPath(format!(
228 "Parent directory does not exist: {}",
229 parent.display()
230 )));
231 }
232
233 let parent_canonical = parent
235 .canonicalize()
236 .map_err(|e| WorkspaceError::InvalidPath(format!("{}: {}", parent.display(), e)))?;
237
238 if !parent_canonical.starts_with(&self.root) {
239 return Err(WorkspaceError::PathOutsideWorkspace {
240 path: path.to_path_buf(),
241 workspace: self.root.clone(),
242 });
243 }
244 }
245
246 Ok(resolved)
247 }
248
249 pub fn resolve_path_for_create(&self, path: &Path) -> WorkspaceResult<PathBuf> {
258 let resolved = self.resolve_path(path)?;
259 self.validate_symlinks_in_path(&resolved)?;
261 Ok(resolved)
262 }
263
264 fn validate_symlinks_in_path(&self, path: &Path) -> WorkspaceResult<()> {
266 let mut current = PathBuf::new();
267
268 for component in path.components() {
269 current.push(component);
270
271 if !current.exists() {
273 continue;
274 }
275
276 if fs::symlink_metadata(¤t).is_ok_and(|m| m.file_type().is_symlink()) {
278 self.validate_symlink(¤t)?;
279 }
280 }
281
282 Ok(())
283 }
284
285 fn validate_symlink(&self, symlink_path: &Path) -> WorkspaceResult<()> {
287 let target = fs::read_link(symlink_path).map_err(|e| {
289 WorkspaceError::InvalidPath(format!(
290 "Failed to read symlink {}: {}",
291 symlink_path.display(),
292 e
293 ))
294 })?;
295
296 let absolute_target = if target.is_absolute() {
298 target.clone()
299 } else {
300 symlink_path
301 .parent()
302 .unwrap_or(Path::new("/"))
303 .join(&target)
304 };
305
306 let canonical_target = absolute_target.canonicalize().map_err(|e| {
308 WorkspaceError::InvalidPath(format!(
309 "Failed to resolve symlink target {}: {}",
310 absolute_target.display(),
311 e
312 ))
313 })?;
314
315 if !canonical_target.starts_with(&self.root) {
317 tracing::warn!(
318 symlink = %symlink_path.display(),
319 target = %canonical_target.display(),
320 workspace = %self.root.display(),
321 "Blocked symlink pointing outside workspace"
322 );
323
324 return Err(WorkspaceError::SymlinkEscape {
325 symlink: symlink_path.to_path_buf(),
326 target: canonical_target,
327 });
328 }
329
330 Ok(())
331 }
332
333 pub fn is_path_allowed(&self, path: &Path) -> bool {
335 if validate_path_safety(path).is_err() {
337 return false;
338 }
339
340 if validate_path_depth(path).is_err() {
341 return false;
342 }
343
344 let resolved = if path.is_absolute() {
345 path.to_path_buf()
346 } else {
347 self.root.join(path)
348 };
349
350 let canonical = if resolved.exists() {
352 resolved.canonicalize().unwrap_or(resolved)
353 } else {
354 normalize_path(&resolved, &self.root).unwrap_or(resolved)
355 };
356
357 canonical.starts_with(&self.root)
358 }
359
360 pub fn path_has_symlinks(&self, path: &Path) -> bool {
362 let mut current = PathBuf::new();
363
364 for component in path.components() {
365 current.push(component);
366
367 if current.exists()
368 && fs::symlink_metadata(¤t).is_ok_and(|m| m.file_type().is_symlink())
369 {
370 return true;
371 }
372 }
373
374 false
375 }
376
377 pub fn get_metadata(&self, path: &Path) -> WorkspaceResult<Metadata> {
379 let resolved = self.resolve_path_for_read(path)?;
380 fs::metadata(&resolved)
381 .map_err(|e| WorkspaceError::InvalidPath(format!("{}: {}", resolved.display(), e)))
382 }
383
384 pub fn get_symlink_metadata(&self, path: &Path) -> WorkspaceResult<Metadata> {
386 let resolved = self.resolve_path(path)?;
387 fs::symlink_metadata(&resolved)
388 .map_err(|e| WorkspaceError::InvalidPath(format!("{}: {}", resolved.display(), e)))
389 }
390
391 pub fn root(&self) -> &Path {
393 &self.root
394 }
395
396 pub fn cleanup(&self) -> WorkspaceResult<()> {
398 if self.is_sandbox && self.root.exists() {
399 tracing::info!(
400 session_id = %self.session_id,
401 workspace = ?self.root,
402 "Cleaning up sandbox workspace"
403 );
404 fs::remove_dir_all(&self.root)?;
405 }
406 Ok(())
407 }
408}
409
410pub fn get_sessions_base_dir() -> PathBuf {
414 dirs::data_local_dir()
415 .unwrap_or_else(std::env::temp_dir)
416 .join("gestura")
417 .join("sessions")
418}
419
420pub fn cleanup_old_sessions(max_age: std::time::Duration) -> WorkspaceResult<usize> {
422 let base_dir = get_sessions_base_dir();
423 if !base_dir.exists() {
424 return Ok(0);
425 }
426
427 let now = std::time::SystemTime::now();
428 let mut cleaned = 0;
429
430 for entry in fs::read_dir(&base_dir)? {
431 let entry = entry?;
432 let path = entry.path();
433
434 if !path.is_dir() {
435 continue;
436 }
437
438 let Ok(metadata) = entry.metadata() else {
440 continue;
441 };
442 let Ok(modified) = metadata.modified() else {
443 continue;
444 };
445 let Ok(age) = now.duration_since(modified) else {
446 continue;
447 };
448 if age <= max_age {
449 continue;
450 }
451
452 tracing::info!(
453 path = ?path,
454 age_days = age.as_secs() / 86400,
455 "Removing old session workspace"
456 );
457 if fs::remove_dir_all(&path).is_ok() {
458 cleaned += 1;
459 }
460 }
461
462 Ok(cleaned)
463}
464
465fn validate_session_id(session_id: &str) -> WorkspaceResult<()> {
471 if session_id.is_empty() {
473 return Err(WorkspaceError::InvalidPath(
474 "Session ID cannot be empty".to_string(),
475 ));
476 }
477
478 for ch in session_id.chars() {
480 match ch {
481 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => {}
483 _ => {
485 return Err(WorkspaceError::DangerousPath(format!(
486 "Session ID contains invalid character: '{}'",
487 ch
488 )));
489 }
490 }
491 }
492
493 if session_id.contains("..") {
495 return Err(WorkspaceError::DangerousPath(
496 "Session ID cannot contain '..'".to_string(),
497 ));
498 }
499
500 if session_id.len() > MAX_COMPONENT_LENGTH {
502 return Err(WorkspaceError::ComponentTooLong);
503 }
504
505 Ok(())
506}
507
508fn validate_path_safety(path: &Path) -> WorkspaceResult<()> {
510 let path_str = path.to_string_lossy();
511
512 if path_str.contains('\0') {
514 return Err(WorkspaceError::DangerousPath(
515 "Path contains null byte".to_string(),
516 ));
517 }
518
519 for ch in path_str.chars() {
521 if ch.is_control() && ch != '\t' && ch != '\n' && ch != '\r' {
522 return Err(WorkspaceError::DangerousPath(format!(
523 "Path contains control character: 0x{:02x}",
524 ch as u32
525 )));
526 }
527 }
528
529 for component in path.components() {
531 if let Component::Normal(os_str) = component {
532 let comp_str = os_str.to_string_lossy();
533
534 if comp_str.len() > MAX_COMPONENT_LENGTH {
536 return Err(WorkspaceError::ComponentTooLong);
537 }
538
539 if comp_str.starts_with("--") {
542 return Err(WorkspaceError::DangerousPath(format!(
543 "Path component looks like a command flag: {}",
544 comp_str
545 )));
546 }
547
548 let upper = comp_str.to_uppercase();
550 let reserved = [
551 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
552 "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
553 "LPT9",
554 ];
555 let base_name = upper.split('.').next().unwrap_or(&upper);
557 if reserved.contains(&base_name) {
558 return Err(WorkspaceError::DangerousPath(format!(
559 "Path contains Windows reserved name: {}",
560 comp_str
561 )));
562 }
563 }
564 }
565
566 Ok(())
567}
568
569fn validate_path_depth(path: &Path) -> WorkspaceResult<()> {
571 let depth = path.components().count();
572 if depth > MAX_PATH_DEPTH {
573 return Err(WorkspaceError::PathTooDeep);
574 }
575 Ok(())
576}
577
578fn normalize_path(path: &Path, workspace_root: &Path) -> WorkspaceResult<PathBuf> {
582 let mut normalized = PathBuf::new();
583
584 for component in path.components() {
585 match component {
586 Component::Prefix(p) => normalized.push(p.as_os_str()),
587 Component::RootDir => normalized.push(Component::RootDir.as_os_str()),
588 Component::CurDir => {
589 }
591 Component::ParentDir => {
592 if !normalized.pop() {
594 return Err(WorkspaceError::PathOutsideWorkspace {
596 path: path.to_path_buf(),
597 workspace: workspace_root.to_path_buf(),
598 });
599 }
600 }
601 Component::Normal(c) => {
602 normalized.push(c);
603 }
604 }
605 }
606
607 let canonical_workspace = workspace_root
610 .canonicalize()
611 .unwrap_or_else(|_| workspace_root.to_path_buf());
612 if !normalized.starts_with(&canonical_workspace) {
613 return Err(WorkspaceError::PathOutsideWorkspace {
614 path: path.to_path_buf(),
615 workspace: workspace_root.to_path_buf(),
616 });
617 }
618
619 Ok(normalized)
620}
621
622pub fn is_shell_command_allowed(command: &str) -> Result<(), String> {
624 let command_lower = command.to_lowercase();
625
626 let blocked_patterns = [
628 "sudo ",
630 "su ",
631 "doas ",
632 "pkexec ",
633 "rm -rf /",
635 "rm -fr /",
636 "rm -rf /*",
637 "chmod -R 777 /",
638 "chown -R",
639 "exec ",
641 "eval ",
642 "kill -9 1",
647 "killall ",
648 "mount ",
650 "umount ",
651 "mkfs",
652 "fdisk",
653 "dd if=",
654 ];
655
656 for pattern in &blocked_patterns {
657 if command_lower.contains(pattern) {
658 return Err(format!("Command contains blocked pattern: {}", pattern));
659 }
660 }
661
662 let blocked_prefixes = ["sudo", "su", "doas"];
664 let first_word = command.split_whitespace().next().unwrap_or("");
665 for prefix in &blocked_prefixes {
666 if first_word == *prefix {
667 return Err(format!("Command starts with blocked prefix: {}", prefix));
668 }
669 }
670
671 Ok(())
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use std::fs::File;
678 use tempfile::tempdir;
679
680 #[test]
681 fn test_sandbox_creation() {
682 let session_id = "test-session-123";
683 let workspace = SessionWorkspace::create_sandbox(session_id).unwrap();
684
685 assert!(workspace.root.exists());
686 assert!(workspace.is_sandbox);
687 assert_eq!(workspace.session_id, session_id);
688
689 workspace.cleanup().unwrap();
691 assert!(!workspace.root.exists());
692 }
693
694 #[test]
695 fn test_from_directory() {
696 let temp = tempdir().unwrap();
697 let session_id = "test-session-456";
698
699 let workspace =
700 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
701
702 assert!(!workspace.is_sandbox);
703 assert_eq!(workspace.session_id, session_id);
704 }
705
706 #[test]
707 fn test_path_resolution() {
708 let temp = tempdir().unwrap();
709 let session_id = "test-session-789";
710
711 let workspace =
712 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
713
714 let test_file = temp.path().join("test.txt");
716 File::create(&test_file).unwrap();
717
718 let resolved = workspace.resolve_path(Path::new("test.txt")).unwrap();
720 assert_eq!(resolved, test_file.canonicalize().unwrap());
721
722 let outside = workspace.resolve_path(Path::new("../../../etc/passwd"));
724 assert!(outside.is_err());
725 }
726
727 #[test]
728 fn test_is_path_allowed() {
729 let temp = tempdir().unwrap();
730 let session_id = "test-session-allowed";
731
732 let workspace =
733 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
734
735 assert!(workspace.is_path_allowed(Path::new("subdir/file.txt")));
736 assert!(workspace.is_path_allowed(temp.path()));
737 }
739
740 #[test]
741 fn test_dangerous_path_null_byte() {
742 let result = validate_path_safety(Path::new("file\0.txt"));
743 assert!(result.is_err());
744 if let Err(WorkspaceError::DangerousPath(msg)) = result {
745 assert!(msg.contains("null byte"));
746 }
747 }
748
749 #[test]
750 fn test_dangerous_path_control_chars() {
751 let result = validate_path_safety(Path::new("file\x07.txt"));
753 assert!(result.is_err());
754 }
755
756 #[test]
757 fn test_dangerous_path_windows_reserved() {
758 let result = validate_path_safety(Path::new("CON"));
759 assert!(result.is_err());
760
761 let result = validate_path_safety(Path::new("LPT1.txt"));
762 assert!(result.is_err());
763
764 let result = validate_path_safety(Path::new("normal.txt"));
765 assert!(result.is_ok());
766 }
767
768 #[test]
769 fn test_path_depth_limit() {
770 let deep_path: PathBuf = (0..100).map(|i| format!("dir{}", i)).collect();
772 let result = validate_path_depth(&deep_path);
773 assert!(result.is_err());
774
775 let normal_path = Path::new("a/b/c/d/e");
777 let result = validate_path_depth(normal_path);
778 assert!(result.is_ok());
779 }
780
781 #[test]
782 fn test_session_id_validation() {
783 assert!(validate_session_id("my-session-123").is_ok());
785 assert!(validate_session_id("session_with_underscore").is_ok());
786 assert!(validate_session_id("UPPERCASE123").is_ok());
787
788 assert!(validate_session_id("").is_err());
790 assert!(validate_session_id("session/with/slashes").is_err());
791 assert!(validate_session_id("session..traversal").is_err());
792 assert!(validate_session_id("session with spaces").is_err());
793 }
794
795 #[test]
796 fn test_shell_command_blocking() {
797 assert!(is_shell_command_allowed("sudo rm -rf /").is_err());
799 assert!(is_shell_command_allowed("rm -rf /").is_err());
800 assert!(is_shell_command_allowed("su - root").is_err());
801
802 assert!(is_shell_command_allowed("ls -la").is_ok());
804 assert!(is_shell_command_allowed("git status").is_ok());
805 assert!(is_shell_command_allowed("cat file.txt").is_ok());
806 assert!(is_shell_command_allowed("rm -rf ./node_modules").is_ok());
807 }
808
809 #[cfg(unix)]
810 #[test]
811 fn test_symlink_validation() {
812 use std::os::unix::fs::symlink;
813
814 let temp = tempdir().unwrap();
815 let session_id = "test-symlink-session";
816
817 let workspace =
818 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
819
820 let target = temp.path().join("target.txt");
822 File::create(&target).unwrap();
823 let valid_link = temp.path().join("valid_link");
824 symlink(&target, &valid_link).unwrap();
825
826 let result = workspace.resolve_path(Path::new("valid_link"));
828 assert!(result.is_ok());
829
830 let outside_link = temp.path().join("escape_link");
832 symlink("/etc/passwd", &outside_link).unwrap();
833
834 let result = workspace.resolve_path(Path::new("escape_link"));
836 assert!(result.is_err());
837 if let Err(WorkspaceError::SymlinkEscape { .. }) = result {
838 } else {
840 panic!("Expected SymlinkEscape error");
841 }
842 }
843
844 #[test]
845 fn test_resolve_path_for_write() {
846 let temp = tempdir().unwrap();
847 let session_id = "test-write-session";
848
849 let workspace =
850 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
851
852 let subdir = temp.path().join("subdir");
854 fs::create_dir(&subdir).unwrap();
855
856 let result = workspace.resolve_path_for_write(Path::new("subdir/new_file.txt"));
858 assert!(result.is_ok());
859
860 let result = workspace.resolve_path_for_write(Path::new("nonexistent/file.txt"));
862 assert!(result.is_err());
863 }
864
865 #[test]
866 fn test_resolve_path_for_create_allows_new_parent_dirs() {
867 let temp = tempdir().unwrap();
868 let session_id = "test-create-session";
869
870 let workspace =
871 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
872
873 let result = workspace.resolve_path_for_create(Path::new("newdir/nested/file.txt"));
875 assert!(result.is_ok());
876
877 let outside = workspace.resolve_path_for_create(Path::new("../../../etc/passwd"));
879 assert!(outside.is_err());
880 }
881
882 #[test]
883 fn test_path_has_symlinks() {
884 let temp = tempdir().unwrap();
885 let session_id = "test-has-symlinks";
886
887 let workspace =
888 SessionWorkspace::from_directory(session_id, temp.path().to_path_buf()).unwrap();
889
890 let regular = temp.path().join("regular.txt");
892 File::create(®ular).unwrap();
893 assert!(!workspace.path_has_symlinks(Path::new("regular.txt")));
894 }
895}