diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 21:05:16 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 21:05:16 +0300 |
| commit | f8e447ffee746cfe6580cc0e78a8a225aa39b546 (patch) | |
| tree | e37ebc6c5edd908fd9f44cd3aaf7bffed8de8a88 /crates/fparkan-vfs/src | |
| parent | 83d763dd70ef20b7d30a905c15cad3d5531ebc6a (diff) | |
| download | fparkan-f8e447ffee746cfe6580cc0e78a8a225aa39b546.tar.xz fparkan-f8e447ffee746cfe6580cc0e78a8a225aa39b546.zip | |
feat: close stage 0-2 audit groundwork
Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root.
Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence.
Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification.
Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration.
Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence.
Diffstat (limited to 'crates/fparkan-vfs/src')
| -rw-r--r-- | crates/fparkan-vfs/src/lib.rs | 123 |
1 files changed, 104 insertions, 19 deletions
diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs index a0cafa1..9ca57da 100644 --- a/crates/fparkan-vfs/src/lib.rs +++ b/crates/fparkan-vfs/src/lib.rs @@ -8,6 +8,10 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; /// VFS metadata. #[derive(Clone, Debug, Eq, PartialEq)] @@ -110,6 +114,7 @@ impl DirectoryVfs { struct CachedHostFingerprint { len: u64, modified: Option<SystemTime>, + identity: Option<u64>, fingerprint: Sha256Digest, } @@ -120,14 +125,23 @@ impl Vfs for DirectoryVfs { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { let host = self.host_path(path)?; - if fs::symlink_metadata(&host) - .map_err(VfsError::Io)? - .file_type() - .is_symlink() + let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?; + if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() { + return Err(VfsError::Path); + } + let pre_identity = file_identity(&pre_metadata); + let pre_len = pre_metadata.len(); + let pre_modified = pre_metadata.modified().ok(); + let bytes = fs::read(&host).map_err(VfsError::Io)?; + let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?; + if post_metadata.file_type().is_symlink() + || !post_metadata.is_file() + || post_metadata.len() != pre_len + || post_metadata.modified().ok() != pre_modified + || file_identity(&post_metadata) != pre_identity { return Err(VfsError::Path); } - let bytes = fs::read(host).map_err(VfsError::Io)?; Ok(Arc::from(bytes.into_boxed_slice())) } @@ -248,7 +262,11 @@ fn metadata_from_host_file_with_cache( .map_err(|_| VfsError::Path)? .get(path) .cloned() - .filter(|cached| cached.len == len && cached.modified == modified) + .filter(|cached| { + cached.len == len + && cached.modified == modified + && cached.identity == file_identity(metadata) + }) { return Ok(VfsMetadata { len, @@ -266,6 +284,7 @@ fn metadata_from_host_file_with_cache( CachedHostFingerprint { len, modified, + identity: file_identity(metadata), fingerprint, }, ); @@ -275,15 +294,15 @@ fn metadata_from_host_file_with_cache( /// In-memory VFS. #[derive(Clone, Debug, Default)] pub struct MemoryVfs { - files: BTreeMap<String, Arc<[u8]>>, - lookup: BTreeMap<Vec<u8>, Vec<String>>, + files: BTreeMap<Vec<u8>, Arc<[u8]>>, + lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>, } 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(); + let path = path.as_bytes().to_vec(); self.files.insert(path, bytes); self.rebuild_lookup(); } @@ -292,7 +311,7 @@ impl MemoryVfs { self.lookup.clear(); for path in self.files.keys() { self.lookup - .entry(ascii_lookup_key(path.as_bytes()).0) + .entry(ascii_lookup_key(path).0) .or_default() .push(path.clone()); } @@ -301,20 +320,39 @@ impl MemoryVfs { } } - fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> { - let key = ascii_lookup_key(path.as_str().as_bytes()).0; + fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> { + let key = ascii_lookup_key(path.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()), + [single] => Ok(single.as_slice()), [] => Err(VfsError::NotFound(path.as_str().to_string())), _ => Err(VfsError::Ambiguous(path.as_str().to_string())), } } } +#[cfg(unix)] +fn file_identity(metadata: &fs::Metadata) -> Option<u64> { + Some((metadata.dev() as u64).rotate_left(32) ^ metadata.ino()) +} + +#[cfg(windows)] +fn file_identity(metadata: &fs::Metadata) -> Option<u64> { + Some( + (metadata.volume_serial_number() as u64).rotate_left(40) + ^ ((metadata.file_index_high() as u64) << 32) + ^ metadata.file_index_low() as u64, + ) +} + +#[cfg(not(any(unix, windows)))] +fn file_identity(_metadata: &fs::Metadata) -> Option<u64> { + None +} + impl Vfs for MemoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { let resolved = self.resolve_path(path)?; @@ -339,13 +377,9 @@ impl Vfs for MemoryVfs { fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, 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())) - { + if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) { let normalized = fparkan_path::normalize_relative( - path.as_bytes(), + path, fparkan_path::PathPolicy::StrictLegacy, ) .map_err(|_| VfsError::Path)?; @@ -362,6 +396,25 @@ impl Vfs for MemoryVfs { } } +fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool { + if haystack.len() < needle.len() { + return false; + } + if haystack.len() == needle.len() { + return haystack + .iter() + .zip(needle.iter()) + .all(|(left, right)| left.eq_ignore_ascii_case(right)); + } + if haystack[needle.len()] != b'/' { + return false; + } + haystack[..needle.len()] + .iter() + .zip(needle.iter()) + .all(|(left, right)| left.eq_ignore_ascii_case(right)) +} + /// Layered VFS with deterministic first-layer precedence. #[derive(Clone, Default)] pub struct OverlayVfs { @@ -508,6 +561,21 @@ mod tests { } #[test] + fn memory_vfs_list_prefix_is_boundary_safe() { + let mut vfs = MemoryVfs::default(); + let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path"); + let sibling = normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path"); + vfs.insert(exact.clone(), Arc::from(b"exact".as_slice())); + vfs.insert(sibling, Arc::from(b"sibling".as_slice())); + + let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); + let entries = vfs.list(&prefix).expect("list"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path.as_str(), exact.as_str()); + } + + #[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"); @@ -590,6 +658,23 @@ mod tests { } #[test] + fn memory_vfs_distinguishes_non_utf8_path_bytes() { + let mut vfs = MemoryVfs::default(); + let ascii = normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible) + .expect("ascii path"); + let binary = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible) + .expect("binary path"); + vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice())); + vfs.insert(binary.clone(), Arc::from(b"binary".as_slice())); + + let binary_query = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible) + .expect("binary query"); + + assert_eq!(vfs.read(&binary_query).expect("read binary").as_ref(), b"binary"); + assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii"); + } + + #[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"); |
