1pub 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#[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 pub loader_version: Option<String>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ModpackFormat {
44 Modrinth,
45 CurseForge,
46}
47
48#[derive(Debug, Clone)]
51pub struct ManualDownload {
52 pub name: String,
53 pub file_name: String,
54 pub website: Option<String>,
56 pub dest: PathBuf,
58}
59
60#[derive(Debug, Clone)]
61pub struct ModpackInstallResult {
62 pub info: ModpackInfo,
63 pub manual_downloads: Vec<ManualDownload>,
64 pub skipped: Vec<String>,
66}
67
68pub 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
88pub 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
118pub(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
172async 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
183pub(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
196pub(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
238pub(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}