1use std::path::{Component, Path, PathBuf};
7
8use crate::syntax::path_normalize::{NormalizePathError, normalize_path_within_workspace};
9
10fn path_has_symlink_component(path: &Path) -> bool {
12 let mut current = PathBuf::new();
13 for component in path.components() {
14 current.push(component.as_os_str());
15 if current.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
16 return true;
17 }
18 }
19 false
20}
21
22fn normalize_filesystem_path(path: PathBuf) -> PathBuf {
23 #[cfg(windows)]
24 {
25 if let Some(path_str) = path.to_str() {
26 if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
27 return PathBuf::from(format!(r"\\{}", stripped));
28 }
29 if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
30 return PathBuf::from(stripped);
31 }
32 }
33 }
34
35 path
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40pub enum WorkspacePathError {
41 #[error("Path traversal attempt detected: {0}")]
43 PathTraversalAttempt(String),
44
45 #[error("Path outside workspace: {0}")]
47 PathOutsideWorkspace(String),
48
49 #[error("Symlink resolves outside workspace: {0}")]
51 SymlinkOutsideWorkspace(String),
52
53 #[error("Invalid path characters detected")]
55 InvalidPathCharacters,
56}
57
58pub fn validate_workspace_path(
62 path: &Path,
63 workspace_root: &Path,
64) -> Result<PathBuf, WorkspacePathError> {
65 if let Some(path_str) = path.to_str()
67 && (path_str.contains('\0') || path_str.chars().any(|c| c.is_control() && c != '\t'))
68 {
69 return Err(WorkspacePathError::InvalidPathCharacters);
70 }
71
72 let workspace_canonical =
73 normalize_filesystem_path(workspace_root.canonicalize().map_err(|error| {
74 WorkspacePathError::PathOutsideWorkspace(format!(
75 "Workspace root not accessible: {} ({error})",
76 workspace_root.display()
77 ))
78 })?);
79
80 let resolved = if path.is_absolute() { path.to_path_buf() } else { workspace_root.join(path) };
82
83 let final_path = if let Ok(canonical) = resolved.canonicalize() {
86 let canonical = normalize_filesystem_path(canonical);
87 if !canonical.starts_with(&workspace_canonical) {
88 if path_has_symlink_component(&resolved) {
91 return Err(WorkspacePathError::SymlinkOutsideWorkspace(format!(
92 "Symlink resolves outside workspace: {} -> {} (workspace: {})",
93 resolved.display(),
94 canonical.display(),
95 workspace_canonical.display()
96 )));
97 }
98 return Err(WorkspacePathError::PathOutsideWorkspace(format!(
99 "Path resolves outside workspace: {} (workspace: {})",
100 canonical.display(),
101 workspace_canonical.display()
102 )));
103 }
104
105 canonical
106 } else {
107 normalize_path_within_workspace(path, &workspace_canonical).map_err(
108 |error| match error {
109 NormalizePathError::PathTraversalAttempt(message) => {
110 WorkspacePathError::PathTraversalAttempt(message)
111 }
112 },
113 )?
114 };
115
116 if !final_path.starts_with(&workspace_canonical) {
117 return Err(WorkspacePathError::PathOutsideWorkspace(format!(
118 "Path outside workspace: {} (workspace: {})",
119 final_path.display(),
120 workspace_canonical.display()
121 )));
122 }
123
124 Ok(final_path)
125}
126
127pub fn sanitize_completion_path_input(path: &str) -> Option<String> {
132 if path.is_empty() {
133 return Some(String::new());
134 }
135
136 if path.contains('\0') {
137 return None;
138 }
139
140 let path_obj = Path::new(path);
141 for component in path_obj.components() {
142 match component {
143 Component::ParentDir => return None,
144 Component::RootDir if path != "/" => return None,
145 Component::Prefix(_) => return None,
146 _ => {}
147 }
148 }
149
150 if path.contains("../") || path.contains("..\\") || path.starts_with('/') && path != "/" {
151 return None;
152 }
153
154 Some(path.replace('\\', "/"))
155}
156
157pub fn split_completion_path_components(path: &str) -> (String, String) {
159 match path.rsplit_once('/') {
160 Some((dir, file)) if !dir.is_empty() => (dir.to_string(), file.to_string()),
161 _ => (".".to_string(), path.to_string()),
162 }
163}
164
165pub fn resolve_completion_base_directory(dir_part: &str) -> Option<PathBuf> {
167 let path = Path::new(dir_part);
168
169 if path.is_absolute() && dir_part != "/" {
170 return None;
171 }
172
173 if dir_part == "." {
174 return Some(Path::new(".").to_path_buf());
175 }
176
177 match path.canonicalize() {
178 Ok(canonical) => Some(normalize_filesystem_path(canonical)),
179 Err(_) => {
180 if path.exists() && path.is_dir() {
181 Some(path.to_path_buf())
182 } else {
183 None
184 }
185 }
186 }
187}
188
189pub fn is_hidden_or_forbidden_entry_name(file_name: &str) -> bool {
191 if file_name.starts_with('.') && file_name.len() > 1 {
192 return true;
193 }
194
195 matches!(
196 file_name,
197 "node_modules"
198 | ".git"
199 | ".svn"
200 | ".hg"
201 | "target"
202 | "build"
203 | ".cargo"
204 | ".rustup"
205 | "System Volume Information"
206 | "$RECYCLE.BIN"
207 | "__pycache__"
208 | ".pytest_cache"
209 | ".mypy_cache"
210 )
211}
212
213pub fn is_safe_completion_filename(filename: &str) -> bool {
215 if filename.is_empty() || filename.len() > 255 {
216 return false;
217 }
218
219 if filename.contains('\0') || filename.chars().any(|c| c.is_control()) {
220 return false;
221 }
222
223 let name_upper = filename.to_uppercase();
224 let reserved = [
225 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
226 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
227 ];
228
229 for reserved_name in &reserved {
230 if name_upper == *reserved_name || name_upper.starts_with(&format!("{}.", reserved_name)) {
231 return false;
232 }
233 }
234
235 true
236}
237
238pub fn build_completion_path(dir_part: &str, filename: &str, is_dir: bool) -> String {
240 let mut path = if dir_part == "." {
241 filename.to_string()
242 } else {
243 format!("{}/{}", dir_part.trim_end_matches('/'), filename)
244 };
245
246 if is_dir {
247 path.push('/');
248 }
249
250 path
251}
252
253#[cfg(test)]
254mod tests {
255 use super::{
256 WorkspacePathError, build_completion_path, is_hidden_or_forbidden_entry_name,
257 is_safe_completion_filename, normalize_filesystem_path, sanitize_completion_path_input,
258 split_completion_path_components, validate_workspace_path,
259 };
260 use std::path::PathBuf;
261
262 type TestResult = Result<(), Box<dyn std::error::Error>>;
263
264 #[test]
265 fn validates_safe_relative_path() -> TestResult {
266 let temp_dir = tempfile::tempdir()?;
267 let workspace = temp_dir.path();
268
269 let validated = validate_workspace_path(&PathBuf::from("src/main.pl"), workspace)?;
270 let canonical_workspace = normalize_filesystem_path(workspace.canonicalize()?);
271 assert!(validated.starts_with(&canonical_workspace));
272 assert!(validated.to_string_lossy().contains("src"));
273 assert!(validated.to_string_lossy().contains("main.pl"));
274
275 Ok(())
276 }
277
278 #[test]
279 fn rejects_parent_directory_escape() -> TestResult {
280 let temp_dir = tempfile::tempdir()?;
281 let workspace = temp_dir.path();
282
283 let result = validate_workspace_path(&PathBuf::from("../../../etc/passwd"), workspace);
284 assert!(result.is_err());
285
286 match result {
287 Err(WorkspacePathError::PathTraversalAttempt(_))
288 | Err(WorkspacePathError::PathOutsideWorkspace(_)) => Ok(()),
289 Err(error) => Err(format!("unexpected error type: {error:?}").into()),
290 Ok(_) => Err("expected path validation error".into()),
291 }
292 }
293
294 #[test]
295 fn rejects_null_byte_injection() -> TestResult {
296 let temp_dir = tempfile::tempdir()?;
297 let workspace = temp_dir.path();
298
299 let result =
300 validate_workspace_path(&PathBuf::from("valid.pl\0../../etc/passwd"), workspace);
301 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
302
303 Ok(())
304 }
305
306 #[test]
307 fn allows_dot_files_inside_workspace() -> TestResult {
308 let temp_dir = tempfile::tempdir()?;
309 let workspace = temp_dir.path();
310
311 let result = validate_workspace_path(&PathBuf::from(".gitignore"), workspace);
312 assert!(result.is_ok());
313
314 Ok(())
315 }
316
317 #[test]
318 fn supports_current_directory_component() -> TestResult {
319 let temp_dir = tempfile::tempdir()?;
320 let workspace = temp_dir.path();
321
322 let validated = validate_workspace_path(&PathBuf::from("./lib/Module.pm"), workspace)?;
323 assert!(validated.to_string_lossy().contains("lib"));
324 assert!(validated.to_string_lossy().contains("Module.pm"));
325
326 Ok(())
327 }
328
329 #[test]
330 fn mixed_separator_behavior_matches_platform_rules() -> TestResult {
331 let workspace = std::env::current_dir()?;
332 let path = PathBuf::from("..\\../etc/passwd");
333
334 let result = validate_workspace_path(&path, &workspace);
335 if cfg!(windows) {
336 assert!(result.is_err());
337 } else {
338 assert!(result.is_ok());
339 }
340
341 Ok(())
342 }
343
344 #[test]
345 fn completion_path_sanitization_blocks_traversal() {
346 assert_eq!(sanitize_completion_path_input(""), Some(String::new()));
347 assert_eq!(sanitize_completion_path_input("lib/Foo.pm"), Some("lib/Foo.pm".to_string()));
348 assert!(sanitize_completion_path_input("../etc/passwd").is_none());
349 }
350
351 #[test]
352 fn completion_path_helpers_work() {
353 assert_eq!(
354 split_completion_path_components("lib/Foo"),
355 ("lib".to_string(), "Foo".to_string())
356 );
357 assert_eq!(split_completion_path_components("Foo"), (".".to_string(), "Foo".to_string()));
358 assert_eq!(build_completion_path(".", "Foo.pm", false), "Foo.pm".to_string());
359 assert_eq!(build_completion_path("lib", "Foo", true), "lib/Foo/".to_string());
360 }
361
362 #[test]
363 fn completion_filename_and_visibility_checks_work() {
364 assert!(is_hidden_or_forbidden_entry_name(".git"));
365 assert!(is_hidden_or_forbidden_entry_name("node_modules"));
366 assert!(!is_hidden_or_forbidden_entry_name("lib"));
367
368 assert!(is_safe_completion_filename("Foo.pm"));
369 assert!(!is_safe_completion_filename("CON"));
370 assert!(!is_safe_completion_filename("bad\0name"));
371 }
372
373 #[test]
378 fn test_traversal_etc_passwd_unix() -> TestResult {
379 let temp_dir = tempfile::tempdir()?;
380 let workspace = temp_dir.path();
381
382 let result = validate_workspace_path(&PathBuf::from("../../etc/passwd"), workspace);
383 assert!(result.is_err());
384 Ok(())
385 }
386
387 #[test]
388 fn test_traversal_deeply_nested_escape() -> TestResult {
389 let temp_dir = tempfile::tempdir()?;
390 let workspace = temp_dir.path();
391
392 let result = validate_workspace_path(
393 &PathBuf::from("a/b/c/../../../../../../../../etc/shadow"),
394 workspace,
395 );
396 assert!(result.is_err());
397 Ok(())
398 }
399
400 #[test]
401 fn test_traversal_windows_backslash_style() {
402 let input = r"..\..\windows\system32";
406 assert!(sanitize_completion_path_input(input).is_none());
407 }
408
409 #[test]
410 fn test_traversal_mixed_forward_back_slash() -> TestResult {
411 let temp_dir = tempfile::tempdir()?;
412 let workspace = temp_dir.path();
413
414 let result = validate_workspace_path(&PathBuf::from("src/../../../etc/passwd"), workspace);
415 assert!(result.is_err());
416 Ok(())
417 }
418
419 #[test]
420 fn test_traversal_single_parent_at_root() -> TestResult {
421 let temp_dir = tempfile::tempdir()?;
422 let workspace = temp_dir.path();
423
424 let result = validate_workspace_path(&PathBuf::from(".."), workspace);
425 assert!(result.is_err());
426 Ok(())
427 }
428
429 #[test]
430 fn test_traversal_encoded_dot_segments_completion() {
431 assert!(
434 sanitize_completion_path_input("..%2f..%2fetc%2fpasswd").is_some()
435 || sanitize_completion_path_input("..%2f..%2fetc%2fpasswd").is_none()
436 );
437 assert!(sanitize_completion_path_input("../foo").is_none());
439 assert!(sanitize_completion_path_input("foo/../../bar").is_none());
440 }
441
442 #[test]
443 fn test_traversal_parent_after_valid_descent() -> TestResult {
444 let temp_dir = tempfile::tempdir()?;
445 let workspace = temp_dir.path();
446
447 let result = validate_workspace_path(&PathBuf::from("lib/../../secret"), workspace);
450 assert!(result.is_err());
451 Ok(())
452 }
453
454 #[test]
459 fn test_null_byte_at_start() -> TestResult {
460 let temp_dir = tempfile::tempdir()?;
461 let workspace = temp_dir.path();
462
463 let result = validate_workspace_path(&PathBuf::from("\0foo.pm"), workspace);
464 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
465 Ok(())
466 }
467
468 #[test]
469 fn test_null_byte_in_middle() -> TestResult {
470 let temp_dir = tempfile::tempdir()?;
471 let workspace = temp_dir.path();
472
473 let result = validate_workspace_path(&PathBuf::from("lib/Foo\0Bar.pm"), workspace);
474 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
475 Ok(())
476 }
477
478 #[test]
479 fn test_null_byte_at_end() -> TestResult {
480 let temp_dir = tempfile::tempdir()?;
481 let workspace = temp_dir.path();
482
483 let result = validate_workspace_path(&PathBuf::from("lib/Foo.pm\0"), workspace);
484 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
485 Ok(())
486 }
487
488 #[test]
489 fn test_null_byte_with_extension_truncation_attack() -> TestResult {
490 let temp_dir = tempfile::tempdir()?;
491 let workspace = temp_dir.path();
492
493 let result = validate_workspace_path(&PathBuf::from("file\0.pm"), workspace);
496 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
497 Ok(())
498 }
499
500 #[test]
501 fn test_null_byte_in_completion_sanitize() {
502 assert!(sanitize_completion_path_input("lib/Foo\0.pm").is_none());
503 assert!(sanitize_completion_path_input("\0").is_none());
504 assert!(sanitize_completion_path_input("a\0b").is_none());
505 }
506
507 #[test]
508 fn test_null_byte_in_safe_filename_check() {
509 assert!(!is_safe_completion_filename("foo\0bar"));
510 assert!(!is_safe_completion_filename("\0"));
511 assert!(!is_safe_completion_filename("file\0.pm"));
512 }
513
514 #[test]
519 fn test_control_char_bell() -> TestResult {
520 let temp_dir = tempfile::tempdir()?;
521 let workspace = temp_dir.path();
522
523 let result = validate_workspace_path(&PathBuf::from("lib/\x07file.pm"), workspace);
524 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
525 Ok(())
526 }
527
528 #[test]
529 fn test_control_char_backspace() -> TestResult {
530 let temp_dir = tempfile::tempdir()?;
531 let workspace = temp_dir.path();
532
533 let result = validate_workspace_path(&PathBuf::from("lib/\x08file.pm"), workspace);
534 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
535 Ok(())
536 }
537
538 #[test]
539 fn test_control_char_newline_in_path() -> TestResult {
540 let temp_dir = tempfile::tempdir()?;
541 let workspace = temp_dir.path();
542
543 let result = validate_workspace_path(&PathBuf::from("lib/file\n.pm"), workspace);
544 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
545 Ok(())
546 }
547
548 #[test]
549 fn test_control_char_carriage_return() -> TestResult {
550 let temp_dir = tempfile::tempdir()?;
551 let workspace = temp_dir.path();
552
553 let result = validate_workspace_path(&PathBuf::from("lib/file\r.pm"), workspace);
554 assert!(matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
555 Ok(())
556 }
557
558 #[test]
559 fn test_tab_is_allowed() -> TestResult {
560 let temp_dir = tempfile::tempdir()?;
563 let workspace = temp_dir.path();
564
565 let result = validate_workspace_path(&PathBuf::from("lib/file\t.pm"), workspace);
566 assert!(!matches!(result, Err(WorkspacePathError::InvalidPathCharacters)));
568 Ok(())
569 }
570
571 #[test]
572 fn test_control_chars_in_safe_filename() {
573 assert!(!is_safe_completion_filename("foo\x07bar"));
574 assert!(!is_safe_completion_filename("file\nname"));
575 assert!(!is_safe_completion_filename("file\rname"));
576 assert!(!is_safe_completion_filename("\x01start"));
577 assert!(!is_safe_completion_filename("end\x1f"));
578 }
579
580 #[test]
585 fn test_symlink_escape_outside_workspace() -> TestResult {
586 let temp_dir = tempfile::tempdir()?;
587 let workspace = temp_dir.path();
588
589 let external_dir = tempfile::tempdir()?;
592 let external_file = external_dir.path().join("secret.txt");
593 std::fs::write(&external_file, "sensitive data")?;
594
595 let _link_path = workspace.join("escape_link");
596 #[cfg(unix)]
597 std::os::unix::fs::symlink(external_dir.path(), &_link_path)?;
598
599 #[cfg(unix)]
600 {
601 let result =
602 validate_workspace_path(&PathBuf::from("escape_link/secret.txt"), workspace);
603 assert!(
604 matches!(result, Err(WorkspacePathError::SymlinkOutsideWorkspace(_))),
605 "Symlink escape should produce SymlinkOutsideWorkspace, got: {result:?}"
606 );
607 }
608
609 Ok(())
610 }
611
612 #[test]
613 fn test_symlink_within_workspace_is_allowed() -> TestResult {
614 let temp_dir = tempfile::tempdir()?;
615 let workspace = temp_dir.path();
616
617 let real_dir = workspace.join("real_lib");
619 std::fs::create_dir_all(&real_dir)?;
620 let real_file = real_dir.join("Module.pm");
621 std::fs::write(&real_file, "package Module;")?;
622
623 let _link_path = workspace.join("lib_link");
624 #[cfg(unix)]
625 std::os::unix::fs::symlink(&real_dir, &_link_path)?;
626
627 #[cfg(unix)]
628 {
629 let result = validate_workspace_path(&PathBuf::from("lib_link/Module.pm"), workspace);
630 assert!(result.is_ok(), "Symlink within workspace should be allowed");
631 }
632
633 Ok(())
634 }
635
636 #[test]
637 fn test_chained_symlinks_escaping_workspace() -> TestResult {
638 let temp_dir = tempfile::tempdir()?;
639 let workspace = temp_dir.path();
640
641 let external_dir = tempfile::tempdir()?;
642 let hop2 = external_dir.path().join("hop2");
643 std::fs::create_dir_all(&hop2)?;
644 std::fs::write(hop2.join("data.txt"), "secret")?;
645
646 let _hop1 = workspace.join("hop1");
648 #[cfg(unix)]
649 std::os::unix::fs::symlink(external_dir.path(), &_hop1)?;
650
651 #[cfg(unix)]
652 {
653 let result = validate_workspace_path(&PathBuf::from("hop1/hop2/data.txt"), workspace);
654 assert!(
655 matches!(result, Err(WorkspacePathError::SymlinkOutsideWorkspace(_))),
656 "Chained symlink escape should produce SymlinkOutsideWorkspace, got: {result:?}"
657 );
658 }
659
660 Ok(())
661 }
662
663 #[test]
668 fn test_windows_reserved_con() {
669 assert!(!is_safe_completion_filename("CON"));
670 assert!(!is_safe_completion_filename("con"));
671 assert!(!is_safe_completion_filename("Con"));
672 }
673
674 #[test]
675 fn test_windows_reserved_prn() {
676 assert!(!is_safe_completion_filename("PRN"));
677 assert!(!is_safe_completion_filename("prn"));
678 }
679
680 #[test]
681 fn test_windows_reserved_aux() {
682 assert!(!is_safe_completion_filename("AUX"));
683 assert!(!is_safe_completion_filename("aux"));
684 }
685
686 #[test]
687 fn test_windows_reserved_nul() {
688 assert!(!is_safe_completion_filename("NUL"));
689 assert!(!is_safe_completion_filename("nul"));
690 }
691
692 #[test]
693 fn test_windows_reserved_com_ports() {
694 for i in 1..=9 {
695 let name = format!("COM{i}");
696 assert!(!is_safe_completion_filename(&name), "COM{i} should be rejected");
697 let lower = name.to_lowercase();
698 assert!(!is_safe_completion_filename(&lower), "com{i} should be rejected");
699 }
700 }
701
702 #[test]
703 fn test_windows_reserved_lpt_ports() {
704 for i in 1..=9 {
705 let name = format!("LPT{i}");
706 assert!(!is_safe_completion_filename(&name), "LPT{i} should be rejected");
707 let lower = name.to_lowercase();
708 assert!(!is_safe_completion_filename(&lower), "lpt{i} should be rejected");
709 }
710 }
711
712 #[test]
713 fn test_windows_reserved_with_extension() {
714 assert!(!is_safe_completion_filename("CON.txt"));
715 assert!(!is_safe_completion_filename("PRN.pm"));
716 assert!(!is_safe_completion_filename("AUX.pl"));
717 assert!(!is_safe_completion_filename("NUL.log"));
718 assert!(!is_safe_completion_filename("COM1.dat"));
719 assert!(!is_safe_completion_filename("LPT1.out"));
720 assert!(!is_safe_completion_filename("con.txt"));
721 assert!(!is_safe_completion_filename("nul.pm"));
722 }
723
724 #[test]
725 fn test_windows_reserved_partial_match_should_pass() {
726 assert!(is_safe_completion_filename("CONSOLE.pm"));
728 assert!(is_safe_completion_filename("PRINTER.pm"));
729 assert!(is_safe_completion_filename("AUXILIARY.pm"));
730 assert!(is_safe_completion_filename("NULL.pm"));
731 assert!(is_safe_completion_filename("COMPORT.pm"));
732 assert!(is_safe_completion_filename("LPTTEST.pm"));
733 }
734
735 #[test]
740 fn test_very_long_filename_rejected() {
741 let long_name = "a".repeat(256);
742 assert!(!is_safe_completion_filename(&long_name));
743 }
744
745 #[test]
746 fn test_filename_exactly_255_chars() {
747 let name_255 = "b".repeat(255);
748 assert!(is_safe_completion_filename(&name_255));
749 }
750
751 #[test]
752 fn test_filename_exactly_256_chars() {
753 let name_256 = "c".repeat(256);
754 assert!(!is_safe_completion_filename(&name_256));
755 }
756
757 #[test]
758 fn test_very_long_path_traversal_via_workspace_validation() -> TestResult {
759 let temp_dir = tempfile::tempdir()?;
760 let workspace = temp_dir.path();
761
762 let segment = "x".repeat(200);
764 let long_segments: Vec<&str> = (0..25).map(|_| segment.as_str()).collect();
765 let long_path = long_segments.join("/") + "/../../../../etc/passwd";
766
767 let result = validate_workspace_path(&PathBuf::from(&long_path), workspace);
768 if let Ok(resolved) = &result {
771 let canonical_ws = normalize_filesystem_path(workspace.canonicalize()?);
772 assert!(resolved.starts_with(&canonical_ws), "Long path must resolve inside workspace");
773 }
774 Ok(())
775 }
776
777 #[test]
778 fn test_very_long_path_completion_sanitize() {
779 let segment = "x".repeat(200);
780 let long_segments: Vec<&str> = (0..25).map(|_| segment.as_str()).collect();
781 let long_path = long_segments.join("/");
782
783 let _ = sanitize_completion_path_input(&long_path);
785 }
786
787 #[test]
788 fn test_empty_filename_rejected() {
789 assert!(!is_safe_completion_filename(""));
790 }
791
792 #[test]
797 fn test_unicode_cjk_filename_is_safe() {
798 assert!(is_safe_completion_filename("\u{4e16}\u{754c}.pm")); }
800
801 #[test]
802 fn test_unicode_emoji_filename_is_safe() {
803 assert!(is_safe_completion_filename("\u{1f600}.pm")); }
805
806 #[test]
807 fn test_unicode_arabic_filename_is_safe() {
808 assert!(is_safe_completion_filename("\u{0645}\u{0644}\u{0641}.pm")); }
810
811 #[test]
812 fn test_unicode_path_in_workspace_validation() -> TestResult {
813 let temp_dir = tempfile::tempdir()?;
814 let workspace = temp_dir.path();
815
816 let result = validate_workspace_path(
817 &PathBuf::from("lib/\u{00e9}\u{00e8}\u{00ea}.pm"), workspace,
819 );
820 if let Ok(resolved) = &result {
822 let canonical_ws = normalize_filesystem_path(workspace.canonicalize()?);
823 assert!(resolved.starts_with(&canonical_ws));
824 }
825 Ok(())
826 }
827
828 #[test]
829 fn test_unicode_bidi_override_in_filename() {
830 let bidi_filename = "safe\u{202e}mp.exe";
832 let _ = is_safe_completion_filename(bidi_filename);
834 }
835
836 #[test]
837 fn test_unicode_zero_width_space_in_filename() {
838 let name = "foo\u{200b}bar.pm"; let _ = is_safe_completion_filename(name);
841 }
842
843 #[test]
844 fn test_unicode_normalization_forms_treated_as_distinct() -> TestResult {
845 let temp_dir = tempfile::tempdir()?;
846 let workspace = temp_dir.path();
847
848 let nfc_path = PathBuf::from("lib/caf\u{00e9}.pm");
850 let nfd_path = PathBuf::from("lib/cafe\u{0301}.pm");
852
853 let _ = validate_workspace_path(&nfc_path, workspace);
855 let _ = validate_workspace_path(&nfd_path, workspace);
856 Ok(())
857 }
858
859 #[test]
864 fn test_hidden_dotfile_detection() {
865 assert!(is_hidden_or_forbidden_entry_name(".bashrc"));
866 assert!(is_hidden_or_forbidden_entry_name(".env"));
867 assert!(is_hidden_or_forbidden_entry_name(".hidden_dir"));
868 assert!(is_hidden_or_forbidden_entry_name(".perltidyrc"));
869 }
870
871 #[test]
872 fn test_single_dot_is_not_hidden() {
873 assert!(!is_hidden_or_forbidden_entry_name("."));
875 }
876
877 #[test]
878 fn test_double_dot_is_not_flagged_as_hidden() {
879 assert!(is_hidden_or_forbidden_entry_name(".."));
883 }
884
885 #[test]
886 fn test_forbidden_directories() {
887 assert!(is_hidden_or_forbidden_entry_name(".git"));
888 assert!(is_hidden_or_forbidden_entry_name(".svn"));
889 assert!(is_hidden_or_forbidden_entry_name(".hg"));
890 assert!(is_hidden_or_forbidden_entry_name("node_modules"));
891 assert!(is_hidden_or_forbidden_entry_name("target"));
892 assert!(is_hidden_or_forbidden_entry_name("build"));
893 assert!(is_hidden_or_forbidden_entry_name(".cargo"));
894 assert!(is_hidden_or_forbidden_entry_name(".rustup"));
895 assert!(is_hidden_or_forbidden_entry_name("System Volume Information"));
896 assert!(is_hidden_or_forbidden_entry_name("$RECYCLE.BIN"));
897 assert!(is_hidden_or_forbidden_entry_name("__pycache__"));
898 assert!(is_hidden_or_forbidden_entry_name(".pytest_cache"));
899 assert!(is_hidden_or_forbidden_entry_name(".mypy_cache"));
900 }
901
902 #[test]
903 fn test_non_hidden_entries_pass() {
904 assert!(!is_hidden_or_forbidden_entry_name("lib"));
905 assert!(!is_hidden_or_forbidden_entry_name("src"));
906 assert!(!is_hidden_or_forbidden_entry_name("Makefile.PL"));
907 assert!(!is_hidden_or_forbidden_entry_name("Module.pm"));
908 assert!(!is_hidden_or_forbidden_entry_name("t"));
909 assert!(!is_hidden_or_forbidden_entry_name("blib"));
910 }
911
912 #[test]
917 fn test_completion_sanitize_blocks_parent_dir_various_forms() {
918 assert!(sanitize_completion_path_input("..").is_none());
919 assert!(sanitize_completion_path_input("../").is_none());
920 assert!(sanitize_completion_path_input("../foo").is_none());
921 assert!(sanitize_completion_path_input("foo/../bar").is_none());
922 assert!(sanitize_completion_path_input("foo/bar/../../baz").is_none());
923 }
924
925 #[test]
926 fn test_completion_sanitize_blocks_windows_backslash_traversal() {
927 assert!(sanitize_completion_path_input(r"..\foo").is_none());
928 assert!(sanitize_completion_path_input(r"foo\..\bar").is_none());
929 assert!(sanitize_completion_path_input(r"..\..\secret").is_none());
930 }
931
932 #[test]
933 fn test_completion_sanitize_blocks_absolute_paths() {
934 assert!(sanitize_completion_path_input("/etc/passwd").is_none());
935 assert!(sanitize_completion_path_input("/usr/bin/perl").is_none());
936 }
937
938 #[test]
939 fn test_completion_sanitize_allows_root_slash() {
940 assert_eq!(sanitize_completion_path_input("/"), Some("/".to_string()));
942 }
943
944 #[test]
945 fn test_completion_sanitize_normalizes_backslashes() {
946 assert_eq!(
947 sanitize_completion_path_input(r"lib\Foo\Bar.pm"),
948 Some("lib/Foo/Bar.pm".to_string())
949 );
950 }
951
952 #[test]
953 fn test_completion_sanitize_allows_valid_paths() {
954 assert_eq!(
955 sanitize_completion_path_input("lib/Foo/Bar.pm"),
956 Some("lib/Foo/Bar.pm".to_string())
957 );
958 assert_eq!(
959 sanitize_completion_path_input("t/01-basic.t"),
960 Some("t/01-basic.t".to_string())
961 );
962 assert_eq!(sanitize_completion_path_input("Makefile.PL"), Some("Makefile.PL".to_string()));
963 }
964
965 #[test]
966 fn test_completion_sanitize_null_byte() {
967 assert!(sanitize_completion_path_input("foo\0bar").is_none());
968 }
969
970 #[test]
975 fn test_split_completion_path_nested() {
976 assert_eq!(
977 split_completion_path_components("lib/Foo/Bar"),
978 ("lib/Foo".to_string(), "Bar".to_string())
979 );
980 }
981
982 #[test]
983 fn test_split_completion_path_bare_filename() {
984 assert_eq!(
985 split_completion_path_components("Module.pm"),
986 (".".to_string(), "Module.pm".to_string())
987 );
988 }
989
990 #[test]
991 fn test_split_completion_path_trailing_slash() {
992 let (dir, file) = split_completion_path_components("lib/");
995 assert_eq!(dir, "lib");
996 assert_eq!(file, "");
997 }
998
999 #[test]
1004 fn test_build_completion_path_directory_trailing_slash() {
1005 let result = build_completion_path("lib", "subdir", true);
1006 assert_eq!(result, "lib/subdir/");
1007 }
1008
1009 #[test]
1010 fn test_build_completion_path_file_no_trailing_slash() {
1011 let result = build_completion_path("lib", "Foo.pm", false);
1012 assert_eq!(result, "lib/Foo.pm");
1013 }
1014
1015 #[test]
1016 fn test_build_completion_path_dot_dir() {
1017 let result = build_completion_path(".", "Foo.pm", false);
1018 assert_eq!(result, "Foo.pm");
1019 }
1020
1021 #[test]
1022 fn test_build_completion_path_dot_dir_directory() {
1023 let result = build_completion_path(".", "subdir", true);
1024 assert_eq!(result, "subdir/");
1025 }
1026
1027 #[test]
1028 fn test_build_completion_path_strips_trailing_slash_on_dir_part() {
1029 let result = build_completion_path("lib/", "Foo.pm", false);
1030 assert_eq!(result, "lib/Foo.pm");
1031 }
1032
1033 #[test]
1038 fn test_absolute_path_outside_workspace_rejected() -> TestResult {
1039 let temp_dir = tempfile::tempdir()?;
1040 let workspace = temp_dir.path();
1041
1042 let result = validate_workspace_path(&PathBuf::from("/etc/passwd"), workspace);
1043 assert!(result.is_err());
1044 Ok(())
1045 }
1046
1047 #[test]
1048 fn test_absolute_path_inside_workspace_accepted() -> TestResult {
1049 let temp_dir = tempfile::tempdir()?;
1050 let workspace = temp_dir.path();
1051
1052 let inner = workspace.join("inner.pm");
1054 std::fs::write(&inner, "1;")?;
1055
1056 let result = validate_workspace_path(&inner, workspace);
1057 assert!(result.is_ok());
1058 Ok(())
1059 }
1060
1061 #[test]
1062 fn test_workspace_root_itself_is_valid() -> TestResult {
1063 let temp_dir = tempfile::tempdir()?;
1064 let workspace = temp_dir.path();
1065
1066 let result = validate_workspace_path(&PathBuf::from("."), workspace);
1067 assert!(result.is_ok());
1068 Ok(())
1069 }
1070
1071 #[test]
1072 fn test_nonexistent_workspace_root_returns_error() {
1073 let result = validate_workspace_path(
1074 &PathBuf::from("foo.pm"),
1075 &PathBuf::from("/nonexistent/workspace/root/that/does/not/exist"),
1076 );
1077 assert!(matches!(result, Err(WorkspacePathError::PathOutsideWorkspace(_))));
1078 }
1079
1080 #[test]
1085 fn test_resolve_completion_base_rejects_absolute() {
1086 use super::resolve_completion_base_directory;
1087
1088 assert!(resolve_completion_base_directory("/etc").is_none());
1089 assert!(resolve_completion_base_directory("/usr/bin").is_none());
1090 }
1091
1092 #[test]
1093 fn test_resolve_completion_base_allows_dot() {
1094 use super::resolve_completion_base_directory;
1095
1096 let result = resolve_completion_base_directory(".");
1097 assert!(result.is_some());
1098 assert_eq!(result, Some(PathBuf::from(".")));
1099 }
1100
1101 #[test]
1102 fn test_resolve_completion_base_nonexistent_returns_none() {
1103 use super::resolve_completion_base_directory;
1104
1105 let result = resolve_completion_base_directory("definitely_not_a_real_dir_xyz123");
1106 assert!(result.is_none());
1107 }
1108
1109 #[test]
1110 #[cfg(windows)]
1111 fn test_normalize_filesystem_path_strips_verbatim_prefix() {
1112 let normalized = normalize_filesystem_path(PathBuf::from(r"\\?\C:\workspace\lib\Foo.pm"));
1113 assert_eq!(normalized, PathBuf::from(r"C:\workspace\lib\Foo.pm"));
1114 }
1115}