loadsmith_thunderstore/r2z/
mod.rs1use std::{
2 fmt::Display,
3 path::{Component, Path},
4};
5
6use camino::Utf8PathBuf;
7use serde::{Deserialize, Serialize};
8use thunderstore::PackageIdent;
9
10use crate::{Error, Result};
11
12mod export;
13mod import;
14
15pub use export::ExportFile;
16pub use import::ImportFile;
17
18#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
42#[serde(rename_all = "camelCase")]
43pub struct ProfileManifest<T = ()> {
44 pub profile_name: String,
45 pub mods: Vec<Mod>,
46 #[serde(flatten)]
47 pub extra: T,
48}
49
50#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
69#[serde(rename_all = "camelCase")]
70pub struct Mod {
71 pub name: PackageIdent,
72 pub version: Version,
73 pub enabled: bool,
74}
75
76#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct Version {
95 pub major: u64,
96 pub minor: u64,
97 pub patch: u64,
98}
99
100impl From<Version> for loadsmith_core::Version {
101 fn from(value: Version) -> Self {
102 loadsmith_core::Version::new(value.major, value.minor, value.patch)
103 }
104}
105
106impl From<loadsmith_core::Version> for Version {
107 fn from(value: loadsmith_core::Version) -> Self {
108 Version::new(value.major, value.minor, value.patch)
109 }
110}
111
112impl<T> ProfileManifest<T> {
113 pub fn new(name: impl Into<String>, mods: Vec<Mod>, extra: T) -> Self {
124 Self {
125 profile_name: name.into(),
126 mods,
127 extra,
128 }
129 }
130}
131
132impl Mod {
133 pub fn new(name: impl Into<PackageIdent>, version: impl Into<Version>, enabled: bool) -> Self {
145 Self {
146 name: name.into(),
147 version: version.into(),
148 enabled,
149 }
150 }
151}
152
153impl Version {
154 pub fn new(major: u64, minor: u64, patch: u64) -> Self {
165 Self {
166 major,
167 minor,
168 patch,
169 }
170 }
171}
172
173impl Display for Version {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
176 }
177}
178
179fn normalize_zip_file_path(path: impl AsRef<Path>) -> Result<Utf8PathBuf> {
182 let path = path.as_ref();
183 return inner(path).ok_or_else(|| Error::InvalidZipFilePath(path.to_path_buf()));
184
185 fn inner(path: &Path) -> Option<Utf8PathBuf> {
186 let mut normalized = Utf8PathBuf::new();
187
188 for component in path.components() {
189 match component {
190 Component::Normal(os_str) => {
191 normalized.push(os_str.to_str()?);
192 }
193 Component::CurDir => {}
194 Component::ParentDir => {
195 if !normalized.pop() {
196 return None;
197 }
198 }
199 _ => return None,
200 }
201 }
202
203 Some(normalized)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use std::{assert_matches, io::Cursor};
210
211 use super::*;
212
213 #[test]
214 fn normalize_path_normalizes() {
215 assert_eq!(normalize_zip_file_path("test.txt").unwrap(), "test.txt");
216 assert_eq!(normalize_zip_file_path("./test.txt").unwrap(), "test.txt");
217 assert_eq!(normalize_zip_file_path("././test.txt").unwrap(), "test.txt");
218 assert_eq!(
219 normalize_zip_file_path("./test.txt/./.").unwrap(),
220 "test.txt"
221 );
222 assert_eq!(
223 normalize_zip_file_path("nested/test.txt").unwrap(),
224 "nested/test.txt"
225 );
226 assert_eq!(
227 normalize_zip_file_path("nested/../test.txt").unwrap(),
228 "test.txt"
229 );
230 }
231
232 #[test]
233 fn normalize_path_rejects_invalid() {
234 assert_matches!(
235 normalize_zip_file_path("../text.txt"),
236 Err(Error::InvalidZipFilePath(_))
237 );
238 assert_matches!(
239 normalize_zip_file_path("nested/../../test.txt"),
240 Err(Error::InvalidZipFilePath(_))
241 );
242 #[cfg(unix)]
243 assert_matches!(
244 normalize_zip_file_path("/absolute/path/test.txt"),
245 Err(Error::InvalidZipFilePath(_))
246 );
247 #[cfg(target_os = "windows")]
248 assert_matches!(
249 normalize_zip_file_path("C:\\absolute\\path\\test.txt"),
250 Err(Error::InvalidZipFilePath(_))
251 );
252 }
253
254 #[test]
255 fn import_export_roundtrip() {
256 let manifest = ProfileManifest::new(
257 "My Profile",
258 vec![Mod::new(
259 PackageIdent::new("BepInEx", "BepInExPack"),
260 Version::new(5, 4, 2100),
261 true,
262 )],
263 (),
264 );
265 let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
266 export
267 .write_file("config/BepInEx.cfg", &b"Some bytes"[..])
268 .unwrap();
269 let data = export.finish().unwrap();
270
271 let mut import = ImportFile::open(data).unwrap();
272 let read_manifest = import.read_manifest().unwrap();
273 assert_eq!(read_manifest, manifest);
274
275 import
276 .read_files(|path, file| {
277 assert_eq!(path, Path::new("config/BepInEx.cfg"));
278
279 let mut str = String::new();
280 file.read_to_string(&mut str)?;
281 assert_eq!(str, "Some bytes");
282
283 Ok(())
284 })
285 .unwrap();
286 }
287}