use std::fmt; use jacquard_common::types::string::AtUri; use unicode_normalization::UnicodeNormalization; /// Build the visible path for a Part membership. /// /// A bare Part name retains the historical `models/` root. Once the author /// supplies a relative path, it is preserved as-is (after `CanonicalPath` /// validation). Parts owned by another authority are put below an explicit /// source namespace. Jacquard validates the URI, collection, and rkey before /// this function reaches the string/SQL boundary. pub fn project_part_path( project: &AtUri, part: &AtUri, declared_name: &str, ) -> Result { if project.authority().as_str() == part.authority().as_str() { let declared = if declared_name.contains('/') { declared_name.to_owned() } else { format!("models/{declared_name}") }; return CanonicalPath::parse(&declared); } let Some(collection) = part.collection() else { return Err(PathDiagnostic::SourceIdentity); }; let Some(rkey) = part.rkey() else { return Err(PathDiagnostic::SourceIdentity); }; if collection.as_str() != "space.polymodel.library.part" { return Err(PathDiagnostic::SourceIdentity); } let path = format!( "external/{}/{}", encode_identity_segment(part.authority().as_str()), encode_identity_segment(rkey.as_str()), ); CanonicalPath::parse(&path) } /// Decode an external Part path into its authority and rkey. /// /// The rkey is deliberately returned unchanged: Jacquard's validated rkey is /// already a safe relative path segment. This is a pure inverse of the /// authority encoding and is useful for route/path round-trip tests. #[cfg(test)] pub fn decode_external_part_path(path: &CanonicalPath) -> Option<(String, String)> { let segments = path.segments().collect::>(); if segments.first().copied()? != "external" || segments.len() != 3 { return None; } let authority = decode_identity_segment(segments[1])?; let rkey = decode_identity_segment(segments[2])?; jacquard_common::types::recordkey::Rkey::::new_owned(rkey.clone()).ok()?; Some((authority, rkey)) } /// Escape one authority component with a self-delimiting, ASCII-only codec. /// /// `x` is the only prefix and `_hh` is the only escape form. The unescaped /// alphabet excludes `_`, `%`, `.`, and path separators, so escaped-looking /// input, dot segments, Unicode normalization, and URL decoding cannot alias /// an encoded identity. fn encode_identity_segment(value: &str) -> String { let mut out = String::from("x"); for byte in value.as_bytes() { if byte.is_ascii_alphanumeric() || *byte == b'-' || *byte == b'~' { out.push(*byte as char); } else { out.push('_'); out.push(hex_digit(byte >> 4)); out.push(hex_digit(byte & 0x0f)); } } out } #[cfg(test)] fn decode_identity_segment(value: &str) -> Option { let bytes = value.strip_prefix('x')?.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { match bytes[index] { b'_' if index + 2 < bytes.len() => { let high = from_hex(bytes[index + 1])?; let low = from_hex(bytes[index + 2])?; decoded.push(high << 4 | low); index += 3; } byte if byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'~' => { decoded.push(byte); index += 1; } _ => return None, } } String::from_utf8(decoded).ok() } fn hex_digit(value: u8) -> char { char::from(b"0123456789abcdef"[value as usize]) } #[cfg(test)] fn from_hex(value: u8) -> Option { match value { b'0'..=b'9' => Some(value - b'0'), b'a'..=b'f' => Some(value - b'a' + 10), _ => None, } } /// 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, External, 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::External => "external", Self::OfficialParts => "official-parts", Self::OfficialP => "official-p", } } pub fn case_insensitive(self) -> bool { !matches!(self, Self::Mpd | Self::Manifest | Self::External) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PathDiagnostic { Slash, Percent, Backslash, QueryOrFragment, ControlOrColon, NotNfc, Segment, SourceIdentity, } #[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, RootId::External], ) }) .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 authority_path_codec_round_trips_and_is_injective() { let project = AtUri::new_owned("at://did:plc:owner/space.polymodel.library.thing/project").unwrap(); let mut paths = Vec::new(); for (authority, rkey) in [ ("did:plc:alpha", "same_rkey"), ("did:plc:beta", "same_rkey"), ("did:plc:alpha", "same-rkey"), ("did:plc:alpha", "a.b"), ("did:plc:alpha", "a~b:c"), ] { let part = AtUri::new_owned(format!( "at://{authority}/space.polymodel.library.part/{rkey}" )) .unwrap(); let path = project_part_path(&project, &part, "ignored.ldr").unwrap(); assert!(path.as_str().starts_with("external/x")); assert!(!path.as_str().starts_with('/')); assert!(!path.as_str().contains("..")); let (decoded_authority, decoded_rkey) = decode_external_part_path(&path).unwrap(); assert_eq!(decoded_authority, authority); assert_eq!(decoded_rkey, rkey); paths.push(path); } for (index, path) in paths.iter().enumerate() { assert!(paths[index + 1..].iter().all(|other| other != path)); } } #[test] fn same_authority_preserves_declared_project_relative_path() { let project = AtUri::new_owned("at://did:plc:owner/space.polymodel.library.thing/project").unwrap(); let part = AtUri::new_owned("at://did:plc:owner/space.polymodel.library.part/part").unwrap(); assert_eq!( project_part_path(&project, &part, "models/sub/main.ldr") .unwrap() .as_str(), "models/sub/main.ldr" ); assert_eq!( project_part_path(&project, &part, "main.ldr") .unwrap() .as_str(), "models/main.ldr" ); } #[test] fn external_codec_rejects_path_aliases_and_invalid_rkeys() { let project = AtUri::new_owned("at://did:plc:owner/space.polymodel.library.thing/project").unwrap(); let part = AtUri::new_owned("at://did:plc:external/space.polymodel.library.part/x").unwrap(); let valid = project_part_path(&project, &part, "x.ldr").unwrap(); assert_eq!( decode_external_part_path(&valid).unwrap(), ("did:plc:external".to_owned(), "x".to_owned()) ); let malformed_escape = CanonicalPath::parse("external/x_zz/x").unwrap(); assert!(decode_external_part_path(&malformed_escape).is_none()); assert!(CanonicalPath::parse("external/xdid_3aplc/a%2Fb").is_err()); assert!(CanonicalPath::parse("external/xdid_3aplc/../x").is_err()); assert!( AtUri::::new_owned( "at://did:plc:owner/space.polymodel.library.part/cafe\u{301}" ) .is_err() ); } #[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" ); } }