2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
2026-06-23 22:32:50 +04:00
|
|
|
#![cfg_attr(
|
|
|
|
|
test,
|
|
|
|
|
allow(
|
|
|
|
|
clippy::cast_possible_truncation,
|
|
|
|
|
clippy::cast_possible_wrap,
|
|
|
|
|
clippy::cast_precision_loss,
|
|
|
|
|
clippy::expect_used,
|
|
|
|
|
clippy::float_cmp,
|
|
|
|
|
clippy::identity_op,
|
|
|
|
|
clippy::too_many_lines,
|
|
|
|
|
clippy::uninlined_format_args,
|
|
|
|
|
clippy::map_unwrap_or,
|
|
|
|
|
clippy::needless_raw_string_hashes,
|
|
|
|
|
clippy::semicolon_if_nothing_returned,
|
|
|
|
|
clippy::type_complexity,
|
|
|
|
|
clippy::panic,
|
|
|
|
|
clippy::unwrap_used
|
|
|
|
|
)
|
|
|
|
|
)]
|
2026-06-22 13:12:27 +04:00
|
|
|
//! Virtual filesystem ports for resource loading.
|
|
|
|
|
|
2026-06-22 16:31:57 +04:00
|
|
|
use fparkan_binary::{sha256, Sha256Digest};
|
2026-06-22 16:12:57 +04:00
|
|
|
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::fs;
|
2026-06-23 22:05:16 +04:00
|
|
|
#[cfg(unix)]
|
|
|
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
use std::os::windows::fs::MetadataExt;
|
2026-06-23 22:32:50 +04:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use std::time::SystemTime;
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
/// 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,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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>>>,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(),
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
2026-06-22 16:12:57 +04:00
|
|
|
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
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,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)?)
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
2026-06-22 16:12:57 +04:00
|
|
|
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
|
2026-06-22 16:12:57 +04:00
|
|
|
{
|
|
|
|
|
return Err(VfsError::Path);
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
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() {
|
2026-06-22 16:12:57 +04:00
|
|
|
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
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,
|
|
|
|
|
)?,
|
2026-06-22 13:12:27 +04:00
|
|
|
});
|
|
|
|
|
return Ok(entries);
|
|
|
|
|
}
|
2026-06-22 16:31:57 +04:00
|
|
|
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
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(¤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) {
|
2026-06-22 16:12:57 +04:00
|
|
|
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
|
|
|
|
return Err(VfsError::Path);
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
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<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> {
|
2026-06-22 13:12:27 +04:00
|
|
|
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 {
|
2026-06-22 16:12:57 +04:00
|
|
|
let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
|
|
|
|
|
if metadata.file_type().is_symlink() {
|
|
|
|
|
return Err(VfsError::Path);
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
if metadata.is_dir() {
|
2026-06-22 16:31:57 +04:00
|
|
|
list_recursive(root, &child, fingerprint_cache, out)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
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)?,
|
2026-06-22 13:12:27 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
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 13:12:27 +04:00
|
|
|
}
|
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 13:12:27 +04:00
|
|
|
}
|
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 })
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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>>>,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-06-22 16:12:57 +04:00
|
|
|
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)
|
2026-06-22 16:12:57 +04:00
|
|
|
.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;
|
2026-06-22 16:12:57 +04:00
|
|
|
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()),
|
2026-06-22 16:12:57 +04:00
|
|
|
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
|
|
|
|
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
#[cfg(unix)]
|
2026-06-23 22:32:50 +04:00
|
|
|
#[allow(clippy::unnecessary_wraps)]
|
2026-06-23 22:05:16 +04:00
|
|
|
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
2026-06-23 22:32:50 +04:00
|
|
|
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
2026-06-23 22:32:50 +04:00
|
|
|
#[allow(clippy::unnecessary_wraps)]
|
2026-06-23 22:05:16 +04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
impl Vfs for MemoryVfs {
|
|
|
|
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
2026-06-22 16:12:57 +04:00
|
|
|
let resolved = self.resolve_path(path)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
let bytes = self
|
|
|
|
|
.files
|
2026-06-22 16:12:57 +04:00
|
|
|
.get(resolved)
|
2026-06-22 13:12:27 +04:00
|
|
|
.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),
|
2026-06-22 13:12:27 +04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
2026-06-22 16:12:57 +04:00
|
|
|
let resolved = self.resolve_path(path)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
self.files
|
2026-06-22 16:12:57 +04:00
|
|
|
.get(resolved)
|
2026-06-22 13:12:27 +04:00
|
|
|
.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()) {
|
2026-06-23 22:32:50 +04:00
|
|
|
let normalized =
|
|
|
|
|
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
|
|
|
|
|
.map_err(|_| VfsError::Path)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
out.push(VfsEntry {
|
|
|
|
|
path: normalized,
|
|
|
|
|
metadata: VfsMetadata {
|
|
|
|
|
len: bytes.len() as u64,
|
2026-06-22 16:31:57 +04:00
|
|
|
fingerprint: sha256(bytes),
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:12:57 +04:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[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");
|
2026-06-23 22:32:50 +04:00
|
|
|
let sibling =
|
|
|
|
|
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
2026-06-23 22:05:16 +04:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:12:57 +04:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[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]
|
2026-06-22 16:12:57 +04:00
|
|
|
fn memory_vfs_uses_ascii_casefold_lookup() {
|
2026-06-22 13:12:27 +04:00
|
|
|
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");
|
2026-06-22 16:12:57 +04:00
|
|
|
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-22 13:12:27 +04:00
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
|
|
|
|
let mut vfs = MemoryVfs::default();
|
2026-06-23 22:32:50 +04:00
|
|
|
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");
|
2026-06-23 22:05:16 +04:00
|
|
|
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
|
|
|
|
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
let binary_query =
|
|
|
|
|
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
|
2026-06-23 22:05:16 +04:00
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
vfs.read(&binary_query).expect("read binary").as_ref(),
|
|
|
|
|
b"binary"
|
|
|
|
|
);
|
2026-06-23 22:05:16 +04:00
|
|
|
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
}
|
|
|
|
|
}
|