aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-inspection/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-30 00:54:57 +0300
committerValentin Popov <valentin@popov.link>2026-06-30 00:54:57 +0300
commit7337492c302f663d1b77130b3291ba891198bc65 (patch)
tree1aa1330e55ff733c873d600742462a71cb460242 /crates/fparkan-inspection/src
parentd0bc7f2f26f0b1a983c26fa24932e9364ae917e2 (diff)
downloadfparkan-7337492c302f663d1b77130b3291ba891198bc65.tar.xz
fparkan-7337492c302f663d1b77130b3291ba891198bc65.zip
fix: route archive inspection through byte-safe boundaries
Diffstat (limited to 'crates/fparkan-inspection/src')
-rw-r--r--crates/fparkan-inspection/src/lib.rs144
1 files changed, 134 insertions, 10 deletions
diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs
index ac63bb9..7fce95e 100644
--- a/crates/fparkan-inspection/src/lib.rs
+++ b/crates/fparkan-inspection/src/lib.rs
@@ -20,14 +20,20 @@
)]
//! Shared inspection helpers for format-backed tooling.
-use fparkan_msh::{decode_msh, validate_msh};
+use fparkan_diagnostics::{
+ diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase,
+};
+use fparkan_msh::{decode_msh, validate_msh, ModelAsset};
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
+use fparkan_path::{normalize_relative, PathPolicy};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_rsli::decode as decode_rsli;
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
-use fparkan_vfs::DirectoryVfs;
+use fparkan_vfs::{DirectoryVfs, Vfs};
use std::fs;
+#[cfg(unix)]
+use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::sync::Arc;
@@ -131,7 +137,70 @@ pub enum LandFileKind {
///
/// Returns a string error when the archive cannot be read or decoded.
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ inspect_archive_file_diagnostic(path, sample_limit).map_err(|diagnostic| render_human(&diagnostic))
+}
+
+/// Inspects a format archive and returns a structured diagnostic on failure.
+///
+/// # Errors
+///
+/// Returns a [`Diagnostic`] when the archive cannot be read or decoded.
+pub fn inspect_archive_file_diagnostic(
+ path: &Path,
+ sample_limit: usize,
+) -> Result<ArchiveInspection, Diagnostic> {
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
+ let file_name = path.file_name().ok_or_else(|| {
+ diagnostic(
+ DiagnosticCode("S1.VFS.PATH"),
+ format!("{}: archive path has no file name", path.display()),
+ )
+ .with_context(DiagnosticContext {
+ phase: Some(Phase::Read),
+ path: Some(path.display().to_string()),
+ ..DiagnosticContext::default()
+ })
+ })?;
+ #[cfg(unix)]
+ let raw_name = file_name.as_bytes();
+ #[cfg(not(unix))]
+ let raw_name = file_name
+ .to_str()
+ .ok_or_else(|| {
+ diagnostic(
+ DiagnosticCode("S1.VFS.PATH"),
+ format!("{}: archive file name is not valid text", path.display()),
+ )
+ .with_context(DiagnosticContext {
+ phase: Some(Phase::Read),
+ path: Some(path.display().to_string()),
+ ..DiagnosticContext::default()
+ })
+ })?
+ .as_bytes();
+ let normalized = normalize_relative(raw_name, PathPolicy::HostCompatible).map_err(|err| {
+ diagnostic(
+ DiagnosticCode("S1.VFS.PATH"),
+ format!("{}: {err}", path.display()),
+ )
+ .with_context(DiagnosticContext {
+ phase: Some(Phase::Read),
+ path: Some(path.display().to_string()),
+ ..DiagnosticContext::default()
+ })
+ })?;
+ let vfs = DirectoryVfs::new(parent);
+ let bytes = vfs.read(&normalized).map_err(|err| {
+ diagnostic(
+ DiagnosticCode("S1.VFS.READ"),
+ format!("{}: {err}", path.display()),
+ )
+ .with_context(DiagnosticContext {
+ phase: Some(Phase::Read),
+ path: Some(path.display().to_string()),
+ ..DiagnosticContext::default()
+ })
+ })?;
inspect_archive_bytes(&bytes, sample_limit, Some(path))
}
@@ -140,13 +209,13 @@ fn inspect_archive_bytes(
bytes: &[u8],
sample_limit: usize,
source: Option<&Path>,
-) -> Result<ArchiveInspection, String> {
+) -> Result<ArchiveInspection, Diagnostic> {
if bytes.starts_with(b"NRes") {
let document = decode_nres(
Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
- .map_err(|err| err.to_string())?;
+ .map_err(|err| archive_parse_diagnostic("S1.NRES.DECODE", source, err.to_string()))?;
let mut sample = Vec::new();
for entry in document.entries().iter().take(sample_limit) {
sample.push(NresEntrySummary {
@@ -165,15 +234,16 @@ fn inspect_archive_bytes(
Arc::from(bytes.to_vec().into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
- .map_err(|err| err.to_string())?;
+ .map_err(|err| archive_parse_diagnostic("S1.RSLI.DECODE", source, err.to_string()))?;
Ok(ArchiveInspection::Rsli {
entries: document.entries().len(),
})
} else {
- match source {
- Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
- None => Err("unsupported archive magic".to_string()),
- }
+ Err(archive_parse_diagnostic(
+ "S1.RESOURCE.UNSUPPORTED_ARCHIVE",
+ source,
+ "unsupported archive magic".to_string(),
+ ))
}
}
@@ -202,6 +272,22 @@ pub fn inspect_model_from_root(
})
}
+/// Loads and validates a model resource through repository-backed lookup.
+///
+/// # Errors
+///
+/// Returns a string error when the resource cannot be resolved or parsed as a
+/// valid model payload.
+pub fn load_model_from_root(
+ root: &Path,
+ archive: &str,
+ resource: &str,
+) -> Result<ModelAsset, String> {
+ let document = load_model_document_from_root(root, archive, resource)?;
+ let msh = decode_msh(&document).map_err(|err| err.to_string())?;
+ validate_msh(&msh).map_err(|err| err.to_string())
+}
+
/// Inspects a texture through repository-backed resource lookup.
///
/// # Errors
@@ -288,6 +374,27 @@ fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8
Ok(Arc::from(bytes.into_owned()))
}
+fn load_model_document_from_root(
+ root: &Path,
+ archive: &str,
+ resource: &str,
+) -> Result<NresDocument, String> {
+ let bytes = read_resource_bytes(root, archive, resource)?;
+ decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())
+}
+
+fn archive_parse_diagnostic(
+ code: &'static str,
+ source: Option<&Path>,
+ message: String,
+) -> Diagnostic {
+ diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
+ phase: Some(Phase::Parse),
+ path: source.map(|path| path.display().to_string()),
+ ..DiagnosticContext::default()
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -307,6 +414,23 @@ mod tests {
}
#[test]
+ fn archive_diagnostic_preserves_source_path() {
+ let dir = temp_dir("inspect-diagnostic");
+ let path = dir.join("broken.nres");
+ fs::write(&path, b"NRes").expect("broken nres");
+
+ let diagnostic =
+ inspect_archive_file_diagnostic(&path, 0).expect_err("diagnostic failure");
+
+ assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
+ let expected_path = path.display().to_string();
+ assert_eq!(
+ diagnostic.context.path.as_deref(),
+ Some(expected_path.as_str())
+ );
+ }
+
+ #[test]
fn nres_entry_summary_fields_are_readable() {
let dir = temp_dir("inspect-nres");
let archive = dir.join("test.nres");