Skip to main content

conduit_cli/core/io/modpack/
conduit.rs

1use 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}