use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy)]
pub struct NotAbsoluteError;
impl std::fmt::Display for NotAbsoluteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("path is not absolute or cannot be represented as a file:// URL")
}
}
impl std::error::Error for NotAbsoluteError {}
pub fn from_path(p: &Path) -> Result<url::Url, NotAbsoluteError> {
url::Url::from_file_path(p).map_err(|_| NotAbsoluteError)
}
pub fn to_path(u: &url::Url) -> Option<PathBuf> {
u.to_file_path().ok()
}
pub fn to_path_within(u: &url::Url, root: &Path) -> Option<PathBuf> {
let p = to_path(u)?;
let normalized = normalize_lexical(&p);
let root_norm = normalize_lexical(root);
if normalized.starts_with(&root_norm) {
Some(normalized)
} else {
None
}
}
fn normalize_lexical(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
out.push(comp.as_os_str());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
const SIMPLE: &str = "/home/user/foo.rs";
#[cfg(windows)]
const SIMPLE: &str = r"C:\Users\user\foo.rs";
#[cfg(unix)]
const WITH_SPACES: &str = "/home/user/my project/foo bar.rs";
#[cfg(windows)]
const WITH_SPACES: &str = r"C:\Users\user\my project\foo bar.rs";
#[cfg(unix)]
const NESTED: &str = "/a/b/c/d/e.toml";
#[cfg(windows)]
const NESTED: &str = r"C:\a\b\c\d\e.toml";
#[test]
fn roundtrip_simple_path() {
let p = Path::new(SIMPLE);
let url = from_path(p).unwrap();
assert_eq!(url.scheme(), "file");
let back = to_path(&url).unwrap();
assert_eq!(back, p);
}
#[test]
fn roundtrip_path_with_spaces() {
let p = Path::new(WITH_SPACES);
let url = from_path(p).unwrap();
assert!(
url.as_str().contains("%20"),
"spaces must be percent-encoded"
);
let back = to_path(&url).unwrap();
assert_eq!(back, p);
}
#[test]
fn roundtrip_nested_path() {
let p = Path::new(NESTED);
let url = from_path(p).unwrap();
let back = to_path(&url).unwrap();
assert_eq!(back, p);
}
#[test]
fn relative_path_returns_err() {
let p = Path::new("relative/path.rs");
assert!(from_path(p).is_err());
}
#[test]
fn non_file_url_returns_none() {
let url = url::Url::parse("https://example.com/foo").unwrap();
assert!(to_path(&url).is_none());
}
#[cfg(unix)]
const ROOT: &str = "/tmp/workspace";
#[cfg(windows)]
const ROOT: &str = r"C:\tmp\workspace";
#[cfg(unix)]
const INSIDE: &str = "/tmp/workspace/src/main.rs";
#[cfg(windows)]
const INSIDE: &str = r"C:\tmp\workspace\src\main.rs";
#[cfg(unix)]
const SIBLING: &str = "/tmp/workspace-evil/src/main.rs";
#[cfg(windows)]
const SIBLING: &str = r"C:\tmp\workspace-evil\src\main.rs";
#[test]
fn to_path_within_accepts_path_inside_root() {
let url = from_path(Path::new(INSIDE)).unwrap();
let p = to_path_within(&url, Path::new(ROOT));
assert_eq!(p, Some(PathBuf::from(INSIDE)));
}
#[test]
fn to_path_within_rejects_parent_escape() {
let url = url::Url::parse("file:///tmp/workspace/../secrets/key.pem").unwrap();
assert!(to_path_within(&url, Path::new("/tmp/workspace")).is_none());
}
#[cfg(unix)]
const DOTTED_INSIDE: &str = "/tmp/workspace/a/../b/c.rs";
#[cfg(windows)]
const DOTTED_INSIDE: &str = r"C:\tmp\workspace\a\..\b\c.rs";
#[cfg(unix)]
const DOTTED_FOLDED: &str = "/tmp/workspace/b/c.rs";
#[cfg(windows)]
const DOTTED_FOLDED: &str = r"C:\tmp\workspace\b\c.rs";
#[test]
fn to_path_within_returns_normalized_path() {
let url = from_path(Path::new(DOTTED_INSIDE)).unwrap();
assert!(
url.as_str().contains("/../"),
"from_path must preserve `..` for this test to exercise anything"
);
assert_eq!(to_path(&url).unwrap(), PathBuf::from(DOTTED_INSIDE));
let p = to_path_within(&url, Path::new(ROOT));
assert_eq!(p, Some(PathBuf::from(DOTTED_FOLDED)));
}
#[test]
fn to_path_within_rejects_sibling_directory() {
let url = from_path(Path::new(SIBLING)).unwrap();
assert!(to_path_within(&url, Path::new(ROOT)).is_none());
}
#[test]
fn normalize_lexical_folds_dot_segments() {
#[cfg(unix)]
{
assert_eq!(
normalize_lexical(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
assert_eq!(
normalize_lexical(Path::new("/a/../../b")),
PathBuf::from("/b")
);
}
#[cfg(windows)]
{
assert_eq!(
normalize_lexical(Path::new(r"C:\a\b\..\c\.\d")),
PathBuf::from(r"C:\a\c\d")
);
}
}
}