Files
fparkan/crates/fparkan-vfs/src/lib.rs
T

701 lines
23 KiB
Rust
Raw Normal View History

#![forbid(unsafe_code)]
//! Virtual filesystem ports for resource loading.
2026-06-22 16:31:57 +04:00
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};
2026-06-22 16:31:57 +04:00
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
2026-06-23 22:05:16 +04:00
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
/// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VfsMetadata {
/// Byte length.
pub len: u64,
2026-06-22 16:31:57 +04:00
/// 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<VfsMetadata, VfsError>;
/// 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<Arc<[u8]>, 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<Vec<VfsEntry>, VfsError>;
}
/// Host directory VFS.
#[derive(Clone, Debug)]
pub struct DirectoryVfs {
root: PathBuf,
2026-06-22 16:31:57 +04:00
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
}
impl DirectoryVfs {
/// Creates a directory VFS.
#[must_use]
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
2026-06-22 16:31:57 +04:00
fingerprint_cache: Arc::default(),
}
}
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
resolve_casefolded(&self.root, path.as_str())
}
2026-06-22 16:31:57 +04:00
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
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<SystemTime>,
2026-06-23 22:05:16 +04:00
identity: Option<u64>,
2026-06-22 16:31:57 +04:00
fingerprint: Sha256Digest,
}
impl Vfs for DirectoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
2026-06-22 16:31:57 +04:00
self.metadata_from_host_file(&self.host_path(path)?)
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let host = self.host_path(path)?;
2026-06-23 22:05:16 +04:00
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);
}
Ok(Arc::from(bytes.into_boxed_slice()))
}
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, 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(),
2026-06-22 16:31:57 +04:00
metadata: metadata_from_host_file_with_cache(
&base,
&metadata,
&self.fingerprint_cache,
)?,
});
return Ok(entries);
}
2026-06-22 16:31:57 +04:00
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<PathBuf, VfsError> {
let mut current = root.to_path_buf();
for segment in normalized.split('/') {
let read_dir = fs::read_dir(&current).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, &current, segment, matches)?;
}
Ok(current)
}
fn select_casefolded_match(
normalized: &str,
current: &Path,
segment: &str,
mut matches: Vec<PathBuf>,
) -> Result<PathBuf, VfsError> {
matches.sort();
match matches.len() {
0 => Err(VfsError::NotFound(normalized.to_string())),
1 => Ok(matches.remove(0)),
_ => Err(VfsError::Ambiguous(format!(
"{}/{}",
current.display(),
segment
))),
}
}
2026-06-22 16:31:57 +04:00
fn list_recursive(
root: &Path,
dir: &Path,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
out: &mut Vec<VfsEntry>,
) -> 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() {
2026-06-22 16:31:57 +04:00
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,
2026-06-22 16:31:57 +04:00
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
});
}
Ok(())
}
2026-06-22 16:31:57 +04:00
fn metadata_from_host_file_with_cache(
path: &Path,
metadata: &fs::Metadata,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
) -> Result<VfsMetadata, VfsError> {
if !metadata.is_file() {
return Err(VfsError::Path);
}
2026-06-22 16:31:57 +04:00
let len = metadata.len();
let modified = metadata.modified().ok();
if let Some(cached) = fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.get(path)
.cloned()
2026-06-23 22:05:16 +04:00
.filter(|cached| {
cached.len == len
&& cached.modified == modified
&& cached.identity == file_identity(metadata)
})
2026-06-22 16:31:57 +04:00
{
return Ok(VfsMetadata {
len,
fingerprint: cached.fingerprint,
});
}
2026-06-22 16:31:57 +04:00
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,
2026-06-23 22:05:16 +04:00
identity: file_identity(metadata),
2026-06-22 16:31:57 +04:00
fingerprint,
},
);
Ok(VfsMetadata { len, fingerprint })
}
/// In-memory VFS.
#[derive(Clone, Debug, Default)]
pub struct MemoryVfs {
2026-06-23 22:05:16 +04:00
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]>) {
2026-06-23 22:05:16 +04:00
let path = path.as_bytes().to_vec();
self.files.insert(path, bytes);
self.rebuild_lookup();
}
fn rebuild_lookup(&mut self) {
self.lookup.clear();
for path in self.files.keys() {
self.lookup
2026-06-23 22:05:16 +04:00
.entry(ascii_lookup_key(path).0)
.or_default()
.push(path.clone());
}
for paths in self.lookup.values_mut() {
paths.sort();
}
}
2026-06-23 22:05:16 +04:00
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() {
2026-06-23 22:05:16 +04:00
[single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.as_str().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
}
}
}
2026-06-23 22:05:16 +04:00
#[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)?;
let bytes = self
.files
.get(resolved)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
Ok(VfsMetadata {
len: bytes.len() as u64,
2026-06-22 16:31:57 +04:00
fingerprint: sha256(bytes),
})
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, 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<Vec<VfsEntry>, VfsError> {
let mut out = Vec::new();
for (path, bytes) in &self.files {
2026-06-23 22:05:16 +04:00
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
let normalized = fparkan_path::normalize_relative(
2026-06-23 22:05:16 +04:00
path,
fparkan_path::PathPolicy::StrictLegacy,
)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path: normalized,
metadata: VfsMetadata {
len: bytes.len() as u64,
2026-06-22 16:31:57 +04:00
fingerprint: sha256(bytes),
},
});
}
}
Ok(out)
}
}
2026-06-23 22:05:16 +04:00
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 {
layers: Vec<Arc<dyn Vfs>>,
}
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<Arc<dyn Vfs>>) -> Self {
Self { layers }
}
/// Appends a lower-priority layer.
pub fn push_layer(&mut self, layer: Arc<dyn Vfs>) {
self.layers.push(layer);
}
}
impl Vfs for OverlayVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
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<Arc<[u8]>, 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<Vec<VfsEntry>, 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");
}
2026-06-23 22:05:16 +04:00
#[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());
}
2026-06-22 16:31:57 +04:00
#[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(_))));
}
2026-06-23 22:05:16 +04:00
#[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");
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
}
}