#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SitePath(String);
impl SitePath {
pub fn new(s: impl Into<String>) -> Result<Self, &'static str> {
let raw = s.into();
if raw.is_empty() {
return Err("site path must not be empty");
}
if raw.starts_with('/') {
return Err("site path must be relative");
}
if raw.ends_with('/') {
return Err("site path must not end with /");
}
if raw.contains('\\') {
return Err("site path must use / as separator");
}
if raw.contains('\0') {
return Err("site path must not contain NUL");
}
if raw
.split('/')
.any(|seg| seg.is_empty() || seg == "." || seg == "..")
{
return Err("site path must not contain empty, '.' or '..' segments");
}
Ok(SitePath(raw))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SitePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for SitePath {
type Error = &'static str;
fn try_from(s: String) -> Result<Self, Self::Error> {
SitePath::new(s)
}
}
impl TryFrom<&str> for SitePath {
type Error = &'static str;
fn try_from(s: &str) -> Result<Self, Self::Error> {
SitePath::new(s)
}
}
#[cfg(test)]
pub mod strategy {
use super::SitePath;
use proptest::prelude::*;
pub fn site_path() -> impl Strategy<Value = SitePath> {
proptest::collection::vec("[a-z0-9][a-z0-9_-]{0,15}", 1..5)
.prop_map(|segs| SitePath::new(segs.join("/")).expect("strategy produces valid paths"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn accepts_simple_relative_path() {
assert_eq!(SitePath::new("index.html").unwrap().as_str(), "index.html");
}
#[test]
fn accepts_nested_path() {
assert_eq!(
SitePath::new("assets/css/site.css").unwrap().as_str(),
"assets/css/site.css"
);
}
#[test]
fn rejects_empty() {
assert!(SitePath::new("").is_err());
}
#[test]
fn rejects_absolute() {
assert!(SitePath::new("/etc/passwd").is_err());
}
#[test]
fn rejects_trailing_slash() {
assert!(SitePath::new("issues/").is_err());
}
#[test]
fn rejects_backslash() {
assert!(SitePath::new("a\\b").is_err());
}
#[test]
fn rejects_parent_segment() {
assert!(SitePath::new("a/../b").is_err());
}
#[test]
fn rejects_dot_segment() {
assert!(SitePath::new("a/./b").is_err());
}
#[test]
fn rejects_empty_segment() {
assert!(SitePath::new("a//b").is_err());
}
#[test]
fn rejects_nul() {
assert!(SitePath::new("a\0b").is_err());
}
proptest! {
#[test]
fn strategy_produces_valid_paths(p in strategy::site_path()) {
prop_assert!(!p.as_str().is_empty());
prop_assert!(!p.as_str().starts_with('/'));
prop_assert!(!p.as_str().ends_with('/'));
prop_assert!(!p.as_str().contains(".."));
}
}
}