use super::{PathReason, StoreError};
pub(crate) const MAX_STORE_PATH_BYTES: usize = 1024;
fn is_reserved_device_name(segment: &str) -> bool {
let base = segment.split('.').next().unwrap_or(segment);
let upper = base.to_ascii_uppercase();
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| is_numbered_device(&upper, "COM")
|| is_numbered_device(&upper, "LPT")
}
fn is_numbered_device(name: &str, prefix: &str) -> bool {
name.strip_prefix(prefix)
.and_then(|rest| rest.parse::<u8>().ok().filter(|_| rest.len() == 1))
.is_some_and(|n| (1..=9).contains(&n))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StorePath(String);
impl StorePath {
pub(crate) fn parse(raw: &str) -> Result<StorePath, StoreError> {
let reject = |reason| {
Err(StoreError::InvalidPath {
path: raw.to_owned(),
reason,
})
};
if raw.is_empty() {
return reject(PathReason::Empty);
}
if raw.len() > MAX_STORE_PATH_BYTES {
return reject(PathReason::TooLong);
}
if raw.starts_with('/') {
return reject(PathReason::Absolute);
}
if raw.bytes().any(|b| b < 0x20 || b == 0x7f) {
return reject(PathReason::Control);
}
if raw.contains('\\') {
return reject(PathReason::Backslash);
}
let mut segments = 0usize;
for segment in raw.split('/') {
if segment.is_empty() {
return reject(PathReason::EmptySegment);
}
if segment == "." || segment == ".." {
return reject(PathReason::Traversal);
}
if segment.ends_with('.') || segment.ends_with(' ') {
return reject(PathReason::UnsafeSuffix);
}
if is_reserved_device_name(segment) {
return reject(PathReason::ReservedName);
}
segments += 1;
}
if segments == 0 {
return reject(PathReason::Empty);
}
Ok(StorePath(raw.to_owned()))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}