fix(resource): type archive and vfs error mapping

This commit is contained in:
2026-06-30 02:34:25 +04:00
parent 716cde2072
commit 2b16e5b118
3 changed files with 267 additions and 31 deletions
+2 -2
View File
@@ -927,7 +927,7 @@ fn resolve_texm_from_candidates<'a, R: ResourceRepository>(
}; };
let archive = match repository.open_archive(path) { let archive = match repository.open_archive(path) {
Ok(archive) => archive, Ok(archive) => archive,
Err(ResourceError::MissingArchive) => { Err(ResourceError::MissingArchive { .. }) => {
missing_archive = true; missing_archive = true;
continue; continue;
} }
@@ -1120,7 +1120,7 @@ fn read_optional_key<R: ResourceRepository>(
) -> Result<Option<Arc<[u8]>>, AssetError> { ) -> Result<Option<Arc<[u8]>>, AssetError> {
let archive = match repository.open_archive(&key.archive) { let archive = match repository.open_archive(&key.archive) {
Ok(archive) => archive, Ok(archive) => archive,
Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None), Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => return Ok(None),
Err(err) => { Err(err) => {
let label = label.unwrap_or("asset"); let label = label.unwrap_or("asset");
return Err(map_resource_error(label, key, err)); return Err(map_resource_error(label, key, err));
+2 -2
View File
@@ -1012,7 +1012,7 @@ fn collect_registry_refs(
} }
let archive_id = match repository.open_archive(registry_archive) { let archive_id = match repository.open_archive(registry_archive) {
Ok(id) => id, Ok(id) => id,
Err(ResourceError::MissingArchive) => return Ok(None), Err(ResourceError::MissingArchive { .. }) => return Ok(None),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
let Some((registry_entry, _matched_name)) = let Some((registry_entry, _matched_name)) =
@@ -1082,7 +1082,7 @@ fn find_mesh_resource(
) -> Result<Option<ResourceKey>, PrototypeError> { ) -> Result<Option<ResourceKey>, PrototypeError> {
let archive_id = match repository.open_archive(archive) { let archive_id = match repository.open_archive(archive) {
Ok(id) => id, Ok(id) => id,
Err(ResourceError::MissingArchive) => return Ok(None), Err(ResourceError::MissingArchive { .. }) => return Ok(None),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
let candidates = mesh_name_candidates(&model_key.0); let candidates = mesh_name_candidates(&model_key.0);
+263 -27
View File
@@ -125,13 +125,47 @@ impl ResourceBytes {
#[derive(Debug)] #[derive(Debug)]
pub enum ResourceError { pub enum ResourceError {
/// Missing archive. /// Missing archive.
MissingArchive, MissingArchive {
/// Logical archive path.
path: NormalizedPath,
},
/// Missing entry. /// Missing entry.
MissingEntry, MissingEntry,
/// Stale or invalid handle. /// Stale or invalid handle.
InvalidHandle, InvalidHandle,
/// Handle belongs to an older archive generation. /// Handle belongs to an older archive generation.
StaleHandle, StaleHandle,
/// Resource archive path is invalid.
InvalidPath {
/// Display form of the rejected path.
path: String,
/// Validation or VFS rejection text.
source: String,
},
/// Host lookup matched multiple candidates.
PathAmbiguous {
/// Ambiguous host path description.
path: String,
},
/// Backing storage failed while reading an archive.
Storage {
/// Logical archive path.
path: NormalizedPath,
/// Underlying storage error.
source: std::io::Error,
},
/// Archive magic is unsupported.
UnsupportedArchive {
/// Logical archive path.
path: NormalizedPath,
},
/// Archive bytes were found but could not be decoded.
ArchiveDecode {
/// Logical archive path.
path: NormalizedPath,
/// Decoder failure text.
source: String,
},
/// Format error. /// Format error.
Format(String), Format(String),
/// Entry-specific read error. /// Entry-specific read error.
@@ -141,6 +175,8 @@ pub enum ResourceError {
/// Source error text. /// Source error text.
source: String, source: String,
}, },
/// Repository exhausted stable archive handle space.
HandleSpaceExhausted,
/// Repository state lock was poisoned. /// Repository state lock was poisoned.
Poisoned, Poisoned,
} }
@@ -148,7 +184,9 @@ pub enum ResourceError {
impl std::fmt::Display for ResourceError { impl std::fmt::Display for ResourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::MissingArchive => write!(f, "archive was not found"), Self::MissingArchive { path } => {
write!(f, "archive was not found: {}", path.display_lossy())
}
Self::MissingEntry => write!(f, "resource entry was not found in the archive"), Self::MissingEntry => write!(f, "resource entry was not found in the archive"),
Self::InvalidHandle => write!( Self::InvalidHandle => write!(
f, f,
@@ -157,6 +195,31 @@ impl std::fmt::Display for ResourceError {
Self::StaleHandle => { Self::StaleHandle => {
write!(f, "resource handle belongs to an older archive generation") write!(f, "resource handle belongs to an older archive generation")
} }
Self::InvalidPath { path, source } => {
write!(f, "invalid resource archive path {path}: {source}")
}
Self::PathAmbiguous { path } => {
write!(f, "resource archive path is ambiguous: {path}")
}
Self::Storage { path, source } => {
write!(
f,
"failed to read archive {}: {source}",
path.display_lossy()
)
}
Self::UnsupportedArchive { path } => write!(
f,
"unsupported archive magic for resource repository: {}",
path.display_lossy()
),
Self::ArchiveDecode { path, source } => {
write!(
f,
"failed to decode archive {}: {source}",
path.display_lossy()
)
}
Self::Format(message) => write!(f, "resource archive format error: {message}"), Self::Format(message) => write!(f, "resource archive format error: {message}"),
Self::EntryRead { key, source } => { Self::EntryRead { key, source } => {
write!( write!(
@@ -169,12 +232,22 @@ impl std::fmt::Display for ResourceError {
source source
) )
} }
Self::HandleSpaceExhausted => {
write!(f, "too many open archives for handle space")
}
Self::Poisoned => write!(f, "resource repository state lock was poisoned"), Self::Poisoned => write!(f, "resource repository state lock was poisoned"),
} }
} }
} }
impl std::error::Error for ResourceError {} impl std::error::Error for ResourceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Storage { source, .. } => Some(source),
_ => None,
}
}
}
/// Repository port. /// Repository port.
pub trait ResourceRepository { pub trait ResourceRepository {
@@ -376,7 +449,10 @@ impl CachedResourceRepository {
impl ResourceRepository for CachedResourceRepository { impl ResourceRepository for CachedResourceRepository {
fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError> { fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError> {
let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?; let bytes = self
.vfs
.read(path)
.map_err(|err| resource_error_from_vfs(path, err))?;
let fingerprint = sha256(&bytes); let fingerprint = sha256(&bytes);
let mut slot = decode_archive(path.clone(), bytes, fingerprint)?; let mut slot = decode_archive(path.clone(), bytes, fingerprint)?;
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
@@ -401,9 +477,9 @@ impl ResourceRepository for CachedResourceRepository {
state.evict_archives(id)?; state.evict_archives(id)?;
return Ok(id); return Ok(id);
} }
let id = ArchiveId(u64::try_from(state.archives.len()).map_err(|_| { let id = ArchiveId(
ResourceError::Format("too many open archives for handle space".to_string()) u64::try_from(state.archives.len()).map_err(|_| ResourceError::HandleSpaceExhausted)?,
})?); );
state.paths.insert(key, id); state.paths.insert(key, id);
state.archives.push(slot); state.archives.push(slot);
state.load_archive(id)?; state.load_archive(id)?;
@@ -722,8 +798,13 @@ fn decode_archive(
) -> Result<ArchiveSlot, ResourceError> { ) -> Result<ArchiveSlot, ResourceError> {
let archive_bytes = bytes.len(); let archive_bytes = bytes.len();
if bytes.starts_with(b"NRes") { if bytes.starts_with(b"NRes") {
let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible) let document =
.map_err(|err| ResourceError::Format(err.to_string()))?; fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|err| {
ResourceError::ArchiveDecode {
path: path.clone(),
source: err.to_string(),
}
})?;
return Ok(ArchiveSlot { return Ok(ArchiveSlot {
path, path,
fingerprint, fingerprint,
@@ -735,8 +816,13 @@ fn decode_archive(
}); });
} }
if bytes.get(0..4) == Some(b"NL\0\x01") { if bytes.get(0..4) == Some(b"NL\0\x01") {
let document = fparkan_rsli::decode(bytes, fparkan_rsli::ReadProfile::Compatible) let document =
.map_err(|err| ResourceError::Format(err.to_string()))?; fparkan_rsli::decode(bytes, fparkan_rsli::ReadProfile::Compatible).map_err(|err| {
ResourceError::ArchiveDecode {
path: path.clone(),
source: err.to_string(),
}
})?;
return Ok(ArchiveSlot { return Ok(ArchiveSlot {
path, path,
fingerprint, fingerprint,
@@ -747,17 +833,21 @@ fn decode_archive(
document: Some(Arc::new(ArchiveDocument::Rsli(document))), document: Some(Arc::new(ArchiveDocument::Rsli(document))),
}); });
} }
Err(ResourceError::Format( Err(ResourceError::UnsupportedArchive { path })
"unsupported archive magic for resource repository".to_string(),
))
} }
fn resource_error_from_vfs(err: VfsError) -> ResourceError { fn resource_error_from_vfs(path: &NormalizedPath, err: VfsError) -> ResourceError {
match err { match err {
VfsError::NotFound(_) => ResourceError::MissingArchive, VfsError::NotFound(_) => ResourceError::MissingArchive { path: path.clone() },
VfsError::Ambiguous(path) => ResourceError::Format(format!("ambiguous VFS path: {path}")), VfsError::Ambiguous(path) => ResourceError::PathAmbiguous { path },
VfsError::Io(source) => ResourceError::Format(source.to_string()), VfsError::Io(source) => ResourceError::Storage {
VfsError::Path => ResourceError::Format("invalid VFS path".to_string()), path: path.clone(),
source,
},
VfsError::Path => ResourceError::InvalidPath {
path: path.display_lossy().to_string(),
source: "invalid VFS path".to_string(),
},
} }
} }
@@ -771,11 +861,14 @@ pub fn resource_name(raw: impl AsRef<[u8]>) -> ResourceName {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`ResourceError::Format`] when the path is not a valid relative /// Returns [`ResourceError::InvalidPath`] when the path is not a valid relative
/// resource path. /// resource path.
pub fn archive_path(raw: impl AsRef<[u8]>) -> Result<NormalizedPath, ResourceError> { pub fn archive_path(raw: impl AsRef<[u8]>) -> Result<NormalizedPath, ResourceError> {
normalize_relative(raw.as_ref(), PathPolicy::StrictLegacy) let raw = raw.as_ref();
.map_err(|err| ResourceError::Format(err.to_string())) normalize_relative(raw, PathPolicy::StrictLegacy).map_err(|err| ResourceError::InvalidPath {
path: String::from_utf8_lossy(raw).to_string(),
source: err.to_string(),
})
} }
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
@@ -786,9 +879,37 @@ fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use fparkan_vfs::{DirectoryVfs, MemoryVfs}; use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs, VfsEntry, VfsError, VfsMetadata};
use std::path::PathBuf; use std::path::PathBuf;
enum FailingReadMode {
Ambiguous(&'static str),
Io,
Path,
}
struct FailingReadVfs {
mode: FailingReadMode,
}
impl Vfs for FailingReadVfs {
fn metadata(&self, _path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
unreachable!("metadata is not used in these tests");
}
fn read(&self, _path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
match self.mode {
FailingReadMode::Ambiguous(path) => Err(VfsError::Ambiguous(path.to_string())),
FailingReadMode::Io => Err(VfsError::Io(std::io::Error::other("disk offline"))),
FailingReadMode::Path => Err(VfsError::Path),
}
}
fn list(&self, _prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
unreachable!("list is not used in these tests");
}
}
#[test] #[test]
fn cached_repository_reads_synthetic_nres() { fn cached_repository_reads_synthetic_nres() {
let path = archive_path(b"archives/test.lib").expect("path"); let path = archive_path(b"archives/test.lib").expect("path");
@@ -882,7 +1003,10 @@ mod tests {
let state = repo.state.lock().expect("state"); let state = repo.state.lock().expect("state");
assert_eq!(state.archives.len(), 1); assert_eq!(state.archives.len(), 1);
assert_eq!(state.payload_cache.entries.len(), 1); assert_eq!(state.payload_cache.entries.len(), 1);
assert_eq!(state.paths.get(path.identity_bytes()).copied(), Some(archive)); assert_eq!(
state.paths.get(path.identity_bytes()).copied(),
Some(archive)
);
drop(state); drop(state);
assert_eq!(repo.open_archive(&path).expect("cached archive"), archive); assert_eq!(repo.open_archive(&path).expect("cached archive"), archive);
@@ -1028,6 +1152,95 @@ mod tests {
} }
} }
#[test]
fn missing_archive_error_carries_logical_path() {
let path = archive_path(b"missing/archive.lib").expect("path");
let repo = CachedResourceRepository::new(Arc::new(MemoryVfs::default()));
let err = repo.open_archive(&path).expect_err("missing archive");
match err {
ResourceError::MissingArchive { path: missing } => assert_eq!(missing, path),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn open_archive_maps_vfs_errors_to_typed_variants() {
let path = archive_path(b"broken/archive.lib").expect("path");
let ambiguous = CachedResourceRepository::new(Arc::new(FailingReadVfs {
mode: FailingReadMode::Ambiguous("/tmp/root/archive.lib"),
}));
match ambiguous
.open_archive(&path)
.expect_err("ambiguous archive")
{
ResourceError::PathAmbiguous { path } => assert_eq!(path, "/tmp/root/archive.lib"),
other => panic!("unexpected error: {other:?}"),
}
let io = CachedResourceRepository::new(Arc::new(FailingReadVfs {
mode: FailingReadMode::Io,
}));
match io.open_archive(&path).expect_err("storage failure") {
ResourceError::Storage {
path: archive,
source,
} => {
assert_eq!(archive, path);
assert_eq!(source.to_string(), "disk offline");
}
other => panic!("unexpected error: {other:?}"),
}
let invalid = CachedResourceRepository::new(Arc::new(FailingReadVfs {
mode: FailingReadMode::Path,
}));
match invalid.open_archive(&path).expect_err("invalid path") {
ResourceError::InvalidPath { path: raw, source } => {
assert_eq!(raw, "broken/archive.lib");
assert_eq!(source, "invalid VFS path");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn open_archive_reports_decode_and_magic_errors() {
let malformed_path = archive_path(b"broken/malformed.lib").expect("malformed path");
let unsupported_path = archive_path(b"broken/unsupported.lib").expect("unsupported path");
let mut vfs = MemoryVfs::default();
vfs.insert(
malformed_path.clone(),
Arc::from(b"NRes".to_vec().into_boxed_slice()),
);
vfs.insert(
unsupported_path.clone(),
Arc::from(b"ABCD".to_vec().into_boxed_slice()),
);
let repo = CachedResourceRepository::new(Arc::new(vfs));
match repo
.open_archive(&malformed_path)
.expect_err("malformed archive should fail")
{
ResourceError::ArchiveDecode { path, source } => {
assert_eq!(path, malformed_path);
assert!(!source.is_empty());
}
other => panic!("unexpected error: {other:?}"),
}
match repo
.open_archive(&unsupported_path)
.expect_err("unsupported archive should fail")
{
ResourceError::UnsupportedArchive { path } => assert_eq!(path, unsupported_path),
other => panic!("unexpected error: {other:?}"),
}
}
#[test] #[test]
fn lossy_equivalent_archive_paths_remain_distinct() { fn lossy_equivalent_archive_paths_remain_distinct() {
let first_path = archive_path(b"DATA/\xFF.lib").expect("first path"); let first_path = archive_path(b"DATA/\xFF.lib").expect("first path");
@@ -1097,10 +1310,16 @@ mod tests {
.find(first_archive, &resource_name(b"a.bin")) .find(first_archive, &resource_name(b"a.bin"))
.expect("find first") .expect("find first")
.expect("first handle"); .expect("first handle");
assert_eq!(repo.read(first_handle).expect("read first").as_slice(), b"first"); assert_eq!(
repo.read(first_handle).expect("read first").as_slice(),
b"first"
);
let _second_archive = repo.open_archive(&second_path).expect("open second"); let _second_archive = repo.open_archive(&second_path).expect("open second");
assert!(matches!(repo.read(first_handle), Err(ResourceError::StaleHandle))); assert!(matches!(
repo.read(first_handle),
Err(ResourceError::StaleHandle)
));
let reopened = repo.open_archive(&first_path).expect("reopen first"); let reopened = repo.open_archive(&first_path).expect("reopen first");
let refreshed = repo let refreshed = repo
@@ -1109,7 +1328,10 @@ mod tests {
.expect("refreshed handle"); .expect("refreshed handle");
assert_eq!(reopened, first_archive); assert_eq!(reopened, first_archive);
assert_ne!(refreshed, first_handle); assert_ne!(refreshed, first_handle);
assert_eq!(repo.read(refreshed).expect("read refreshed").as_slice(), b"first"); assert_eq!(
repo.read(refreshed).expect("read refreshed").as_slice(),
b"first"
);
} }
#[test] #[test]
@@ -1132,6 +1354,20 @@ mod tests {
ResourceError::StaleHandle.to_string(), ResourceError::StaleHandle.to_string(),
"resource handle belongs to an older archive generation" "resource handle belongs to an older archive generation"
); );
assert_eq!(
ResourceError::MissingArchive {
path: archive_path(b"missing.lib").expect("missing path")
}
.to_string(),
"archive was not found: missing.lib"
);
assert_eq!(
ResourceError::PathAmbiguous {
path: "/tmp/root/MATERIAL.LIB".to_string()
}
.to_string(),
"resource archive path is ambiguous: /tmp/root/MATERIAL.LIB"
);
} }
#[test] #[test]