Skip to main content

hexomc_lib/modpack/
mod.rs

1//! Modpack installation: Modrinth (`.mrpack`), CurseForge (`manifest.json` zip) and
2//! ATLauncher and FTB (online packs).
3//!
4//! Every format follows the same flow: parse the pack, install vanilla + the loader via
5//! `install_with_loader`, extract overrides/configs into `instance/{name}/.minecraft`,
6//! then download the files listed by the pack.
7//! The `*_files` entry points only install pack contents, leaving Minecraft and its
8//! loader for a later launch. They do not create `instance_config.json`.
9
10pub 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/// Format-independent description of a modpack.
32#[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    /// Loader version without the MC prefix (e.g. Forge `47.2.0`, Fabric `0.16.5`).
39    /// None uses the latest.
40    pub loader_version: Option<String>,
41}
42
43impl ModpackInfo {
44    /// Normalize Forge versions and reject loaders that cannot be installed later.
45    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/// Format of a local modpack file. ATLauncher and FTB packs have no file format; use
61/// [`atpack::install_atlauncher_pack`] or [`ftbpack::install_ftb_pack`] for those.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ModpackFormat {
64    Modrinth,
65    CurseForge,
66}
67
68/// A file that could not be downloaded automatically (CurseForge files whose authors
69/// disallow third-party distribution, ATLauncher `browser` downloads).
70#[derive(Debug, Clone)]
71pub struct ManualDownload {
72    pub name: String,
73    pub file_name: String,
74    /// Page the user should download the file from.
75    pub website: Option<String>,
76    /// Where the downloaded file belongs.
77    pub dest: PathBuf,
78}
79
80#[derive(Debug, Clone)]
81pub struct ModpackInstallResult {
82    pub info: ModpackInfo,
83    pub manual_downloads: Vec<ManualDownload>,
84    /// Names of entries skipped because their type is unsupported or no download source exists.
85    pub skipped: Vec<String>,
86}
87
88/// Detect a modpack's format from the zip contents.
89pub 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
108/// Install a local modpack file, detecting its format.
109///
110/// `java_path`: needed by the Forge/NeoForge installers; None finds a Java matching the
111/// MC version automatically.
112/// `curseforge`: required for CurseForge modpacks.
113pub 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
134/// Extract overrides and download pack files, detecting the local pack format.
135/// Minecraft and the mod loader are not installed; install them later with
136/// [`install_with_loader`] using the returned [`ModpackInfo`]. No Java is required.
137/// A CurseForge client is required for CurseForge archives.
138pub 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
162/// Install vanilla plus the loader the pack asks for.
163///
164/// NeoForge for 1.20.1 is rejected: it is published under the old `forge` artifact,
165/// which the NeoForge installer does not handle.
166pub(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
216/// Map a bare Forge version (`47.2.0`) to the full string used by the Forge version list
217/// (`1.20.1-47.2.0`, or legacy forms like `1.7.10-10.13.4.1614-1.7.10`).
218pub 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
227/// Join a pack-provided relative path onto `root`, rejecting absolute paths and `..`
228/// so a pack cannot write outside the instance.
229pub(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
240/// Extract every file under `prefix/` in the zip into `dest`, stripping the prefix.
241/// An empty `prefix` extracts everything. Returns the number of files written.
242pub(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
282/// Read and deserialize a single JSON file from a zip.
283pub(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}