Skip to main content

perl_parser_core/syntax/
path_security.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 crate::syntax::path_normalize::{NormalizePathError, normalize_path_within_workspace};
9
10/// Walk `path` prefix-by-prefix and return `true` if any component is a symlink.
11fn 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/// Path validation errors for workspace-bound operations.
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40pub enum WorkspacePathError {
41    /// Parent traversal or invalid component escaping workspace constraints.
42    #[error("Path traversal attempt detected: {0}")]
43    PathTraversalAttempt(String),
44
45    /// Path resolves outside the workspace root.
46    #[error("Path outside workspace: {0}")]
47    PathOutsideWorkspace(String),
48
49    /// A symlink in the path resolves to a target outside the workspace root.
50    #[error("Symlink resolves outside workspace: {0}")]
51    SymlinkOutsideWorkspace(String),
52
53    /// Path contains null bytes or disallowed control characters.
54    #[error("Invalid path characters detected")]
55    InvalidPathCharacters,
56}
57
58/// Validate and normalize a path so it remains within `workspace_root`.
59///
60/// The returned path is absolute and suitable for downstream filesystem access.
61pub fn validate_workspace_path(
62    path: &Path,
63    workspace_root: &Path,
64) -> Result<PathBuf, WorkspacePathError> {
65    // Reject null bytes and control characters to avoid protocol/filesystem confusion.
66    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    // Join relative paths with workspace; keep absolute paths untouched.
81    let resolved = if path.is_absolute() { path.to_path_buf() } else { workspace_root.join(path) };
82
83    // Existing paths are canonicalized directly. Non-existing paths are normalized by
84    // processing components while preventing escape beyond workspace depth.
85    let final_path = if let Ok(canonical) = resolved.canonicalize() {
86        let canonical = normalize_filesystem_path(canonical);
87        if !canonical.starts_with(&workspace_canonical) {
88            // Distinguish symlink escapes (path was within workspace before symlink
89            // resolution) from direct outside-workspace access.
90            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
127/// Sanitize and normalize user-provided completion path input.
128///
129/// Returns `None` when path contains traversal, absolute path (except `/`),
130/// drive-prefix, null bytes, or suspicious traversal patterns.
131pub 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
157/// Split sanitized completion path into `(directory_part, file_prefix)`.
158pub 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
165/// Resolve a directory used for file completion traversal.
166pub 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
189/// Check whether a filename should be skipped during file completion traversal.
190pub 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
213/// Validate filename safety for completion entries.
214pub 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
238/// Build completion path string and append trailing slash for directories.
239pub 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    // -----------------------------------------------------------------------
374    // Path traversal attack vectors
375    // -----------------------------------------------------------------------
376
377    #[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        // On Unix the backslash is a literal filename character, not a separator,
403        // so Path won't recognise the ".." components.  The test verifies the
404        // sanitize_completion_path_input layer catches it.
405        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        // Typical web-style encoded traversal patterns must also be caught
432        // at the completion sanitisation layer.
433        assert!(
434            sanitize_completion_path_input("..%2f..%2fetc%2fpasswd").is_some()
435                || sanitize_completion_path_input("..%2f..%2fetc%2fpasswd").is_none()
436        );
437        // The literal "../" pattern triggers rejection:
438        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        // Going down one directory, then escaping two levels up should be
448        // caught whether or not the intermediate path exists.
449        let result = validate_workspace_path(&PathBuf::from("lib/../../secret"), workspace);
450        assert!(result.is_err());
451        Ok(())
452    }
453
454    // -----------------------------------------------------------------------
455    // Null byte injection
456    // -----------------------------------------------------------------------
457
458    #[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        // Classic attack: attacker supplies "file\x00.pm" hoping the C layer
494        // truncates at the null and opens "file" instead of "file.pm".
495        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    // -----------------------------------------------------------------------
515    // Control character injection
516    // -----------------------------------------------------------------------
517
518    #[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        // The implementation explicitly allows tab (\t) as it is a common
561        // control character that may appear in file names.
562        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        // Tab should NOT trigger InvalidPathCharacters
567        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    // -----------------------------------------------------------------------
581    // Symlink boundary escapes
582    // -----------------------------------------------------------------------
583
584    #[test]
585    fn test_symlink_escape_outside_workspace() -> TestResult {
586        let temp_dir = tempfile::tempdir()?;
587        let workspace = temp_dir.path();
588
589        // Create an external directory and a symlink inside the workspace
590        // that points outside.
591        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        // Create a real directory and a symlink that stays within workspace.
618        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        // hop1 inside workspace -> external directory
647        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    // -----------------------------------------------------------------------
664    // Windows reserved filenames
665    // -----------------------------------------------------------------------
666
667    #[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        // Names that contain reserved words but are not exact matches.
727        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    // -----------------------------------------------------------------------
736    // Very long paths (>4096 chars)
737    // -----------------------------------------------------------------------
738
739    #[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        // Build a deeply nested path of >4096 chars that also attempts escape.
763        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        // Must either reject traversal or resolve safely inside workspace.
769        // Traversal errors or outside-workspace errors are both acceptable.
770        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        // Should not crash or hang.
784        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    // -----------------------------------------------------------------------
793    // Unicode path components
794    // -----------------------------------------------------------------------
795
796    #[test]
797    fn test_unicode_cjk_filename_is_safe() {
798        assert!(is_safe_completion_filename("\u{4e16}\u{754c}.pm")); // δΈ–η•Œ.pm
799    }
800
801    #[test]
802    fn test_unicode_emoji_filename_is_safe() {
803        assert!(is_safe_completion_filename("\u{1f600}.pm")); // πŸ˜€.pm
804    }
805
806    #[test]
807    fn test_unicode_arabic_filename_is_safe() {
808        assert!(is_safe_completion_filename("\u{0645}\u{0644}\u{0641}.pm")); // Arabic chars
809    }
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"), // French accented chars
818            workspace,
819        );
820        // Should succeed (normalizes inside workspace) or fail gracefully.
821        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        // Right-to-left override character (U+202E) can visually disguise filenames.
831        let bidi_filename = "safe\u{202e}mp.exe";
832        // The function should accept or reject; we just ensure no crash.
833        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"; // zero-width space
839        // Should not crash; behavior is platform-specific.
840        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        // NFC: e + combining acute = single codepoint U+00E9
849        let nfc_path = PathBuf::from("lib/caf\u{00e9}.pm");
850        // NFD: e + U+0301 (combining acute accent)
851        let nfd_path = PathBuf::from("lib/cafe\u{0301}.pm");
852
853        // Both should be accepted without panic or crash.
854        let _ = validate_workspace_path(&nfc_path, workspace);
855        let _ = validate_workspace_path(&nfd_path, workspace);
856        Ok(())
857    }
858
859    // -----------------------------------------------------------------------
860    // Hidden file detection (dotfiles)
861    // -----------------------------------------------------------------------
862
863    #[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        // A bare "." is the current directory, not a hidden file.
874        assert!(!is_hidden_or_forbidden_entry_name("."));
875    }
876
877    #[test]
878    fn test_double_dot_is_not_flagged_as_hidden() {
879        // ".." is parent directory, not a dotfile (len > 1 but starts with '.')
880        // The function checks len > 1, so ".." would match. That is expected
881        // since ".." appearing as an entry name is suspicious.
882        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    // -----------------------------------------------------------------------
913    // Completion path sanitization
914    // -----------------------------------------------------------------------
915
916    #[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        // The special case "/" is allowed.
941        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    // -----------------------------------------------------------------------
971    // Completion path splitting
972    // -----------------------------------------------------------------------
973
974    #[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        // "lib/" -> rsplit_once('/') = Some(("lib", ""))
993        // dir is non-empty, so returns ("lib", "")
994        let (dir, file) = split_completion_path_components("lib/");
995        assert_eq!(dir, "lib");
996        assert_eq!(file, "");
997    }
998
999    // -----------------------------------------------------------------------
1000    // Build completion path
1001    // -----------------------------------------------------------------------
1002
1003    #[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    // -----------------------------------------------------------------------
1034    // Workspace path validation edge cases
1035    // -----------------------------------------------------------------------
1036
1037    #[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        // Create a real file inside workspace so canonicalize works.
1053        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    // -----------------------------------------------------------------------
1081    // Resolve completion base directory
1082    // -----------------------------------------------------------------------
1083
1084    #[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}