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}