Skip to main content

winx_code_agent/utils/
path.rs

1use std::io;
2use std::path::{Path, PathBuf};
3
4/// Security error for path validation
5#[derive(Debug)]
6pub enum PathSecurityError {
7    /// Path escapes the workspace root (path traversal attempt)
8    PathTraversal { path: PathBuf, workspace: PathBuf },
9    /// Path is a symlink pointing outside workspace
10    SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
11    /// Failed to canonicalize path
12    CanonicalizationFailed { path: PathBuf, error: io::Error },
13}
14
15impl std::fmt::Display for PathSecurityError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            PathSecurityError::PathTraversal { path, workspace } => {
19                write!(
20                    f,
21                    "Path traversal detected: '{}' escapes workspace '{}'",
22                    path.display(),
23                    workspace.display()
24                )
25            }
26            PathSecurityError::SymlinkEscape { path, target, workspace } => {
27                write!(
28                    f,
29                    "Symlink escape detected: '{}' points to '{}' outside workspace '{}'",
30                    path.display(),
31                    target.display(),
32                    workspace.display()
33                )
34            }
35            PathSecurityError::CanonicalizationFailed { path, error } => {
36                write!(f, "Failed to resolve path '{}': {}", path.display(), error)
37            }
38        }
39    }
40}
41
42impl std::error::Error for PathSecurityError {}
43
44/// Validates that a path is within the workspace root.
45/// Returns the canonicalized path if valid.
46///
47/// # Security
48/// - Prevents path traversal attacks (../)
49/// - Detects symlinks pointing outside workspace
50/// - Canonicalizes path before comparison
51pub fn validate_path_in_workspace(
52    path: &Path,
53    workspace_root: &Path,
54) -> Result<PathBuf, PathSecurityError> {
55    // First, check if it's a symlink and validate target
56    if let Ok(metadata) = std::fs::symlink_metadata(path) {
57        if metadata.file_type().is_symlink() {
58            // Resolve the symlink target
59            if let Ok(target) = std::fs::read_link(path) {
60                let absolute_target = if target.is_absolute() {
61                    target.clone()
62                } else {
63                    path.parent().unwrap_or(Path::new("/")).join(&target)
64                };
65
66                // Canonicalize target and check if it's in workspace
67                if let Ok(canonical_target) = absolute_target.canonicalize() {
68                    if let Ok(canonical_workspace) = workspace_root.canonicalize() {
69                        if !canonical_target.starts_with(&canonical_workspace) {
70                            return Err(PathSecurityError::SymlinkEscape {
71                                path: path.to_path_buf(),
72                                target: canonical_target,
73                                workspace: canonical_workspace,
74                            });
75                        }
76                    }
77                }
78            }
79        }
80    }
81
82    // Canonicalize the path (resolves .., symlinks, etc.)
83    let canonical_path = match path.canonicalize() {
84        Ok(p) => p,
85        Err(e) if e.kind() == io::ErrorKind::NotFound => {
86            // If path doesn't exist (creating new file), validate parent
87            if let Some(parent) = path.parent() {
88                // If parent exists, canonicalize it and check
89                if parent.exists() {
90                    let canonical_parent = parent.canonicalize().map_err(|e| {
91                        PathSecurityError::CanonicalizationFailed {
92                            path: parent.to_path_buf(),
93                            error: e,
94                        }
95                    })?;
96                    // Return the canonical parent joined with the filename
97                    // This gives us a "pseudo-canonical" path for the new file
98                    canonical_parent.join(path.file_name().unwrap_or_default())
99                } else {
100                    // If parent also doesn't exist, we rely on the workspace check of the "best effort" path
101                    // This is slightly looser but allows recursive directory creation if implemented.
102                    // However, standard canonicalize fails.
103                    // For security, we might want to just enforce that we are inside workspace by simple string check
104                    // or walk up until we find an existing directory.
105                    // For now, let's just attempt to resolve relative components manually if possible,
106                    // or return error.
107                    // But simpler: just fallback to checking the parent recursively?
108                    // A simple fallback: just assume the provided path is relative to CWD if relative,
109                    // and if absolute, sanitize .. components.
110
111                    // BETTER APPROACH: Walk up until we find an existing directory
112                    let mut current = path.to_path_buf();
113                    while !current.exists() {
114                        if let Some(parent) = current.parent() {
115                            current = parent.to_path_buf();
116                        } else {
117                            // Hit root and it doesn't exist? unwritable.
118                            break;
119                        }
120                    }
121                    if current.exists() {
122                        let canonical_base = current.canonicalize().map_err(|e| {
123                            PathSecurityError::CanonicalizationFailed {
124                                path: current.clone(),
125                                error: e,
126                            }
127                        })?;
128                        // Reconstruct the full path
129                        // This identifies the "real" location of the base
130                        // We can't easily reconstruct the full canonical path without resolving the missing components' ..
131                        // But if we assume no .. in the missing part, we can join.
132
133                        // For Winx, let's keep it simple: if file doesn't exist, parent MUST exist for now?
134                        // Or just allow the error to bubble up if we can't verify safety?
135                        // WCGW Python allowed anything under workspace.
136
137                        // Let's return the error for now if simple parent check fails, to match strict security.
138                        return Err(PathSecurityError::CanonicalizationFailed {
139                            path: path.to_path_buf(),
140                            error: e,
141                        });
142                    }
143                    return Err(PathSecurityError::CanonicalizationFailed {
144                        path: path.to_path_buf(),
145                        error: e,
146                    });
147                }
148            } else {
149                return Err(PathSecurityError::CanonicalizationFailed {
150                    path: path.to_path_buf(),
151                    error: e,
152                });
153            }
154        }
155        Err(e) => {
156            return Err(PathSecurityError::CanonicalizationFailed {
157                path: path.to_path_buf(),
158                error: e,
159            })
160        }
161    };
162
163    // Canonicalize workspace root
164    let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
165        PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
166    })?;
167
168    // Check if path is within workspace
169    if !canonical_path.starts_with(&canonical_workspace) {
170        return Err(PathSecurityError::PathTraversal {
171            path: path.to_path_buf(),
172            workspace: canonical_workspace,
173        });
174    }
175
176    Ok(canonical_path)
177}
178
179/// Check if a path is a symlink without following it
180pub fn is_symlink(path: &Path) -> bool {
181    std::fs::symlink_metadata(path).map(|m| m.file_type().is_symlink()).unwrap_or(false)
182}
183
184/// Expands a path that starts with ~ to the user's home directory
185pub fn expand_user(path: &str) -> String {
186    if path.starts_with('~') {
187        if let Some(home_dir) = home::home_dir() {
188            return path.replacen('~', home_dir.to_str().unwrap_or(""), 1);
189        }
190    }
191    path.to_string()
192}
193
194/// Ensures a directory exists, creating it if necessary
195pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
196    if !path.exists() {
197        std::fs::create_dir_all(path)?;
198    }
199    Ok(())
200}