use std::fmt;
use nutype::nutype;
use snafu::{Snafu, ensure};
use unicode_general_category::{GeneralCategory, get_general_category};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ForbiddenCategory {
Control,
Format,
LineSeparator,
PrivateUse,
Unassigned,
}
impl fmt::Display for ForbiddenCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ForbiddenCategory::Control => "control",
ForbiddenCategory::Format => "format",
ForbiddenCategory::LineSeparator => "line/paragraph separator",
ForbiddenCategory::PrivateUse => "private-use",
ForbiddenCategory::Unassigned => "unassigned",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum SegmentError {
#[snafu(display("path segment is empty"))]
Empty,
#[snafu(display("path segment is the literal `.`"))]
Dot,
#[snafu(display("path segment is the literal `..`"))]
DotDot,
#[snafu(display("path segment contains separator '/'"))]
ContainsSeparator,
#[snafu(display(
"path segment contains forbidden {category} codepoint U+{:04X}",
u32::from(*c)
))]
ContainsForbidden {
c: char,
category: ForbiddenCategory,
},
}
pub(crate) fn forbidden_category(c: char) -> Option<ForbiddenCategory> {
match get_general_category(c) {
GeneralCategory::Control => Some(ForbiddenCategory::Control),
GeneralCategory::Format => Some(ForbiddenCategory::Format),
GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator => {
Some(ForbiddenCategory::LineSeparator)
}
GeneralCategory::PrivateUse => Some(ForbiddenCategory::PrivateUse),
GeneralCategory::Unassigned => Some(ForbiddenCategory::Unassigned),
_ => None,
}
}
pub(crate) fn validate(s: &str) -> Result<(), SegmentError> {
ensure!(!s.is_empty(), EmptySnafu);
ensure!(s != ".", DotSnafu);
ensure!(s != "..", DotDotSnafu);
for c in s.chars() {
ensure!(c != '/', ContainsSeparatorSnafu);
if let Some(category) = forbidden_category(c) {
return ContainsForbiddenSnafu { c, category }.fail();
}
}
Ok(())
}
#[nutype(
validate(with = validate, error = SegmentError),
derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Display,
FromStr,
AsRef,
),
)]
pub struct PathSegment(String);
impl PathSegment {
#[must_use]
pub fn as_str(&self) -> &str {
self.as_ref()
}
}
#[cfg(test)]
mod tests {
use crate::path::segment::{ForbiddenCategory, PathSegment, SegmentError, validate};
#[test]
fn path_002_rejects_empty() {
assert!(matches!(validate(""), Err(SegmentError::Empty)));
}
#[test]
fn path_002_rejects_dot() {
assert!(matches!(validate("."), Err(SegmentError::Dot)));
}
#[test]
fn path_002_rejects_dotdot() {
assert!(matches!(validate(".."), Err(SegmentError::DotDot)));
}
#[test]
fn path_002_rejects_separator() {
assert!(matches!(
validate("a/b"),
Err(SegmentError::ContainsSeparator)
));
}
#[test]
fn path_002_rejects_nul() {
assert!(matches!(
validate("foo\u{0000}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{0000}',
category: ForbiddenCategory::Control,
})
));
}
#[test]
fn path_002_rejects_tab() {
assert!(matches!(
validate("foo\tbar"),
Err(SegmentError::ContainsForbidden {
c: '\t',
category: ForbiddenCategory::Control,
})
));
}
#[test]
fn path_002_rejects_del() {
assert!(matches!(
validate("foo\u{007F}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{007F}',
category: ForbiddenCategory::Control,
})
));
}
#[test]
fn path_002_rejects_c1_control() {
assert!(matches!(
validate("foo\u{0085}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{0085}',
category: ForbiddenCategory::Control,
})
));
}
#[test]
fn path_002_rejects_zero_width_space() {
assert!(matches!(
validate("foo\u{200B}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{200B}',
category: ForbiddenCategory::Format,
})
));
}
#[test]
fn path_002_rejects_zero_width_joiner() {
assert!(matches!(
validate("foo\u{200D}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{200D}',
category: ForbiddenCategory::Format,
})
));
}
#[test]
fn path_002_rejects_byte_order_mark() {
assert!(matches!(
validate("foo\u{FEFF}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{FEFF}',
category: ForbiddenCategory::Format,
})
));
}
#[test]
fn path_002_rejects_bidirectional_override() {
assert!(matches!(
validate("foo\u{202E}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{202E}',
category: ForbiddenCategory::Format,
})
));
}
#[test]
fn path_002_rejects_soft_hyphen() {
assert!(matches!(
validate("foo\u{00AD}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{00AD}',
category: ForbiddenCategory::Format,
})
));
}
#[test]
fn path_002_rejects_line_separator() {
assert!(matches!(
validate("foo\u{2028}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{2028}',
category: ForbiddenCategory::LineSeparator,
})
));
}
#[test]
fn path_002_rejects_paragraph_separator() {
assert!(matches!(
validate("foo\u{2029}bar"),
Err(SegmentError::ContainsForbidden {
c: '\u{2029}',
category: ForbiddenCategory::LineSeparator,
})
));
}
#[test]
fn path_002_accepts_alnum_segment() {
assert!(validate("lib").is_ok());
assert!(validate("Makefile").is_ok());
}
#[test]
fn path_002_accepts_segment_with_dot() {
assert!(validate("lib.rs").is_ok());
assert!(validate("logo.png").is_ok());
assert!(validate(".dotfile").is_ok());
}
#[test]
fn path_002_accepts_regular_space() {
assert!(validate("hello world.txt").is_ok());
}
#[test]
fn path_002_accepts_nbsp() {
assert!(validate("hello\u{00A0}world.txt").is_ok());
}
#[test]
fn path_002_accepts_latin_extended() {
assert!(validate("caf\u{00E9}.txt").is_ok());
}
#[test]
fn path_002_accepts_cyrillic() {
assert!(validate("\u{0436}urnal.txt").is_ok());
}
#[test]
fn path_002_accepts_cjk() {
assert!(validate("\u{65E5}.txt").is_ok());
}
#[test]
fn path_002_accepts_combining_mark() {
assert!(validate("caf\u{0061}\u{0301}.txt").is_ok());
}
#[test]
fn round_trip_via_try_new_and_display() {
let s = PathSegment::try_new("lib.rs").unwrap();
assert_eq!(s.to_string(), "lib.rs");
assert_eq!(s.as_str(), "lib.rs");
}
}