Compare commits
13 Commits
e593af8dc0
..
devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
8a8ef614f2
|
|||
|
51b54c155c
|
|||
|
0fd96faf54
|
|||
|
c7a9c43b5b
|
|||
|
c0116d32be
|
|||
|
0a7ba55b44
|
|||
|
29f7a398ff
|
|||
|
6f84761b83
|
|||
|
2b16e5b118
|
|||
|
716cde2072
|
|||
|
7337492c30
|
|||
|
d0bc7f2f26
|
|||
|
146446d3e2
|
Generated
+27
-7
@@ -475,6 +475,8 @@ dependencies = [
|
||||
"fparkan-resource",
|
||||
"fparkan-runtime",
|
||||
"fparkan-vfs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -537,8 +539,10 @@ dependencies = [
|
||||
name = "fparkan-inspection"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fparkan-diagnostics",
|
||||
"fparkan-msh",
|
||||
"fparkan-nres",
|
||||
"fparkan-path",
|
||||
"fparkan-resource",
|
||||
"fparkan-rsli",
|
||||
"fparkan-terrain-format",
|
||||
@@ -650,6 +654,7 @@ name = "fparkan-rsli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fparkan-binary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1678,17 +1683,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1707,9 +1721,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1718,7 +1732,7 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2128,6 +2142,12 @@ dependencies = [
|
||||
"xkbcommon-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
|
||||
@@ -13,6 +13,8 @@ fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -31,10 +31,23 @@ use fparkan_runtime::{
|
||||
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use serde::Serialize;
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ArchiveInspectOutput<'a> {
|
||||
schema_version: &'static str,
|
||||
path: &'a str,
|
||||
kind: &'a str,
|
||||
entries: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
lookup_order_valid: Option<bool>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = run(&args);
|
||||
@@ -237,14 +250,14 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
|
||||
"NRes",
|
||||
entries,
|
||||
Some(lookup_order_valid),
|
||||
)
|
||||
)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
ArchiveInspection::Rsli { entries } => {
|
||||
println!(
|
||||
"{}",
|
||||
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)
|
||||
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -259,18 +272,14 @@ fn archive_inspect_json(
|
||||
kind: &str,
|
||||
entries: usize,
|
||||
lookup_order_valid: Option<bool>,
|
||||
) -> String {
|
||||
let mut out = format!(
|
||||
"{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}",
|
||||
json_string(path),
|
||||
json_string(kind),
|
||||
entries
|
||||
);
|
||||
if let Some(valid) = lookup_order_valid {
|
||||
let _ = write!(out, ",\"lookup_order_valid\":{valid}");
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
) -> Result<String, String> {
|
||||
serialize_json(&ArchiveInspectOutput {
|
||||
schema_version: ARCHIVE_INSPECT_SCHEMA,
|
||||
path,
|
||||
kind,
|
||||
entries,
|
||||
lookup_order_valid,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
|
||||
@@ -301,6 +310,10 @@ fn json_string(value: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> {
|
||||
serde_json::to_string(value).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
|
||||
}
|
||||
@@ -333,7 +346,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn archive_json_has_schema_version() {
|
||||
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true));
|
||||
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
|
||||
.expect("serialize archive inspection");
|
||||
|
||||
assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
|
||||
assert!(json.contains("\"kind\":\"NRes\""));
|
||||
|
||||
@@ -927,7 +927,7 @@ fn resolve_texm_from_candidates<'a, R: ResourceRepository>(
|
||||
};
|
||||
let archive = match repository.open_archive(path) {
|
||||
Ok(archive) => archive,
|
||||
Err(ResourceError::MissingArchive) => {
|
||||
Err(ResourceError::MissingArchive { .. }) => {
|
||||
missing_archive = true;
|
||||
continue;
|
||||
}
|
||||
@@ -1120,7 +1120,7 @@ fn read_optional_key<R: ResourceRepository>(
|
||||
) -> Result<Option<Arc<[u8]>>, AssetError> {
|
||||
let archive = match repository.open_archive(&key.archive) {
|
||||
Ok(archive) => archive,
|
||||
Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None),
|
||||
Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => return Ok(None),
|
||||
Err(err) => {
|
||||
let label = label.unwrap_or("asset");
|
||||
return Err(map_resource_error(label, key, err));
|
||||
|
||||
@@ -35,6 +35,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -69,6 +71,8 @@ pub struct DiscoverOptions {
|
||||
pub struct ManifestEntry {
|
||||
/// Normalized relative path.
|
||||
pub path: String,
|
||||
/// Byte-exact relative host path used for reopening corpus files.
|
||||
pub host_rel_path: PathBuf,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// SHA-256 content fingerprint.
|
||||
@@ -188,7 +192,7 @@ pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest,
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(root, root, options, &mut files)?;
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
files.sort_by(|a, b| a.host_rel_path.cmp(&b.host_rel_path));
|
||||
|
||||
let kind = classify(root, &files);
|
||||
let casefold_collisions = detect_casefold_collisions(&files);
|
||||
@@ -243,17 +247,22 @@ fn walk(
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let rel_text = rel
|
||||
#[cfg(unix)]
|
||||
let rel_bytes = rel.as_os_str().as_bytes();
|
||||
#[cfg(not(unix))]
|
||||
let rel_bytes = rel
|
||||
.to_str()
|
||||
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible)
|
||||
.map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?;
|
||||
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?
|
||||
.as_bytes();
|
||||
let normalized = normalize_relative(rel_bytes, PathPolicy::HostCompatible)
|
||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
out.push(ManifestEntry {
|
||||
path: normalized.as_str().to_string(),
|
||||
path: normalized.display_lossy().to_string(),
|
||||
host_rel_path: rel.to_path_buf(),
|
||||
size: metadata.len(),
|
||||
hash: sha256(&bytes),
|
||||
});
|
||||
@@ -285,7 +294,7 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
|
||||
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
|
||||
for file in files {
|
||||
grouped
|
||||
.entry(ascii_lookup_key(file.path.as_bytes()).0)
|
||||
.entry(ascii_lookup_key(path_identity_bytes(&file.host_rel_path)).0)
|
||||
.or_default()
|
||||
.insert(file.path.clone());
|
||||
}
|
||||
@@ -353,7 +362,7 @@ fn inspect_report_file(
|
||||
) -> CorpusFileRecord {
|
||||
let lower = entry.path.to_ascii_lowercase();
|
||||
let mut variant = inspect_path_metrics(&lower, metrics);
|
||||
let path = root.join(&entry.path);
|
||||
let path = root.join(&entry.host_rel_path);
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(source) => {
|
||||
@@ -439,6 +448,17 @@ fn inspect_report_file(
|
||||
}
|
||||
}
|
||||
|
||||
fn path_identity_bytes(path: &Path) -> &[u8] {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
path.as_os_str().as_bytes()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
path.to_str().unwrap_or_default().as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
|
||||
let mut variant = "file";
|
||||
if lower.ends_with("data.tma") {
|
||||
@@ -767,11 +787,7 @@ mod tests {
|
||||
fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Part1,
|
||||
files: vec![ManifestEntry {
|
||||
path: "secret/payload.bin".to_string(),
|
||||
size: 4,
|
||||
hash: sha256(b"DATA"),
|
||||
}],
|
||||
files: vec![manifest_entry("secret/payload.bin", 4, sha256(b"DATA"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
let report = report(Path::new("."), &manifest).expect("report");
|
||||
@@ -791,11 +807,7 @@ mod tests {
|
||||
let root = temp_dir("report-missing");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "missing.lib".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"missing"),
|
||||
}],
|
||||
files: vec![manifest_entry("missing.lib", 1, sha256(b"missing"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -814,11 +826,7 @@ mod tests {
|
||||
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "bad.lib".to_string(),
|
||||
size: 4,
|
||||
hash: sha256(b"NRes"),
|
||||
}],
|
||||
files: vec![manifest_entry("bad.lib", 4, sha256(b"NRes"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -857,11 +865,11 @@ mod tests {
|
||||
fs::write(root.join("archive.lib"), &archive).expect("archive");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "archive.lib".to_string(),
|
||||
size: u64::try_from(archive.len()).expect("archive size"),
|
||||
hash: sha256(&archive),
|
||||
}],
|
||||
files: vec![manifest_entry(
|
||||
"archive.lib",
|
||||
u64::try_from(archive.len()).expect("archive size"),
|
||||
sha256(&archive),
|
||||
)],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -886,11 +894,7 @@ mod tests {
|
||||
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "WORLD/MAP/land.map".to_string(),
|
||||
size: 16,
|
||||
hash: sha256(b"land.map"),
|
||||
}],
|
||||
files: vec![manifest_entry("WORLD/MAP/land.map", 16, sha256(b"land.map"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -909,11 +913,7 @@ mod tests {
|
||||
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "WORLD/MAP/land.msh".to_string(),
|
||||
size: 16,
|
||||
hash: sha256(b"land.msh"),
|
||||
}],
|
||||
files: vec![manifest_entry("WORLD/MAP/land.msh", 16, sha256(b"land.msh"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -932,11 +932,11 @@ mod tests {
|
||||
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "MISSIONS/test/data.tma".to_string(),
|
||||
size: 12,
|
||||
hash: sha256(b"malformed tma"),
|
||||
}],
|
||||
files: vec![manifest_entry(
|
||||
"MISSIONS/test/data.tma",
|
||||
12,
|
||||
sha256(b"malformed tma"),
|
||||
)],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -955,11 +955,7 @@ mod tests {
|
||||
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "units/unit.dat".to_string(),
|
||||
size: 120,
|
||||
hash: sha256(&[0u8; 120]),
|
||||
}],
|
||||
files: vec![manifest_entry("units/unit.dat", 120, sha256(&[0u8; 120]))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -977,11 +973,7 @@ mod tests {
|
||||
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "patch.nl".to_string(),
|
||||
size: 12,
|
||||
hash: sha256(b"NL malformed"),
|
||||
}],
|
||||
files: vec![manifest_entry("patch.nl", 12, sha256(b"NL malformed"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -1052,16 +1044,8 @@ mod tests {
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![
|
||||
ManifestEntry {
|
||||
path: "Textures/Foo.TEX".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"first"),
|
||||
},
|
||||
ManifestEntry {
|
||||
path: "textures/foo.tex".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"second"),
|
||||
},
|
||||
manifest_entry("Textures/Foo.TEX", 1, sha256(b"first")),
|
||||
manifest_entry("textures/foo.tex", 1, sha256(b"second")),
|
||||
],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
@@ -1081,11 +1065,7 @@ mod tests {
|
||||
fn fingerprint_changes() {
|
||||
let mut manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "a".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"before"),
|
||||
}],
|
||||
files: vec![manifest_entry("a", 1, sha256(b"before"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
let a = fingerprint(&manifest);
|
||||
@@ -1118,6 +1098,29 @@ mod tests {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn discover_supports_non_utf8_host_paths() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let root = temp_dir("non-utf8");
|
||||
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
|
||||
let file_path = root.join(&file_name);
|
||||
if let Err(err) = fs::write(&file_path, b"raw") {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
||||
|
||||
assert_eq!(manifest.files.len(), 1);
|
||||
assert_eq!(manifest.files[0].path, "\u{FFFD}.bin");
|
||||
assert_eq!(manifest.files[0].host_rel_path, PathBuf::from(&file_name));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
struct TestNresEntry<'a> {
|
||||
name: &'a str,
|
||||
type_id: u32,
|
||||
@@ -1164,6 +1167,15 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
fn manifest_entry(path: &str, size: u64, hash: Sha256Digest) -> ManifestEntry {
|
||||
ManifestEntry {
|
||||
path: path.to_string(),
|
||||
host_rel_path: PathBuf::from(path),
|
||||
size,
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-diagnostics = { path = "../fparkan-diagnostics" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
|
||||
@@ -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, SourceSpan,
|
||||
};
|
||||
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, bytes, err.to_string()))?;
|
||||
let mut sample = Vec::new();
|
||||
for entry in document.entries().iter().take(sample_limit) {
|
||||
sample.push(NresEntrySummary {
|
||||
@@ -165,15 +234,17 @@ 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, bytes, 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,
|
||||
bytes,
|
||||
"unsupported archive magic".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +259,17 @@ pub fn inspect_model_from_root(
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<ModelInspection, String> {
|
||||
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
|
||||
let bytes =
|
||||
read_resource_bytes_diagnostic(root, archive, resource).map_err(|err| render_human(&err))?;
|
||||
let document = decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
|
||||
render_human(&resource_parse_diagnostic(
|
||||
"S1.NRES.DECODE",
|
||||
archive,
|
||||
resource,
|
||||
&bytes,
|
||||
err.to_string(),
|
||||
))
|
||||
})?;
|
||||
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
||||
Ok(ModelInspection {
|
||||
@@ -202,6 +282,25 @@ 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_diagnostic(root, archive, resource).map_err(|err| {
|
||||
render_human(&err)
|
||||
})?;
|
||||
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
|
||||
@@ -213,7 +312,8 @@ pub fn inspect_texture_from_root(
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<TextureInspection, String> {
|
||||
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||
let bytes =
|
||||
read_resource_bytes_diagnostic(root, archive, resource).map_err(|err| render_human(&err))?;
|
||||
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
|
||||
Ok(TextureInspection {
|
||||
width: document.width(),
|
||||
@@ -268,32 +368,134 @@ fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
|
||||
fn read_resource_bytes_diagnostic(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
name: &str,
|
||||
) -> Result<Arc<[u8]>, Diagnostic> {
|
||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
|
||||
let archive_path = archive_path(archive.as_bytes()).map_err(|err| {
|
||||
diagnostic(DiagnosticCode("S1.PATH.ARCHIVE"), err.to_string()).with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Resolve),
|
||||
path: Some(archive.to_string()),
|
||||
archive_entry: Some(name.to_string()),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
)
|
||||
})?;
|
||||
let resource_name = resource_name(name.as_bytes());
|
||||
let archive_handle = repository
|
||||
.open_archive(&archive_path)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
.map_err(|err| {
|
||||
diagnostic(DiagnosticCode("S1.RESOURCE.OPEN_ARCHIVE"), err.to_string()).with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Read),
|
||||
path: Some(archive.to_string()),
|
||||
archive_entry: Some(name.to_string()),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
)
|
||||
})?;
|
||||
let Some(handle) = repository
|
||||
.find(archive_handle, &resource_name)
|
||||
.map_err(|err| format!("{err}"))?
|
||||
.map_err(|err| {
|
||||
diagnostic(DiagnosticCode("S1.RESOURCE.FIND"), err.to_string()).with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Resolve),
|
||||
path: Some(archive.to_string()),
|
||||
archive_entry: Some(name.to_string()),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
)
|
||||
})?
|
||||
else {
|
||||
return Err(format!(
|
||||
return Err(
|
||||
diagnostic(
|
||||
DiagnosticCode("S1.RESOURCE.MISSING_ENTRY"),
|
||||
format!(
|
||||
"resource not found: {archive}/{}",
|
||||
String::from_utf8_lossy(name.as_bytes())
|
||||
));
|
||||
),
|
||||
)
|
||||
.with_context(DiagnosticContext {
|
||||
phase: Some(Phase::Resolve),
|
||||
path: Some(archive.to_string()),
|
||||
archive_entry: Some(name.to_string()),
|
||||
..DiagnosticContext::default()
|
||||
}),
|
||||
);
|
||||
};
|
||||
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
|
||||
let bytes = repository.read(handle).map_err(|err| {
|
||||
diagnostic(DiagnosticCode("S1.RESOURCE.READ"), err.to_string()).with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Read),
|
||||
path: Some(archive.to_string()),
|
||||
archive_entry: Some(name.to_string()),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
)
|
||||
})?;
|
||||
Ok(Arc::from(bytes.into_owned()))
|
||||
}
|
||||
|
||||
fn load_model_document_from_root_diagnostic(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<NresDocument, Diagnostic> {
|
||||
let bytes = read_resource_bytes_diagnostic(root, archive, resource)?;
|
||||
decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
|
||||
resource_parse_diagnostic("S1.NRES.DECODE", archive, resource, &bytes, err.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn archive_parse_diagnostic(
|
||||
code: &'static str,
|
||||
source: Option<&Path>,
|
||||
bytes: &[u8],
|
||||
message: String,
|
||||
) -> Diagnostic {
|
||||
diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
|
||||
phase: Some(Phase::Parse),
|
||||
path: source.map(|path| path.display().to_string()),
|
||||
span: Some(SourceSpan {
|
||||
offset: 0,
|
||||
length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
|
||||
}),
|
||||
..DiagnosticContext::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn resource_parse_diagnostic(
|
||||
code: &'static str,
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
bytes: &[u8],
|
||||
message: String,
|
||||
) -> Diagnostic {
|
||||
diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
|
||||
phase: Some(Phase::Parse),
|
||||
path: Some(archive.to_string()),
|
||||
archive_entry: Some(resource.to_string()),
|
||||
span: Some(SourceSpan {
|
||||
offset: 0,
|
||||
length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
|
||||
}),
|
||||
..DiagnosticContext::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const TEST_NRES_HEADER_LEN: usize = 16;
|
||||
const TEST_NRES_NAME_LEN: usize = 36;
|
||||
const TEST_NRES_VERSION_0100: u32 = 0x100;
|
||||
|
||||
#[test]
|
||||
fn inspect_rsli_rejects_malformed_archive() {
|
||||
let dir = temp_dir("inspect");
|
||||
@@ -306,6 +508,31 @@ mod tests {
|
||||
assert!(error.contains("entry table out of bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_diagnostic_preserves_source_path_phase_and_span() {
|
||||
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())
|
||||
);
|
||||
assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
|
||||
assert_eq!(
|
||||
diagnostic.context.span,
|
||||
Some(SourceSpan {
|
||||
offset: 0,
|
||||
length: 4
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_entry_summary_fields_are_readable() {
|
||||
let dir = temp_dir("inspect-nres");
|
||||
@@ -316,6 +543,28 @@ mod tests {
|
||||
let _ = inspect_archive_file(&archive, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_archive_diagnostic_preserves_archive_entry_context() {
|
||||
let dir = temp_dir("inspect-model-diagnostic");
|
||||
let archive = dir.join("models.rlb");
|
||||
fs::write(&archive, build_single_entry_nres(b"BROKEN.MSH", b"NRes")).expect("archive");
|
||||
|
||||
let diagnostic = load_model_document_from_root_diagnostic(&dir, "models.rlb", "BROKEN.MSH")
|
||||
.expect_err("nested diagnostic failure");
|
||||
|
||||
assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
|
||||
assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
|
||||
assert_eq!(diagnostic.context.path.as_deref(), Some("models.rlb"));
|
||||
assert_eq!(diagnostic.context.archive_entry.as_deref(), Some("BROKEN.MSH"));
|
||||
assert_eq!(
|
||||
diagnostic.context.span,
|
||||
Some(SourceSpan {
|
||||
offset: 0,
|
||||
length: 4
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let base = PathBuf::from("/tmp")
|
||||
.join("fparkan-inspection-tests")
|
||||
@@ -324,4 +573,34 @@ mod tests {
|
||||
fs::create_dir_all(&base).expect("tmp dir");
|
||||
base
|
||||
}
|
||||
|
||||
fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0; TEST_NRES_HEADER_LEN];
|
||||
let payload_offset = u32::try_from(out.len()).expect("payload offset");
|
||||
out.extend_from_slice(payload);
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
out.resize(out.len() + padding, 0);
|
||||
|
||||
push_u32(&mut out, 1);
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(&mut out, u32::try_from(payload.len()).expect("payload len"));
|
||||
push_u32(&mut out, 0);
|
||||
let mut raw_name = [0; TEST_NRES_NAME_LEN];
|
||||
raw_name[..name.len()].copy_from_slice(name);
|
||||
out.extend_from_slice(&raw_name);
|
||||
push_u32(&mut out, payload_offset);
|
||||
push_u32(&mut out, 0);
|
||||
|
||||
out[0..4].copy_from_slice(b"NRes");
|
||||
out[4..8].copy_from_slice(&TEST_NRES_VERSION_0100.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&1_u32.to_le_bytes());
|
||||
let total_size = u32::try_from(out.len()).expect("total size");
|
||||
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
+203
-20
@@ -20,7 +20,7 @@
|
||||
)]
|
||||
//! Strict and lossless `NRes` archive support.
|
||||
|
||||
use fparkan_binary::{Cursor, DecodeError};
|
||||
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError};
|
||||
use fparkan_path::{ascii_lookup_key, LookupKey};
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
@@ -51,6 +51,33 @@ pub enum WriteProfile {
|
||||
CanonicalCompact,
|
||||
}
|
||||
|
||||
/// Decode-time archive limits.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DecodeLimits {
|
||||
/// Maximum accepted source archive bytes.
|
||||
pub max_input_bytes: u64,
|
||||
/// Maximum accepted entry count.
|
||||
pub max_entries: u32,
|
||||
/// Maximum accepted single payload byte length.
|
||||
pub max_decoded_entry_bytes: u64,
|
||||
/// Maximum accepted cumulative payload bytes.
|
||||
pub max_total_decoded_bytes: u64,
|
||||
/// Maximum accepted preserved-region bytes.
|
||||
pub max_preserved_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for DecodeLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_input_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 1_000_000,
|
||||
max_decoded_entry_bytes: 256 * 1024 * 1024,
|
||||
max_total_decoded_bytes: 512 * 1024 * 1024,
|
||||
max_preserved_bytes: 256 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NRes` archive header.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NresHeader {
|
||||
@@ -343,16 +370,31 @@ impl From<DecodeError> for NresError {
|
||||
/// Returns [`NresError`] when the header, directory, payload ranges, or strict
|
||||
/// lookup permutation are malformed for the selected [`ReadProfile`].
|
||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
|
||||
let header = parse_header(&bytes)?;
|
||||
let entries = parse_entries(&bytes, &header)?;
|
||||
decode_with_limits(bytes, profile, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Decodes `NRes` bytes with explicit archive limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NresError`] when the input exceeds configured limits, the header,
|
||||
/// directory, payload ranges, or strict lookup permutation are malformed.
|
||||
pub fn decode_with_limits(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: ReadProfile,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<NresDocument, NresError> {
|
||||
let header = parse_header(&bytes, limits)?;
|
||||
let entries = parse_entries(&bytes, &header, limits)?;
|
||||
validate_names(&entries)?;
|
||||
validate_payload_ranges(&entries)?;
|
||||
validate_payload_ranges(&entries, limits)?;
|
||||
let lookup_order_valid = match validate_lookup_order(&entries) {
|
||||
Ok(valid) => valid,
|
||||
Ok(()) => true,
|
||||
Err(err) if profile == ReadProfile::Strict => return Err(err),
|
||||
Err(_) => false,
|
||||
};
|
||||
let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?;
|
||||
let preserved_regions =
|
||||
find_preserved_regions(&bytes, &entries, header.directory_offset, limits)?;
|
||||
Ok(NresDocument {
|
||||
bytes,
|
||||
header,
|
||||
@@ -684,7 +726,11 @@ impl NresEntry {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||
fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result<NresHeader, NresError> {
|
||||
enforce_limit(
|
||||
u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?,
|
||||
limits.max_input_bytes,
|
||||
)?;
|
||||
if bytes.len() < HEADER_LEN {
|
||||
let mut got = [0; 4];
|
||||
let copy_len = bytes.len().min(4);
|
||||
@@ -711,6 +757,7 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||
}
|
||||
let entry_count =
|
||||
u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(u64::from(entry_count), u64::from(limits.max_entries))?;
|
||||
let total_size = cursor.read_u32_le()?;
|
||||
let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
if u64::from(total_size) != actual {
|
||||
@@ -750,8 +797,16 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result<Vec<NresEntry>, NresError> {
|
||||
let mut entries = Vec::with_capacity(header.entry_count as usize);
|
||||
fn parse_entries(
|
||||
bytes: &[u8],
|
||||
header: &NresHeader,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<NresEntry>, NresError> {
|
||||
let capacity = checked_allocation_len(
|
||||
u64::from(header.entry_count),
|
||||
u64::from(limits.max_entries),
|
||||
)?;
|
||||
let mut entries = Vec::with_capacity(capacity);
|
||||
let directory_offset =
|
||||
usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
for index in 0..header.entry_count {
|
||||
@@ -832,7 +887,7 @@ fn parse_entry(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
fn validate_payload_ranges(entries: &[NresEntry], limits: DecodeLimits) -> Result<(), NresError> {
|
||||
let mut ranges: Vec<(u32, Range<usize>)> = entries
|
||||
.iter()
|
||||
.map(|entry| (entry.id.0, entry.data_range.clone()))
|
||||
@@ -843,6 +898,15 @@ fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
.cmp(&right.1.start)
|
||||
.then_with(|| left.1.end.cmp(&right.1.end))
|
||||
});
|
||||
let mut total_payload_bytes = 0_u64;
|
||||
for entry in entries {
|
||||
let payload_len = u64::from(entry.meta.data_size);
|
||||
enforce_limit(payload_len, limits.max_decoded_entry_bytes)?;
|
||||
total_payload_bytes = total_payload_bytes
|
||||
.checked_add(payload_len)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(total_payload_bytes, limits.max_total_decoded_bytes)?;
|
||||
}
|
||||
for pair in ranges.windows(2) {
|
||||
if pair[0].1.end > pair[1].1.start {
|
||||
return Err(NresError::EntryDataOverlap {
|
||||
@@ -863,7 +927,7 @@ fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||
fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
let entry_count = saturating_u32_len(entries.len());
|
||||
let mut seen = vec![false; entries.len()];
|
||||
for (position, entry) in entries.iter().enumerate() {
|
||||
@@ -881,7 +945,7 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||
}
|
||||
seen[index_usize] = true;
|
||||
}
|
||||
for pair in entries.windows(2) {
|
||||
for (position, pair) in entries.windows(2).enumerate() {
|
||||
let left_index =
|
||||
usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
let right_index =
|
||||
@@ -889,16 +953,19 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||
let left = entries[left_index].name_bytes();
|
||||
let right = entries[right_index].name_bytes();
|
||||
if cmp_ascii_casefold(left, right) == Ordering::Greater {
|
||||
return Ok(false);
|
||||
return Err(NresError::SortOrderMismatch {
|
||||
position: saturating_u32_len(position.saturating_add(1)),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_preserved_regions(
|
||||
bytes: &[u8],
|
||||
entries: &[NresEntry],
|
||||
directory_offset: u32,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<PreservedRegion>, NresError> {
|
||||
let mut ranges: Vec<Range<usize>> = entries
|
||||
.iter()
|
||||
@@ -914,18 +981,44 @@ fn find_preserved_regions(
|
||||
let directory_offset =
|
||||
usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
let mut preserved = Vec::new();
|
||||
let mut preserved_bytes = 0_u64;
|
||||
for range in ranges {
|
||||
if cursor < range.start {
|
||||
preserved_bytes = preserved_bytes
|
||||
.checked_add(
|
||||
u64::try_from(range.start - cursor)
|
||||
.map_err(|_| DecodeError::IntegerOverflow)?,
|
||||
)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
|
||||
preserved.push(make_preserved_region(bytes, cursor..range.start)?);
|
||||
}
|
||||
cursor = cursor.max(range.end);
|
||||
}
|
||||
if cursor < directory_offset {
|
||||
preserved_bytes = preserved_bytes
|
||||
.checked_add(
|
||||
u64::try_from(directory_offset - cursor)
|
||||
.map_err(|_| DecodeError::IntegerOverflow)?,
|
||||
)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
|
||||
preserved.push(make_preserved_region(bytes, cursor..directory_offset)?);
|
||||
}
|
||||
Ok(preserved)
|
||||
}
|
||||
|
||||
fn enforce_limit(value: u64, limit: u64) -> Result<(), NresError> {
|
||||
if value > limit {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: value,
|
||||
limit,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> {
|
||||
let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
|
||||
Ok(PreservedRegion {
|
||||
@@ -1176,7 +1269,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
decode(arc(bytes), ReadProfile::Strict),
|
||||
Err(NresError::DirectoryOutOfBounds { .. })
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
||||
count,
|
||||
limit
|
||||
})) if count == i32::MAX as u64 && limit == DecodeLimits::default().max_entries as u64
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1469,7 +1565,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_lookup_table_falls_back_to_linear_lookup() {
|
||||
fn strict_rejects_unsorted_lookup_table() {
|
||||
let mut bytes = build_archive(&[
|
||||
SyntheticEntry {
|
||||
type_id: 1,
|
||||
@@ -1497,9 +1593,96 @@ mod tests {
|
||||
bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
|
||||
.copy_from_slice(&1_u32.to_le_bytes());
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres");
|
||||
assert!(!doc.lookup_order_valid());
|
||||
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
||||
assert!(matches!(
|
||||
decode(arc(bytes), ReadProfile::Strict),
|
||||
Err(NresError::SortOrderMismatch { position: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_entry_count_above_limit() {
|
||||
let bytes = build_archive(&[
|
||||
SyntheticEntry {
|
||||
type_id: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "a",
|
||||
payload: b"a",
|
||||
},
|
||||
SyntheticEntry {
|
||||
type_id: 2,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "b",
|
||||
payload: b"b",
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_entries: 1,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded { count: 2, limit: 1 }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_input_bytes_above_limit() {
|
||||
let bytes = build_archive(&[SyntheticEntry {
|
||||
type_id: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "payload",
|
||||
payload: b"data",
|
||||
}]);
|
||||
let exact_size = u64::try_from(bytes.len()).expect("archive size");
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_input_bytes: exact_size - 1,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
||||
count,
|
||||
limit
|
||||
})) if count == exact_size && limit == exact_size - 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_preserved_bytes_above_limit() {
|
||||
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
|
||||
type_id: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "payload",
|
||||
payload: b"data",
|
||||
}]);
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_preserved_bytes: 4,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1989,7 +2172,7 @@ mod tests {
|
||||
let mut has_nonzero_preserved_region = false;
|
||||
for path in &files {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
let doc = decode(arc(bytes.clone()), ReadProfile::Strict)
|
||||
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
|
||||
.map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
total_entries = total_entries
|
||||
.checked_add(doc.entry_count())
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
)]
|
||||
//! Legacy path normalization and ASCII lookup semantics.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Original bytes.
|
||||
@@ -42,23 +44,41 @@ impl OriginalPathBytes {
|
||||
}
|
||||
|
||||
/// Normalized relative path.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NormalizedPath {
|
||||
raw: Vec<u8>,
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl NormalizedPath {
|
||||
/// Returns string view.
|
||||
/// Returns normalized byte view used for identity, ordering, and hashing.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.display
|
||||
pub fn identity_bytes(&self) -> &[u8] {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
/// Returns an ASCII-only lookup key for case-insensitive archive matching.
|
||||
#[must_use]
|
||||
pub fn lookup_key(&self) -> LookupKey {
|
||||
ascii_lookup_key(&self.raw)
|
||||
}
|
||||
|
||||
/// Returns normalized byte view.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.raw
|
||||
self.identity_bytes()
|
||||
}
|
||||
|
||||
/// Returns a lossy display representation.
|
||||
#[must_use]
|
||||
pub fn display_lossy(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Returns a lossy string view for UI and diagnostics only.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.display_lossy()
|
||||
}
|
||||
|
||||
/// Returns an OS path owned path buffer.
|
||||
@@ -68,6 +88,32 @@ impl NormalizedPath {
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for NormalizedPath {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.raw == other.raw
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for NormalizedPath {}
|
||||
|
||||
impl PartialOrd for NormalizedPath {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for NormalizedPath {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.raw.cmp(&other.raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for NormalizedPath {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.raw.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized path paired with its original byte image.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NormalizedPathWithOriginal {
|
||||
@@ -353,7 +399,8 @@ mod tests {
|
||||
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("raw legacy bytes");
|
||||
|
||||
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
|
||||
assert_eq!(path.display_lossy(), "DATA/\u{FFFD}.bin");
|
||||
assert_eq!(path.identity_bytes(), b"DATA/\xFF.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -364,4 +411,18 @@ mod tests {
|
||||
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
|
||||
assert_eq!(path.original().as_bytes(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_display_does_not_affect_identity_or_ordering() {
|
||||
let first = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("first raw path");
|
||||
let second = normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible)
|
||||
.expect("second raw path");
|
||||
|
||||
assert_eq!(first.display_lossy(), second.display_lossy());
|
||||
assert_ne!(first, second);
|
||||
assert_ne!(first.identity_bytes(), second.identity_bytes());
|
||||
assert_ne!(first.cmp(&second), Ordering::Equal);
|
||||
assert_ne!(first.lookup_key(), second.lookup_key());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1012,7 +1012,7 @@ fn collect_registry_refs(
|
||||
}
|
||||
let archive_id = match repository.open_archive(registry_archive) {
|
||||
Ok(id) => id,
|
||||
Err(ResourceError::MissingArchive) => return Ok(None),
|
||||
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let Some((registry_entry, _matched_name)) =
|
||||
@@ -1082,7 +1082,7 @@ fn find_mesh_resource(
|
||||
) -> Result<Option<ResourceKey>, PrototypeError> {
|
||||
let archive_id = match repository.open_archive(archive) {
|
||||
Ok(id) => id,
|
||||
Err(ResourceError::MissingArchive) => return Ok(None),
|
||||
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let candidates = mesh_name_candidates(&model_key.0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[lints]
|
||||
|
||||
+320
-17
@@ -20,6 +20,7 @@
|
||||
)]
|
||||
//! Stage-1 `RsLi` archive contract.
|
||||
|
||||
use fparkan_binary::DecodeError;
|
||||
use std::fmt;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
@@ -78,6 +79,33 @@ pub enum WriteProfile {
|
||||
Lossless,
|
||||
}
|
||||
|
||||
/// Decode and payload loading limits.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DecodeLimits {
|
||||
/// Maximum accepted source archive bytes.
|
||||
pub max_input_bytes: u64,
|
||||
/// Maximum accepted entry count.
|
||||
pub max_entries: u32,
|
||||
/// Maximum accepted packed entry bytes.
|
||||
pub max_packed_entry_bytes: u64,
|
||||
/// Maximum accepted decoded entry bytes.
|
||||
pub max_decoded_entry_bytes: u64,
|
||||
/// Maximum accepted cumulative decoded bytes for a single load operation.
|
||||
pub max_total_decoded_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for DecodeLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_input_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 1_000_000,
|
||||
max_packed_entry_bytes: 64 * 1024 * 1024,
|
||||
max_decoded_entry_bytes: 128 * 1024 * 1024,
|
||||
max_total_decoded_bytes: 128 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when mutable editing is attempted.
|
||||
#[derive(Debug)]
|
||||
pub enum RsliMutationError {
|
||||
@@ -105,6 +133,11 @@ pub enum RsliMutationError {
|
||||
/// Format maximum (`u32::MAX`).
|
||||
max: usize,
|
||||
},
|
||||
/// Method cannot be represented by the on-disk flags field.
|
||||
UnsupportedMethod {
|
||||
/// Requested method.
|
||||
method: RsliMethod,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RsliMutationError {
|
||||
@@ -120,6 +153,9 @@ impl std::fmt::Display for RsliMutationError {
|
||||
Self::PackedPayloadTooLarge { size, max } => {
|
||||
write!(f, "packed payload is too large: {size} > {max}")
|
||||
}
|
||||
Self::UnsupportedMethod { method } => {
|
||||
write!(f, "unsupported authoring method: {method:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,6 +410,8 @@ pub enum RsliError {
|
||||
},
|
||||
/// Integer conversion or arithmetic overflow.
|
||||
IntegerOverflow,
|
||||
/// Shared bounded decode failure.
|
||||
Binary(DecodeError),
|
||||
}
|
||||
|
||||
impl fmt::Display for RsliError {
|
||||
@@ -432,11 +470,25 @@ impl fmt::Display for RsliError {
|
||||
write!(f, "output size mismatch: expected={expected}, got={got}")
|
||||
}
|
||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Self::Binary(source) => write!(f, "{source}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RsliError {}
|
||||
impl std::error::Error for RsliError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Binary(source) => Some(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DecodeError> for RsliError {
|
||||
fn from(value: DecodeError) -> Self {
|
||||
Self::Binary(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document.
|
||||
///
|
||||
@@ -446,7 +498,21 @@ impl std::error::Error for RsliError {}
|
||||
/// compatibility quirks, or packed payloads are invalid for the selected
|
||||
/// profile.
|
||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
||||
decode_with_profile(bytes, profile.into())
|
||||
decode_with_limits(bytes, profile, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document with explicit archive limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when the input exceeds configured limits or the
|
||||
/// archive is malformed for the selected profile.
|
||||
pub fn decode_with_limits(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: ReadProfile,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<RsliDocument, RsliError> {
|
||||
decode_with_profile_and_limits(bytes, profile.into(), limits)
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document with explicit compatibility switches.
|
||||
@@ -459,17 +525,33 @@ pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, Rs
|
||||
pub fn decode_with_profile(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: RsliReadProfile,
|
||||
) -> Result<RsliDocument, RsliError> {
|
||||
decode_with_profile_and_limits(bytes, profile, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document with explicit profile and archive limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when the input exceeds configured limits or the
|
||||
/// archive is malformed for the selected profile.
|
||||
pub fn decode_with_profile_and_limits(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: RsliReadProfile,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<RsliDocument, RsliError> {
|
||||
let options = match profile {
|
||||
RsliReadProfile::Strict => ParseOptions {
|
||||
allow_ao_trailer: false,
|
||||
allow_deflate_eof_plus_one: false,
|
||||
allow_invalid_presorted_fallback: false,
|
||||
limits,
|
||||
},
|
||||
RsliReadProfile::Compatible(profile) => ParseOptions {
|
||||
allow_ao_trailer: profile.allow_ao_trailer,
|
||||
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
||||
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
||||
limits,
|
||||
},
|
||||
};
|
||||
let ParsedRsli {
|
||||
@@ -545,6 +627,16 @@ impl RsliDocument {
|
||||
/// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
|
||||
/// be decoded to the declared size.
|
||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
|
||||
self.load_with_limits(id, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Loads and unpacks an entry with explicit decode limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when the packed payload exceeds configured
|
||||
/// limits, `id` is invalid, or the payload cannot be decoded.
|
||||
pub fn load_with_limits(&self, id: EntryId, limits: DecodeLimits) -> Result<Vec<u8>, RsliError> {
|
||||
let record = self.record_by_id(id)?;
|
||||
let packed = self.packed_slice(id, record)?;
|
||||
decode_payload(
|
||||
@@ -552,6 +644,7 @@ impl RsliDocument {
|
||||
record.meta.method,
|
||||
record.key16,
|
||||
record.meta.unpacked_size,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -651,6 +744,7 @@ impl RsliEditor {
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown.
|
||||
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.flags = flags_with_method(entry.meta.flags, method)?;
|
||||
entry.meta.method = method;
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
@@ -837,6 +931,7 @@ struct ParseOptions {
|
||||
allow_ao_trailer: bool,
|
||||
allow_deflate_eof_plus_one: bool,
|
||||
allow_invalid_presorted_fallback: bool,
|
||||
limits: DecodeLimits,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -857,6 +952,10 @@ struct EntryRecord {
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
|
||||
enforce_limit(
|
||||
u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
||||
options.limits.max_input_bytes,
|
||||
)?;
|
||||
if bytes.len() < 32 {
|
||||
return Err(RsliError::EntryTableOutOfBounds {
|
||||
table_offset: 32,
|
||||
@@ -892,6 +991,10 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
||||
if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
|
||||
return Err(RsliError::TooManyEntries { got: count });
|
||||
}
|
||||
enforce_limit(
|
||||
u64::try_from(count).map_err(|_| RsliError::IntegerOverflow)?,
|
||||
u64::from(options.limits.max_entries),
|
||||
)?;
|
||||
|
||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
@@ -935,6 +1038,14 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
||||
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
||||
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
||||
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
||||
enforce_limit(
|
||||
u64::from(packed_size_declared),
|
||||
options.limits.max_packed_entry_bytes,
|
||||
)?;
|
||||
enforce_limit(
|
||||
u64::from(unpacked_size),
|
||||
options.limits.max_decoded_entry_bytes,
|
||||
)?;
|
||||
let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
|
||||
let method = parse_method(method_raw);
|
||||
|
||||
@@ -1009,9 +1120,12 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
||||
}
|
||||
|
||||
if presorted_flag == 0xABBA {
|
||||
if validate_permutation(&records).is_err() {
|
||||
let permutation = validate_permutation(&records);
|
||||
let order = validate_lookup_order(&records);
|
||||
if permutation.is_err() || order.is_err() {
|
||||
if !options.allow_invalid_presorted_fallback {
|
||||
validate_permutation(&records)?;
|
||||
permutation?;
|
||||
order?;
|
||||
}
|
||||
rebuild_sorted_mapping(&mut records)?;
|
||||
}
|
||||
@@ -1086,6 +1200,29 @@ fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lookup_order(records: &[EntryRecord]) -> Result<(), RsliError> {
|
||||
for pair in records.windows(2) {
|
||||
let left_original = usize::try_from(i32::from(pair[0].meta.sort_to_original))
|
||||
.map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let right_original = usize::try_from(i32::from(pair[1].meta.sort_to_original))
|
||||
.map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let left = records
|
||||
.get(left_original)
|
||||
.ok_or(RsliError::CorruptEntryTable("sort_to_original is not a permutation"))?;
|
||||
let right = records
|
||||
.get(right_original)
|
||||
.ok_or(RsliError::CorruptEntryTable("sort_to_original is not a permutation"))?;
|
||||
if cmp_c_string(c_name_bytes(&left.meta.name_raw), c_name_bytes(&right.meta.name_raw))
|
||||
== std::cmp::Ordering::Greater
|
||||
{
|
||||
return Err(RsliError::CorruptEntryTable(
|
||||
"presorted lookup names are not sorted",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_method(raw: u32) -> RsliMethod {
|
||||
match raw {
|
||||
0x000 => RsliMethod::Stored,
|
||||
@@ -1147,32 +1284,39 @@ fn decode_payload(
|
||||
method: RsliMethod,
|
||||
key16: u16,
|
||||
unpacked_size: u32,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<u8>, RsliError> {
|
||||
enforce_limit(
|
||||
u64::try_from(packed.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
||||
limits.max_packed_entry_bytes,
|
||||
)?;
|
||||
enforce_limit(u64::from(unpacked_size), limits.max_decoded_entry_bytes)?;
|
||||
enforce_limit(u64::from(unpacked_size), limits.max_total_decoded_bytes)?;
|
||||
let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let out = match method {
|
||||
RsliMethod::Stored => {
|
||||
if packed.len() < expected {
|
||||
if packed.len() != expected {
|
||||
return Err(RsliError::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
packed[..expected].to_vec()
|
||||
packed.to_vec()
|
||||
}
|
||||
RsliMethod::XorOnly => {
|
||||
if packed.len() < expected {
|
||||
if packed.len() != expected {
|
||||
return Err(RsliError::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
xor_stream(&packed[..expected], key16)
|
||||
xor_stream(packed, key16)
|
||||
}
|
||||
RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
||||
RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
|
||||
RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
|
||||
RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
|
||||
RsliMethod::RawDeflate => decode_deflate(packed)?,
|
||||
RsliMethod::RawDeflate => decode_deflate(packed, expected)?,
|
||||
RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
|
||||
};
|
||||
if out.len() != expected {
|
||||
@@ -1276,15 +1420,61 @@ fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option<XorState>) -> Op
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>, RsliError> {
|
||||
let mut out = Vec::new();
|
||||
fn decode_deflate(packed: &[u8], expected_size: usize) -> Result<Vec<u8>, RsliError> {
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
let mut chunk = [0u8; 4096];
|
||||
let mut decoder = flate2::read::DeflateDecoder::new(packed);
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
loop {
|
||||
let read = decoder
|
||||
.read(&mut chunk)
|
||||
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let next_len = out
|
||||
.len()
|
||||
.checked_add(read)
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
if next_len > expected_size {
|
||||
return Err(RsliError::OutputSizeMismatch {
|
||||
expected: u32::try_from(expected_size).unwrap_or(u32::MAX),
|
||||
got: u32::try_from(next_len).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
out.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn method_bits(method: RsliMethod) -> Result<u16, RsliMutationError> {
|
||||
match method {
|
||||
RsliMethod::Stored => Ok(0x000),
|
||||
RsliMethod::XorOnly => Ok(0x020),
|
||||
RsliMethod::Lzss => Ok(0x040),
|
||||
RsliMethod::XorLzss => Ok(0x060),
|
||||
RsliMethod::AdaptiveLzss => Ok(0x080),
|
||||
RsliMethod::XorAdaptiveLzss => Ok(0x0A0),
|
||||
RsliMethod::RawDeflate => Ok(0x100),
|
||||
RsliMethod::Unknown(_) => Err(RsliMutationError::UnsupportedMethod { method }),
|
||||
}
|
||||
}
|
||||
|
||||
fn flags_with_method(flags: i32, method: RsliMethod) -> Result<i32, RsliMutationError> {
|
||||
let method = i32::from(method_bits(method)?);
|
||||
Ok((flags & !0x1E0) | method)
|
||||
}
|
||||
|
||||
fn enforce_limit(value: u64, limit: u64) -> Result<(), RsliError> {
|
||||
if value > limit {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: value,
|
||||
limit,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const LZH_N: usize = 4096;
|
||||
const LZH_F: usize = 60;
|
||||
const LZH_THRESHOLD: usize = 2;
|
||||
@@ -1702,6 +1892,28 @@ mod tests {
|
||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rejects_unsorted_presorted_mapping() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"B", 0, b"bee"),
|
||||
SyntheticEntry::stored(b"A", 1, b"aye"),
|
||||
],
|
||||
true,
|
||||
0x0103,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
decode(arc(bytes.clone()), ReadProfile::Strict),
|
||||
Err(RsliError::CorruptEntryTable("presorted lookup names are not sorted"))
|
||||
));
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback");
|
||||
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_controls_invalid_presorted_fallback() {
|
||||
let bytes = synthetic_rsli(
|
||||
@@ -1756,8 +1968,8 @@ mod tests {
|
||||
let packed = xor_stream(&plain, 1);
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed),
|
||||
SyntheticEntry::stored(b"B", 0, b"plain"),
|
||||
SyntheticEntry::with_payload(b"B", 0x020, 1, &plain, packed),
|
||||
SyntheticEntry::stored(b"A", 0, b"plain"),
|
||||
],
|
||||
true,
|
||||
0x2222,
|
||||
@@ -2141,8 +2353,9 @@ mod tests {
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
editor.set_name(EntryId(1), b"ZETA").expect("edit name");
|
||||
let repacked = deflate_bytes(b"repacked-alpha");
|
||||
editor
|
||||
.set_packed_payload(EntryId(0), b"repacked-alpha", 14)
|
||||
.set_packed_payload(EntryId(0), repacked, 14)
|
||||
.expect("edit packed payload");
|
||||
editor
|
||||
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
||||
@@ -2163,10 +2376,91 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
doc.entries()[original.0 as usize].method,
|
||||
RsliMethod::Stored
|
||||
RsliMethod::RawDeflate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_method_rejects_unknown_authoring_method() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||
true,
|
||||
0x7780,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
|
||||
assert!(matches!(
|
||||
editor.set_method(EntryId(0), RsliMethod::Unknown(0x1E0)),
|
||||
Err(RsliMutationError::UnsupportedMethod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_entry_count_above_limit() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||
],
|
||||
true,
|
||||
0x7781,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_entries: 1,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(RsliError::Binary(DecodeError::LimitExceeded { count: 2, limit: 1 }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_entries_require_exact_packed_size() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::with_payload(b"A", 0x000, 0, b"ok", b"ok!".to_vec())],
|
||||
true,
|
||||
0x7782,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
|
||||
|
||||
assert!(matches!(
|
||||
doc.load(EntryId(0)),
|
||||
Err(RsliError::OutputSizeMismatch { expected: 2, got: 3 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_unpacked_size_above_limit_before_allocation() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||
true,
|
||||
0x7783,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
|
||||
|
||||
assert!(matches!(
|
||||
doc.load_with_limits(
|
||||
EntryId(0),
|
||||
DecodeLimits {
|
||||
max_decoded_entry_bytes: 4,
|
||||
max_total_decoded_bytes: 4,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(RsliError::Binary(DecodeError::LimitExceeded { count: 5, limit: 4 }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_rejects_unknown_entry_id_and_invalid_name() {
|
||||
let bytes = synthetic_rsli(
|
||||
@@ -2637,6 +2931,15 @@ mod tests {
|
||||
bytes
|
||||
}
|
||||
|
||||
fn deflate_bytes(plain: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
encoder.write_all(plain).expect("deflate write");
|
||||
encoder.finish().expect("deflate finish")
|
||||
}
|
||||
|
||||
fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
|
||||
let mut a = [0u8; 32];
|
||||
let mut b = [0u8; 32];
|
||||
|
||||
+125
-69
@@ -25,12 +25,13 @@ use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// VFS metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -105,7 +106,6 @@ pub trait Vfs: Send + Sync {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DirectoryVfs {
|
||||
root: PathBuf,
|
||||
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
|
||||
}
|
||||
|
||||
impl DirectoryVfs {
|
||||
@@ -114,29 +114,20 @@ impl DirectoryVfs {
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
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())
|
||||
resolve_casefolded(&self.root, path)
|
||||
}
|
||||
|
||||
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)
|
||||
metadata_from_host_file(path, &metadata)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct CachedHostFingerprint {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
identity: Option<u64>,
|
||||
fingerprint: Sha256Digest,
|
||||
}
|
||||
|
||||
impl Vfs for DirectoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
self.metadata_from_host_file(&self.host_path(path)?)
|
||||
@@ -171,21 +162,60 @@ impl Vfs for DirectoryVfs {
|
||||
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
||||
entries.push(VfsEntry {
|
||||
path: prefix.clone(),
|
||||
metadata: metadata_from_host_file_with_cache(
|
||||
&base,
|
||||
&metadata,
|
||||
&self.fingerprint_cache,
|
||||
)?,
|
||||
metadata: metadata_from_host_file(&base, &metadata)?,
|
||||
});
|
||||
return Ok(entries);
|
||||
}
|
||||
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
list_recursive(&self.root, &base, &mut entries)?;
|
||||
entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
||||
fn resolve_casefolded(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
return resolve_casefolded_unix(root, normalized);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
resolve_casefolded_text(root, normalized.display_lossy())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolve_casefolded_unix(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
let mut current = root.to_path_buf();
|
||||
for segment in normalized.as_bytes().split(|byte| *byte == b'/') {
|
||||
current = resolve_casefolded_segment(¤t, segment, normalized)?;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolve_casefolded_segment(
|
||||
dir: &Path,
|
||||
segment: &[u8],
|
||||
normalized: &NormalizedPath,
|
||||
) -> Result<PathBuf, VfsError> {
|
||||
let read_dir = fs::read_dir(dir).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();
|
||||
if name.as_bytes().eq_ignore_ascii_case(segment) {
|
||||
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
matches.push(entry.path());
|
||||
}
|
||||
}
|
||||
select_casefolded_match(normalized.display_lossy(), dir, segment, matches)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn resolve_casefolded_text(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)?;
|
||||
@@ -211,10 +241,11 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
|
||||
fn select_casefolded_match(
|
||||
normalized: &str,
|
||||
current: &Path,
|
||||
segment: &str,
|
||||
segment: impl AsRef<[u8]>,
|
||||
mut matches: Vec<PathBuf>,
|
||||
) -> Result<PathBuf, VfsError> {
|
||||
matches.sort();
|
||||
let segment = String::from_utf8_lossy(segment.as_ref());
|
||||
match matches.len() {
|
||||
0 => Err(VfsError::NotFound(normalized.to_string())),
|
||||
1 => Ok(matches.remove(0)),
|
||||
@@ -229,7 +260,6 @@ fn select_casefolded_match(
|
||||
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)?;
|
||||
@@ -245,68 +275,40 @@ fn list_recursive(
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
list_recursive(root, &child, fingerprint_cache, out)?;
|
||||
list_recursive(root, &child, 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)?;
|
||||
#[cfg(unix)]
|
||||
let rel_bytes = rel.as_os_str().as_bytes();
|
||||
#[cfg(not(unix))]
|
||||
let rel_bytes = rel.to_str().ok_or(VfsError::Path)?.as_bytes();
|
||||
let path = fparkan_path::normalize_relative(
|
||||
rel_text.as_bytes(),
|
||||
rel_bytes,
|
||||
fparkan_path::PathPolicy::HostCompatible,
|
||||
)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path,
|
||||
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
|
||||
metadata: metadata_from_host_file(&child, &metadata)?,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_from_host_file_with_cache(
|
||||
fn metadata_from_host_file(
|
||||
path: &Path,
|
||||
metadata: &fs::Metadata,
|
||||
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||
) -> Result<VfsMetadata, VfsError> {
|
||||
if !metadata.is_file() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let len = metadata.len();
|
||||
let modified = metadata.modified().ok();
|
||||
if let Some(cached) = fingerprint_cache
|
||||
.lock()
|
||||
.map_err(|_| VfsError::Path)?
|
||||
.get(path)
|
||||
.cloned()
|
||||
.filter(|cached| {
|
||||
cached.len == len
|
||||
&& cached.modified == modified
|
||||
&& cached.identity == file_identity(metadata)
|
||||
})
|
||||
{
|
||||
return Ok(VfsMetadata {
|
||||
len,
|
||||
fingerprint: cached.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
identity: file_identity(metadata),
|
||||
fingerprint,
|
||||
},
|
||||
);
|
||||
Ok(VfsMetadata { len, fingerprint })
|
||||
}
|
||||
|
||||
@@ -344,11 +346,11 @@ impl MemoryVfs {
|
||||
let matches = self
|
||||
.lookup
|
||||
.get(&key)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
|
||||
match matches.as_slice() {
|
||||
[single] => Ok(single.as_slice()),
|
||||
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||
[] => Err(VfsError::NotFound(path.display_lossy().to_string())),
|
||||
_ => Err(VfsError::Ambiguous(path.display_lossy().to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,7 +382,7 @@ impl Vfs for MemoryVfs {
|
||||
let bytes = self
|
||||
.files
|
||||
.get(resolved)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
|
||||
Ok(VfsMetadata {
|
||||
len: bytes.len() as u64,
|
||||
fingerprint: sha256(bytes),
|
||||
@@ -392,7 +394,7 @@ impl Vfs for MemoryVfs {
|
||||
self.files
|
||||
.get(resolved)
|
||||
.cloned()
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
@@ -476,7 +478,7 @@ impl Vfs for OverlayVfs {
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||
Err(VfsError::NotFound(path.display_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
@@ -487,7 +489,7 @@ impl Vfs for OverlayVfs {
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||
Err(VfsError::NotFound(path.display_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
@@ -496,7 +498,7 @@ impl Vfs for OverlayVfs {
|
||||
match layer.list(prefix) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
let key = entry.path.as_str().to_ascii_uppercase();
|
||||
let key = ascii_lookup_key(entry.path.as_bytes()).0;
|
||||
by_key.entry(key).or_insert(entry);
|
||||
}
|
||||
}
|
||||
@@ -505,7 +507,7 @@ impl Vfs for OverlayVfs {
|
||||
}
|
||||
}
|
||||
let mut entries: Vec<_> = by_key.into_values().collect();
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
@@ -514,6 +516,10 @@ impl Vfs for OverlayVfs {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_path::{normalize_relative, PathPolicy};
|
||||
#[cfg(unix)]
|
||||
use std::ffi::OsString;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_resolves_ascii_casefolded_segments() {
|
||||
@@ -634,6 +640,34 @@ mod tests {
|
||||
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes() {
|
||||
let root = unique_test_dir("non-utf8");
|
||||
let data_dir = root.join("DATA");
|
||||
std::fs::create_dir_all(&data_dir).expect("mkdir");
|
||||
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
|
||||
let raw_path = data_dir.join(&file_name);
|
||||
if let Err(err) = std::fs::write(&raw_path, b"raw") {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let path =
|
||||
normalize_relative(b"data/\xFF.bin", PathPolicy::HostCompatible).expect("path");
|
||||
|
||||
assert_eq!(vfs.read(&path).expect("read raw path").as_ref(), b"raw");
|
||||
let entries = vfs
|
||||
.list(&normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"))
|
||||
.expect("list");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].path.identity_bytes(), b"DATA/\xFF.bin");
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casefold_selector_reports_ambiguous_segments() {
|
||||
let err = select_casefolded_match(
|
||||
@@ -714,6 +748,28 @@ mod tests {
|
||||
assert_eq!(entries[0].metadata.len, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vfs_keeps_lossy_equivalent_entries_distinct() {
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let mut high = MemoryVfs::default();
|
||||
let mut low = MemoryVfs::default();
|
||||
high.insert(
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("high path"),
|
||||
Arc::from(b"high".as_slice()),
|
||||
);
|
||||
low.insert(
|
||||
normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible).expect("low path"),
|
||||
Arc::from(b"low".as_slice()),
|
||||
);
|
||||
|
||||
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
|
||||
let entries = overlay.list(&prefix).expect("list");
|
||||
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].path.display_lossy(), entries[1].path.display_lossy());
|
||||
assert_ne!(entries[0].path.identity_bytes(), entries[1].path.identity_bytes());
|
||||
}
|
||||
|
||||
fn unique_test_dir(name: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
|
||||
|
||||
@@ -102,6 +102,8 @@ S1-NRES-022 covered cargo test -p fparkan-nres --offline canonical_compact_round
|
||||
S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size
|
||||
S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping
|
||||
S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names
|
||||
S1-NRES-026 covered cargo test -p fparkan-nres --offline strict_rejects_unsorted_lookup_table
|
||||
S1-LIMIT-001 covered cargo test -p fparkan-nres --offline rejects_directory_size_before_allocation decode_rejects_entry_count_above_limit decode_rejects_input_bytes_above_limit decode_rejects_preserved_bytes_above_limit
|
||||
S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics
|
||||
S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip
|
||||
S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate
|
||||
@@ -114,6 +116,8 @@ S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_
|
||||
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
|
||||
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
|
||||
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
|
||||
S1-PATH-010 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes
|
||||
S1-PATH-011 covered cargo test -p fparkan-path -p fparkan-vfs -p fparkan-resource --offline lossy_display_does_not_affect_identity_or_ordering overlay_vfs_keeps_lossy_equivalent_entries_distinct lossy_equivalent_archive_paths_remain_distinct
|
||||
S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
|
||||
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
|
||||
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
|
||||
@@ -138,16 +142,25 @@ S1-RSLI-020 covered cargo test -p fparkan-rsli --offline rejects_registered_quir
|
||||
S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry
|
||||
S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless
|
||||
S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes
|
||||
S1-RSLI-024 covered cargo test -p fparkan-rsli --offline strict_rejects_unsorted_presorted_mapping
|
||||
S1-RSLI-025 covered cargo test -p fparkan-rsli --offline editor_can_mutate_names_and_payloads set_method_rejects_unknown_authoring_method
|
||||
S1-LIMIT-002 covered cargo test -p fparkan-rsli --offline decode_rejects_entry_count_above_limit load_rejects_unpacked_size_above_limit_before_allocation
|
||||
S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes
|
||||
S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic
|
||||
S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres
|
||||
S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified
|
||||
S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently
|
||||
S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name
|
||||
S1-RES-005 covered cargo test -p fparkan-resource --offline archive_cache_evicts_by_byte_budget
|
||||
S1-RES-006 covered cargo test -p fparkan-resource --offline archive_cache_eviction_makes_old_handles_stale
|
||||
S1-RES-007 covered cargo test -p fparkan-resource --offline lossy_equivalent_archive_paths_remain_distinct
|
||||
S1-DIAG-001 covered cargo test -p fparkan-inspection --offline archive_diagnostic_preserves_source_path_phase_and_span model_archive_diagnostic_preserves_archive_entry_context
|
||||
S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup
|
||||
S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer
|
||||
S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments
|
||||
S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments
|
||||
S1-LICENSED-001 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
|
||||
S1-LICENSED-002 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
|
||||
L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
|
||||
L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
|
||||
L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned
|
||||
|
||||
|
+1
-1
@@ -10,7 +10,7 @@ fparkan-corpus = { path = "../crates/fparkan-corpus" }
|
||||
cargo_metadata = "0.23.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "1.0"
|
||||
toml = "0.9"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
Reference in New Issue
Block a user