Skip to main content

hexomc_lib/modpack/
mod.rs

1//! Modpack installation: Modrinth (`.mrpack`), CurseForge (`manifest.json` zip) and
2//! ATLauncher (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
8pub mod atpack;
9pub mod cfpack;
10pub mod mrpack;
11
12use std::path::{Component, Path, PathBuf};
13
14use crate::{
15    error::{HexoError, Result},
16    install::{
17        forge::get_forge_versions,
18        loader::{
19            install_with_loader, FabricInstaller, ForgeInstaller, LoaderInstaller,
20            NeoForgeInstaller, ProgressFn, VanillaInstaller,
21        },
22        vanilla::{InstanceConfig, LoaderType},
23    },
24    java::detector::find_java,
25    mods::curseforge::CurseForgeClient,
26};
27
28/// Format-independent description of a modpack.
29#[derive(Debug, Clone)]
30pub struct ModpackInfo {
31    pub name: String,
32    pub version: Option<String>,
33    pub mc_version: String,
34    pub loader: LoaderType,
35    /// Loader version without the MC prefix (e.g. Forge `47.2.0`, Fabric `0.16.5`).
36    /// None uses the latest.
37    pub loader_version: Option<String>,
38}
39
40/// Format of a local modpack file. ATLauncher packs have no file format; use
41/// [`atpack::install_atlauncher_pack`] for those.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ModpackFormat {
44    Modrinth,
45    CurseForge,
46}
47
48/// A file that could not be downloaded automatically (CurseForge files whose authors
49/// disallow third-party distribution, ATLauncher `browser` downloads).
50#[derive(Debug, Clone)]
51pub struct ManualDownload {
52    pub name: String,
53    pub file_name: String,
54    /// Page the user should download the file from.
55    pub website: Option<String>,
56    /// Where the downloaded file belongs.
57    pub dest: PathBuf,
58}
59
60#[derive(Debug, Clone)]
61pub struct ModpackInstallResult {
62    pub info: ModpackInfo,
63    pub manual_downloads: Vec<ManualDownload>,
64    /// Names of entries skipped because their type is not supported.
65    pub skipped: Vec<String>,
66}
67
68/// Detect a modpack's format from the zip contents.
69pub async fn detect_modpack_format(pack_path: &Path) -> Result<ModpackFormat> {
70    let pack_path = pack_path.to_path_buf();
71    tokio::task::spawn_blocking(move || {
72        let archive = zip::ZipArchive::new(std::fs::File::open(&pack_path)?)?;
73        if archive.index_for_name(mrpack::INDEX_FILE).is_some() {
74            Ok(ModpackFormat::Modrinth)
75        } else if archive.index_for_name(cfpack::MANIFEST_FILE).is_some() {
76            Ok(ModpackFormat::CurseForge)
77        } else {
78            Err(HexoError::Other(format!(
79                "unrecognized modpack format: {}",
80                pack_path.display()
81            )))
82        }
83    })
84    .await
85    .map_err(|e| HexoError::Other(e.to_string()))?
86}
87
88/// Install a local modpack file, detecting its format.
89///
90/// `java_path`: needed by the Forge/NeoForge installers; None finds a Java matching the
91/// MC version automatically.
92/// `curseforge`: required for CurseForge modpacks.
93pub async fn install_modpack(
94    pack_path: &Path,
95    instance_name: &str,
96    base_dir: &Path,
97    java_path: Option<&Path>,
98    curseforge: Option<&CurseForgeClient>,
99    progress: ProgressFn,
100) -> Result<ModpackInstallResult> {
101    match detect_modpack_format(pack_path).await? {
102        ModpackFormat::Modrinth => {
103            mrpack::install_mrpack(pack_path, instance_name, base_dir, java_path, progress).await
104        }
105        ModpackFormat::CurseForge => {
106            let cf = curseforge.ok_or_else(|| {
107                HexoError::Other("installing a CurseForge modpack requires a CurseForgeClient".into())
108            })?;
109            cfpack::install_cfpack(pack_path, instance_name, base_dir, java_path, cf, progress).await
110        }
111    }
112}
113
114pub(crate) fn instance_game_dir(base_dir: &Path, instance_name: &str) -> PathBuf {
115    base_dir.join("instance").join(instance_name).join(".minecraft")
116}
117
118/// Install vanilla plus the loader the pack asks for.
119///
120/// NeoForge for 1.20.1 is rejected: it is published under the old `forge` artifact,
121/// which the NeoForge installer does not handle.
122pub(crate) async fn install_pack_loader(
123    info: &ModpackInfo,
124    instance_name: &str,
125    base_dir: &Path,
126    java_path: Option<&Path>,
127    progress: ProgressFn,
128) -> Result<()> {
129    let mc = info.mc_version.as_str();
130
131    let loader: Box<dyn LoaderInstaller> = match info.loader {
132        LoaderType::Vanilla => Box::new(VanillaInstaller),
133        LoaderType::Fabric => Box::new(match &info.loader_version {
134            Some(v) => FabricInstaller::new(v),
135            None => FabricInstaller::latest(),
136        }),
137        LoaderType::Forge | LoaderType::NeoForge => {
138            if info.loader == LoaderType::NeoForge && mc == "1.20.1" {
139                return Err(HexoError::UnsupportedLoader(format!("NeoForge for {}", mc)));
140            }
141            VanillaInstaller
142                .install(mc, instance_name, base_dir, progress.clone())
143                .await?;
144            let java = match java_path {
145                Some(p) => p.to_path_buf(),
146                None => {
147                    let instance_dir = base_dir.join("instance").join(instance_name);
148                    let required = InstanceConfig::load(&instance_dir).await?.java_version;
149                    find_java(required)
150                        .ok_or(HexoError::JavaNotFound { required })?
151                        .path
152                }
153            };
154            if info.loader == LoaderType::Forge {
155                let version = match &info.loader_version {
156                    Some(v) => Some(resolve_forge_version(mc, v).await?),
157                    None => None,
158                };
159                Box::new(ForgeInstaller { forge_version: version, java_path: java })
160            } else {
161                Box::new(NeoForgeInstaller {
162                    neoforge_version: info.loader_version.clone(),
163                    java_path: java,
164                })
165            }
166        }
167    };
168
169    install_with_loader(mc, instance_name, base_dir, loader.as_ref(), progress).await
170}
171
172/// Map a bare Forge version (`47.2.0`) to the full string used by the Forge version list
173/// (`1.20.1-47.2.0`, or legacy forms like `1.7.10-10.13.4.1614-1.7.10`).
174async fn resolve_forge_version(mc_version: &str, loader_version: &str) -> Result<String> {
175    let full = format!("{}-{}", mc_version, loader_version);
176    let versions = get_forge_versions(mc_version).await?;
177    Ok(versions
178        .into_iter()
179        .find(|v| *v == full || v.starts_with(&format!("{}-", full)))
180        .unwrap_or(full))
181}
182
183/// Join a pack-provided relative path onto `root`, rejecting absolute paths and `..`
184/// so a pack cannot write outside the instance.
185pub(crate) fn safe_join(root: &Path, relative: &str) -> Result<PathBuf> {
186    let rel = Path::new(relative);
187    if rel
188        .components()
189        .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
190    {
191        return Err(HexoError::Other(format!("unsafe path in modpack: {}", relative)));
192    }
193    Ok(root.join(rel))
194}
195
196/// Extract every file under `prefix/` in the zip into `dest`, stripping the prefix.
197/// An empty `prefix` extracts everything. Returns the number of files written.
198pub(crate) async fn extract_zip_dir(zip_path: &Path, prefix: &str, dest: &Path) -> Result<usize> {
199    let zip_path = zip_path.to_path_buf();
200    let dest = dest.to_path_buf();
201    let prefix = prefix.trim_matches('/').to_string();
202
203    tokio::task::spawn_blocking(move || -> Result<usize> {
204        let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip_path)?)?;
205        let mut count = 0;
206        for i in 0..archive.len() {
207            let mut entry = archive.by_index(i)?;
208            let Some(name) = entry.enclosed_name() else { continue };
209            let rel = if prefix.is_empty() {
210                name
211            } else {
212                match name.strip_prefix(&prefix) {
213                    Ok(r) => r.to_path_buf(),
214                    Err(_) => continue,
215                }
216            };
217            if rel.as_os_str().is_empty() {
218                continue;
219            }
220            let out = dest.join(&rel);
221            if entry.is_dir() {
222                std::fs::create_dir_all(&out)?;
223                continue;
224            }
225            if let Some(parent) = out.parent() {
226                std::fs::create_dir_all(parent)?;
227            }
228            let mut file = std::fs::File::create(&out)?;
229            std::io::copy(&mut entry, &mut file)?;
230            count += 1;
231        }
232        Ok(count)
233    })
234    .await
235    .map_err(|e| HexoError::Other(e.to_string()))?
236}
237
238/// Read and deserialize a single JSON file from a zip.
239pub(crate) async fn read_zip_json<T>(zip_path: &Path, name: &'static str) -> Result<T>
240where
241    T: serde::de::DeserializeOwned + Send + 'static,
242{
243    let zip_path = zip_path.to_path_buf();
244    tokio::task::spawn_blocking(move || -> Result<T> {
245        let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip_path)?)?;
246        let entry = archive.by_name(name)?;
247        Ok(serde_json::from_reader(entry)?)
248    })
249    .await
250    .map_err(|e| HexoError::Other(e.to_string()))?
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn safe_join_rejects_escape() {
259        let root = Path::new("/game");
260        assert!(safe_join(root, "mods/a.jar").is_ok());
261        assert!(safe_join(root, "../evil.jar").is_err());
262        assert!(safe_join(root, "mods/../../evil.jar").is_err());
263        assert!(safe_join(root, "/etc/passwd").is_err());
264    }
265
266    #[tokio::test]
267    async fn extract_zip_dir_strips_prefix() {
268        use std::io::Write;
269        use zip::write::{SimpleFileOptions, ZipWriter};
270
271        let tmp = tempfile::tempdir().unwrap();
272        let zip_path = tmp.path().join("pack.zip");
273        {
274            let mut w = ZipWriter::new(std::fs::File::create(&zip_path).unwrap());
275            let opts = SimpleFileOptions::default();
276            w.start_file("overrides/config/a.toml", opts).unwrap();
277            w.write_all(b"a = 1").unwrap();
278            w.start_file("other/b.txt", opts).unwrap();
279            w.write_all(b"b").unwrap();
280            w.finish().unwrap();
281        }
282
283        let dest = tmp.path().join("out");
284        let n = extract_zip_dir(&zip_path, "overrides", &dest).await.unwrap();
285        assert_eq!(n, 1);
286        assert_eq!(std::fs::read_to_string(dest.join("config/a.toml")).unwrap(), "a = 1");
287        assert!(!dest.join("other").exists());
288    }
289}