Skip to main content

perl_module_resolution_path/
lib.rs

1//! Workspace-aware Perl module path resolution.
2//!
3//! This microcrate has a narrow responsibility: convert a Perl module name into a
4//! canonical filesystem path candidate under a workspace root.
5
6#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9#![warn(clippy::all)]
10
11use std::path::{Path, PathBuf};
12
13use perl_module_path::module_name_to_path;
14use perl_path_security::validate_workspace_path;
15
16/// Resolve a Perl module name to a workspace-relative filesystem path candidate.
17///
18/// The search order is:
19/// 1. Each `include_path` under `root`, rejecting path traversal.
20/// 2. Fallback to `root/lib/<module>.pm`.
21#[must_use]
22pub fn resolve_module_path(
23    root: &Path,
24    module_name: &str,
25    include_paths: &[String],
26) -> Option<PathBuf> {
27    let relative_path = module_name_to_path(module_name);
28
29    for base in include_paths {
30        let candidate = if base == "." {
31            root.join(&relative_path)
32        } else {
33            root.join(base).join(&relative_path)
34        };
35
36        let safe_candidate = match validate_workspace_path(&candidate, root) {
37            Ok(path) => path,
38            Err(_) => continue,
39        };
40
41        if safe_candidate.exists() {
42            return Some(safe_candidate);
43        }
44    }
45
46    Some(root.join("lib").join(relative_path))
47}
48
49#[cfg(test)]
50mod tests {
51    use std::fs;
52    use tempfile::tempdir;
53
54    use super::resolve_module_path;
55
56    #[test]
57    fn returns_first_safe_candidate() -> Result<(), Box<dyn std::error::Error>> {
58        let temp = tempdir()?;
59        let root = temp.path().to_path_buf();
60        let module_file = root.join("lib").join("Foo").join("Bar.pm");
61
62        fs::create_dir_all(module_file.parent().ok_or("no parent")?)?;
63        fs::write(&module_file, "package Foo::Bar; 1;")?;
64
65        let resolved = resolve_module_path(&root, "Foo::Bar", &["lib".to_string()]);
66        assert_eq!(resolved, Some(root.join("lib").join("Foo/Bar.pm")));
67        Ok(())
68    }
69
70    #[test]
71    fn rejects_traversal_candidate_and_falls_back_to_lib() -> Result<(), Box<dyn std::error::Error>>
72    {
73        let root = tempfile::tempdir()?.path().to_path_buf();
74        let resolved = resolve_module_path(&root, "Escaped::Target", &["..".to_string()]);
75
76        assert_eq!(resolved, Some(root.join("lib").join("Escaped/Target.pm")));
77        Ok(())
78    }
79
80    #[test]
81    fn accepts_current_directory_as_include_path() -> Result<(), Box<dyn std::error::Error>> {
82        let temp = tempfile::tempdir()?;
83        let root = temp.path().to_path_buf();
84        let module_file = root.join("Local").join("Only.pm");
85
86        std::fs::create_dir_all(module_file.parent().ok_or("no parent")?)?;
87        std::fs::write(&module_file, "package Local::Only; 1;")?;
88
89        let resolved = resolve_module_path(&root, "Local::Only", &[".".to_string()]);
90        assert_eq!(resolved, Some(root.join("Local/Only.pm")));
91        Ok(())
92    }
93}