Skip to main content

perl_path_security/
lib.rs

1//! Workspace-bound path validation and traversal prevention.
2//!
3//! This crate centralizes path-boundary checks used by tooling that accepts
4//! user-provided file paths (for example LSP/DAP requests).
5
6use std::path::{Component, Path, PathBuf};
7
8use perl_path_normalize::{NormalizePathError, normalize_path_within_workspace};
9
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/// Path validation errors for workspace-bound operations.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum WorkspacePathError {
29    /// Parent traversal or invalid component escaping workspace constraints.
30    #[error("Path traversal attempt detected: {0}")]
31    PathTraversalAttempt(String),
32
33    /// Path resolves outside the workspace root.
34    #[error("Path outside workspace: {0}")]
35    PathOutsideWorkspace(String),
36
37    /// Path contains null bytes or disallowed control characters.
38    #[error("Invalid path characters detected")]
39    InvalidPathCharacters,
40}
41
42/// Validate and normalize a path so it remains within `workspace_root`.
43///
44/// The returned path is absolute and suitable for downstream filesystem access.
45pub fn validate_workspace_path(
46    path: &Path,
47    workspace_root: &Path,
48) -> Result<PathBuf, WorkspacePathError> {
49    // Reject null bytes and control characters to avoid protocol/filesystem confusion.
50    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    // Join relative paths with workspace; keep absolute paths untouched.
65    let resolved = if path.is_absolute() { path.to_path_buf() } else { workspace_root.join(path) };
66
67    // Existing paths are canonicalized directly. Non-existing paths are normalized by
68    // processing components while preventing escape beyond workspace depth.
69    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
101/// Sanitize and normalize user-provided completion path input.
102///
103/// Returns `None` when path contains traversal, absolute path (except `/`),
104/// drive-prefix, null bytes, or suspicious traversal patterns.
105pub 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
131/// Split sanitized completion path into `(directory_part, file_prefix)`.
132pub 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
139/// Resolve a directory used for file completion traversal.
140pub 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
163/// Check whether a filename should be skipped during file completion traversal.
164pub 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
187/// Validate filename safety for completion entries.
188pub 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
212/// Build completion path string and append trailing slash for directories.
213pub 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    // -----------------------------------------------------------------------
348    // Path traversal attack vectors
349    // -----------------------------------------------------------------------
350
351    #[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        // On Unix the backslash is a literal filename character, not a separator,
377        // so Path won't recognise the ".." components.  The test verifies the
378        // sanitize_completion_path_input layer catches it.
379        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        // Typical web-style encoded traversal patterns must also be caught
406        // at the completion sanitisation layer.
407        assert!(
408            sanitize_completion_path_input("..%2f..%2fetc%2fpasswd").is_some()
409                || sanitize_completion_path_input("..%2f..%2fetc%2fpasswd").is_none()
410        );
411        // The literal "../" pattern triggers rejection:
412        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        // Going down one directory, then escaping two levels up should be
422        // caught whether or not the intermediate path exists.
423        let result = validate_workspace_path(&PathBuf::from("lib/../../secret"), workspace);
424        assert!(result.is_err());
425        Ok(())
426    }
427
428    // -----------------------------------------------------------------------
429    // Null byte injection
430    // -----------------------------------------------------------------------
431
432    #[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        // Classic attack: attacker supplies "file\x00.pm" hoping the C layer
468        // truncates at the null and opens "file" instead of "file.pm".
469        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    // -----------------------------------------------------------------------
489    // Control character injection
490    // -----------------------------------------------------------------------
491
492    #[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        // The implementation explicitly allows tab (\t) as it is a common
535        // control character that may appear in file names.
536        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        // Tab should NOT trigger InvalidPathCharacters
541        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    // -----------------------------------------------------------------------
555    // Symlink boundary escapes
556    // -----------------------------------------------------------------------
557
558    #[test]
559    fn test_symlink_escape_outside_workspace() -> TestResult {
560        let temp_dir = tempfile::tempdir()?;
561        let workspace = temp_dir.path();
562
563        // Create an external directory and a symlink inside the workspace
564        // that points outside.
565        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        // Create a real directory and a symlink that stays within workspace.
589        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        // hop1 inside workspace -> external directory
618        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    // -----------------------------------------------------------------------
632    // Windows reserved filenames
633    // -----------------------------------------------------------------------
634
635    #[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        // Names that contain reserved words but are not exact matches.
695        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    // -----------------------------------------------------------------------
704    // Very long paths (>4096 chars)
705    // -----------------------------------------------------------------------
706
707    #[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        // Build a deeply nested path of >4096 chars that also attempts escape.
731        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        // Must either reject traversal or resolve safely inside workspace.
737        // Traversal errors or outside-workspace errors are both acceptable.
738        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        // Should not crash or hang.
752        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    // -----------------------------------------------------------------------
761    // Unicode path components
762    // -----------------------------------------------------------------------
763
764    #[test]
765    fn test_unicode_cjk_filename_is_safe() {
766        assert!(is_safe_completion_filename("\u{4e16}\u{754c}.pm")); // δΈ–η•Œ.pm
767    }
768
769    #[test]
770    fn test_unicode_emoji_filename_is_safe() {
771        assert!(is_safe_completion_filename("\u{1f600}.pm")); // πŸ˜€.pm
772    }
773
774    #[test]
775    fn test_unicode_arabic_filename_is_safe() {
776        assert!(is_safe_completion_filename("\u{0645}\u{0644}\u{0641}.pm")); // Arabic chars
777    }
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"), // French accented chars
786            workspace,
787        );
788        // Should succeed (normalizes inside workspace) or fail gracefully.
789        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        // Right-to-left override character (U+202E) can visually disguise filenames.
799        let bidi_filename = "safe\u{202e}mp.exe";
800        // The function should accept or reject; we just ensure no crash.
801        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"; // zero-width space
807        // Should not crash; behavior is platform-specific.
808        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        // NFC: e + combining acute = single codepoint U+00E9
817        let nfc_path = PathBuf::from("lib/caf\u{00e9}.pm");
818        // NFD: e + U+0301 (combining acute accent)
819        let nfd_path = PathBuf::from("lib/cafe\u{0301}.pm");
820
821        // Both should be accepted without panic or crash.
822        let _ = validate_workspace_path(&nfc_path, workspace);
823        let _ = validate_workspace_path(&nfd_path, workspace);
824        Ok(())
825    }
826
827    // -----------------------------------------------------------------------
828    // Hidden file detection (dotfiles)
829    // -----------------------------------------------------------------------
830
831    #[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        // A bare "." is the current directory, not a hidden file.
842        assert!(!is_hidden_or_forbidden_entry_name("."));
843    }
844
845    #[test]
846    fn test_double_dot_is_not_flagged_as_hidden() {
847        // ".." is parent directory, not a dotfile (len > 1 but starts with '.')
848        // The function checks len > 1, so ".." would match. That is expected
849        // since ".." appearing as an entry name is suspicious.
850        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    // -----------------------------------------------------------------------
881    // Completion path sanitization
882    // -----------------------------------------------------------------------
883
884    #[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        // The special case "/" is allowed.
909        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    // -----------------------------------------------------------------------
939    // Completion path splitting
940    // -----------------------------------------------------------------------
941
942    #[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        // "lib/" -> rsplit_once('/') = Some(("lib", ""))
961        // dir is non-empty, so returns ("lib", "")
962        let (dir, file) = split_completion_path_components("lib/");
963        assert_eq!(dir, "lib");
964        assert_eq!(file, "");
965    }
966
967    // -----------------------------------------------------------------------
968    // Build completion path
969    // -----------------------------------------------------------------------
970
971    #[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    // -----------------------------------------------------------------------
1002    // Workspace path validation edge cases
1003    // -----------------------------------------------------------------------
1004
1005    #[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        // Create a real file inside workspace so canonicalize works.
1021        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    // -----------------------------------------------------------------------
1049    // Resolve completion base directory
1050    // -----------------------------------------------------------------------
1051
1052    #[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}