From 853e8a31274436262dc5528fb7023a2b898f94b3 Mon Sep 17 00:00:00 2001 From: Florian Gebhardt Date: Thu, 2 Oct 2025 04:09:17 +0200 Subject: [PATCH 1/2] feat(mod_util): add `Mod::read_dir` --- Cargo.lock | 64 ++++++++------ mod_util/Cargo.toml | 4 +- mod_util/src/mod_loader.rs | 166 ++++++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c559c47..e853971c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -506,6 +506,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1599,26 +1614,6 @@ dependencies = [ "cc", ] -[[package]] -name = "liblzma" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "libm" version = "0.2.15" @@ -1678,6 +1673,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" +dependencies = [ + "crc", + "sha2", +] + [[package]] name = "lzw" version = "0.10.0" @@ -1789,7 +1794,7 @@ dependencies = [ [[package]] name = "mod_util" -version = "0.4.1" +version = "0.5.0" dependencies = [ "byteorder", "natord", @@ -2951,6 +2956,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4104,9 +4120,9 @@ dependencies = [ [[package]] name = "zip" -version = "4.6.1" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532" dependencies = [ "aes", "arbitrary", @@ -4118,7 +4134,7 @@ dependencies = [ "getrandom 0.3.3", "hmac", "indexmap 2.11.4", - "liblzma", + "lzma-rust2", "memchr", "pbkdf2", "ppmd-rust", diff --git a/mod_util/Cargo.toml b/mod_util/Cargo.toml index 76178a7a..3f8dbfac 100644 --- a/mod_util/Cargo.toml +++ b/mod_util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mod_util" -version = "0.4.1" +version = "0.5.0" authors.workspace = true edition.workspace = true @@ -21,4 +21,4 @@ serde_json.workspace = true serde_with.workspace = true thiserror.workspace = true tracing.workspace = true -zip = "4.3" +zip = "5" diff --git a/mod_util/src/mod_loader.rs b/mod_util/src/mod_loader.rs index e6a69ee8..f62d106c 100644 --- a/mod_util/src/mod_loader.rs +++ b/mod_util/src/mod_loader.rs @@ -1,5 +1,6 @@ use std::{ cell::RefCell, + collections::HashMap, fs::File, io::{Read, Seek}, path::{Path, PathBuf}, @@ -14,6 +15,9 @@ pub enum ModError { #[error("mod path does not exist: {0:?}")] PathDoesNotExist(PathBuf), + #[error("path is not valid utf8: {0:?}")] + PathInvalidUtf8(PathBuf), + #[error("mod path is not a zip file or directory: {0:?}")] PathNotZipOrDir(PathBuf), @@ -48,8 +52,11 @@ pub enum ModError { #[error("mod zip error: {0}")] ZipError(#[from] zip::result::ZipError), + #[error("mod borrow error: {0}")] + BorrowError(#[from] std::cell::BorrowError), + #[error("mod mutable borrow error: {0}")] - BorrowError(#[from] std::cell::BorrowMutError), + BorrowMutError(#[from] std::cell::BorrowMutError), } type Result = std::result::Result; @@ -134,8 +141,20 @@ impl Mod { } } - pub fn get_file(&self, path: &str) -> Result> { - self.internal.get_file(path) + pub fn get_file(&self, path: impl AsRef) -> Result> { + self.internal.get_file( + path.as_ref() + .to_str() + .ok_or_else(|| ModError::PathInvalidUtf8(path.as_ref().into()))?, + ) + } + + pub fn read_dir(&self, dir: impl AsRef) -> Result { + self.internal.read_dir( + dir.as_ref() + .to_str() + .ok_or_else(|| ModError::PathInvalidUtf8(dir.as_ref().into()))?, + ) } #[must_use] @@ -256,6 +275,68 @@ impl ModType { } } + fn read_dir(&self, dir: &str) -> Result { + match self { + Self::Folder { path } => path + .join(dir) + .read_dir() + .map(|rd| ReadModDir(ReadModDirInner::Folder(rd))) + .map_err(ModError::IoError), + Self::Zip { + internal_prefix, + zip, + .. + } => { + let filter = dir.trim_matches('/').to_string() + "/"; + + let zip = zip.try_borrow_mut()?; + let mut processed = HashMap::new(); + + eprintln!("names: {:#?}", zip.file_names().collect::>()); + + let paths = zip + .file_names() + .filter_map(|name| { + use std::collections::hash_map::Entry; + + let name = name.strip_prefix(internal_prefix)?; + + let inner = name.strip_prefix(&filter)?; + let mut parts = inner.split('/'); + let entry_name = parts.next()?; + let full_path = PathBuf::from(filter.clone() + entry_name); + + let is_dir = parts.next().is_some(); + match processed.entry(full_path.clone()) { + Entry::Vacant(vacant_entry) => { + vacant_entry.insert(is_dir); + } + Entry::Occupied(mut occupied_entry) => { + if is_dir { + occupied_entry.insert(true); + } + + return None; + } + } + + Some(full_path) + }) + .collect::>(); + + let entries = paths + .into_iter() + .map(|full_path| ZipEntry { + is_dir: processed[&full_path], + full_path, + }) + .collect(); + + Ok(ReadModDir(ReadModDirInner::Zip { entries, idx: 0 })) + } + } + } + fn get_info(&self, name: &str) -> Result { let info_file = self.get_file("info.json")?; let info = serde_json::from_slice(&info_file) @@ -300,3 +381,82 @@ fn verify_info(info: &ModInfo, expected_name: &str, expected_version: Version) - Ok(()) } + +pub struct ReadModDir(ReadModDirInner); + +impl Iterator for ReadModDir { + type Item = Result; + + fn next(&mut self) -> Option { + self.0.next().map(|entry| entry.map(ModDirEntry)) + } +} + +enum ReadModDirInner { + Folder(std::fs::ReadDir), + Zip { + entries: Box<[ZipEntry]>, + idx: usize, + }, +} + +#[derive(Clone)] +struct ZipEntry { + full_path: PathBuf, + is_dir: bool, +} + +impl Iterator for ReadModDirInner { + type Item = Result; + + fn next(&mut self) -> Option { + match self { + Self::Folder(rd) => rd.next().map(|entry| { + entry + .map(ModDirEntryInner::Folder) + .map_err(ModError::IoError) + }), + Self::Zip { entries, idx } => { + let entry = entries.get(*idx)?; + *idx += 1; + + Some(Ok(ModDirEntryInner::Zip(entry.clone()))) + } + } + } +} + +pub struct ModDirEntry(ModDirEntryInner); + +impl ModDirEntry { + #[must_use] + pub fn path(&self) -> PathBuf { + self.0.path() + } + + #[must_use] + pub fn is_dir(&self) -> bool { + self.0.is_dir() + } +} + +enum ModDirEntryInner { + Folder(std::fs::DirEntry), + Zip(ZipEntry), +} + +impl ModDirEntryInner { + fn path(&self) -> PathBuf { + match self { + Self::Folder(dir_entry) => dir_entry.path(), + Self::Zip(ZipEntry { full_path, .. }) => full_path.clone(), + } + } + + fn is_dir(&self) -> bool { + match self { + Self::Folder(dir_entry) => dir_entry.path().is_dir(), + Self::Zip(ZipEntry { is_dir, .. }) => *is_dir, + } + } +} From 76f7d6ceea31edc1160846845a017d626163be91 Mon Sep 17 00:00:00 2001 From: Florian Gebhardt Date: Thu, 2 Oct 2025 07:07:25 +0200 Subject: [PATCH 2/2] feat(mod_util): tests for `mod_loader` --- Cargo.lock | 7 + mod_util/Cargo.toml | 1 + mod_util/src/mod_loader.rs | 159 +++++++++++++++++- .../test_mods/dummy1/data-final-fixes.lua | 1 + mod_util/test_mods/dummy1/data.lua | 1 + mod_util/test_mods/dummy1/info.json | 7 + mod_util/test_mods/dummy1/locale/de/mod.cfg | 5 + mod_util/test_mods/dummy1/locale/en/mod.cfg | 5 + mod_util/test_mods/dummy2_4.5.6.zip | Bin 0 -> 1992 bytes 9 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 mod_util/test_mods/dummy1/data-final-fixes.lua create mode 100644 mod_util/test_mods/dummy1/data.lua create mode 100644 mod_util/test_mods/dummy1/info.json create mode 100644 mod_util/test_mods/dummy1/locale/de/mod.cfg create mode 100644 mod_util/test_mods/dummy1/locale/en/mod.cfg create mode 100644 mod_util/test_mods/dummy2_4.5.6.zip diff --git a/Cargo.lock b/Cargo.lock index e853971c..334bced2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1798,6 +1798,7 @@ version = "0.5.0" dependencies = [ "byteorder", "natord", + "normalize-path", "petgraph", "regex", "serde", @@ -1862,6 +1863,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normalize-path" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" + [[package]] name = "num" version = "0.4.3" diff --git a/mod_util/Cargo.toml b/mod_util/Cargo.toml index 3f8dbfac..c5ae290e 100644 --- a/mod_util/Cargo.toml +++ b/mod_util/Cargo.toml @@ -14,6 +14,7 @@ bp_meta_info = [] byteorder = "1.5" natord = "1.0" petgraph = "0.8" +normalize-path = "0.2" regex.workspace = true serde.workspace = true serde_helper.workspace = true diff --git a/mod_util/src/mod_loader.rs b/mod_util/src/mod_loader.rs index f62d106c..17415c66 100644 --- a/mod_util/src/mod_loader.rs +++ b/mod_util/src/mod_loader.rs @@ -6,6 +6,7 @@ use std::{ path::{Path, PathBuf}, }; +use normalize_path::NormalizePath as _; use zip::ZipArchive; use crate::mod_info::{ModInfo, Version}; @@ -144,6 +145,7 @@ impl Mod { pub fn get_file(&self, path: impl AsRef) -> Result> { self.internal.get_file( path.as_ref() + .normalize() .to_str() .ok_or_else(|| ModError::PathInvalidUtf8(path.as_ref().into()))?, ) @@ -152,6 +154,7 @@ impl Mod { pub fn read_dir(&self, dir: impl AsRef) -> Result { self.internal.read_dir( dir.as_ref() + .normalize() .to_str() .ok_or_else(|| ModError::PathInvalidUtf8(dir.as_ref().into()))?, ) @@ -287,21 +290,27 @@ impl ModType { zip, .. } => { - let filter = dir.trim_matches('/').to_string() + "/"; + let filter = dir.trim_matches('/').to_string(); + let filter = if filter.is_empty() { + String::new() + } else { + filter + "/" + }; let zip = zip.try_borrow_mut()?; let mut processed = HashMap::new(); - eprintln!("names: {:#?}", zip.file_names().collect::>()); - let paths = zip .file_names() .filter_map(|name| { use std::collections::hash_map::Entry; let name = name.strip_prefix(internal_prefix)?; - let inner = name.strip_prefix(&filter)?; + if inner.is_empty() { + return None; + } + let mut parts = inner.split('/'); let entry_name = parts.next()?; let full_path = PathBuf::from(filter.clone() + entry_name); @@ -460,3 +469,145 @@ impl ModDirEntryInner { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + const TESTMODS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test_mods"); + const DUMMY1: (&str, Version) = ("dummy1", Version::new(1, 2, 3)); + const DUMMY2: (&str, Version) = ("dummy2", Version::new(4, 5, 6)); + const DATA_LUA: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test_mods/dummy1/data.lua" + )); + + fn dummy1_path() -> PathBuf { + PathBuf::new().join(TESTMODS_DIR).join(DUMMY1.0) + } + + fn dummy2_path() -> PathBuf { + PathBuf::new() + .join(TESTMODS_DIR) + .join(format!("{}_{}.zip", DUMMY2.0, DUMMY2.1)) + } + + #[test] + fn zip_internal_folder_name() { + let path = dummy2_path(); + let zip = ZipArchive::new(File::open(&path).unwrap()).unwrap(); + + let internal = get_zip_internal_folder(path, &zip).unwrap(); + assert_eq!(internal, "internal-root-folder-can-be-named-anything/"); + } + + #[test] + fn load_mod_folder() { + let name = DUMMY1.0; + let version = DUMMY1.1; + let incorrect_version = Version::new(0, 0, 0); + + // read_path is only relevant for wube mods -> can be safely set to empty str + let m = Mod::load_custom("", TESTMODS_DIR, name, incorrect_version); + assert!(m.is_err()); + let err = m.err().unwrap(); + let expected_err = ModError::VersionMismatch { + name: name.to_string(), + expected: incorrect_version, + actual: version, + }; + assert_eq!(err.to_string(), expected_err.to_string()); + + let m = Mod::load_custom("", TESTMODS_DIR, name, version).unwrap(); + assert_eq!(m.info.name, name); + assert_eq!(m.info.version, version); + } + + #[test] + fn load_mod_zip() { + let name = DUMMY2.0; + let version = DUMMY2.1; + let incorrect_version = Version::new(0, 0, 0); + + // read_path is only relevant for wube mods -> can be safely set to empty str + let m = Mod::load_custom("", TESTMODS_DIR, name, incorrect_version); + assert!(m.is_err()); + let err = m.err().unwrap(); + let expected_err = ModError::ModNotFound(name.to_string(), incorrect_version); + assert_eq!(err.to_string(), expected_err.to_string()); + + let m = Mod::load_custom("", TESTMODS_DIR, name, version).unwrap(); + assert_eq!(m.info.name, name); + assert_eq!(m.info.version, version); + } + + #[test] + fn load_mod_folder_by_path() { + let m = Mod::load_from_path(dummy1_path()).unwrap(); + + assert_eq!(m.info.name, DUMMY1.0); + assert_eq!(m.info.version, DUMMY1.1); + } + + #[test] + fn load_mod_zip_by_path() { + let m = Mod::load_from_path(dummy2_path()).unwrap(); + + assert_eq!(m.info.name, DUMMY2.0); + assert_eq!(m.info.version, DUMMY2.1); + } + + fn get_file(m: &Mod) { + let data = m.get_file("data.lua").unwrap(); + assert_eq!(data, DATA_LUA); + } + + #[test] + fn get_file_from_folder() { + get_file(&Mod::load_from_path(dummy1_path()).unwrap()); + } + + #[test] + fn get_file_from_zip() { + get_file(&Mod::load_from_path(dummy2_path()).unwrap()); + } + + fn read_dir(m: &Mod) { + let rd = m.read_dir(".").unwrap(); + let root = rd.collect::>>().unwrap(); + + assert_eq!(root.len(), 4); + for entry in &root { + let path = entry.path(); + let name = path.file_name().unwrap().to_str().unwrap(); + + if entry.is_dir() { + assert_eq!(name, "locale"); + } else { + assert!(matches!( + name, + "info.json" | "data.lua" | "data-final-fixes.lua" + )); + } + } + + let rd = m.read_dir("locale").unwrap(); + let locale = rd.collect::>>().unwrap(); + + assert_eq!(locale.len(), 2); + assert!(locale[0].is_dir()); + assert!(locale[1].is_dir()); + } + + #[test] + fn read_dir_from_folder() { + read_dir(&Mod::load_from_path(dummy1_path()).unwrap()); + } + + #[test] + fn read_dir_from_zip() { + read_dir(&Mod::load_from_path(dummy2_path()).unwrap()); + } +} diff --git a/mod_util/test_mods/dummy1/data-final-fixes.lua b/mod_util/test_mods/dummy1/data-final-fixes.lua new file mode 100644 index 00000000..98e10232 --- /dev/null +++ b/mod_util/test_mods/dummy1/data-final-fixes.lua @@ -0,0 +1 @@ +log("dummy1 final-fixing") diff --git a/mod_util/test_mods/dummy1/data.lua b/mod_util/test_mods/dummy1/data.lua new file mode 100644 index 00000000..5c62348c --- /dev/null +++ b/mod_util/test_mods/dummy1/data.lua @@ -0,0 +1 @@ +-- not doing anything :) diff --git a/mod_util/test_mods/dummy1/info.json b/mod_util/test_mods/dummy1/info.json new file mode 100644 index 00000000..0923e220 --- /dev/null +++ b/mod_util/test_mods/dummy1/info.json @@ -0,0 +1,7 @@ +{ + "name": "dummy1", + "version": "1.2.3", + "title": "dummy1 test mod", + "author": "fgardt", + "factorio_version": "2.0" +} \ No newline at end of file diff --git a/mod_util/test_mods/dummy1/locale/de/mod.cfg b/mod_util/test_mods/dummy1/locale/de/mod.cfg new file mode 100644 index 00000000..deae5b1c --- /dev/null +++ b/mod_util/test_mods/dummy1/locale/de/mod.cfg @@ -0,0 +1,5 @@ +[mod-name] +dummy1=Dummy1 Test Mod + +[mod-description] +dummy1=Unglaublich nützliche Beschreibung der Dummy1 Test Mod. diff --git a/mod_util/test_mods/dummy1/locale/en/mod.cfg b/mod_util/test_mods/dummy1/locale/en/mod.cfg new file mode 100644 index 00000000..77dfeba9 --- /dev/null +++ b/mod_util/test_mods/dummy1/locale/en/mod.cfg @@ -0,0 +1,5 @@ +[mod-name] +dummy1=Dummy1 Test Mod + +[mod-description] +dummy1=Incredibly useful description of the Dummy1 Test Mod. diff --git a/mod_util/test_mods/dummy2_4.5.6.zip b/mod_util/test_mods/dummy2_4.5.6.zip new file mode 100644 index 0000000000000000000000000000000000000000..19ee9a2875e9d239422425889caa75d2d18f79cb GIT binary patch literal 1992 zcmWIWW@Zs#0D&A0r)V$(N@xS=%)FA+qP)Z$-J<;b65X`?oRrie-Q>hP-K13Ayu{qp z6y3zU%94!Cymb8ls6iYIO6pG0y(Rx=O8^a!1Y%>-3`$8XNz}_JO}u z&v=J>oLQ}NlEH9|$xa5CMGOp{Ko{TNblE~0XaNY@lWvi2S|-SQX_*zN#UM*_^3yex zQc81kD~%N3Qb6x1X>uX#MGq+>((KL2PfpB9g$EdB$eWU7UTPlPNDhWbV9@M!sJs{i zG^z-QZOAn;H$O!$IW4{W+*$vNXT8t(_CEE}JbQMn$)-}qU@pa#M^(A@{nU(!xu~G3 zx$@{q*O{!UGk;bVOr2MG(siZgOi#J(nLdAw?s4 zaFXZ!6hw$}FoXiVFSJRw4wRrv$@V_bNHV>@u=1s4=TcA0nI|Q=D%-zL32BU2$>ry; zRMK_jOwXm2FS#^Vx^iWxy((mchXV(LE6~v^9rmt{0Xnz?h)sZ)gbbLOmzJ-WRh*x< z`dJVI)6zXh?u4jnvo4)->CPQ5WmVaZjL59O!n8U$k3%l!L(cQ4#Z8Kw)Vw5Sp~}`B zE{f*%tZMTkq9#p@X9=1xZC=EbASPjdZy&vr`lrtHyBpo`IPcK8dKx#-wTw*m%(x2- zpp(Jits{sc!Sx7(u@@i^V;LBhG