gestura_core_sessions/
session_workspace.rs

1//! Session workspace management for sandboxed file operations
2//!
3//! This module provides utilities for creating and managing session-specific
4//! workspace directories. All file operations, shell commands, and tool calls
5//! are scoped to the session's workspace directory for security and isolation.
6//!
7//! # Security Features
8//!
9//! - **Path Traversal Prevention**: All paths are validated to ensure they resolve within the workspace
10//! - **Symlink Validation**: Symlinks are checked to ensure their targets are within the workspace
11//! - **Dangerous Path Blocking**: Paths containing null bytes, control characters, or suspicious patterns are rejected
12//! - **Depth Limiting**: Maximum path component depth is enforced to prevent abuse
13//! - **Race Condition Mitigation**: Time-of-check-time-of-use (TOCTOU) considerations are documented
14
15use std::fs::{self, Metadata};
16use std::path::{Component, Path, PathBuf};
17use thiserror::Error;
18
19/// Maximum depth of path components allowed (prevents deeply nested attacks)
20const MAX_PATH_DEPTH: usize = 64;
21
22/// Maximum length of a single path component
23const MAX_COMPONENT_LENGTH: usize = 255;
24
25/// Errors that can occur during workspace operations
26#[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
56/// Result type for workspace operations
57pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
58
59/// Session workspace configuration
60#[derive(Debug, Clone)]
61pub struct SessionWorkspace {
62    /// The root directory for this session's workspace
63    pub root: PathBuf,
64    /// Session ID this workspace belongs to
65    pub session_id: String,
66    /// Whether this is a user-provided directory or auto-generated sandbox
67    pub is_sandbox: bool,
68}
69
70impl SessionWorkspace {
71    /// Create a new session workspace with an auto-generated sandbox directory
72    ///
73    /// Creates a directory at `~/.gestura/sessions/<session_id>/`
74    pub fn create_sandbox(session_id: &str) -> WorkspaceResult<Self> {
75        // Validate session ID to prevent path injection
76        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        // Canonicalize after creation to get the real path
84        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    /// Create a session workspace using an existing directory
102    ///
103    /// This is used when the user specifies a project directory (CLI cwd or GUI selection)
104    pub fn from_directory(session_id: &str, directory: PathBuf) -> WorkspaceResult<Self> {
105        // Validate session ID
106        validate_session_id(session_id)?;
107
108        // Verify the directory exists
109        if !directory.exists() {
110            return Err(WorkspaceError::WorkspaceNotFound(directory));
111        }
112
113        // Check that the directory is actually a directory (not a symlink to a file)
114        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        // Canonicalize the path to resolve symlinks and relative paths
125        let canonical = directory
126            .canonicalize()
127            .map_err(|e| WorkspaceError::InvalidPath(format!("{}: {}", directory.display(), e)))?;
128
129        // Double-check the canonical path is a directory
130        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    /// Resolve a path relative to the workspace root with full security validation
151    ///
152    /// This method performs comprehensive security checks:
153    /// 1. Validates path for dangerous characters/patterns
154    /// 2. Checks path depth limits
155    /// 3. Validates symlinks point within workspace
156    /// 4. Ensures resolved path is within workspace bounds
157    ///
158    /// Returns an error if any security check fails.
159    pub fn resolve_path(&self, path: &Path) -> WorkspaceResult<PathBuf> {
160        // First, validate the path string for dangerous patterns
161        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
170        validate_path_depth(&resolved)?;
171
172        // For existing paths, perform symlink validation
173        if resolved.exists() {
174            self.validate_symlinks_in_path(&resolved)?;
175        }
176
177        // Canonicalize to resolve .. and symlinks
178        // For non-existent paths, we need to normalize manually
179        let canonical = if resolved.exists() {
180            resolved.canonicalize().map_err(|e| {
181                WorkspaceError::InvalidPath(format!("{}: {}", resolved.display(), e))
182            })?
183        } else {
184            // For new files, normalize the path without requiring existence
185            normalize_path(&resolved, &self.root)?
186        };
187
188        // Check if the path is within the workspace
189        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    /// Resolve a path for reading (file must exist)
200    ///
201    /// Additional validation for read operations including symlink target checks.
202    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        // Final symlink check on the resolved path
213        self.validate_symlinks_in_path(&resolved)?;
214
215        Ok(resolved)
216    }
217
218    /// Resolve a path for writing (parent directory must exist)
219    ///
220    /// Validates the parent directory exists and is writable.
221    pub fn resolve_path_for_write(&self, path: &Path) -> WorkspaceResult<PathBuf> {
222        let resolved = self.resolve_path(path)?;
223
224        // Check parent directory exists and is within workspace
225        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            // Validate parent is within workspace
234            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    /// Resolve a path for creation (file/dir may not exist yet).
250    ///
251    /// This is similar to [`Self::resolve_path_for_write`], but it does **not** require the
252    /// parent directory to exist. It is intended for tools that legitimately create output
253    /// directories as part of their operation (e.g. screen capture artifacts).
254    ///
255    /// Security note: even when the target doesn't exist yet, we still validate any *existing*
256    /// path components for symlink escapes.
257    pub fn resolve_path_for_create(&self, path: &Path) -> WorkspaceResult<PathBuf> {
258        let resolved = self.resolve_path(path)?;
259        // Validate symlinks on the existing prefix even if the final path doesn't exist yet.
260        self.validate_symlinks_in_path(&resolved)?;
261        Ok(resolved)
262    }
263
264    /// Validate all symlinks in a path point to targets within the workspace
265    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            // Skip if this component doesn't exist yet
272            if !current.exists() {
273                continue;
274            }
275
276            // Check if this path component is a symlink
277            if fs::symlink_metadata(&current).is_ok_and(|m| m.file_type().is_symlink()) {
278                self.validate_symlink(&current)?;
279            }
280        }
281
282        Ok(())
283    }
284
285    /// Validate a single symlink points within the workspace
286    fn validate_symlink(&self, symlink_path: &Path) -> WorkspaceResult<()> {
287        // Read the symlink target
288        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        // Resolve the target relative to the symlink's parent
297        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        // Canonicalize the target to get the real path
307        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        // Check if the target is within the workspace
316        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    /// Check if a path is within the workspace (without resolving)
334    pub fn is_path_allowed(&self, path: &Path) -> bool {
335        // Quick validation checks
336        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        // Try to canonicalize, fall back to normalization
351        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    /// Check if a path contains any symlinks
361    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(&current).is_ok_and(|m| m.file_type().is_symlink())
369            {
370                return true;
371            }
372        }
373
374        false
375    }
376
377    /// Get file metadata with symlink awareness
378    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    /// Get symlink metadata (doesn't follow symlinks)
385    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    /// Get the workspace root directory
392    pub fn root(&self) -> &Path {
393        &self.root
394    }
395
396    /// Clean up the workspace directory (only for sandbox workspaces)
397    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
410/// Get the base directory for session workspaces
411///
412/// Returns `~/.gestura/sessions/` on Unix or `%LOCALAPPDATA%\gestura\sessions\` on Windows
413pub 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
420/// Clean up old session workspaces that are older than the specified duration
421pub 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        // Check the modification time
439        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
465// ============================================================================
466// Security Validation Helper Functions
467// ============================================================================
468
469/// Validate a session ID to prevent path injection attacks
470fn validate_session_id(session_id: &str) -> WorkspaceResult<()> {
471    // Check for empty session ID
472    if session_id.is_empty() {
473        return Err(WorkspaceError::InvalidPath(
474            "Session ID cannot be empty".to_string(),
475        ));
476    }
477
478    // Check for dangerous characters
479    for ch in session_id.chars() {
480        match ch {
481            // Allow alphanumeric, hyphens, and underscores
482            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => {}
483            // Block everything else
484            _ => {
485                return Err(WorkspaceError::DangerousPath(format!(
486                    "Session ID contains invalid character: '{}'",
487                    ch
488                )));
489            }
490        }
491    }
492
493    // Check for path traversal patterns
494    if session_id.contains("..") {
495        return Err(WorkspaceError::DangerousPath(
496            "Session ID cannot contain '..'".to_string(),
497        ));
498    }
499
500    // Check length
501    if session_id.len() > MAX_COMPONENT_LENGTH {
502        return Err(WorkspaceError::ComponentTooLong);
503    }
504
505    Ok(())
506}
507
508/// Validate a path for dangerous patterns and characters
509fn validate_path_safety(path: &Path) -> WorkspaceResult<()> {
510    let path_str = path.to_string_lossy();
511
512    // Check for null bytes (can be used to truncate paths in some systems)
513    if path_str.contains('\0') {
514        return Err(WorkspaceError::DangerousPath(
515            "Path contains null byte".to_string(),
516        ));
517    }
518
519    // Check for control characters (ASCII 0-31 except tab, newline)
520    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    // Check each component for validity
530    for component in path.components() {
531        if let Component::Normal(os_str) = component {
532            let comp_str = os_str.to_string_lossy();
533
534            // Check component length
535            if comp_str.len() > MAX_COMPONENT_LENGTH {
536                return Err(WorkspaceError::ComponentTooLong);
537            }
538
539            // Block components that start with hyphen (could be interpreted as flags)
540            // Allow hidden files (starting with .) as they're common in development
541            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            // Block Windows reserved names (even on Unix for portability)
549            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            // Check if it's exactly the reserved name or reserved name with extension
556            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
569/// Validate that a path doesn't exceed the maximum depth
570fn 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
578/// Normalize a path without requiring it to exist
579///
580/// This handles `..` and `.` components safely for paths that don't exist yet.
581fn 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                // Skip `.` components
590            }
591            Component::ParentDir => {
592                // Handle `..` by popping the last component
593                if !normalized.pop() {
594                    // Can't go above root
595                    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    // Ensure the normalized path is still within the workspace
608    // This catches cases where many `..` components escape the workspace
609    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
622/// Blocked shell command patterns for additional security
623pub fn is_shell_command_allowed(command: &str) -> Result<(), String> {
624    let command_lower = command.to_lowercase();
625
626    // Block commands that could escape the workspace or cause system damage
627    let blocked_patterns = [
628        // Privilege escalation
629        "sudo ",
630        "su ",
631        "doas ",
632        "pkexec ",
633        // Dangerous destructive commands with root paths
634        "rm -rf /",
635        "rm -fr /",
636        "rm -rf /*",
637        "chmod -R 777 /",
638        "chown -R",
639        // Shell escapes
640        "exec ",
641        "eval ",
642        // Network exfiltration (could be too aggressive, consider removing)
643        // "curl ",
644        // "wget ",
645        // Process manipulation
646        "kill -9 1",
647        "killall ",
648        // System modification
649        "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    // Block commands that start with certain dangerous prefixes
663    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        // Cleanup
690        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        // Create a file in the workspace
715        let test_file = temp.path().join("test.txt");
716        File::create(&test_file).unwrap();
717
718        // Relative path should resolve within workspace
719        let resolved = workspace.resolve_path(Path::new("test.txt")).unwrap();
720        assert_eq!(resolved, test_file.canonicalize().unwrap());
721
722        // Path outside workspace should fail
723        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        // Note: is_path_allowed may return true for non-existent paths that would be inside
738    }
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        // Test with a control character (bell)
752        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        // Create a very deep path
771        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        // Normal depth should be fine
776        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        // Valid session IDs
784        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        // Invalid session IDs
789        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        // Blocked commands
798        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        // Allowed commands
803        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        // Create a valid symlink within workspace
821        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        // Valid symlink should be allowed
827        let result = workspace.resolve_path(Path::new("valid_link"));
828        assert!(result.is_ok());
829
830        // Create a symlink pointing outside workspace
831        let outside_link = temp.path().join("escape_link");
832        symlink("/etc/passwd", &outside_link).unwrap();
833
834        // Invalid symlink should be blocked
835        let result = workspace.resolve_path(Path::new("escape_link"));
836        assert!(result.is_err());
837        if let Err(WorkspaceError::SymlinkEscape { .. }) = result {
838            // Expected
839        } 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        // Create a subdirectory
853        let subdir = temp.path().join("subdir");
854        fs::create_dir(&subdir).unwrap();
855
856        // Writing to existing directory should work
857        let result = workspace.resolve_path_for_write(Path::new("subdir/new_file.txt"));
858        assert!(result.is_ok());
859
860        // Writing to non-existent parent should fail
861        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        // Parent doesn't exist yet, but creation-oriented resolution should succeed.
874        let result = workspace.resolve_path_for_create(Path::new("newdir/nested/file.txt"));
875        assert!(result.is_ok());
876
877        // Should still block attempts to escape.
878        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        // Regular file should not have symlinks
891        let regular = temp.path().join("regular.txt");
892        File::create(&regular).unwrap();
893        assert!(!workspace.path_has_symlinks(Path::new("regular.txt")));
894    }
895}