1pub mod atpack;
11pub mod cfpack;
12pub mod ftbpack;
13pub mod mrpack;
14
15use std::path::{Component, Path, PathBuf};
16
17use crate::{
18 error::{HexoError, Result},
19 install::{
20 forge::get_forge_versions,
21 loader::{
22 install_with_loader, FabricInstaller, ForgeInstaller, LoaderInstaller,
23 NeoForgeInstaller, ProgressFn, VanillaInstaller,
24 },
25 vanilla::{InstanceConfig, LoaderType},
26 },
27 java::detector::find_java,
28 mods::curseforge::CurseForgeClient,
29};
30
31#[derive(Debug, Clone)]
33pub struct ModpackInfo {
34 pub name: String,
35 pub version: Option<String>,
36 pub mc_version: String,
37 pub loader: LoaderType,
38 pub loader_version: Option<String>,
41}
42
43impl ModpackInfo {
44 pub(crate) fn validated(mut self) -> Result<Self> {
46 if self.loader == LoaderType::NeoForge && self.mc_version == "1.20.1" {
47 return Err(HexoError::UnsupportedLoader("NeoForge for 1.20.1".into()));
48 }
49 if self.loader == LoaderType::Forge {
50 if let Some(version) = &mut self.loader_version {
51 if let Some(bare) = version.strip_prefix(&format!("{}-", self.mc_version)) {
52 *version = bare.to_string();
53 }
54 }
55 }
56 Ok(self)
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ModpackFormat {
64 Modrinth,
65 CurseForge,
66}
67
68#[derive(Debug, Clone)]
71pub struct ManualDownload {
72 pub name: String,
73 pub file_name: String,
74 pub website: Option<String>,
76 pub dest: PathBuf,
78}
79
80#[derive(Debug, Clone)]
81pub struct ModpackInstallResult {
82 pub info: ModpackInfo,
83 pub manual_downloads: Vec<ManualDownload>,
84 pub skipped: Vec<String>,
86}
87
88pub async fn detect_modpack_format(pack_path: &Path) -> Result<ModpackFormat> {
90 let pack_path = pack_path.to_path_buf();
91 tokio::task::spawn_blocking(move || {
92 let archive = zip::ZipArchive::new(std::fs::File::open(&pack_path)?)?;
93 if archive.index_for_name(mrpack::INDEX_FILE).is_some() {
94 Ok(ModpackFormat::Modrinth)
95 } else if archive.index_for_name(cfpack::MANIFEST_FILE).is_some() {
96 Ok(ModpackFormat::CurseForge)
97 } else {
98 Err(HexoError::Other(format!(
99 "unrecognized modpack format: {}",
100 pack_path.display()
101 )))
102 }
103 })
104 .await
105 .map_err(|e| HexoError::Other(e.to_string()))?
106}
107
108pub async fn install_modpack(
114 pack_path: &Path,
115 instance_name: &str,
116 base_dir: &Path,
117 java_path: Option<&Path>,
118 curseforge: Option<&CurseForgeClient>,
119 progress: ProgressFn,
120) -> Result<ModpackInstallResult> {
121 match detect_modpack_format(pack_path).await? {
122 ModpackFormat::Modrinth => {
123 mrpack::install_mrpack(pack_path, instance_name, base_dir, java_path, progress).await
124 }
125 ModpackFormat::CurseForge => {
126 let cf = curseforge.ok_or_else(|| {
127 HexoError::Other("installing a CurseForge modpack requires a CurseForgeClient".into())
128 })?;
129 cfpack::install_cfpack(pack_path, instance_name, base_dir, java_path, cf, progress).await
130 }
131 }
132}
133
134pub async fn install_modpack_files(
139 pack_path: &Path,
140 instance_name: &str,
141 base_dir: &Path,
142 curseforge: Option<&CurseForgeClient>,
143 progress: ProgressFn,
144) -> Result<ModpackInstallResult> {
145 match detect_modpack_format(pack_path).await? {
146 ModpackFormat::Modrinth => {
147 mrpack::install_mrpack_files(pack_path, instance_name, base_dir, progress).await
148 }
149 ModpackFormat::CurseForge => {
150 let cf = curseforge.ok_or_else(|| {
151 HexoError::Other("installing a CurseForge modpack requires a CurseForgeClient".into())
152 })?;
153 cfpack::install_cfpack_files(pack_path, instance_name, base_dir, cf, progress).await
154 }
155 }
156}
157
158pub(crate) fn instance_game_dir(base_dir: &Path, instance_name: &str) -> PathBuf {
159 base_dir.join("instance").join(instance_name).join(".minecraft")
160}
161
162pub(crate) async fn install_pack_loader(
167 info: &ModpackInfo,
168 instance_name: &str,
169 base_dir: &Path,
170 java_path: Option<&Path>,
171 progress: ProgressFn,
172) -> Result<()> {
173 let mc = info.mc_version.as_str();
174
175 let loader: Box<dyn LoaderInstaller> = match info.loader {
176 LoaderType::Vanilla => Box::new(VanillaInstaller),
177 LoaderType::Fabric => Box::new(match &info.loader_version {
178 Some(v) => FabricInstaller::new(v),
179 None => FabricInstaller::latest(),
180 }),
181 LoaderType::Forge | LoaderType::NeoForge => {
182 if info.loader == LoaderType::NeoForge && mc == "1.20.1" {
183 return Err(HexoError::UnsupportedLoader(format!("NeoForge for {}", mc)));
184 }
185 VanillaInstaller
186 .install(mc, instance_name, base_dir, progress.clone())
187 .await?;
188 let java = match java_path {
189 Some(p) => p.to_path_buf(),
190 None => {
191 let instance_dir = base_dir.join("instance").join(instance_name);
192 let required = InstanceConfig::load(&instance_dir).await?.java_version;
193 find_java(required)
194 .ok_or(HexoError::JavaNotFound { required })?
195 .path
196 }
197 };
198 if info.loader == LoaderType::Forge {
199 let version = match &info.loader_version {
200 Some(v) => Some(resolve_forge_version(mc, v).await?),
201 None => None,
202 };
203 Box::new(ForgeInstaller { forge_version: version, java_path: java })
204 } else {
205 Box::new(NeoForgeInstaller {
206 neoforge_version: info.loader_version.clone(),
207 java_path: java,
208 })
209 }
210 }
211 };
212
213 install_with_loader(mc, instance_name, base_dir, loader.as_ref(), progress).await
214}
215
216pub async fn resolve_forge_version(mc_version: &str, loader_version: &str) -> Result<String> {
219 let full = format!("{}-{}", mc_version, loader_version);
220 let versions = get_forge_versions(mc_version).await?;
221 Ok(versions
222 .into_iter()
223 .find(|v| *v == full || v.starts_with(&format!("{}-", full)))
224 .unwrap_or(full))
225}
226
227pub(crate) fn safe_join(root: &Path, relative: &str) -> Result<PathBuf> {
230 let rel = Path::new(relative);
231 if rel
232 .components()
233 .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
234 {
235 return Err(HexoError::Other(format!("unsafe path in modpack: {}", relative)));
236 }
237 Ok(root.join(rel))
238}
239
240pub(crate) async fn extract_zip_dir(zip_path: &Path, prefix: &str, dest: &Path) -> Result<usize> {
243 let zip_path = zip_path.to_path_buf();
244 let dest = dest.to_path_buf();
245 let prefix = prefix.trim_matches('/').to_string();
246
247 tokio::task::spawn_blocking(move || -> Result<usize> {
248 let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip_path)?)?;
249 let mut count = 0;
250 for i in 0..archive.len() {
251 let mut entry = archive.by_index(i)?;
252 let Some(name) = entry.enclosed_name() else { continue };
253 let rel = if prefix.is_empty() {
254 name
255 } else {
256 match name.strip_prefix(&prefix) {
257 Ok(r) => r.to_path_buf(),
258 Err(_) => continue,
259 }
260 };
261 if rel.as_os_str().is_empty() {
262 continue;
263 }
264 let out = dest.join(&rel);
265 if entry.is_dir() {
266 std::fs::create_dir_all(&out)?;
267 continue;
268 }
269 if let Some(parent) = out.parent() {
270 std::fs::create_dir_all(parent)?;
271 }
272 let mut file = std::fs::File::create(&out)?;
273 std::io::copy(&mut entry, &mut file)?;
274 count += 1;
275 }
276 Ok(count)
277 })
278 .await
279 .map_err(|e| HexoError::Other(e.to_string()))?
280}
281
282pub(crate) async fn read_zip_json<T>(zip_path: &Path, name: &'static str) -> Result<T>
284where
285 T: serde::de::DeserializeOwned + Send + 'static,
286{
287 let zip_path = zip_path.to_path_buf();
288 tokio::task::spawn_blocking(move || -> Result<T> {
289 let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip_path)?)?;
290 let entry = archive.by_name(name)?;
291 Ok(serde_json::from_reader(entry)?)
292 })
293 .await
294 .map_err(|e| HexoError::Other(e.to_string()))?
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[tokio::test]
302 async fn local_pack_files_install_without_minecraft_or_java() {
303 use std::io::Write;
304 use zip::write::{SimpleFileOptions, ZipWriter};
305 use serde_json::json;
306
307 let cases = [
308 (mrpack::INDEX_FILE, json!({
309 "formatVersion": 1, "game": "minecraft", "versionId": "1.0",
310 "name": "Content Pack", "files": [],
311 "dependencies": {"minecraft": "1.20.1", "forge": "1.20.1-47.2.0"}
312 })),
313 (cfpack::MANIFEST_FILE, json!({
314 "name": "Content Pack", "version": "1.0", "files": [],
315 "minecraft": {"version": "1.20.1", "modLoaders": [{"id": "forge-47.2.0", "primary": true}]}
316 })),
317 ];
318 for (manifest_name, manifest) in cases {
319 let temp = tempfile::tempdir().unwrap();
320 let pack_path = temp.path().join("pack.zip");
321 let base = temp.path().join("launcher");
322 {
323 let mut zip = ZipWriter::new(std::fs::File::create(&pack_path).unwrap());
324 let options = SimpleFileOptions::default();
325 zip.start_file(manifest_name, options).unwrap();
326 zip.write_all(manifest.to_string().as_bytes()).unwrap();
327 zip.start_file("overrides/config/a.toml", options).unwrap();
328 zip.write_all(b"enabled = true").unwrap();
329 if manifest_name == mrpack::INDEX_FILE {
330 zip.start_file("client-overrides/config/a.toml", options).unwrap();
331 zip.write_all(b"client = true").unwrap();
332 }
333 zip.finish().unwrap();
334 }
335 let client = CurseForgeClient::new("");
336 let cf = (manifest_name == cfpack::MANIFEST_FILE).then_some(&client);
337 let result = install_modpack_files(&pack_path, "content", &base, cf, crate::no_progress()).await.unwrap();
338 let instance = base.join("instance/content");
339 let expected = if manifest_name == mrpack::INDEX_FILE { "client = true" } else { "enabled = true" };
340 assert_eq!(std::fs::read_to_string(instance.join(".minecraft/config/a.toml")).unwrap(), expected);
341 assert!(!instance.join("instance_config.json").exists());
342 assert!(!base.join("libraries").exists());
343 assert!(!base.join("assets").exists());
344 assert_eq!(result.info.name, "Content Pack");
345 assert_eq!(result.info.version.as_deref(), Some("1.0"));
346 assert_eq!(result.info.mc_version, "1.20.1");
347 assert_eq!(result.info.loader, LoaderType::Forge);
348 assert_eq!(result.info.loader_version.as_deref(), Some("47.2.0"));
349 assert!(result.manual_downloads.is_empty());
350 assert!(result.skipped.is_empty());
351 }
352 }
353
354 #[test]
355 fn every_format_rejects_neoforge_1_20_1_in_info() {
356 use serde_json::json;
357 let mr: mrpack::MrpackIndex = serde_json::from_value(json!({
358 "formatVersion": 1, "game": "minecraft", "versionId": "1", "name": "Test", "files": [],
359 "dependencies": {"minecraft": "1.20.1", "neoforge": "47.1.0"}
360 })).unwrap();
361 let cf: cfpack::CfManifest = serde_json::from_value(json!({
362 "minecraft": {"version": "1.20.1", "modLoaders": [{"id": "neoforge-47.1.0"}]}
363 })).unwrap();
364 let at: atpack::AtPackConfig = serde_json::from_value(json!({
365 "version": "1", "minecraft": "1.20.1", "loader": {"type": "neoforge", "metadata": {"version": "47.1.0"}}
366 })).unwrap();
367 let ftb: ftbpack::FtbVersionManifest = serde_json::from_value(json!({
368 "id": 1, "name": "1", "targets": [
369 {"name": "minecraft", "version": "1.20.1"},
370 {"name": "neoforge", "version": "47.1.0"}
371 ]
372 })).unwrap();
373 for result in [mr.info(), cf.info(), at.info("Test"), ftb.info("Test")] {
374 assert!(matches!(result, Err(HexoError::UnsupportedLoader(message)) if message.contains("NeoForge for 1.20.1")));
375 }
376 }
377
378 #[test]
379 fn safe_join_rejects_escape() {
380 let root = Path::new("/game");
381 assert!(safe_join(root, "mods/a.jar").is_ok());
382 assert!(safe_join(root, "../evil.jar").is_err());
383 assert!(safe_join(root, "mods/../../evil.jar").is_err());
384 assert!(safe_join(root, "/etc/passwd").is_err());
385 }
386
387 #[tokio::test]
388 async fn extract_zip_dir_strips_prefix() {
389 use std::io::Write;
390 use zip::write::{SimpleFileOptions, ZipWriter};
391
392 let tmp = tempfile::tempdir().unwrap();
393 let zip_path = tmp.path().join("pack.zip");
394 {
395 let mut w = ZipWriter::new(std::fs::File::create(&zip_path).unwrap());
396 let opts = SimpleFileOptions::default();
397 w.start_file("overrides/config/a.toml", opts).unwrap();
398 w.write_all(b"a = 1").unwrap();
399 w.start_file("other/b.txt", opts).unwrap();
400 w.write_all(b"b").unwrap();
401 w.finish().unwrap();
402 }
403
404 let dest = tmp.path().join("out");
405 let n = extract_zip_dir(&zip_path, "overrides", &dest).await.unwrap();
406 assert_eq!(n, 1);
407 assert_eq!(std::fs::read_to_string(dest.join("config/a.toml")).unwrap(), "a = 1");
408 assert!(!dest.join("other").exists());
409 }
410}