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