use crate::ids::string_id;
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AbsolutePath {
normalized: String,
components: Vec<PathComponent>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PathComponent(String);
string_id! {
DisplayName,
error = PathError,
validate = validate_display_name
}
pub const MAX_DISPLAY_NAME_BYTES: usize = 255;
pub const MAX_PATH_BYTES: usize = 4_096;
pub const MAX_PATH_DEPTH: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PathError {
#[error("absolute path must not be empty")]
EmptyPath,
#[error("path `{path:?}` is not absolute")]
RelativePath {
path: String,
},
#[error("path `{path:?}` contains `.` component")]
DotComponent {
path: String,
},
#[error("path `{path:?}` contains `..` component")]
ParentComponent {
path: String,
},
#[error("display name must not be empty")]
EmptyDisplayName,
#[error("display name `{display_name:?}` contains `/`")]
DisplayNameContainsSeparator {
display_name: String,
},
#[error("display name `{display_name:?}` is reserved")]
ReservedDisplayName {
display_name: String,
},
#[error("display name contains control character U+{code_point:04X}")]
DisplayNameContainsControlCharacter {
code_point: u32,
},
#[error("display name is {byte_length} bytes; the maximum is {MAX_DISPLAY_NAME_BYTES} bytes")]
DisplayNameTooLong {
byte_length: usize,
},
#[error("path is {byte_length} bytes; the maximum is {MAX_PATH_BYTES} bytes")]
PathTooLong {
byte_length: usize,
},
#[error("path has {depth} components; the maximum is {MAX_PATH_DEPTH}")]
PathTooDeep {
depth: usize,
},
#[error("display name `{display_name}` {reason}")]
UnportableDisplayName {
display_name: String,
reason: &'static str,
},
#[error(
"display name `{display_name}` contains `{character}`, which Windows cannot store; \
the reserved characters are `:` `?` `*` `|` `\"` `<` `>` `\\`"
)]
UnportableDisplayNameCharacter {
display_name: String,
character: char,
},
#[error(
"display name folds to a {byte_length}-byte name key; the maximum is \
{max} bytes",
max = crate::ids::MAX_NAME_KEY_BYTES
)]
FoldedNameKeyTooLong {
byte_length: usize,
},
}
impl AbsolutePath {
pub fn parse(value: impl AsRef<str>) -> Result<Self, PathError> {
let value = value.as_ref();
if value.is_empty() {
return Err(PathError::EmptyPath);
}
if !value.starts_with('/') {
return Err(PathError::RelativePath {
path: value.to_owned(),
});
}
if value == "/" {
return Ok(Self::root());
}
let mut components = Vec::new();
for component in value[1..].split('/') {
if component.is_empty() {
return Err(PathError::EmptyDisplayName);
}
if component == "." {
return Err(PathError::DotComponent {
path: value.to_owned(),
});
}
if component == ".." {
return Err(PathError::ParentComponent {
path: value.to_owned(),
});
}
validate_display_name(component)?;
components.push(PathComponent(component.to_owned()));
}
validate_path_bounds(value.len(), components.len())?;
Ok(Self::from_components(components))
}
pub fn root() -> Self {
Self {
normalized: "/".to_owned(),
components: Vec::new(),
}
}
pub fn as_str(&self) -> &str {
&self.normalized
}
pub fn is_root(&self) -> bool {
self.components.is_empty()
}
pub fn components(&self) -> &[PathComponent] {
&self.components
}
pub fn parent(&self) -> Option<Self> {
if self.is_root() {
return None;
}
if self.components.len() == 1 {
return Some(Self::root());
}
Some(Self::from_components(
self.components[..self.components.len() - 1].to_vec(),
))
}
pub fn final_component(&self) -> Option<&PathComponent> {
self.components.last()
}
pub fn join(&self, display_name: &DisplayName) -> Self {
let mut components = self.components.clone();
components.push(PathComponent(display_name.as_str().to_owned()));
Self::from_components(components)
}
fn from_components(components: Vec<PathComponent>) -> Self {
let normalized = normalized_path(&components);
Self {
normalized,
components,
}
}
}
impl AsRef<str> for AbsolutePath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl std::ops::Deref for AbsolutePath {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl PartialEq<&str> for AbsolutePath {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl fmt::Display for AbsolutePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.normalized)
}
}
#[cfg(feature = "openapi")]
impl utoipa::PartialSchema for AbsolutePath {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::Object::builder()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some(
"Validated complete absolute namespace path, serialized as a plain string.",
))
.into()
}
}
#[cfg(feature = "openapi")]
impl utoipa::ToSchema for AbsolutePath {}
impl Serialize for AbsolutePath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.normalized)
}
}
impl<'de> Deserialize<'de> for AbsolutePath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
fn normalized_path(components: &[PathComponent]) -> String {
if components.is_empty() {
"/".to_owned()
} else {
format!(
"/{}",
components
.iter()
.map(PathComponent::as_str)
.collect::<Vec<_>>()
.join("/")
)
}
}
impl PathComponent {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_display_name(&self) -> DisplayName {
DisplayName(self.0.clone())
}
}
impl AsRef<str> for PathComponent {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for PathComponent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn validate_display_name(value: &str) -> Result<(), PathError> {
if value.is_empty() {
return Err(PathError::EmptyDisplayName);
}
if value.contains('/') {
return Err(PathError::DisplayNameContainsSeparator {
display_name: value.to_owned(),
});
}
if value == "." || value == ".." {
return Err(PathError::ReservedDisplayName {
display_name: value.to_owned(),
});
}
if let Some(control) = value.chars().find(|character| character.is_control()) {
return Err(PathError::DisplayNameContainsControlCharacter {
code_point: control as u32,
});
}
if value.len() > MAX_DISPLAY_NAME_BYTES {
return Err(PathError::DisplayNameTooLong {
byte_length: value.len(),
});
}
if let Some(character) = first_windows_reserved_character(value) {
return Err(PathError::UnportableDisplayNameCharacter {
display_name: value.to_owned(),
character,
});
}
if value.chars().all(char::is_whitespace) {
return Err(PathError::UnportableDisplayName {
display_name: value.to_owned(),
reason: "is entirely whitespace",
});
}
if value.ends_with(' ') {
return Err(PathError::UnportableDisplayName {
display_name: value.to_owned(),
reason: "ends with a space, which Windows cannot store",
});
}
if value.ends_with('.') {
return Err(PathError::UnportableDisplayName {
display_name: value.to_owned(),
reason: "ends with a dot, which Windows cannot store",
});
}
if is_windows_reserved_device_name(value) {
return Err(PathError::UnportableDisplayName {
display_name: value.to_owned(),
reason: "is a Windows reserved device name",
});
}
let folded_length = crate::name_policy::name_key_for_display_name(value).len();
if folded_length > crate::ids::MAX_NAME_KEY_BYTES {
return Err(PathError::FoldedNameKeyTooLong {
byte_length: folded_length,
});
}
Ok(())
}
fn validate_path_bounds(byte_length: usize, depth: usize) -> Result<(), PathError> {
if byte_length > MAX_PATH_BYTES {
return Err(PathError::PathTooLong { byte_length });
}
if depth > MAX_PATH_DEPTH {
return Err(PathError::PathTooDeep { depth });
}
Ok(())
}
const WINDOWS_RESERVED_CHARACTERS: [char; 8] = [':', '?', '*', '|', '"', '<', '>', '\\'];
fn first_windows_reserved_character(value: &str) -> Option<char> {
value
.chars()
.find(|character| WINDOWS_RESERVED_CHARACTERS.contains(character))
}
fn is_windows_reserved_device_name(value: &str) -> bool {
let stem = value.split('.').next().unwrap_or(value);
let stem = stem.trim_end_matches(' ');
let upper = stem.to_ascii_uppercase();
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (upper.len() == 4
&& (upper.starts_with("COM") || upper.starts_with("LPT"))
&& upper[3..].chars().all(|digit| digit.is_ascii_digit())
&& &upper[3..] != "0")
}
impl PathError {
pub fn invalid_path_input(&self) -> &str {
match self {
Self::EmptyPath => "",
Self::RelativePath { path }
| Self::DotComponent { path }
| Self::ParentComponent { path } => path,
Self::EmptyDisplayName => "",
Self::DisplayNameContainsSeparator { display_name }
| Self::ReservedDisplayName { display_name } => display_name,
Self::UnportableDisplayName { display_name, .. }
| Self::UnportableDisplayNameCharacter { display_name, .. } => display_name,
Self::DisplayNameContainsControlCharacter { .. }
| Self::DisplayNameTooLong { .. }
| Self::FoldedNameKeyTooLong { .. }
| Self::PathTooLong { .. }
| Self::PathTooDeep { .. } => "",
}
}
}
#[cfg(test)]
mod tests {
use super::{AbsolutePath, DisplayName, PathError};
use crate::{name_key_for_display_name, NameKey};
#[test]
fn unportable_names_are_rejected() {
for name in [
" ",
"report ",
"archive.",
"CON",
"con.txt",
"Com1.log",
"lpt9",
"aux.files.d",
] {
assert!(
DisplayName::parse(name).is_err(),
"`{name}` should be rejected"
);
}
for name in ["CONSOLE", "com10", "lpt10.txt", ".hidden", "a.b"] {
assert!(
DisplayName::parse(name).is_ok(),
"`{name}` should be accepted"
);
}
}
#[test]
fn windows_reserved_characters_are_rejected() {
for (name, character) in [
("c:drive", ':'),
("what?", '?'),
("glob*.txt", '*'),
("a|b", '|'),
("say \"hi\"", '"'),
("<draft>", '<'),
("out>", '>'),
("back\\slash.txt", '\\'),
] {
let error = DisplayName::parse(name).expect_err("`{name}` should be rejected");
assert_eq!(
error,
PathError::UnportableDisplayNameCharacter {
display_name: name.to_owned(),
character,
},
"`{name}` should name the character it broke on"
);
let message = error.to_string();
assert!(
message.contains(name) && message.contains(character),
"the diagnostic must name the name and the character, got: {message}"
);
assert_eq!(
AbsolutePath::parse(format!("/docs/{name}")),
Err(PathError::UnportableDisplayNameCharacter {
display_name: name.to_owned(),
character,
})
);
}
assert_eq!(
DisplayName::parse("a?b:c"),
Err(PathError::UnportableDisplayNameCharacter {
display_name: "a?b:c".to_owned(),
character: '?',
})
);
for name in [
"report;final.txt",
"hello!.txt",
"it's.txt",
"a+b=c.txt",
"~backup#1.txt",
"100%.txt",
"a&b.txt",
"notes (draft).txt",
"list[0].txt",
"set{a}.txt",
"a,b.txt",
"user@host.txt",
"a^b$c.txt",
] {
assert!(
DisplayName::parse(name).is_ok(),
"`{name}` should be accepted"
);
}
}
#[test]
fn paths_are_bounded_in_bytes_and_depth() {
let deep = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH + 1].join("/"));
assert!(matches!(
AbsolutePath::parse(&deep),
Err(PathError::PathTooDeep { .. })
));
let long_component = "a".repeat(200);
let mut long = String::new();
while long.len() <= super::MAX_PATH_BYTES {
long.push('/');
long.push_str(&long_component);
}
assert!(matches!(
AbsolutePath::parse(&long),
Err(PathError::PathTooLong { .. })
));
let fine = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH].join("/"));
assert!(AbsolutePath::parse(&fine).is_ok());
}
#[test]
fn absolute_path_root_is_valid() {
let path = AbsolutePath::parse("/").expect("root should parse");
assert_eq!(path.as_str(), "/");
assert!(path.is_root());
assert!(path.components().is_empty());
assert!(path.parent().is_none());
assert!(path.final_component().is_none());
}
#[test]
fn absolute_path_rejects_dot_and_dotdot_components() {
assert!(matches!(
AbsolutePath::parse("/docs/./a.txt"),
Err(PathError::DotComponent { .. })
));
assert!(matches!(
AbsolutePath::parse("/docs/../a.txt"),
Err(PathError::ParentComponent { .. })
));
}
#[test]
fn absolute_path_rejects_noncanonical_spellings() {
assert_eq!(AbsolutePath::parse("//a"), Err(PathError::EmptyDisplayName));
assert_eq!(
AbsolutePath::parse("/a//b"),
Err(PathError::EmptyDisplayName)
);
assert_eq!(AbsolutePath::parse("/a/"), Err(PathError::EmptyDisplayName));
assert!(matches!(
AbsolutePath::parse("a"),
Err(PathError::RelativePath { .. })
));
assert_eq!(AbsolutePath::parse(""), Err(PathError::EmptyPath));
}
#[test]
fn absolute_path_serde_is_a_validated_plain_string() {
let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
assert_eq!(
serde_json::to_string(&path).expect("serialize path"),
r#""/Docs/ReadMe.TXT""#
);
assert_eq!(
serde_json::from_str::<AbsolutePath>(r#""/Docs/ReadMe.TXT""#)
.expect("deserialize path"),
path
);
assert!(serde_json::from_str::<AbsolutePath>(r#""relative/path""#).is_err());
}
#[test]
fn absolute_path_parent_final_component_and_join_preserve_display_spelling() {
let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
let parent = path.parent().expect("non-root path should have parent");
assert_eq!(parent.as_str(), "/Docs");
assert_eq!(
path.final_component()
.expect("non-root path has a final component")
.as_str(),
"ReadMe.TXT"
);
assert_eq!(
parent
.join(&DisplayName::parse("Child.TXT").expect("display name should parse"))
.as_str(),
"/Docs/Child.TXT"
);
}
#[test]
fn display_name_rejects_invalid_spellings() {
assert_eq!(DisplayName::parse(""), Err(PathError::EmptyDisplayName));
assert!(matches!(
DisplayName::parse("a/b"),
Err(PathError::DisplayNameContainsSeparator { .. })
));
assert!(matches!(
DisplayName::parse("."),
Err(PathError::ReservedDisplayName { .. })
));
}
#[test]
fn display_name_rejects_control_characters() {
assert_eq!(
DisplayName::parse("a\u{0}b"),
Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
);
assert_eq!(
DisplayName::parse("line\nbreak"),
Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x0A })
);
assert_eq!(
DisplayName::parse("c1\u{85}"),
Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x85 })
);
DisplayName::parse("bidi\u{202E}name").expect("format characters are allowed");
}
#[test]
fn display_name_enforces_the_byte_cap_as_stored() {
DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES))
.expect("255 bytes is the maximum, inclusive");
assert_eq!(
DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES + 1)),
Err(PathError::DisplayNameTooLong { byte_length: 256 })
);
assert_eq!(
DisplayName::parse("é".repeat(128)),
Err(PathError::DisplayNameTooLong { byte_length: 256 })
);
}
#[test]
fn maximal_casefold_expansion_stays_within_the_name_key_cap() {
let display_name =
DisplayName::parse("\u{0390}".repeat(127)).expect("maximal expander parses");
let key = NameKey::for_display_name(&display_name);
assert!(key.as_str().len() <= crate::ids::MAX_NAME_KEY_BYTES);
}
#[test]
fn absolute_path_components_satisfy_the_display_name_grammar() {
assert!(matches!(
AbsolutePath::parse("/docs/bad\u{0}name"),
Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
));
assert!(matches!(
AbsolutePath::parse(format!("/docs/{}", "a".repeat(256))),
Err(PathError::DisplayNameTooLong { .. })
));
}
#[test]
fn name_key_matches_folding_helper() {
let display_name = DisplayName::parse("Cafe\u{301}.TXT").expect("display name");
let key = NameKey::for_display_name(&display_name);
assert_eq!(
key.as_str(),
name_key_for_display_name(display_name.as_str())
);
}
}