dscode_extension_host/
path_validator.rs1use std::fs;
2use std::path::{Path, PathBuf};
9
10fn 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 result.push(byte as char);
26 result.push(h as char);
27 result.push(l as char);
28 } else {
29 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_roots: Vec<PathBuf>,
58 extensions_dir: Option<PathBuf>,
60 storage_dir: Option<PathBuf>,
62 logs_dir: Option<PathBuf>,
64 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 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 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 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 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 pub fn get_relative_path(&self, canonical_path: &Path) -> PathBuf {
233 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 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 let this_file = current_dir.join("Cargo.toml");
267 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 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 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 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 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 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 assert_eq!(percent_decode_str("hello%20world"), "hello world");
346 assert_eq!(percent_decode_str("hello+world"), "hello world");
348 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 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 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 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 let validator = PathValidator::new();
407 let result = validator.validate_path("file:/etc/passwd");
408 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 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 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 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 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 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 }
495}