diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 12:12:27 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 12:13:32 +0300 |
| commit | d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch) | |
| tree | a0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-path | |
| parent | 7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff) | |
| download | fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip | |
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-path')
| -rw-r--r-- | crates/fparkan-path/Cargo.toml | 11 | ||||
| -rw-r--r-- | crates/fparkan-path/src/lib.rs | 259 |
2 files changed, 270 insertions, 0 deletions
diff --git a/crates/fparkan-path/Cargo.toml b/crates/fparkan-path/Cargo.toml new file mode 100644 index 0000000..57664b7 --- /dev/null +++ b/crates/fparkan-path/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-path" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs new file mode 100644 index 0000000..d15aae8 --- /dev/null +++ b/crates/fparkan-path/src/lib.rs @@ -0,0 +1,259 @@ +#![forbid(unsafe_code)] +//! Legacy path normalization and ASCII lookup semantics. + +use std::fmt; +use std::path::{Path, PathBuf}; + +/// Original bytes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OriginalPathBytes(pub Vec<u8>); + +impl OriginalPathBytes { + /// Returns the preserved byte image. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Returns the preserved byte image as an owned vector. + #[must_use] + pub fn into_vec(self) -> Vec<u8> { + self.0 + } +} + +/// Normalized relative path. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct NormalizedPath(String); + +impl NormalizedPath { + /// Returns string view. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Normalized path paired with its original byte image. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NormalizedPathWithOriginal { + normalized: NormalizedPath, + original: OriginalPathBytes, +} + +impl NormalizedPathWithOriginal { + /// Returns normalized path. + #[must_use] + pub fn normalized(&self) -> &NormalizedPath { + &self.normalized + } + + /// Returns original path bytes. + #[must_use] + pub fn original(&self) -> &OriginalPathBytes { + &self.original + } + + /// Splits into normalized and original path parts. + #[must_use] + pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) { + (self.normalized, self.original) + } +} + +/// ASCII lookup key. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct LookupKey(pub Vec<u8>); + +/// Resource name bytes. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ResourceName(pub Vec<u8>); + +/// Path policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PathPolicy { + /// Strict legacy relative resource path. + StrictLegacy, + /// Host compatible relative path. + HostCompatible, +} + +/// Path error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PathError { + /// Empty path. + Empty, + /// Embedded NUL. + EmbeddedNul, + /// Absolute path. + Absolute, + /// Parent traversal. + ParentTraversal, + /// Host path escape. + EscapesRoot, + /// Invalid UTF-8 after normalization. + InvalidUtf8, +} + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for PathError {} + +/// Normalizes a relative path. +/// +/// # Errors +/// +/// Returns [`PathError`] when the input is empty, absolute, contains an +/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after +/// legacy separator normalization. +pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedPath, PathError> { + if raw.is_empty() { + return Err(PathError::Empty); + } + if raw.contains(&0) { + return Err(PathError::EmbeddedNul); + } + let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?; + if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) { + return Err(PathError::Absolute); + } + let mut parts = Vec::new(); + for part in text.split(['/', '\\']) { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + return Err(PathError::ParentTraversal); + } + parts.push(part); + } + if parts.is_empty() { + return Err(PathError::Empty); + } + Ok(NormalizedPath(parts.join("/"))) +} + +/// Normalizes a relative path while preserving its original bytes. +/// +/// # Errors +/// +/// Returns [`PathError`] under the same conditions as [`normalize_relative`]. +pub fn normalize_relative_with_original( + raw: &[u8], + policy: PathPolicy, +) -> Result<NormalizedPathWithOriginal, PathError> { + let normalized = normalize_relative(raw, policy)?; + Ok(NormalizedPathWithOriginal { + normalized, + original: OriginalPathBytes(raw.to_vec()), + }) +} + +fn has_drive_prefix(text: &str) -> bool { + let bytes = text.as_bytes(); + bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() +} + +/// Builds an ASCII-only casefold lookup key. +#[must_use] +pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey { + LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect()) +} + +/// Ensures relative path does not escape. +/// +/// # Errors +/// +/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts +/// to address a parent directory. +pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> { + if rel.0.split('/').any(|part| part == "..") { + Err(PathError::ParentTraversal) + } else { + Ok(()) + } +} + +/// Joins normalized path under root. +/// +/// # Errors +/// +/// Returns [`PathError`] if the normalized path fails the escape check. +pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> { + reject_escape(rel)?; + Ok(root.join(rel.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_separators() { + let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy) + .expect("path"); + assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh"); + } + + #[test] + fn rejects_escape() { + assert_eq!( + normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy), + Err(PathError::ParentTraversal) + ); + } + + #[test] + fn rejects_absolute_drive_and_nul_paths() { + assert_eq!( + normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy), + Err(PathError::Absolute) + ); + assert_eq!( + normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy), + Err(PathError::Absolute) + ); + assert_eq!( + normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy), + Err(PathError::EmbeddedNul) + ); + } + + #[test] + fn join_under_keeps_normalized_path_below_root() { + let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy) + .expect("relative path"); + let joined = join_under(Path::new("/game"), &rel).expect("join"); + + assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map")); + } + + #[test] + fn ascii_casefold_does_not_unicode_fold() { + assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec())); + } + + #[test] + fn non_ascii_original_bytes_remain_stable() { + let raw = "DATA/Тест.bin".as_bytes(); + let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy) + .expect("path with non-ASCII UTF-8"); + + assert_eq!(path.normalized().as_str().as_bytes(), raw); + assert_eq!(path.original().as_bytes(), raw); + assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]); + } + + #[test] + fn original_separators_and_raw_bytes_are_preserved() { + let raw = b"DATA\\Maps/Intro\\Land.msh"; + let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path"); + + assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh"); + assert_eq!(path.original().as_bytes(), raw); + } +} |
