1use std::path::{Component, Path, PathBuf};
7
8use perl_path_normalize::{NormalizePathError, normalize_path_within_workspace};
9
10#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
12pub enum WorkspacePathError {
13 #[error("Path traversal attempt detected: {0}")]
15 PathTraversalAttempt(String),
16
17 #[error("Path outside workspace: {0}")]
19 PathOutsideWorkspace(String),
20
21 #[error("Invalid path characters detected")]
23 InvalidPathCharacters,
24}
25
26pub fn validate_workspace_path(
30 path: &Path,
31 workspace_root: &Path,
32) -> Result<PathBuf, WorkspacePathError> {
33 if let Some(path_str) = path.to_str()
35 && (path_str.contains('\0') || path_str.chars().any(|c| c.is_control() && c != '\t'))
36 {
37 return Err(WorkspacePathError::InvalidPathCharacters);
38 }
39
40 let workspace_canonical = workspace_root.canonicalize().map_err(|error| {
41 WorkspacePathError::PathOutsideWorkspace(format!(
42 "Workspace root not accessible: {} ({error})",
43 workspace_root.display()
44 ))
45 })?;
46
47 let resolved = if path.is_absolute() { path.to_path_buf() } else { workspace_root.join(path) };
49
50 let final_path = if let Ok(canonical) = resolved.canonicalize() {
53 if !canonical.starts_with(&workspace_canonical) {
54 return Err(WorkspacePathError::PathOutsideWorkspace(format!(
55 "Path resolves outside workspace: {} (workspace: {})",
56 canonical.display(),
57 workspace_canonical.display()
58 )));
59 }
60
61 canonical
62 } else {
63 normalize_path_within_workspace(path, &workspace_canonical).map_err(
64 |error| match error {
65 NormalizePathError::PathTraversalAttempt(message) => {
66 WorkspacePathError::PathTraversalAttempt(message)
67 }
68 },
69 )?
70 };
71
72 if !final_path.starts_with(&workspace_canonical) {
73 return Err(WorkspacePathError::PathOutsideWorkspace(format!(
74 "Path outside workspace: {} (workspace: {})",
75 final_path.display(),
76 workspace_canonical.display()
77 )));
78 }
79
80 Ok(final_path)
81}
82
83pub fn sanitize_completion_path_input(path: &str) -> Option<String> {
88 if path.is_empty() {
89 return Some(String::new());
90 }
91
92 if path.contains('\0') {
93 return None;
94 }
95
96 let path_obj = Path::new(path);
97 for component in path_obj.components() {
98 match component {
99 Component::ParentDir => return None,
100 Component::RootDir if path != "/" => return None,
101 Component::Prefix(_) => return None,
102 _ => {}
103 }
104 }
105
106 if path.contains("../") || path.contains("..\\") || path.starts_with('/') && path != "/" {
107 return None;
108 }
109
110 Some(path.replace('\\', "/"))
111}
112
113pub fn split_completion_path_components(path: &str) -> (String, String) {
115 match path.rsplit_once('/') {
116 Some((dir, file)) if !dir.is_empty() => (dir.to_string(), file.to_string()),
117 _ => (".".to_string(), path.to_string()),
118 }
119}
120
121pub fn resolve_completion_base_directory(dir_part: &str) -> Option<PathBuf> {
123 let path = Path::new(dir_part);
124
125 if path.is_absolute() && dir_part != "/" {
126 return None;
127 }
128
129 if dir_part == "." {
130 return Some(Path::new(".").to_path_buf());
131 }
132
133 match path.canonicalize() {
134 Ok(canonical) => Some(canonical),
135 Err(_) => {
136 if path.exists() && path.is_dir() {
137 Some(path.to_path_buf())
138 } else {
139 None
140 }
141 }
142 }
143}
144
145pub fn is_hidden_or_forbidden_entry_name(file_name: &str) -> bool {
147 if file_name.starts_with('.') && file_name.len() > 1 {
148 return true;
149 }
150
151 matches!(
152 file_name,
153 "node_modules"
154 | ".git"
155 | ".svn"
156 | ".hg"
157 | "target"
158 | "build"
159 | ".cargo"
160 | ".rustup"
161 | "System Volume Information"
162 | "$RECYCLE.BIN"
163 | "__pycache__"
164 | ".pytest_cache"
165 | ".mypy_cache"
166 )
167}
168
169pub fn is_safe_completion_filename(filename: &str) -> bool {
171 if filename.is_empty() || filename.len() > 255 {
172 return false;
173 }
174
175 if filename.contains('\0') || filename.chars().any(|c| c.is_control()) {
176 return false;
177 }
178
179 let name_upper = filename.to_uppercase();
180 let reserved = [
181 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
182 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
183 ];
184
185 for reserved_name in &reserved {
186 if name_upper == *reserved_name || name_upper.starts_with(&format!("{}.", reserved_name)) {
187 return false;
188 }
189 }
190
191 true
192}
193
194pub fn build_completion_path(dir_part: &str, filename: &str, is_dir: bool) -> String {
196 let mut path = if dir_part == "." {
197 filename.to_string()
198 } else {
199 format!("{}/{}", dir_part.trim_end_matches('/'), filename)
200 };
201
202 if is_dir {
203 path.push('/');
204 }
205
206 path
207}
208
209#[cfg(test)]
210mod tests {
211 use super::{
212 WorkspacePathError, build_completion_path, is_hidden_or_forbidden_entry_name,
213 is_safe_completion_filename, sanitize_completion_path_input,
214 split_completion_path_components, validate_workspace_path,
215 };
216 use std::path::PathBuf;
217
218 type TestResult = Result<(), Box<dyn std::error::Error>>;
219
220 #[test]
221 fn validates_safe_relative_path() -> TestResult {
222 let temp_dir = tempfile::tempdir()?;
223 let workspace = temp_dir.path();
224
225 let validated = validate_workspace_path(&PathBuf::from("src/main.pl"), workspace)?;
226 assert!(validated.starts_with(workspace.canonicalize()?));
227 assert!(validated.to_string_lossy().contains("src"));
228 assert!(validated.to_string_lossy().contains("main.pl"));
229
230 Ok(())
231 }
232
233 #[test]
234 fn rejects_parent_directory_escape() -> TestResult {
235 let temp_dir = tempfile::tempdir()?;
236 let workspace = temp_dir.path();
237
238 let result = validate_workspace_path(&PathBuf::from("../../../etc/passwd"), workspace);
239 assert!(result.is_err());
240
241 match result {
242 Err(WorkspacePathError::PathTraversalAttempt(_))
243 | Err(WorkspacePathError::PathOutsideWorkspace(_)) => Ok(()),
244 Err(error) => Err(format!("unexpected error type: {error:?}").into()),
245 Ok(_) => Err("expected path validation error".into()),
246 }
247 }
248
249 #[test]
250 fn rejects_null_byte_injection() -> TestResult {
251 let temp_dir = tempfile::tempdir()?;
252 let workspace = temp_dir.path();
253
254 let result =
255 validate_workspace_path(&PathBuf::from("valid.pl\0../../etc/passwd"), workspace);
256 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
257
258 Ok(())
259 }
260
261 #[test]
262 fn allows_dot_files_inside_workspace() -> TestResult {
263 let temp_dir = tempfile::tempdir()?;
264 let workspace = temp_dir.path();
265
266 let result = validate_workspace_path(&PathBuf::from(".gitignore"), workspace);
267 assert!(result.is_ok());
268
269 Ok(())
270 }
271
272 #[test]
273 fn supports_current_directory_component() -> TestResult {
274 let temp_dir = tempfile::tempdir()?;
275 let workspace = temp_dir.path();
276
277 let validated = validate_workspace_path(&PathBuf::from("./lib/Module.pm"), workspace)?;
278 assert!(validated.to_string_lossy().contains("lib"));
279 assert!(validated.to_string_lossy().contains("Module.pm"));
280
281 Ok(())
282 }
283
284 #[test]
285 fn mixed_separator_behavior_matches_platform_rules() -> TestResult {
286 let workspace = std::env::current_dir()?;
287 let path = PathBuf::from("..\\../etc/passwd");
288
289 let result = validate_workspace_path(&path, &workspace);
290 if cfg!(windows) {
291 assert!(result.is_err());
292 } else {
293 assert!(result.is_ok());
294 }
295
296 Ok(())
297 }
298
299 #[test]
300 fn completion_path_sanitization_blocks_traversal() {
301 assert_eq!(sanitize_completion_path_input(""), Some(String::new()));
302 assert_eq!(sanitize_completion_path_input("lib/Foo.pm"), Some("lib/Foo.pm".to_string()));
303 assert!(sanitize_completion_path_input("../etc/passwd").is_none());
304 }
305
306 #[test]
307 fn completion_path_helpers_work() {
308 assert_eq!(
309 split_completion_path_components("lib/Foo"),
310 ("lib".to_string(), "Foo".to_string())
311 );
312 assert_eq!(split_completion_path_components("Foo"), (".".to_string(), "Foo".to_string()));
313 assert_eq!(build_completion_path(".", "Foo.pm", false), "Foo.pm".to_string());
314 assert_eq!(build_completion_path("lib", "Foo", true), "lib/Foo/".to_string());
315 }
316
317 #[test]
318 fn completion_filename_and_visibility_checks_work() {
319 assert!(is_hidden_or_forbidden_entry_name(".git"));
320 assert!(is_hidden_or_forbidden_entry_name("node_modules"));
321 assert!(!is_hidden_or_forbidden_entry_name("lib"));
322
323 assert!(is_safe_completion_filename("Foo.pm"));
324 assert!(!is_safe_completion_filename("CON"));
325 assert!(!is_safe_completion_filename("bad\0name"));
326 }
327}