use anyhow::{bail, Result};
use std::path::{Component, Path};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NormalizedPath(String);
impl NormalizedPath {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl AsRef<str> for NormalizedPath {
fn as_ref(&self) -> &str {
&self.0
}
}
pub fn normalize_policy_path(path: impl AsRef<Path>) -> Result<NormalizedPath> {
let raw = path.as_ref();
if raw.as_os_str().is_empty() {
bail!("cannot normalize an empty path");
}
let mut parts: Vec<String> = Vec::new();
let mut absolute = false;
let mut drive: Option<String> = None;
for component in raw.components() {
match component {
Component::Prefix(prefix) => {
let text = prefix.as_os_str().to_string_lossy().replace('\\', "/");
let trimmed = text.trim_end_matches('/').to_string();
if trimmed.is_empty() {
bail!("invalid path prefix in {}", raw.display());
}
drive = Some(trimmed);
absolute = true;
parts.clear();
}
Component::RootDir => {
absolute = true;
if drive.is_none() {
parts.clear();
}
}
Component::CurDir => {}
Component::ParentDir => {
if parts.pop().is_none() {
bail!(
"path escapes its root during normalization: {}",
raw.display()
);
}
}
Component::Normal(part) => {
let text = part.to_string_lossy();
if text.contains('\0') {
bail!("path contains NUL: {}", raw.display());
}
parts.push(text.into_owned());
}
}
}
let body = parts.join("/");
let normalized = if let Some(drive) = drive {
if body.is_empty() {
format!("{drive}/")
} else {
format!("{drive}/{body}")
}
} else if absolute {
format!("/{body}")
} else {
if body.is_empty() {
bail!("normalized relative path is empty: {}", raw.display());
}
body
};
Ok(NormalizedPath(normalized))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn collapses_dot_and_rejects_escape() {
let normalized = normalize_policy_path("a/./b/../c").unwrap();
assert_eq!(normalized.as_str(), "a/c");
assert!(normalize_policy_path("../outside").is_err());
assert!(normalize_policy_path("a/../../outside").is_err());
}
#[test]
fn normalizes_separators() {
let mixed = PathBuf::from("src").join("main.rs");
let normalized = normalize_policy_path(&mixed).unwrap();
assert_eq!(normalized.as_str(), "src/main.rs");
}
#[test]
fn absolute_unix_style_paths_keep_root() {
let normalized = normalize_policy_path("/tmp/scratch/./file").unwrap();
assert_eq!(normalized.as_str(), "/tmp/scratch/file");
}
#[test]
fn identical_relative_shapes_compare_equal() {
let left = normalize_policy_path("workspace/src/lib.rs").unwrap();
let right = normalize_policy_path("./workspace/./src/lib.rs").unwrap();
assert_eq!(left, right);
}
}