Skip to main content

perl_path_security/
lib.rs

1//! Workspace-bound path validation and traversal prevention.
2//!
3//! This crate centralizes path-boundary checks used by tooling that accepts
4//! user-provided file paths (for example LSP/DAP requests).
5
6use std::path::{Component, Path, PathBuf};
7
8use perl_path_normalize::{NormalizePathError, normalize_path_within_workspace};
9
10/// Path validation errors for workspace-bound operations.
11#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
12pub enum WorkspacePathError {
13    /// Parent traversal or invalid component escaping workspace constraints.
14    #[error("Path traversal attempt detected: {0}")]
15    PathTraversalAttempt(String),
16
17    /// Path resolves outside the workspace root.
18    #[error("Path outside workspace: {0}")]
19    PathOutsideWorkspace(String),
20
21    /// Path contains null bytes or disallowed control characters.
22    #[error("Invalid path characters detected")]
23    InvalidPathCharacters,
24}
25
26/// Validate and normalize a path so it remains within `workspace_root`.
27///
28/// The returned path is absolute and suitable for downstream filesystem access.
29pub fn validate_workspace_path(
30    path: &Path,
31    workspace_root: &Path,
32) -> Result<PathBuf, WorkspacePathError> {
33    // Reject null bytes and control characters to avoid protocol/filesystem confusion.
34    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    // Join relative paths with workspace; keep absolute paths untouched.
48    let resolved = if path.is_absolute() { path.to_path_buf() } else { workspace_root.join(path) };
49
50    // Existing paths are canonicalized directly. Non-existing paths are normalized by
51    // processing components while preventing escape beyond workspace depth.
52    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
83/// Sanitize and normalize user-provided completion path input.
84///
85/// Returns `None` when path contains traversal, absolute path (except `/`),
86/// drive-prefix, null bytes, or suspicious traversal patterns.
87pub 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
113/// Split sanitized completion path into `(directory_part, file_prefix)`.
114pub 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
121/// Resolve a directory used for file completion traversal.
122pub 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
145/// Check whether a filename should be skipped during file completion traversal.
146pub 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
169/// Validate filename safety for completion entries.
170pub 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
194/// Build completion path string and append trailing slash for directories.
195pub 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}