diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 15:12:57 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 15:12:57 +0300 |
| commit | f69c893a401730339ad72610c573e20282573045 (patch) | |
| tree | 8db67167f966463f979aa5417714ed23b333c3d1 /crates | |
| parent | 543672796161e1ab500ed446611d391a451add09 (diff) | |
| download | fparkan-f69c893a401730339ad72610c573e20282573045.tar.xz fparkan-f69c893a401730339ad72610c573e20282573045.zip | |
fix: harden path lookup and mark gl backend gap
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-path/src/lib.rs | 27 | ||||
| -rw-r--r-- | crates/fparkan-vfs/src/lib.rs | 137 |
2 files changed, 149 insertions, 15 deletions
diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs index d15aae8..f59fda0 100644 --- a/crates/fparkan-path/src/lib.rs +++ b/crates/fparkan-path/src/lib.rs @@ -110,7 +110,7 @@ impl std::error::Error for PathError {} /// 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> { +pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> { if raw.is_empty() { return Err(PathError::Empty); } @@ -124,11 +124,17 @@ pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedP let mut parts = Vec::new(); for part in text.split(['/', '\\']) { if part.is_empty() || part == "." { + if policy == PathPolicy::StrictLegacy { + return Err(PathError::ParentTraversal); + } continue; } if part == ".." { return Err(PathError::ParentTraversal); } + if policy == PathPolicy::StrictLegacy && part.contains(':') { + return Err(PathError::Absolute); + } parts.push(part); } if parts.is_empty() { @@ -224,6 +230,25 @@ mod tests { } #[test] + fn strict_legacy_rejects_host_only_segments() { + assert_eq!( + normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy), + Err(PathError::ParentTraversal) + ); + assert_eq!( + normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy), + Err(PathError::ParentTraversal) + ); + assert_eq!( + normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy), + Err(PathError::Absolute) + ); + + let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host"); + assert_eq!(host.as_str(), "DATA/MAPS"); + } + + #[test] fn join_under_keeps_normalized_path_below_root() { let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy) .expect("relative path"); diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs index dd71670..2545ecf 100644 --- a/crates/fparkan-vfs/src/lib.rs +++ b/crates/fparkan-vfs/src/lib.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] //! Virtual filesystem ports for resource loading. -use fparkan_path::{join_under, NormalizedPath}; +use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -92,22 +92,27 @@ impl DirectoryVfs { } fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> { - let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?; - if exact.exists() { - return Ok(exact); - } + join_under(&self.root, path).map_err(|_| VfsError::Path)?; resolve_casefolded(&self.root, path.as_str()) } } impl Vfs for DirectoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { - let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?; + let meta = fs::symlink_metadata(self.host_path(path)?).map_err(VfsError::Io)?; Ok(metadata_from_fs(&meta)) } fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { - let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?; + let host = self.host_path(path)?; + if fs::symlink_metadata(&host) + .map_err(VfsError::Io)? + .file_type() + .is_symlink() + { + return Err(VfsError::Path); + } + let bytes = fs::read(host).map_err(VfsError::Io)?; Ok(Arc::from(bytes.into_boxed_slice())) } @@ -115,7 +120,7 @@ impl Vfs for DirectoryVfs { let base = self.host_path(prefix)?; let mut entries = Vec::new(); if base.is_file() { - let metadata = fs::metadata(&base).map_err(VfsError::Io)?; + let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?; entries.push(VfsEntry { path: prefix.clone(), metadata: metadata_from_fs(&metadata), @@ -140,6 +145,9 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError continue; }; if name.eq_ignore_ascii_case(segment) { + if entry.file_type().map_err(VfsError::Io)?.is_symlink() { + return Err(VfsError::Path); + } matches.push(entry.path()); } } @@ -175,7 +183,10 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<() } children.sort(); for child in children { - let metadata = fs::metadata(&child).map_err(VfsError::Io)?; + let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?; + if metadata.file_type().is_symlink() { + return Err(VfsError::Path); + } if metadata.is_dir() { list_recursive(root, &child, out)?; continue; @@ -217,21 +228,51 @@ fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata { #[derive(Clone, Debug, Default)] pub struct MemoryVfs { files: BTreeMap<String, Arc<[u8]>>, + lookup: BTreeMap<Vec<u8>, Vec<String>>, } impl MemoryVfs { /// Inserts a file. #[allow(clippy::needless_pass_by_value)] pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { - self.files.insert(path.as_str().to_string(), bytes); + let path = path.as_str().to_string(); + self.files.insert(path, bytes); + self.rebuild_lookup(); + } + + fn rebuild_lookup(&mut self) { + self.lookup.clear(); + for path in self.files.keys() { + self.lookup + .entry(ascii_lookup_key(path.as_bytes()).0) + .or_default() + .push(path.clone()); + } + for paths in self.lookup.values_mut() { + paths.sort(); + } + } + + fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> { + let key = ascii_lookup_key(path.as_str().as_bytes()).0; + let matches = self + .lookup + .get(&key) + .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; + match matches.as_slice() { + [single] => Ok(single.as_str()), + [] => Err(VfsError::NotFound(path.as_str().to_string())), + _ => Err(VfsError::Ambiguous(path.as_str().to_string())), + } } } impl Vfs for MemoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { + let resolved = self.resolve_path(path)?; let bytes = self .files - .get(path.as_str()) + .get(resolved) .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; Ok(VfsMetadata { len: bytes.len() as u64, @@ -240,8 +281,9 @@ impl Vfs for MemoryVfs { } fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { + let resolved = self.resolve_path(path)?; self.files - .get(path.as_str()) + .get(resolved) .cloned() .ok_or_else(|| VfsError::NotFound(path.as_str().to_string())) } @@ -385,6 +427,36 @@ mod tests { } #[test] + fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() { + let root = unique_test_dir("casefold-ambiguous"); + std::fs::create_dir_all(root.join("Data")).expect("mkdir first"); + std::fs::create_dir_all(root.join("data")).expect("mkdir second"); + std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first"); + std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second"); + let collision_count = std::fs::read_dir(&root) + .expect("read root") + .flatten() + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("data")) + }) + .count(); + if collision_count < 2 { + std::fs::remove_dir_all(root).expect("cleanup"); + return; + } + + let vfs = DirectoryVfs::new(&root); + let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path"); + + assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_)))); + + std::fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] fn directory_vfs_lists_files_below_prefix() { let root = unique_test_dir("list"); std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir"); @@ -403,6 +475,27 @@ mod tests { std::fs::remove_dir_all(root).expect("cleanup"); } + #[cfg(unix)] + #[test] + fn directory_vfs_rejects_symlink_escape() { + let root = unique_test_dir("symlink-escape"); + let outside = unique_test_dir("symlink-outside"); + std::fs::create_dir_all(&root).expect("mkdir root"); + std::fs::create_dir_all(&outside).expect("mkdir outside"); + std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside"); + std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink"); + + let vfs = DirectoryVfs::new(&root); + let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path"); + let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); + + assert!(matches!(vfs.read(&path), Err(VfsError::Path))); + assert!(matches!(vfs.list(&prefix), Err(VfsError::Path))); + + std::fs::remove_dir_all(root).expect("cleanup root"); + std::fs::remove_dir_all(outside).expect("cleanup outside"); + } + #[test] fn casefold_selector_reports_ambiguous_segments() { let err = select_casefolded_match( @@ -417,7 +510,7 @@ mod tests { } #[test] - fn memory_vfs_uses_exact_lookup() { + fn memory_vfs_uses_ascii_casefold_lookup() { let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path"); let mut vfs = MemoryVfs::default(); vfs.insert(path.clone(), Arc::from(b"payload".as_slice())); @@ -427,7 +520,23 @@ mod tests { let other_case = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path"); - assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_)))); + assert_eq!( + vfs.read(&other_case).expect("casefold read").as_ref(), + b"payload" + ); + } + + #[test] + fn memory_vfs_reports_casefold_ambiguity() { + let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first"); + let second = + normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second"); + let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query"); + let mut vfs = MemoryVfs::default(); + vfs.insert(first, Arc::from(b"first".as_slice())); + vfs.insert(second, Arc::from(b"second".as_slice())); + + assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_)))); } #[test] |
