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