conduit_cli/core/io/modpack/
conduit.rs1use std::collections::HashSet;
2use std::fs::{self, File};
3use std::io::{Read, Write, copy};
4use std::path::Path;
5use walkdir::WalkDir;
6use zip::write::SimpleFileOptions;
7
8use crate::core::error::{CoreError, CoreResult};
9use crate::core::events::{CoreCallbacks, CoreEvent};
10use crate::core::io::modpack::metadata::{
11 ConduitInfo, ConduitPackMetadata, ContentFlags, PackInfo,
12};
13use crate::core::io::modpack::{ModpackProvider, PackAnalysis};
14use crate::core::io::project::ProjectFiles;
15use crate::core::paths::CorePaths;
16
17pub struct ConduitProvider;
18
19impl ConduitProvider {
20 const MAX_DECOMPRESSION_RATIO: u64 = 200;
21 const MAX_TOTAL_SIZE: u64 = 1024 * 1024 * 1024 * 2;
22 const MAX_SINGLE_FILE_SIZE: u64 = 1024 * 1024 * 500;
23
24 fn is_dangerous(extension: &str) -> bool {
25 let blacklist = [
26 "exe", "bat", "sh", "py", "js", "vbs", "msi", "com", "cmd", "scr",
27 ];
28 blacklist.contains(&extension.to_lowercase().as_str())
29 }
30
31 pub fn validate_zip_entry<R>(
32 file: &zip::read::ZipFile<'_, R>,
33 current_total: u64,
34 ) -> CoreResult<u64>
35 where
36 R: std::io::Read + std::io::Seek,
37 {
38 let name = file.name();
39 let uncompressed_size = file.size();
40 let compressed_size = file.compressed_size();
41
42 if uncompressed_size > Self::MAX_SINGLE_FILE_SIZE {
43 return Err(CoreError::RuntimeError(format!("File too large: {name}")));
44 }
45
46 if compressed_size > 0 {
47 let ratio = uncompressed_size / compressed_size;
48 if ratio > Self::MAX_DECOMPRESSION_RATIO && uncompressed_size > 1024 * 1024 {
49 return Err(CoreError::RuntimeError(format!(
50 "Abnormal compression ratio: {name}"
51 )));
52 }
53 }
54
55 let new_total = current_total + uncompressed_size;
56 if new_total > Self::MAX_TOTAL_SIZE {
57 return Err(CoreError::RuntimeError(
58 "Pack exceeds total size limit".into(),
59 ));
60 }
61
62 Ok(new_total)
63 }
64}
65
66impl ModpackProvider for ConduitProvider {
67 fn export(
68 &self,
69 paths: &CorePaths,
70 output_path: &Path,
71 include_config: bool,
72 ) -> CoreResult<()> {
73 let manifest = ProjectFiles::load_manifest(paths)?;
74 let mut local_mod_filenames = HashSet::new();
75
76 if paths.lock_path().exists() {
77 let lock = ProjectFiles::load_lock(paths)?;
78 for (slug, locked_mod) in lock.locked_mods {
79 if slug.starts_with("local:") || slug.starts_with("f:") || slug.starts_with("file:")
80 {
81 local_mod_filenames.insert(locked_mod.filename);
82 }
83 }
84 }
85
86 let file = File::create(output_path)?;
87 let mut zip = zip::ZipWriter::new(file);
88 let options =
89 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
90
91 zip.start_file("conduit.json", options)?;
92 zip.write_all(manifest.to_json()?.as_bytes())?;
93
94 if paths.lock_path().exists() {
95 let lock = ProjectFiles::load_lock(paths)?;
96 zip.start_file("conduit.lock", options)?;
97 zip.write_all(lock.to_toml_with_header()?.as_bytes())?;
98 }
99
100 let meta = ConduitPackMetadata {
101 conduit: ConduitInfo {
102 version: env!("CARGO_PKG_VERSION").to_string(),
103 format_version: 1,
104 },
105 pack: PackInfo {
106 title: manifest.name.clone(),
107 creator: Some("Conduit User".to_string()),
108 description: None,
109 homepage: None,
110 repository: None,
111 pack_type: manifest.instance_type,
112 },
113 content: ContentFlags {
114 has_configs: include_config && paths.project_dir().join("config").exists(),
115 has_mods_overrides: !local_mod_filenames.is_empty(),
116 },
117 };
118
119 zip.start_file("metadata.toml", options)?;
120 zip.write_all(meta.to_toml()?.as_bytes())?;
121
122 let mut folders = Vec::new();
123 if include_config {
124 folders.push("config");
125 }
126 if paths.mods_dir().exists() {
127 folders.push("mods");
128 }
129
130 for folder_name in folders {
131 let folder_path = paths.project_dir().join(folder_name);
132 let is_mods_folder = folder_name == "mods";
133
134 for entry in WalkDir::new(&folder_path)
135 .into_iter()
136 .filter_map(Result::ok)
137 {
138 if entry.path().is_file() {
139 let filename = entry.file_name().to_string_lossy().to_string();
140
141 if is_mods_folder && !local_mod_filenames.contains(&filename) {
142 continue;
143 }
144
145 let relative_path = entry
146 .path()
147 .strip_prefix(paths.project_dir())
148 .map_err(|_| CoreError::RuntimeError("Path error".into()))?;
149
150 let zip_path = format!(
151 "overrides/{}",
152 relative_path.to_string_lossy().replace('\\', "/")
153 );
154
155 zip.start_file(zip_path, options)?;
156 zip.write_all(&fs::read(entry.path())?)?;
157 }
158 }
159 }
160
161 zip.finish()
162 .map_err(|e| CoreError::RuntimeError(e.to_string()))?;
163 Ok(())
164 }
165
166 fn analyze(&self, input_path: &Path) -> CoreResult<PackAnalysis> {
167 let file = File::open(input_path)?;
168 let mut archive =
169 zip::ZipArchive::new(file).map_err(|e| CoreError::RuntimeError(e.to_string()))?;
170
171 let mut files = Vec::new();
172 let mut extensions = HashSet::new();
173 let mut suspicious = Vec::new();
174 let mut dangerous_count = 0;
175 let mut local_jars_count = 0;
176 let mut total_uncompressed_size: u64 = 0;
177
178 for i in 0..archive.len() {
179 let file = archive
180 .by_index(i)
181 .map_err(|e| CoreError::RuntimeError(e.to_string()))?;
182 let name = file.name().to_string();
183
184 if file.is_file() {
185 total_uncompressed_size =
186 ConduitProvider::validate_zip_entry(&file, total_uncompressed_size)?;
187
188 files.push(name.clone());
189
190 if let Some(ext) = Path::new(&name).extension().and_then(|e| e.to_str()) {
191 let ext_lower = ext.to_lowercase();
192 extensions.insert(ext_lower.clone());
193
194 if Self::is_dangerous(&ext_lower) {
195 dangerous_count += 1;
196 suspicious.push(format!("[DANGER] {name}"));
197 }
198 }
199
200 if name.contains("overrides/")
201 && Path::new(&name)
202 .extension()
203 .is_some_and(|ext| ext.eq_ignore_ascii_case("jar"))
204 {
205 local_jars_count += 1;
206 suspicious.push(format!("[LOCAL JAR] {name}"));
207 }
208 }
209 }
210
211 Ok(PackAnalysis {
212 files,
213 extensions: extensions.into_iter().collect(),
214 dangerous_count,
215 local_jars_count,
216 suspicious_files: suspicious,
217 })
218 }
219
220 fn import(
221 &self,
222 paths: &CorePaths,
223 input_path: &Path,
224 callbacks: &mut dyn CoreCallbacks,
225 ) -> CoreResult<()> {
226 let file = File::open(input_path)?;
227 let mut archive = zip::ZipArchive::new(file)?;
228
229 let target_dir = paths.project_dir();
230 if !target_dir.exists() {
231 fs::create_dir_all(target_dir)?;
232 }
233
234 let mut total_uncompressed_size: u64 = 0;
235
236 for i in 0..archive.len() {
237 let file = archive.by_index(i)?;
238 let raw_name = file.name().to_string();
239
240 total_uncompressed_size =
241 ConduitProvider::validate_zip_entry(&file, total_uncompressed_size)?;
242
243 let outpath = if let Some(stripped) = raw_name.strip_prefix("overrides/") {
244 target_dir.join(stripped)
245 } else {
246 target_dir.join(&raw_name)
247 };
248
249 if !outpath.starts_with(target_dir) {
250 return Err(CoreError::RuntimeError(format!(
251 "Invalid path in ZIP: {raw_name}"
252 )));
253 }
254
255 if file.is_dir() {
256 fs::create_dir_all(&outpath)?;
257 } else {
258 if let Some(p) = outpath.parent() {
259 fs::create_dir_all(p)?;
260 }
261 let mut outfile = File::create(&outpath)?;
262 let mut limiter = file.take(Self::MAX_SINGLE_FILE_SIZE);
263 copy(&mut limiter, &mut outfile)?;
264
265 if Path::new(&raw_name)
266 .extension()
267 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
268 || Path::new(&raw_name)
269 .extension()
270 .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
271 {
272 callbacks.on_event(CoreEvent::LinkedFile { filename: raw_name });
273 }
274 }
275 }
276
277 callbacks.on_event(CoreEvent::Success(
278 "Modpack imported successfully".to_string(),
279 ));
280 Ok(())
281 }
282}