aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-path
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-path
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-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.toml11
-rw-r--r--crates/fparkan-path/src/lib.rs259
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);
+ }
+}