#![forbid(unsafe_code)] //! Virtual filesystem ports for resource loading. use fparkan_binary::{sha256, Sha256Digest}; use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; /// VFS metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct VfsMetadata { /// Byte length. pub len: u64, /// SHA-256 content fingerprint for cache invalidation. pub fingerprint: Sha256Digest, } /// VFS entry. #[derive(Clone, Debug, Eq, PartialEq)] pub struct VfsEntry { /// Path. pub path: NormalizedPath, /// Metadata. pub metadata: VfsMetadata, } /// VFS error. #[derive(Debug)] pub enum VfsError { /// Missing entry. NotFound(String), /// Ambiguous host path. Ambiguous(String), /// I/O error. Io(std::io::Error), /// Invalid path. Path, } impl std::fmt::Display for VfsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NotFound(path) => write!(f, "not found: {path}"), Self::Ambiguous(path) => write!(f, "ambiguous host path: {path}"), Self::Io(err) => write!(f, "{err}"), Self::Path => write!(f, "invalid path"), } } } impl std::error::Error for VfsError {} /// Resource VFS. pub trait Vfs: Send + Sync { /// Reads metadata. /// /// # Errors /// /// Returns [`VfsError`] when the path is invalid, missing, or cannot be /// inspected by the backing store. fn metadata(&self, path: &NormalizedPath) -> Result; /// Reads bytes. /// /// # Errors /// /// Returns [`VfsError`] when the path is invalid, missing, or cannot be /// read by the backing store. fn read(&self, path: &NormalizedPath) -> Result, VfsError>; /// Lists entries below prefix. /// /// # Errors /// /// Returns [`VfsError`] when the prefix is invalid, missing, or cannot be /// traversed by the backing store. fn list(&self, prefix: &NormalizedPath) -> Result, VfsError>; } /// Host directory VFS. #[derive(Clone, Debug)] pub struct DirectoryVfs { root: PathBuf, fingerprint_cache: Arc>>, } impl DirectoryVfs { /// Creates a directory VFS. #[must_use] pub fn new(root: impl AsRef) -> Self { Self { root: root.as_ref().to_path_buf(), fingerprint_cache: Arc::default(), } } fn host_path(&self, path: &NormalizedPath) -> Result { join_under(&self.root, path).map_err(|_| VfsError::Path)?; resolve_casefolded(&self.root, path.as_str()) } fn metadata_from_host_file(&self, path: &Path) -> Result { let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?; metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache) } } #[derive(Clone, Debug, Eq, PartialEq)] struct CachedHostFingerprint { len: u64, modified: Option, fingerprint: Sha256Digest, } impl Vfs for DirectoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result { self.metadata_from_host_file(&self.host_path(path)?) } fn read(&self, path: &NormalizedPath) -> Result, VfsError> { 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())) } fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { let base = self.host_path(prefix)?; let mut entries = Vec::new(); if base.is_file() { let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?; entries.push(VfsEntry { path: prefix.clone(), metadata: metadata_from_host_file_with_cache( &base, &metadata, &self.fingerprint_cache, )?, }); return Ok(entries); } list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?; entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); Ok(entries) } } fn resolve_casefolded(root: &Path, normalized: &str) -> Result { let mut current = root.to_path_buf(); for segment in normalized.split('/') { let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?; let mut matches = Vec::new(); for entry in read_dir { let entry = entry.map_err(VfsError::Io)?; let name = entry.file_name(); let Some(name) = name.to_str() else { 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()); } } current = select_casefolded_match(normalized, ¤t, segment, matches)?; } Ok(current) } fn select_casefolded_match( normalized: &str, current: &Path, segment: &str, mut matches: Vec, ) -> Result { matches.sort(); match matches.len() { 0 => Err(VfsError::NotFound(normalized.to_string())), 1 => Ok(matches.remove(0)), _ => Err(VfsError::Ambiguous(format!( "{}/{}", current.display(), segment ))), } } fn list_recursive( root: &Path, dir: &Path, fingerprint_cache: &Mutex>, out: &mut Vec, ) -> Result<(), VfsError> { let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?; let mut children = Vec::new(); for entry in read_dir { let entry = entry.map_err(VfsError::Io)?; children.push(entry.path()); } children.sort(); for child in children { 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, fingerprint_cache, out)?; continue; } if !metadata.is_file() { continue; } let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?; let rel_text = rel.to_str().ok_or(VfsError::Path)?; let path = fparkan_path::normalize_relative( rel_text.as_bytes(), fparkan_path::PathPolicy::HostCompatible, ) .map_err(|_| VfsError::Path)?; out.push(VfsEntry { path, metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?, }); } Ok(()) } fn metadata_from_host_file_with_cache( path: &Path, metadata: &fs::Metadata, fingerprint_cache: &Mutex>, ) -> Result { if !metadata.is_file() { return Err(VfsError::Path); } let len = metadata.len(); let modified = metadata.modified().ok(); if let Some(cached) = fingerprint_cache .lock() .map_err(|_| VfsError::Path)? .get(path) .cloned() .filter(|cached| cached.len == len && cached.modified == modified) { return Ok(VfsMetadata { len, fingerprint: cached.fingerprint, }); } let bytes = fs::read(path).map_err(VfsError::Io)?; let fingerprint = sha256(&bytes); fingerprint_cache .lock() .map_err(|_| VfsError::Path)? .insert( path.to_path_buf(), CachedHostFingerprint { len, modified, fingerprint, }, ); Ok(VfsMetadata { len, fingerprint }) } /// In-memory VFS. #[derive(Clone, Debug, Default)] pub struct MemoryVfs { files: BTreeMap>, lookup: BTreeMap, Vec>, } impl MemoryVfs { /// Inserts a file. #[allow(clippy::needless_pass_by_value)] pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { 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 { let resolved = self.resolve_path(path)?; let bytes = self .files .get(resolved) .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; Ok(VfsMetadata { len: bytes.len() as u64, fingerprint: sha256(bytes), }) } fn read(&self, path: &NormalizedPath) -> Result, VfsError> { let resolved = self.resolve_path(path)?; self.files .get(resolved) .cloned() .ok_or_else(|| VfsError::NotFound(path.as_str().to_string())) } fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { let mut out = Vec::new(); for (path, bytes) in &self.files { if path .as_bytes() .get(..prefix.as_str().len()) .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes())) { let normalized = fparkan_path::normalize_relative( path.as_bytes(), fparkan_path::PathPolicy::StrictLegacy, ) .map_err(|_| VfsError::Path)?; out.push(VfsEntry { path: normalized, metadata: VfsMetadata { len: bytes.len() as u64, fingerprint: sha256(bytes), }, }); } } Ok(out) } } /// Layered VFS with deterministic first-layer precedence. #[derive(Clone, Default)] pub struct OverlayVfs { layers: Vec>, } impl std::fmt::Debug for OverlayVfs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OverlayVfs") .field("layers", &self.layers.len()) .finish() } } impl OverlayVfs { /// Creates an empty overlay. #[must_use] pub fn new() -> Self { Self::default() } /// Creates an overlay from ordered layers. #[must_use] pub fn from_layers(layers: Vec>) -> Self { Self { layers } } /// Appends a lower-priority layer. pub fn push_layer(&mut self, layer: Arc) { self.layers.push(layer); } } impl Vfs for OverlayVfs { fn metadata(&self, path: &NormalizedPath) -> Result { for layer in &self.layers { match layer.metadata(path) { Ok(metadata) => return Ok(metadata), Err(VfsError::NotFound(_)) => {} Err(err) => return Err(err), } } Err(VfsError::NotFound(path.as_str().to_string())) } fn read(&self, path: &NormalizedPath) -> Result, VfsError> { for layer in &self.layers { match layer.read(path) { Ok(bytes) => return Ok(bytes), Err(VfsError::NotFound(_)) => {} Err(err) => return Err(err), } } Err(VfsError::NotFound(path.as_str().to_string())) } fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { let mut by_key = BTreeMap::new(); for layer in &self.layers { match layer.list(prefix) { Ok(entries) => { for entry in entries { let key = entry.path.as_str().to_ascii_uppercase(); by_key.entry(key).or_insert(entry); } } Err(VfsError::NotFound(_)) => {} Err(err) => return Err(err), } } let mut entries: Vec<_> = by_key.into_values().collect(); entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); Ok(entries) } } #[cfg(test)] mod tests { use super::*; use fparkan_path::{normalize_relative, PathPolicy}; #[test] fn directory_vfs_resolves_ascii_casefolded_segments() { let root = unique_test_dir("casefold"); let dir = root.join("data").join("MAPS").join("Tut_1"); std::fs::create_dir_all(&dir).expect("mkdir"); std::fs::write(dir.join("Land.msh"), b"mesh").expect("write"); let vfs = DirectoryVfs::new(&root); let path = normalize_relative(b"DATA/maps/tut_1/land.MSH", PathPolicy::StrictLegacy) .expect("path"); assert_eq!(vfs.read(&path).expect("read").as_ref(), b"mesh"); std::fs::remove_dir_all(root).expect("cleanup"); } #[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"); std::fs::write(root.join("DATA").join("MAPS").join("Land.map"), b"map").expect("write"); std::fs::write(root.join("BuildDat.lst"), b"build").expect("write"); let vfs = DirectoryVfs::new(&root); let prefix = normalize_relative(b"data", PathPolicy::StrictLegacy).expect("prefix"); let entries = vfs.list(&prefix).expect("list"); assert_eq!(entries.len(), 1); assert!(entries[0] .path .as_str() .eq_ignore_ascii_case("DATA/MAPS/Land.map")); std::fs::remove_dir_all(root).expect("cleanup"); } #[test] fn directory_vfs_fingerprint_changes_for_same_length_content() { let root = unique_test_dir("content-fingerprint"); std::fs::create_dir_all(root.join("DATA")).expect("mkdir"); std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before"); let vfs = DirectoryVfs::new(&root); let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path"); let before = vfs.metadata(&path).expect("before metadata"); std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after"); let after = vfs.metadata(&path).expect("after metadata"); assert_eq!(before.len, after.len); assert_ne!(before.fingerprint, after.fingerprint); 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( "data/file.bin", Path::new("/game"), "data", vec![PathBuf::from("/game/Data"), PathBuf::from("/game/DATA")], ) .expect_err("ambiguous path"); assert!(matches!(err, VfsError::Ambiguous(_))); } #[test] 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())); assert_eq!(vfs.metadata(&path).expect("metadata").len, 7); assert_eq!(vfs.read(&path).expect("read").as_ref(), b"payload"); let other_case = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path"); 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] fn overlay_vfs_uses_first_matching_layer() { let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path"); let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); let mut high = MemoryVfs::default(); let mut low = MemoryVfs::default(); high.insert(path.clone(), Arc::from(b"high".as_slice())); low.insert(path.clone(), Arc::from(b"low".as_slice())); let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]); assert_eq!(overlay.read(&path).expect("read").as_ref(), b"high"); let entries = overlay.list(&prefix).expect("list"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].metadata.len, 4); } fn unique_test_dir(name: &str) -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!("fparkan-vfs-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&path); path } }