14 Commits

Author SHA1 Message Date
Valentin Popov 8a8ef614f2 refactor(cli): limit json refactor to stage1 scope
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m54s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-30 04:43:27 +04:00
Valentin Popov 51b54c155c test(inspection): cover archive entry diagnostic context 2026-06-30 03:10:54 +04:00
Valentin Popov 0fd96faf54 fix(nres): align licensed closure with corpus limits 2026-06-30 02:55:23 +04:00
Valentin Popov c7a9c43b5b test(inspection): cover archive diagnostic span context 2026-06-30 02:47:24 +04:00
Valentin Popov c0116d32be test(nres): cover input byte limit enforcement 2026-06-30 02:45:09 +04:00
Valentin Popov 0a7ba55b44 test(resource): cover archive byte-budget eviction 2026-06-30 02:43:25 +04:00
Valentin Popov 29f7a398ff test(acceptance): record remaining stage1 coverage gaps 2026-06-30 02:41:43 +04:00
Valentin Popov 6f84761b83 fix(resource): prevent stale archive refresh overwrite 2026-06-30 02:38:01 +04:00
Valentin Popov 2b16e5b118 fix(resource): type archive and vfs error mapping 2026-06-30 02:34:25 +04:00
Valentin Popov 716cde2072 refactor(cli): replace manual json assembly 2026-06-30 02:29:42 +04:00
Valentin Popov 7337492c30 fix: route archive inspection through byte-safe boundaries 2026-06-30 01:54:57 +04:00
Valentin Popov d0bc7f2f26 fix: tighten nres and rsli decode contracts 2026-06-30 01:49:03 +04:00
Valentin Popov 146446d3e2 fix: harden stage1 path and archive identity 2026-06-30 01:40:47 +04:00
renovate[bot] 7db0bf8e3d fix(deps): update rust crate cargo_metadata to 0.23.0
Test / Lint (pull_request) Failing after 1m52s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m3s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-26 00:02:18 +00:00
16 changed files with 1866 additions and 723 deletions
Generated
+13 -412
View File
@@ -106,12 +106,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -194,38 +188,22 @@ dependencies = [
[[package]]
name = "cargo-platform"
version = "0.2.0"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84982c6c0ae343635a3a4ee6dedef965513735c8b183caa7289fa6e27399ebd4"
checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
dependencies = [
"serde",
]
[[package]]
name = "cargo-util-schemas"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dc1a6f7b5651af85774ae5a34b4e8be397d9cf4bc063b7e6dbd99a841837830"
dependencies = [
"semver",
"serde",
"serde-untagged",
"serde-value",
"thiserror 2.0.18",
"toml 0.8.23",
"unicode-xid",
"url",
"serde_core",
]
[[package]]
name = "cargo_metadata"
version = "0.21.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cfca2aaa699835ba88faf58a06342a314a950d2b9686165e038286c30316868"
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
dependencies = [
"camino",
"cargo-platform",
"cargo-util-schemas",
"semver",
"serde",
"serde_json",
@@ -372,17 +350,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dlib"
version = "0.5.3"
@@ -419,17 +386,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -483,15 +439,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fparkan-animation"
version = "0.1.0"
@@ -528,6 +475,8 @@ dependencies = [
"fparkan-resource",
"fparkan-runtime",
"fparkan-vfs",
"serde",
"serde_json",
]
[[package]]
@@ -590,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",
@@ -703,6 +654,7 @@ name = "fparkan-rsli"
version = "0.1.0"
dependencies = [
"flate2",
"fparkan-binary",
]
[[package]]
@@ -842,109 +794,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1080,12 +929,6 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
version = "0.4.33"
@@ -1156,15 +999,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.6"
@@ -1415,15 +1249,6 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -1491,22 +1316,13 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
"toml_edit",
]
[[package]]
@@ -1667,28 +1483,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
dependencies = [
"erased-serde",
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -1722,15 +1516,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -1814,12 +1599,6 @@ dependencies = [
"serde",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -1837,17 +1616,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1913,28 +1681,6 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -1943,22 +1689,13 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.1.1",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -1977,20 +1714,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
@@ -2012,12 +1735,6 @@ dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
@@ -2046,12 +1763,6 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2064,30 +1775,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -2460,9 +2147,6 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
@@ -2479,12 +2163,6 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x11-dl"
version = "2.21.0"
@@ -2550,30 +2228,7 @@ dependencies = [
"fparkan-corpus",
"serde",
"serde_json",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
"toml",
]
[[package]]
@@ -2596,60 +2251,6 @@ dependencies = [
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
+2
View File
@@ -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
+29 -15
View File
@@ -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\""));
+2 -2
View File
@@ -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));
+80 -68
View File
@@ -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());
}
+2
View File
@@ -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" }
+299 -20
View File
@@ -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
View File
@@ -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())
+67 -6
View File
@@ -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());
}
}
+2 -2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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(&current, 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(&current).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()));
+13
View File
@@ -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 # Acceptance coverage manifest.
102 S1-NRES-023
103 S1-NRES-024
104 S1-NRES-025
105 S1-NRES-026
106 S1-LIMIT-001
107 S1-NRES-PROP-001
108 S1-NRES-PROP-002
109 S1-NRES-FUZZ-001
116 S1-PATH-007
117 S1-PATH-008
118 S1-PATH-009
119 S1-PATH-010
120 S1-PATH-011
121 S1-VFS-005
122 S1-RSLI-001
123 S1-RSLI-002
142 S1-RSLI-021
143 S1-RSLI-022
144 S1-RSLI-023
145 S1-RSLI-024
146 S1-RSLI-025
147 S1-LIMIT-002
148 S1-RSLI-PROP-001
149 S1-RSLI-FUZZ-001
150 S1-RES-001
151 S1-RES-002
152 S1-RES-003
153 S1-RES-004
154 S1-RES-005
155 S1-RES-006
156 S1-RES-007
157 S1-DIAG-001
158 S1-VFS-001
159 S1-VFS-002
160 S1-VFS-003
161 S1-VFS-004
162 S1-LICENSED-001
163 S1-LICENSED-002
164 L2-P1-UNIT-001
165 L2-P2-UNIT-001
166 L2-P1-REG-001
+1 -1
View File
@@ -7,7 +7,7 @@ repository.workspace = true
[dependencies]
fparkan-corpus = { path = "../crates/fparkan-corpus" }
cargo_metadata = "0.21.0"
cargo_metadata = "0.23.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9"