Skip to main content

conduit_cli/core/io/modpack/
conduit.rs

1use std::collections::HashSet;
2use std::fs::{self, File};
3use std::io::{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    fn is_dangerous(extension: &str) -> bool {
21        let blacklist = [
22            "exe", "bat", "sh", "py", "js", "vbs", "msi", "com", "cmd", "scr",
23        ];
24        blacklist.contains(&extension.to_lowercase().as_str())
25    }
26}
27
28impl ModpackProvider for ConduitProvider {
29    fn export(
30        &self,
31        paths: &CorePaths,
32        output_path: &Path,
33        include_config: bool,
34    ) -> CoreResult<()> {
35        let manifest = ProjectFiles::load_manifest(paths)?;
36        let mut local_mod_filenames = HashSet::new();
37
38        if paths.lock_path().exists() {
39            let lock = ProjectFiles::load_lock(paths)?;
40            for (slug, locked_mod) in lock.locked_mods {
41                if slug.starts_with("local:") || slug.starts_with("f:") || slug.starts_with("file:")
42                {
43                    local_mod_filenames.insert(locked_mod.filename);
44                }
45            }
46        }
47
48        let file = File::create(output_path)?;
49        let mut zip = zip::ZipWriter::new(file);
50        let options =
51            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
52
53        zip.start_file("conduit.json", options)?;
54        zip.write_all(manifest.to_json()?.as_bytes())?;
55
56        if paths.lock_path().exists() {
57            let lock = ProjectFiles::load_lock(paths)?;
58            zip.start_file("conduit.lock", options)?;
59            zip.write_all(lock.to_toml_with_header()?.as_bytes())?;
60        }
61
62        let meta = ConduitPackMetadata {
63            conduit: ConduitInfo {
64                version: env!("CARGO_PKG_VERSION").to_string(),
65                format_version: 1,
66            },
67            pack: PackInfo {
68                title: manifest.name.clone(),
69                creator: Some("Conduit User".to_string()),
70                description: None,
71                homepage: None,
72                repository: None,
73                pack_type: manifest.instance_type,
74            },
75            content: ContentFlags {
76                has_configs: include_config && paths.project_dir().join("config").exists(),
77                has_mods_overrides: !local_mod_filenames.is_empty(),
78            },
79        };
80
81        zip.start_file("metadata.toml", options)?;
82        zip.write_all(meta.to_toml()?.as_bytes())?;
83
84        let mut folders = Vec::new();
85        if include_config {
86            folders.push("config");
87        }
88        if paths.mods_dir().exists() {
89            folders.push("mods");
90        }
91
92        for folder_name in folders {
93            let folder_path = paths.project_dir().join(folder_name);
94            let is_mods_folder = folder_name == "mods";
95
96            for entry in WalkDir::new(&folder_path)
97                .into_iter()
98                .filter_map(Result::ok)
99            {
100                if entry.path().is_file() {
101                    let filename = entry.file_name().to_string_lossy().to_string();
102
103                    if is_mods_folder && !local_mod_filenames.contains(&filename) {
104                        continue;
105                    }
106
107                    let relative_path = entry
108                        .path()
109                        .strip_prefix(paths.project_dir())
110                        .map_err(|_| CoreError::RuntimeError("Path error".into()))?;
111
112                    let zip_path = format!(
113                        "overrides/{}",
114                        relative_path.to_string_lossy().replace('\\', "/")
115                    );
116
117                    zip.start_file(zip_path, options)?;
118                    zip.write_all(&fs::read(entry.path())?)?;
119                }
120            }
121        }
122
123        zip.finish()
124            .map_err(|e| CoreError::RuntimeError(e.to_string()))?;
125        Ok(())
126    }
127
128    fn analyze(&self, input_path: &Path) -> CoreResult<PackAnalysis> {
129        let file = File::open(input_path)?;
130        let mut archive =
131            zip::ZipArchive::new(file).map_err(|e| CoreError::RuntimeError(e.to_string()))?;
132
133        let mut files = Vec::new();
134        let mut extensions = HashSet::new();
135        let mut suspicious = Vec::new();
136        let mut dangerous_count = 0;
137        let mut local_jars_count = 0;
138
139        for i in 0..archive.len() {
140            let file = archive
141                .by_index(i)
142                .map_err(|e| CoreError::RuntimeError(e.to_string()))?;
143            let name = file.name().to_string();
144
145            if file.is_file() {
146                files.push(name.clone());
147
148                if let Some(ext) = Path::new(&name).extension().and_then(|e| e.to_str()) {
149                    let ext_lower = ext.to_lowercase();
150                    extensions.insert(ext_lower.clone());
151
152                    if Self::is_dangerous(&ext_lower) {
153                        dangerous_count += 1;
154                        suspicious.push(format!("[DANGER] {name}"));
155                    }
156                }
157
158                if name.contains("overrides/")
159                    && Path::new(&name)
160                        .extension()
161                        .is_some_and(|ext| ext.eq_ignore_ascii_case("jar"))
162                {
163                    local_jars_count += 1;
164                    suspicious.push(format!("[LOCAL JAR] {name}"));
165                }
166            }
167        }
168
169        Ok(PackAnalysis {
170            files,
171            extensions: extensions.into_iter().collect(),
172            dangerous_count,
173            local_jars_count,
174            suspicious_files: suspicious,
175        })
176    }
177
178    fn import(
179        &self,
180        paths: &CorePaths,
181        input_path: &Path,
182        callbacks: &mut dyn CoreCallbacks,
183    ) -> CoreResult<()> {
184        let file = File::open(input_path)?;
185        let mut archive = zip::ZipArchive::new(file)?;
186
187        let target_dir = paths.project_dir();
188        if !target_dir.exists() {
189            fs::create_dir_all(target_dir)?;
190        }
191
192        for i in 0..archive.len() {
193            let mut file = archive.by_index(i)?;
194            let raw_name = file.name().to_string();
195
196            let outpath = if let Some(stripped) = raw_name.strip_prefix("overrides/") {
197                target_dir.join(stripped)
198            } else {
199                target_dir.join(&raw_name)
200            };
201
202            if file.is_dir() {
203                fs::create_dir_all(&outpath)?;
204            } else {
205                if let Some(p) = outpath.parent() {
206                    fs::create_dir_all(p)?;
207                }
208                let mut outfile = File::create(&outpath)?;
209                copy(&mut file, &mut outfile)?;
210
211                if Path::new(&raw_name)
212                    .extension()
213                    .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
214                    || Path::new(&raw_name)
215                        .extension()
216                        .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
217                {
218                    callbacks.on_event(CoreEvent::LinkedFile { filename: raw_name });
219                }
220            }
221        }
222
223        callbacks.on_event(CoreEvent::Success(
224            "Modpack imported successfully".to_string(),
225        ));
226        Ok(())
227    }
228}