use std::fmt; use unicode_normalization::UnicodeNormalization; /// A validated, NFC-preserving virtual filename. It is never interpreted as a URL. #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct CanonicalPath(String); impl CanonicalPath { pub fn parse(input: &str) -> Result { if input.is_empty() || input.starts_with('/') || input.ends_with('/') { return Err(PathDiagnostic::Slash); } if input.contains('%') { return Err(PathDiagnostic::Percent); } if input.contains('\\') { return Err(PathDiagnostic::Backslash); } if input.contains('?') || input.contains('#') { return Err(PathDiagnostic::QueryOrFragment); } if input.chars().any(|c| c.is_control() || c == ':') { return Err(PathDiagnostic::ControlOrColon); } if input.nfc().collect::() != input { return Err(PathDiagnostic::NotNfc); } for segment in input.split('/') { if segment.is_empty() || segment == "." || segment == ".." { return Err(PathDiagnostic::Segment); } } Ok(Self(input.to_owned())) } pub fn as_str(&self) -> &str { &self.0 } pub fn segments(&self) -> impl Iterator { self.0.split('/') } } impl fmt::Display for CanonicalPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum RootId { Mpd, Manifest, Models, Parts, P, OfficialParts, OfficialP, } impl RootId { pub fn as_str(self) -> &'static str { match self { Self::Mpd => "mpd", Self::Manifest => "manifest", Self::Models => "models", Self::Parts => "parts", Self::P => "p", Self::OfficialParts => "official-parts", Self::OfficialP => "official-p", } } pub fn case_insensitive(self) -> bool { !matches!(self, Self::Mpd | Self::Manifest) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PathDiagnostic { Slash, Percent, Backslash, QueryOrFragment, ControlOrColon, NotNfc, Segment, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct LookupEntry { pub root: RootId, pub path: CanonicalPath, pub target: String, } /// Resolve according to PM-75's fixed source precedence. /// The maps contain already validated entries and only perform filename lookup. pub fn resolve_precedence( path: &CanonicalPath, mpd: &[LookupEntry], manifest: &[LookupEntry], uploaded: &[LookupEntry], official: &[LookupEntry], ) -> Option { exact(path, mpd) .or_else(|| exact(path, manifest)) .or_else(|| roots(path, uploaded, &[RootId::Models, RootId::Parts, RootId::P])) .or_else(|| roots(path, official, &[RootId::OfficialParts, RootId::OfficialP])) } fn exact(path: &CanonicalPath, entries: &[LookupEntry]) -> Option { entries.iter().find(|e| e.path == *path).cloned() } fn roots(path: &CanonicalPath, entries: &[LookupEntry], roots: &[RootId]) -> Option { roots.iter().find_map(|root| { entries .iter() .filter(|e| e.root == *root) .find(|e| { e.path == *path || (root.case_insensitive() && e.path.as_str().eq_ignore_ascii_case(path.as_str())) }) .cloned() }) } #[cfg(test)] mod tests { use super::*; #[test] fn rejects_unsafe_and_non_nfc_paths() { for value in [ "", "/a", "a/", "a//b", "a/../b", "a/./b", "a%2Fb", "a\\b", "a:b", "a?b", "a#b", "a\u{0000}b", ] { assert!(CanonicalPath::parse(value).is_err(), "{value:?}"); } assert!(CanonicalPath::parse("cafe\u{301}").is_err()); assert_eq!(CanonicalPath::parse("café").unwrap().as_str(), "café"); } #[test] fn precedence_and_ascii_case_policy_are_stable() { let path = CanonicalPath::parse("parts/3001.dat").unwrap(); let entry = |root, target: &str| LookupEntry { root, path: path.clone(), target: target.into(), }; let found = resolve_precedence(&path, &[], &[], &[entry(RootId::Models, "model")], &[]).unwrap(); assert_eq!(found.target, "model"); let upper = CanonicalPath::parse("PARTS/3001.DAT").unwrap(); assert!( resolve_precedence(&upper, &[], &[], &[entry(RootId::Models, "model")], &[]).is_some() ); let mpd = entry(RootId::Mpd, "mpd"); assert_eq!( resolve_precedence(&path, &[mpd], &[], &[], &[]) .unwrap() .target, "mpd" ); } }