Skip to main content

dscode_extension_host/
path_validator.rs

1use std::fs;
2/**
3 * Path Validation and Sandboxing
4 *
5 * Prevents path traversal attacks and ensures extensions can only access
6 * files within allowed directories (workspace, extensions folder, temp).
7 */
8use std::path::{Path, PathBuf};
9
10/// Percent-decode a URI path component
11fn percent_decode_str(input: &str) -> String {
12    let mut result = String::with_capacity(input.len());
13    let mut chars = input.bytes();
14
15    while let Some(byte) = chars.next() {
16        if byte == b'%' {
17            let hi = chars.next();
18            let lo = chars.next();
19            if let (Some(h), Some(l)) = (hi, lo) {
20                if let (Some(hv), Some(lv)) = (hex_digit(h), hex_digit(l)) {
21                    result.push(char::from(hv * 16 + lv));
22                    continue;
23                }
24                // Invalid hex digits — push back what we consumed
25                result.push(byte as char);
26                result.push(h as char);
27                result.push(l as char);
28            } else {
29                // Incomplete sequence — push back what we consumed
30                result.push(byte as char);
31                if let Some(h) = hi {
32                    result.push(h as char);
33                }
34            }
35        } else if byte == b'+' {
36            result.push(' ');
37        } else {
38            result.push(byte as char);
39        }
40    }
41
42    result
43}
44
45fn hex_digit(b: u8) -> Option<u8> {
46    match b {
47        b'0'..=b'9' => Some(b - b'0'),
48        b'a'..=b'f' => Some(b - b'a' + 10),
49        b'A'..=b'F' => Some(b - b'A' + 10),
50        _ => None,
51    }
52}
53
54#[derive(Debug, Clone)]
55pub struct PathValidator {
56    /// Allowed root directories (workspace folders)
57    allowed_roots: Vec<PathBuf>,
58    /// Extensions directory
59    extensions_dir: Option<PathBuf>,
60    /// Storage directory
61    storage_dir: Option<PathBuf>,
62    /// Logs directory
63    logs_dir: Option<PathBuf>,
64    /// Temp directory for this workspace
65    temp_dir: Option<PathBuf>,
66}
67
68impl PathValidator {
69    pub fn new() -> Self {
70        Self {
71            allowed_roots: Vec::new(),
72            extensions_dir: None,
73            storage_dir: None,
74            logs_dir: None,
75            temp_dir: None,
76        }
77    }
78
79    pub fn add_workspace_folder(&mut self, path: PathBuf) {
80        if let Ok(canonical) = fs::canonicalize(&path) {
81            self.allowed_roots.push(canonical);
82        }
83    }
84
85    pub fn set_extensions_dir(&mut self, path: PathBuf) {
86        if let Ok(canonical) = fs::canonicalize(&path) {
87            self.extensions_dir = Some(canonical);
88        }
89    }
90
91    pub fn set_storage_dir(&mut self, path: PathBuf) {
92        if let Ok(canonical) = fs::canonicalize(&path) {
93            self.storage_dir = Some(canonical);
94        }
95    }
96
97    pub fn set_logs_dir(&mut self, path: PathBuf) {
98        if let Ok(canonical) = fs::canonicalize(&path) {
99            self.logs_dir = Some(canonical);
100        }
101    }
102
103    pub fn set_temp_dir(&mut self, path: PathBuf) {
104        if let Ok(canonical) = fs::canonicalize(&path) {
105            self.temp_dir = Some(canonical);
106        }
107    }
108
109    /// Validate and normalize a path from a URI
110    pub fn validate_path(&self, uri: &str) -> Result<PathBuf, String> {
111        let path_str = if let Some(rest) = uri.strip_prefix("file:///") {
112            rest
113        } else if let Some(after_slashes) = uri.strip_prefix("file://") {
114            if let Some(slash_pos) = after_slashes.find('/') {
115                &after_slashes[slash_pos..]
116            } else {
117                after_slashes
118            }
119        } else if uri.starts_with("file:/") {
120            &uri[5..]
121        } else {
122            uri
123        };
124
125        let decoded = percent_decode_str(path_str);
126        self.validate_file_path(&decoded)
127    }
128
129    pub fn validate_file_path(&self, path_str: &str) -> Result<PathBuf, String> {
130        let path = Path::new(path_str);
131
132        let canonical = if path.exists() {
133            fs::canonicalize(path)
134                .map_err(|e| Self::sanitize_error(&format!("Invalid path: {}", e)))?
135        } else {
136            if let Some(parent) = path.parent() {
137                if parent.as_os_str().is_empty() {
138                    fs::canonicalize(".")
139                        .map_err(|e| Self::sanitize_error(&format!("{}", e)))?
140                        .join(path)
141                } else if parent.exists() {
142                    let canonical_parent = fs::canonicalize(parent)
143                        .map_err(|e| Self::sanitize_error(&format!("{}", e)))?;
144                    let joined = canonical_parent.join(path.file_name().unwrap_or_default());
145                    if self.is_path_allowed(&joined) {
146                        joined
147                    } else {
148                        path.to_path_buf()
149                    }
150                } else {
151                    // Parent doesn't exist yet (e.g. create_dir_all target).
152                    // Walk up to find an existing ancestor and validate from there.
153                    let mut ancestor = parent;
154                    loop {
155                        if ancestor.exists() {
156                            let canonical_ancestor = fs::canonicalize(ancestor)
157                                .map_err(|e| Self::sanitize_error(&format!("{}", e)))?;
158                            let relative = path.strip_prefix(ancestor).unwrap_or(path);
159                            let joined = canonical_ancestor.join(relative);
160                            if self.is_path_allowed(&joined) {
161                                return Ok(joined);
162                            } else {
163                                return Err("Access denied: Path outside allowed directories".to_string());
164                            }
165                        }
166                        match ancestor.parent() {
167                            Some(gp) if gp.as_os_str().is_empty() => break,
168                            Some(gp) => ancestor = gp,
169                            None => break,
170                        }
171                    }
172                    return Err("Parent directory does not exist".to_string());
173                }
174            } else {
175                return Err("Invalid path: no parent directory".to_string());
176            }
177        };
178
179        if self.is_path_allowed(&canonical) {
180            Ok(canonical)
181        } else {
182            Err("Access denied: Path outside allowed directories".to_string())
183        }
184    }
185
186    /// Check if a canonical path is within allowed directories
187    fn is_path_allowed(&self, canonical_path: &Path) -> bool {
188        for root in &self.allowed_roots {
189            if canonical_path.starts_with(root) {
190                return true;
191            }
192        }
193
194        if let Some(ext_dir) = &self.extensions_dir {
195            if canonical_path.starts_with(ext_dir) {
196                return true;
197            }
198        }
199
200        if let Some(storage) = &self.storage_dir {
201            if canonical_path.starts_with(storage) {
202                return true;
203            }
204        }
205
206        if let Some(logs) = &self.logs_dir {
207            if canonical_path.starts_with(logs) {
208                return true;
209            }
210        }
211
212        if let Some(temp) = &self.temp_dir {
213            if canonical_path.starts_with(temp) {
214                return true;
215            }
216        }
217
218        false
219    }
220
221    /// Sanitize error messages to not leak full file system paths
222    pub fn sanitize_error(error: &dyn std::fmt::Display) -> String {
223        let error_str = error.to_string();
224        if error_str.contains("/") || error_str.contains("\\") {
225            "Operation failed: Invalid or inaccessible path".to_string()
226        } else {
227            error_str
228        }
229    }
230
231    /// Get relative path for display (doesn't leak full FS structure)
232    pub fn get_relative_path(&self, canonical_path: &Path) -> PathBuf {
233        // Try to make path relative to workspace roots
234        for root in &self.allowed_roots {
235            if let Ok(rel) = canonical_path.strip_prefix(root) {
236                return rel.to_path_buf();
237            }
238        }
239
240        // Fallback to filename only
241        canonical_path
242            .file_name()
243            .map(PathBuf::from)
244            .unwrap_or_else(|| PathBuf::from("unknown"))
245    }
246}
247
248impl Default for PathValidator {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use std::env;
258
259    #[test]
260    fn test_path_validation() {
261        let mut validator = PathValidator::new();
262        let current_dir = env::current_dir().unwrap();
263        validator.add_workspace_folder(current_dir.clone());
264
265        // This file should be accessible (Cargo.toml always exists in workspace root)
266        let this_file = current_dir.join("Cargo.toml");
267        // Use file:/// URI format (3 slashes for absolute paths)
268        let uri = if this_file.is_absolute() {
269            format!("file:///{}", this_file.display())
270        } else {
271            format!("file://{}", this_file.display())
272        };
273
274        if this_file.exists() {
275            assert!(validator.validate_path(&uri).is_ok());
276        }
277
278        // Parent directory traversal should fail
279        let parent_attack = "file:///../../../etc/passwd";
280        assert!(validator.validate_path(parent_attack).is_err());
281    }
282
283    #[test]
284    fn test_error_sanitization() {
285        let error = PathValidator::sanitize_error(&"/home/user/secret/file.txt: No such file");
286        assert!(!error.contains("/home"));
287        assert!(!error.contains("/secret"));
288    }
289
290    #[test]
291    fn test_path_validator_allows_workspace_paths() {
292        let mut validator = PathValidator::new();
293        let current_dir = env::current_dir().unwrap();
294        validator.add_workspace_folder(current_dir.clone());
295
296        // A path within the workspace should be allowed
297        let test_path = current_dir.join("src").join("lib.rs");
298        let test_path_str = test_path.to_string_lossy().to_string();
299        if test_path.exists() {
300            let result = validator.validate_file_path(&test_path_str);
301            assert!(result.is_ok());
302        }
303
304        // The workspace root itself should be allowed
305        let root_str = current_dir.to_string_lossy().to_string();
306        if current_dir.exists() {
307            let result = validator.validate_file_path(&root_str);
308            assert!(result.is_ok());
309        }
310    }
311
312    #[test]
313    fn test_path_validator_blocks_traversal() {
314        let mut validator = PathValidator::new();
315        let current_dir = env::current_dir().unwrap();
316        validator.add_workspace_folder(current_dir);
317
318        // Paths with ../ should be blocked (or resolve outside workspace)
319        let traversal_uris = [
320            "file://../../../etc/passwd",
321            "file://../../tmp/malicious",
322        ];
323
324        for uri in &traversal_uris {
325            let result = validator.validate_path(uri);
326            assert!(result.is_err(), "Expected path traversal to be blocked: {}", uri);
327        }
328    }
329
330    #[test]
331    fn test_path_validator_blocks_absolute_paths() {
332        let validator = PathValidator::new();
333
334        // A validator with no allowed roots should block everything
335        let result = validator.validate_file_path("/etc/passwd");
336        assert!(result.is_err(), "Absolute path should be blocked with no workspace roots");
337
338        let result = validator.validate_file_path("/usr/bin/python");
339        assert!(result.is_err(), "Absolute path should be blocked with no workspace roots");
340    }
341
342    #[test]
343    fn test_percent_decode_basic() {
344        // Simple space encoding
345        assert_eq!(percent_decode_str("hello%20world"), "hello world");
346        // Plus sign for space
347        assert_eq!(percent_decode_str("hello+world"), "hello world");
348        // Multiple encoded chars
349        assert_eq!(percent_decode_str("a%2Fb%3Dc"), "a/b=c");
350    }
351
352    #[test]
353    fn test_percent_decode_no_encoding() {
354        assert_eq!(percent_decode_str("plain_text"), "plain_text");
355        assert_eq!(percent_decode_str(""), "");
356    }
357
358    #[test]
359    fn test_percent_decode_incomplete_sequence() {
360        // Incomplete percent sequences should be passed through
361        assert_eq!(percent_decode_str("%2"), "%2");
362        assert_eq!(percent_decode_str("%"), "%");
363        assert_eq!(percent_decode_str("%%20"), "%%20");
364    }
365
366    #[test]
367    fn test_percent_decode_upper_and_lower_hex() {
368        assert_eq!(percent_decode_str("%2f"), "/");
369        assert_eq!(percent_decode_str("%2F"), "/");
370        assert_eq!(percent_decode_str("%aB"), "\u{AB}");
371    }
372
373    #[test]
374    fn test_path_validator_uri_format_file_three_slashes() {
375        let mut validator = PathValidator::new();
376        let current_dir = env::current_dir().unwrap();
377        validator.add_workspace_folder(current_dir.clone());
378
379        // file:/// with three slashes for absolute path
380        let cargo_path = current_dir.join("Cargo.toml");
381        if cargo_path.exists() {
382            let uri = format!("file:///{}", cargo_path.display());
383            let result = validator.validate_path(&uri);
384            assert!(result.is_ok(), "file:/// URI should resolve for workspace file");
385        }
386    }
387
388    #[test]
389    fn test_path_validator_uri_format_file_two_slashes() {
390        let mut validator = PathValidator::new();
391        let current_dir = env::current_dir().unwrap();
392        validator.add_workspace_folder(current_dir.clone());
393
394        // file:// with two slashes (hostname + path)
395        let cargo_path = current_dir.join("Cargo.toml");
396        if cargo_path.exists() {
397            let uri = format!("file://localhost{}", cargo_path.display());
398            let result = validator.validate_path(&uri);
399            assert!(result.is_ok(), "file://localhost URI should resolve for workspace file");
400        }
401    }
402
403    #[test]
404    fn test_path_validator_uri_format_file_one_slash() {
405        // file:/ with one slash should be treated as a path
406        let validator = PathValidator::new();
407        let result = validator.validate_path("file:/etc/passwd");
408        // With no allowed roots, this should be denied
409        assert!(result.is_err(), "file:/ path should be blocked with no workspace roots");
410    }
411
412    #[test]
413    fn test_path_validator_uri_no_scheme() {
414        // A plain path (no file:// prefix) should be used as-is
415        let validator = PathValidator::new();
416        let result = validator.validate_path("/etc/passwd");
417        assert!(result.is_err(), "Plain path should be blocked with no workspace roots");
418    }
419
420    #[test]
421    fn test_path_validator_new_default() {
422        let validator = PathValidator::new();
423        // A new validator with no roots should block everything
424        assert!(validator.validate_file_path("/etc/passwd").is_err());
425        assert!(validator.validate_file_path("/tmp/test").is_err());
426    }
427
428    #[test]
429    fn test_path_validator_get_relative_path() {
430        let mut validator = PathValidator::new();
431        let current_dir = env::current_dir().unwrap();
432        validator.add_workspace_folder(current_dir.clone());
433
434        let child_path = current_dir.join("src").join("main.rs");
435        let relative = validator.get_relative_path(&child_path);
436        // Should be relative to workspace root
437        assert!(!relative.to_string_lossy().starts_with('/'), "Relative path should not start with /");
438    }
439
440    #[test]
441    fn test_path_validator_get_relative_path_fallback() {
442        let validator = PathValidator::new();
443        // No workspace roots set, so should fall back to filename only
444        let some_path = Path::new("/some/random/path/file.txt");
445        let relative = validator.get_relative_path(some_path);
446        assert_eq!(relative, PathBuf::from("file.txt"));
447    }
448
449    #[test]
450    fn test_path_validator_sanitize_error_no_path() {
451        let error = "Something went wrong";
452        let sanitized = PathValidator::sanitize_error(&error);
453        assert_eq!(sanitized, "Something went wrong", "Error without paths should pass through");
454    }
455
456    #[test]
457    fn test_path_validator_sanitize_error_with_path() {
458        let error = "Failed to access /home/user/secret/file.txt";
459        let sanitized = PathValidator::sanitize_error(&error);
460        assert!(!sanitized.contains("/home"), "Sanitized error should not contain file paths");
461        assert!(!sanitized.contains("secret"), "Sanitized error should not contain file paths");
462    }
463
464    #[test]
465    fn test_path_validator_blocks_double_dot_traversal() {
466        let mut validator = PathValidator::new();
467        let current_dir = env::current_dir().unwrap();
468        validator.add_workspace_folder(current_dir);
469
470        // Direct ../ in URI
471        let attack_uris = [
472            "file:///../../../etc/shadow",
473            "file:///../../tmp/evil",
474        ];
475        for uri in &attack_uris {
476            let result = validator.validate_path(uri);
477            assert!(result.is_err(), "Path traversal attack should be blocked: {}", uri);
478        }
479    }
480
481    #[test]
482    fn test_path_validator_set_dirs() {
483        let mut validator = PathValidator::new();
484        let current_dir = env::current_dir().unwrap();
485
486        validator.add_workspace_folder(current_dir.clone());
487        validator.set_extensions_dir(current_dir.join("extensions"));
488        validator.set_storage_dir(current_dir.join("storage"));
489        validator.set_logs_dir(current_dir.join("logs"));
490        validator.set_temp_dir(std::env::temp_dir());
491
492        // Verify that the validator doesn't crash and can still validate
493        // (The actual validation depends on whether dirs exist on disk)
494    }
495}