use std::path::{Component, Path as StdPath, PathBuf};
use serde::Serialize;
use crate::extract::PathType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Verdict {
Ok,
Symlinked,
NonCanonical,
Missing,
EscapesRoot,
Unresolved,
}
impl Verdict {
pub(crate) fn is_finding(self, deny_symlinks: bool) -> bool {
match self {
Verdict::Missing | Verdict::EscapesRoot | Verdict::NonCanonical => true,
Verdict::Symlinked => deny_symlinks,
Verdict::Ok | Verdict::Unresolved => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Resolution {
pub(crate) verdict: Verdict,
pub(crate) canonical: Option<String>,
pub(crate) symlink: Option<String>,
pub(crate) reason: Option<String>,
}
impl Resolution {
pub(crate) fn unresolved(reason: &str) -> Self {
Self {
verdict: Verdict::Unresolved,
canonical: None,
symlink: None,
reason: Some(reason.to_string()),
}
}
}
pub(crate) fn resolve(
value: &str,
kind: PathType,
base_dir: &StdPath,
root: &StdPath,
) -> Resolution {
if kind == PathType::Url {
return Resolution::unresolved("not a filesystem path");
}
if value.starts_with('#') {
return Resolution::unresolved("a fragment, not a filesystem path");
}
if value.contains("://") {
return Resolution::unresolved("a scheme-qualified locator, not a filesystem path");
}
if is_windows_path(value) && !cfg!(windows) {
return Resolution::unresolved("a Windows path, not resolvable on this platform");
}
let absolute = StdPath::new(value).is_absolute();
let target = if absolute {
lexical_normalise(StdPath::new(value))
} else {
lexical_normalise(&base_dir.join(value))
};
if !absolute && !target.starts_with(root) {
return Resolution {
verdict: Verdict::EscapesRoot,
canonical: Some(display(&target)),
symlink: None,
reason: Some(format!("resolves outside {}", display(root))),
};
}
let link = std::fs::symlink_metadata(&target)
.ok()
.and_then(|metadata| {
metadata
.file_type()
.is_symlink()
.then(|| std::fs::read_link(&target).ok())
.flatten()
});
let symlink = link.as_deref().map(display);
let found = std::fs::canonicalize(&target)
.ok()
.map(|canonical| (canonical, None))
.or_else(|| probe_extensions(&target).map(|(path, name)| (path, Some(name))));
let Some((canonical, probed)) = found else {
if symlink.is_none() && !commits_to_being_a_path(value) {
return Resolution::unresolved(
"nothing here by that name, and the value does not commit to being a path — \
no leading ./ and no file extension, so its absence is not evidence",
);
}
let reason = if symlink.is_some() {
"a broken symlink".to_string()
} else {
"no such file or directory".to_string()
};
return Resolution {
verdict: Verdict::Missing,
canonical: Some(display(&target)),
symlink,
reason: Some(reason),
};
};
if let Some(name) = probed {
return Resolution {
verdict: Verdict::Ok,
canonical: Some(display(&canonical)),
symlink,
reason: Some(format!("written without an extension; resolved to {name}")),
};
}
if let Some(reason) = non_canonical_reason(value) {
return Resolution {
verdict: Verdict::NonCanonical,
canonical: Some(display(&canonical)),
symlink,
reason: Some(reason),
};
}
if symlink.is_some() {
return Resolution {
verdict: Verdict::Symlinked,
canonical: Some(display(&canonical)),
symlink,
reason: None,
};
}
Resolution {
verdict: Verdict::Ok,
canonical: Some(display(&canonical)),
symlink: None,
reason: None,
}
}
fn commits_to_being_a_path(value: &str) -> bool {
if is_composite(value) {
return false;
}
if value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with('/')
|| is_windows_path(value)
{
return true;
}
let Some((_, last)) = value.rsplit_once(['/', '\\']) else {
return false;
};
last.rsplit_once('.')
.is_some_and(|(stem, extension)| !stem.is_empty() && !extension.is_empty())
}
fn is_composite(value: &str) -> bool {
let tail = if is_windows_path(value) {
&value[2..]
} else {
value
};
tail.contains(':')
}
const MODULE_EXTENSIONS: [&str; 7] = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "json"];
fn probe_extensions(target: &StdPath) -> Option<(PathBuf, String)> {
let file_name = target.file_name()?.to_str()?.to_string();
MODULE_EXTENSIONS.iter().find_map(|extension| {
let name = format!("{file_name}.{extension}");
std::fs::canonicalize(target.with_file_name(&name))
.ok()
.map(|canonical| (canonical, name))
})
}
fn non_canonical_reason(value: &str) -> Option<String> {
if value.contains("//") {
return Some("contains a duplicate separator".to_string());
}
if value.len() > 1 && value.ends_with('/') {
return Some("ends with a separator".to_string());
}
if value.contains('\\') && value.contains('/') {
return Some("mixes backslash and forward-slash separators".to_string());
}
has_embedded_traversal(value).then(|| "traverses upward mid-path".to_string())
}
fn has_embedded_traversal(value: &str) -> bool {
let mut seen_named_segment = false;
for segment in value.split('/') {
match segment {
".." if seen_named_segment => return true,
".." | "." | "" => {}
_ => seen_named_segment = true,
}
}
false
}
fn is_windows_path(value: &str) -> bool {
let mut chars = value.chars();
let Some(letter) = chars.next() else {
return false;
};
letter.is_ascii_alphabetic()
&& chars.next() == Some(':')
&& matches!(chars.next(), Some('/' | '\\'))
}
fn lexical_normalise(path: &StdPath) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub(crate) fn display(path: &StdPath) -> String {
let rendered = path.to_string_lossy();
if cfg!(windows) {
return forward_slashes(&rendered);
}
rendered.into_owned()
}
fn forward_slashes(rendered: &str) -> String {
let bare = match rendered.strip_prefix(r"\\?\UNC\") {
Some(tail) => format!(r"\\{tail}"),
None => rendered
.strip_prefix(r"\\?\")
.unwrap_or(rendered)
.to_string(),
};
bare.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn resolve_in(tree: &TempTree, value: &str) -> Resolution {
resolve(value, PathType::Relative, tree.path(), tree.path())
}
#[test]
fn a_reported_path_spells_its_separators_forward() {
assert_eq!(
forward_slashes(r"C:\Users\me\src\app.ts"),
"C:/Users/me/src/app.ts"
);
assert_eq!(forward_slashes(r"\\?\C:\a\b.txt"), "C:/a/b.txt");
assert_eq!(
forward_slashes(r"\\?\UNC\host\share\a.txt"),
"//host/share/a.txt"
);
assert_eq!(forward_slashes(r"\\host\share\a.txt"), "//host/share/a.txt");
assert_eq!(forward_slashes("already/forward.ts"), "already/forward.ts");
}
#[cfg(unix)]
#[test]
fn a_backslash_in_a_unix_filename_survives_the_report() {
assert_eq!(display(StdPath::new(r"/tmp/od\d.txt")), r"/tmp/od\d.txt");
}
#[test]
fn an_existing_file_is_ok() {
let tree = TempTree::new("resolve-ok");
tree.write("a/b.txt", "x");
let resolution = resolve_in(&tree, "./a/b.txt");
assert_eq!(resolution.verdict, Verdict::Ok);
assert!(resolution.canonical.is_some());
assert_eq!(resolution.symlink, None);
}
#[test]
fn a_missing_file_says_where_it_looked() {
let tree = TempTree::new("resolve-missing");
let resolution = resolve_in(&tree, "./nope.txt");
assert_eq!(resolution.verdict, Verdict::Missing);
assert_eq!(
resolution.reason.as_deref(),
Some("no such file or directory")
);
assert!(
resolution
.canonical
.expect("a target")
.ends_with("nope.txt"),
"the report must say where it looked"
);
}
#[test]
fn a_relative_path_resolves_against_the_file_not_the_working_directory() {
let tree = TempTree::new("resolve-base");
tree.write("pkg/inner/x.txt", "x");
let base = tree.path().join("pkg/inner");
let resolution = resolve("./x.txt", PathType::Relative, &base, tree.path());
assert_eq!(resolution.verdict, Verdict::Ok);
}
#[test]
fn a_relative_path_that_climbs_out_of_the_root_is_a_finding() {
let tree = TempTree::new("resolve-escape");
tree.write("pkg/x.txt", "x");
let base = tree.path().join("pkg");
let resolution = resolve("../../outside.txt", PathType::Relative, &base, tree.path());
assert_eq!(resolution.verdict, Verdict::EscapesRoot);
assert!(resolution.reason.expect("a reason").contains("outside"));
}
#[test]
fn climbing_within_the_root_is_not_an_escape() {
let tree = TempTree::new("resolve-climb");
tree.write("a/x.txt", "x");
let base = tree.path().join("b");
std::fs::create_dir_all(&base).expect("a directory");
let resolution = resolve("../a/x.txt", PathType::Relative, &base, tree.path());
assert_eq!(resolution.verdict, Verdict::Ok);
}
#[test]
fn a_backslash_alone_is_not_non_canonical() {
assert_eq!(non_canonical_reason(r"\\?\C:\tmp\x.txt"), None);
assert_eq!(non_canonical_reason(r"src\lib\a.ts"), None);
assert_eq!(non_canonical_reason(r"C:\tmp\x.txt"), None);
}
#[test]
fn genuinely_mixed_separators_are_non_canonical() {
assert_eq!(
non_canonical_reason(r"src\lib/a.ts").as_deref(),
Some("mixes backslash and forward-slash separators")
);
}
#[test]
fn an_absolute_path_never_escapes() {
let tree = TempTree::new("resolve-absolute");
tree.write("x.txt", "x");
let outside = tree.path().join("x.txt");
let inner_root = tree.path().join("inner");
std::fs::create_dir_all(&inner_root).expect("a directory");
let resolution = resolve(
&outside.to_string_lossy(),
PathType::Absolute,
&inner_root,
&inner_root,
);
assert_eq!(resolution.verdict, Verdict::Ok);
}
#[test]
fn a_url_is_never_resolved() {
let tree = TempTree::new("resolve-url");
let resolution = resolve(
"https://example.com/a",
PathType::Url,
tree.path(),
tree.path(),
);
assert_eq!(resolution.verdict, Verdict::Unresolved);
assert_eq!(resolution.reason.as_deref(), Some("not a filesystem path"));
}
#[test]
fn a_fragment_is_never_resolved() {
let tree = TempTree::new("resolve-fragment");
let resolution = resolve("#section", PathType::Unknown, tree.path(), tree.path());
assert_eq!(resolution.verdict, Verdict::Unresolved);
}
#[test]
fn a_windows_path_is_unresolved_off_windows() {
let tree = TempTree::new("resolve-windows");
let resolution = resolve(
r"C:\Temp\x.txt",
PathType::Absolute,
tree.path(),
tree.path(),
);
if cfg!(windows) {
assert_eq!(resolution.verdict, Verdict::Missing);
} else {
assert_eq!(resolution.verdict, Verdict::Unresolved);
}
}
#[test]
fn duplicate_separators_and_trailing_slashes_are_non_canonical() {
let tree = TempTree::new("resolve-noncanon");
tree.write("a/b.txt", "x");
assert_eq!(resolve_in(&tree, "a//b.txt").verdict, Verdict::NonCanonical);
tree.write("a/dir/keep.txt", "x");
assert_eq!(resolve_in(&tree, "a/dir/").verdict, Verdict::NonCanonical);
}
#[test]
fn an_embedded_traversal_is_non_canonical_but_a_leading_one_is_not() {
let tree = TempTree::new("resolve-traversal");
tree.write("a/b.txt", "x");
tree.write("c/keep.txt", "x");
assert_eq!(
resolve_in(&tree, "c/../a/b.txt").verdict,
Verdict::NonCanonical
);
let base = tree.path().join("c");
assert_eq!(
resolve("../a/b.txt", PathType::Relative, &base, tree.path()).verdict,
Verdict::Ok,
"a leading climb is idiomatic, not a finding"
);
}
#[test]
fn a_leading_dot_slash_is_not_a_finding() {
let tree = TempTree::new("resolve-dotslash");
tree.write("a.txt", "x");
assert_eq!(resolve_in(&tree, "./a.txt").verdict, Verdict::Ok);
}
#[test]
fn an_extensionless_import_resolves_to_the_file_it_names() {
let tree = TempTree::new("resolve-probe");
tree.write("ui/notifier.ts", "");
tree.write("dedupe.tsx", "");
tree.write("legacy.js", "");
for (written, found) in [
("./ui/notifier", "notifier.ts"),
("./dedupe", "dedupe.tsx"),
("./legacy", "legacy.js"),
] {
let resolution = resolve_in(&tree, written);
assert_eq!(resolution.verdict, Verdict::Ok, "{written}");
assert_eq!(
resolution.reason.as_deref(),
Some(format!("written without an extension; resolved to {found}").as_str()),
"the substitution must be visible to be checkable"
);
}
}
#[test]
fn an_extensionless_import_naming_a_directory_resolves_to_it() {
let tree = TempTree::new("resolve-probe-index");
tree.write("feature/index.ts", "");
let resolution = resolve_in(&tree, "./feature");
assert_eq!(resolution.verdict, Verdict::Ok);
assert_eq!(
resolution.reason, None,
"the directory resolved as itself; nothing was substituted"
);
}
#[test]
fn a_path_naming_a_file_that_is_not_there_is_still_missing() {
let tree = TempTree::new("resolve-probe-exact");
tree.write("gone.js", "");
assert_eq!(resolve_in(&tree, "./gone.ts").verdict, Verdict::Missing);
}
#[test]
fn a_dotted_name_that_is_not_an_extension_still_probes() {
let tree = TempTree::new("resolve-probe-dotted");
tree.write("tool-facts.generated.ts", "");
let resolution = resolve_in(&tree, "./tool-facts.generated");
assert_eq!(resolution.verdict, Verdict::Ok);
assert!(
resolution
.reason
.expect("a reason")
.contains("tool-facts.generated.ts")
);
}
#[test]
fn any_scheme_qualified_locator_is_unresolved() {
let tree = TempTree::new("resolve-schemes");
for value in [
"ftp://example.com/pub",
"postgresql://user:pw@host:5432/db",
"mongodb://host:27017/db",
"git+https://github.com/a/b.git",
] {
let resolution = resolve(value, PathType::File, tree.path(), tree.path());
assert_eq!(resolution.verdict, Verdict::Unresolved, "{value}");
assert_eq!(
resolution.reason.as_deref(),
Some("a scheme-qualified locator, not a filesystem path")
);
}
}
#[test]
fn an_extensionless_path_with_nothing_behind_it_is_still_missing() {
let tree = TempTree::new("resolve-probe-none");
assert_eq!(resolve_in(&tree, "./nowhere").verdict, Verdict::Missing);
}
#[test]
fn a_value_that_does_not_commit_to_being_a_path_is_unresolved() {
let tree = TempTree::new("resolve-noncommittal");
for token in [
"image/png",
"text/html",
"@heroui/styles",
"io.github.nolindnaidoo/paths-le",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"RGB/RGBA\u{306e}\u{307f}",
"^1.101.0",
"paths-le.extractPaths",
"example.com",
] {
let resolution = resolve(token, PathType::File, tree.path(), tree.path());
assert_eq!(resolution.verdict, Verdict::Unresolved, "{token}");
}
}
#[test]
fn a_noncommittal_value_that_is_there_still_resolves() {
let tree = TempTree::new("resolve-noncommittal-real");
tree.write("index.js", "");
tree.write("image/png", "");
for token in ["index.js", "image/png"] {
let resolution = resolve(token, PathType::File, tree.path(), tree.path());
assert_eq!(resolution.verdict, Verdict::Ok, "{token}");
}
}
#[test]
fn a_committed_path_that_is_not_there_is_still_missing() {
let tree = TempTree::new("resolve-committed");
for token in ["images/bg.png", "./anything", "../up", "/etc/nope-xyz"] {
let resolution = resolve(token, PathType::File, tree.path(), tree.path());
assert_ne!(resolution.verdict, Verdict::Unresolved, "{token}");
}
}
#[test]
fn commitment_is_explicit_syntax_or_an_extension_after_a_separator() {
for committed in [
"./a",
"../a",
"/a",
r"C:\a",
"src/app.ts",
r"src\app.ts",
"a/b.c",
] {
assert!(commits_to_being_a_path(committed), "{committed}");
}
for not in [
"index.js",
"^1.101.0",
"a/b",
"image/png",
"a/b.",
"a/.hidden",
"",
] {
assert!(!commits_to_being_a_path(not), "{not}");
}
}
#[test]
fn a_colon_joined_composite_does_not_commit_to_being_a_path() {
for composite in [
"/etc/localtime:/etc/localtime:ro",
"/var/run/docker.sock:/var/run/docker.sock",
"./stack.conf:/redis-stack.conf:ro",
"/usr/bin:/usr/local/bin",
"src/app.ts:42",
] {
assert!(!commits_to_being_a_path(composite), "{composite}");
}
assert!(commits_to_being_a_path(r"C:\Temp\out.txt"));
assert!(commits_to_being_a_path("C:/Temp/out.txt"));
}
#[test]
#[cfg(unix)]
fn a_composite_that_is_actually_there_still_resolves() {
let tree = TempTree::new("resolve-composite");
tree.write("a:b.txt", "");
let resolution = resolve_in(&tree, "./a:b.txt");
assert_eq!(resolution.verdict, Verdict::Ok);
}
#[test]
fn a_composite_that_is_not_there_stays_unresolved() {
let tree = TempTree::new("resolve-composite-absent");
let absent = resolve_in(&tree, "./nope:also-nope.txt");
assert_eq!(absent.verdict, Verdict::Unresolved);
}
#[test]
fn canonicalisation_counts_by_default_and_links_count_on_request() {
for denied in [false, true] {
assert!(Verdict::Missing.is_finding(denied));
assert!(Verdict::EscapesRoot.is_finding(denied));
assert!(Verdict::NonCanonical.is_finding(denied));
assert!(!Verdict::Ok.is_finding(denied));
assert!(!Verdict::Unresolved.is_finding(denied));
}
assert!(!Verdict::Symlinked.is_finding(false));
assert!(Verdict::Symlinked.is_finding(true));
}
#[cfg(unix)]
mod unix {
use super::*;
#[test]
fn a_symlink_is_reported_with_its_target() {
let tree = TempTree::new("resolve-symlink");
tree.write("real/file.txt", "x");
tree.symlink("real/file.txt", "link.txt");
let resolution = resolve_in(&tree, "link.txt");
assert_eq!(resolution.verdict, Verdict::Symlinked);
assert_eq!(resolution.symlink.as_deref(), Some("real/file.txt"));
assert!(
resolution
.canonical
.expect("a target")
.ends_with("file.txt"),
"the canonical form follows the link"
);
}
#[test]
fn a_symlink_is_not_a_finding_unless_denied() {
let tree = TempTree::new("resolve-symlink-ok");
tree.write("real/file.txt", "x");
tree.symlink("real/file.txt", "link.txt");
let verdict = resolve_in(&tree, "link.txt").verdict;
assert!(!verdict.is_finding(false), "a link is a fact by default");
assert!(verdict.is_finding(true), "and a finding when denied");
}
#[test]
fn a_broken_symlink_is_missing_and_names_its_target() {
let tree = TempTree::new("resolve-broken");
tree.symlink("gone.txt", "link.txt");
let resolution = resolve_in(&tree, "link.txt");
assert_eq!(resolution.verdict, Verdict::Missing);
assert_eq!(resolution.reason.as_deref(), Some("a broken symlink"));
assert_eq!(resolution.symlink.as_deref(), Some("gone.txt"));
}
#[test]
fn a_link_pointing_out_of_the_tree_is_a_link_not_an_escape() {
let tree = TempTree::new("resolve-link-out");
tree.write("outside/target.txt", "x");
tree.mkdir("root");
tree.symlink("../outside/target.txt", "root/inside.txt");
let root = tree.path().join("root");
let resolution = resolve("inside.txt", PathType::File, &root, &root);
assert_eq!(resolution.verdict, Verdict::Symlinked);
}
}
}