Skip to main content

minecraft_java_rs_core/utils/
archive.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use crate::error::LaunchError;
5
6// ── Public types ──────────────────────────────────────────────────────────────
7
8#[derive(Debug, Clone)]
9pub struct ArchiveEntry {
10    pub name: String,
11    pub size: u64,
12    pub is_dir: bool,
13}
14
15/// Polymorphic result of `get_file_from_archive`.
16///
17/// - `file = Some(name)` → `FileData` (or `NotFound`)
18/// - `file = None, include_dirs = true` → `Entries` with full metadata
19/// - `file = None, include_dirs = false` → `Names` of file entries only
20///
21/// When `prefix` is provided it filters by entry name prefix in all list modes.
22#[derive(Debug)]
23pub enum ArchiveQueryResult {
24    /// Raw bytes of the requested file.
25    FileData(Vec<u8>),
26    /// Full entry listing (includes directories when `include_dirs = true`).
27    Entries(Vec<ArchiveEntry>),
28    /// Entry names only (file-only when `include_dirs = false`).
29    Names(Vec<String>),
30    /// The requested `file` was not found.
31    NotFound,
32}
33
34// ── ZIP / JAR ─────────────────────────────────────────────────────────────────
35
36/// Query a ZIP or JAR archive.
37///
38/// All I/O is done inside `spawn_blocking` so this is safe to `.await` from
39/// an async context without blocking the Tokio runtime.
40pub async fn get_file_from_archive(
41    path: PathBuf,
42    file: Option<String>,
43    prefix: Option<String>,
44    include_dirs: bool,
45) -> Result<ArchiveQueryResult, LaunchError> {
46    tokio::task::spawn_blocking(move || {
47        query_zip_sync(&path, file.as_deref(), prefix.as_deref(), include_dirs)
48    })
49    .await
50    .map_err(|e| LaunchError::Archive(e.to_string()))?
51}
52
53fn query_zip_sync(
54    path: &Path,
55    file: Option<&str>,
56    prefix: Option<&str>,
57    include_dirs: bool,
58) -> Result<ArchiveQueryResult, LaunchError> {
59    let f = std::fs::File::open(path)?;
60    let mut archive =
61        zip::ZipArchive::new(f).map_err(|e| LaunchError::Archive(e.to_string()))?;
62
63    if let Some(name) = file {
64        return match archive.by_name(name) {
65            Ok(mut entry) => {
66                let mut data = Vec::with_capacity(entry.size() as usize);
67                entry.read_to_end(&mut data)?;
68                Ok(ArchiveQueryResult::FileData(data))
69            }
70            Err(zip::result::ZipError::FileNotFound) => Ok(ArchiveQueryResult::NotFound),
71            Err(e) => Err(LaunchError::Archive(e.to_string())),
72        };
73    }
74
75    if include_dirs {
76        let mut entries = Vec::with_capacity(archive.len());
77        for i in 0..archive.len() {
78            let entry = archive
79                .by_index(i)
80                .map_err(|e| LaunchError::Archive(e.to_string()))?;
81            let name = entry.name().to_string();
82            if let Some(p) = prefix {
83                if !name.starts_with(p) {
84                    continue;
85                }
86            }
87            entries.push(ArchiveEntry {
88                is_dir: entry.is_dir(),
89                size: entry.size(),
90                name,
91            });
92        }
93        Ok(ArchiveQueryResult::Entries(entries))
94    } else {
95        let mut names = Vec::new();
96        for i in 0..archive.len() {
97            let entry = archive
98                .by_index(i)
99                .map_err(|e| LaunchError::Archive(e.to_string()))?;
100            if entry.is_dir() {
101                continue;
102            }
103            let name = entry.name().to_string();
104            if let Some(p) = prefix {
105                if !name.starts_with(p) {
106                    continue;
107                }
108            }
109            names.push(name);
110        }
111        Ok(ArchiveQueryResult::Names(names))
112    }
113}
114
115// ── TAR.GZ ────────────────────────────────────────────────────────────────────
116
117/// Extract a `.tar.gz` archive to `dest`, optionally stripping leading
118/// path components (e.g. `strip_components = 1` removes the top-level
119/// directory that most JDK tarballs include).
120///
121/// All I/O is done inside `spawn_blocking`.
122pub async fn extract_tar_gz(
123    src: PathBuf,
124    dest: PathBuf,
125    strip_components: usize,
126) -> Result<(), LaunchError> {
127    tokio::task::spawn_blocking(move || extract_tar_gz_sync(&src, &dest, strip_components))
128        .await
129        .map_err(|e| LaunchError::Archive(e.to_string()))?
130}
131
132fn extract_tar_gz_sync(
133    src: &Path,
134    dest: &Path,
135    strip_components: usize,
136) -> Result<(), LaunchError> {
137    let file = std::fs::File::open(src)?;
138    let decoder = flate2::read::GzDecoder::new(file);
139    let mut archive = tar::Archive::new(decoder);
140
141    // Follow symlinks inside the archive (needed for some JDK tarballs).
142    archive.set_preserve_permissions(true);
143
144    for entry in archive.entries().map_err(|e| LaunchError::Archive(e.to_string()))? {
145        let mut entry = entry.map_err(|e| LaunchError::Archive(e.to_string()))?;
146
147        let raw_path = entry
148            .path()
149            .map_err(|e| LaunchError::Archive(e.to_string()))?
150            .into_owned();
151
152        let stripped: PathBuf = raw_path.components().skip(strip_components).collect();
153        if stripped.as_os_str().is_empty() {
154            continue;
155        }
156
157        let out = dest.join(&stripped);
158
159        if entry.header().entry_type().is_dir() {
160            std::fs::create_dir_all(&out)?;
161        } else {
162            if let Some(parent) = out.parent() {
163                std::fs::create_dir_all(parent)?;
164            }
165            entry
166                .unpack(&out)
167                .map_err(|e| LaunchError::Archive(e.to_string()))?;
168        }
169    }
170
171    Ok(())
172}
173
174// ── Tests ─────────────────────────────────────────────────────────────────────
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::io::Write;
180    use tempfile::{NamedTempFile, TempDir};
181
182    // Build an in-memory ZIP and write it to a temp file.
183    fn make_test_zip() -> NamedTempFile {
184        use zip::write::SimpleFileOptions;
185
186        let mut tmp = NamedTempFile::new().unwrap();
187        {
188            let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
189            let opts = SimpleFileOptions::default();
190
191            w.add_directory("META-INF/", opts).unwrap();
192            w.start_file("META-INF/MANIFEST.MF", opts).unwrap();
193            w.write_all(b"Manifest-Version: 1.0\n").unwrap();
194
195            w.start_file("data/hello.txt", opts).unwrap();
196            w.write_all(b"hello world").unwrap();
197
198            w.start_file("data/world.txt", opts).unwrap();
199            w.write_all(b"world hello").unwrap();
200
201            let finished = w.finish().unwrap();
202            tmp.write_all(finished.get_ref()).unwrap();
203        }
204        tmp
205    }
206
207    #[tokio::test]
208    async fn read_specific_file() {
209        let zip_file = make_test_zip();
210        let result = get_file_from_archive(
211            zip_file.path().to_path_buf(),
212            Some("META-INF/MANIFEST.MF".into()),
213            None,
214            false,
215        )
216        .await
217        .unwrap();
218
219        match result {
220            ArchiveQueryResult::FileData(data) => {
221                assert_eq!(data, b"Manifest-Version: 1.0\n");
222            }
223            other => panic!("unexpected result: {other:?}"),
224        }
225    }
226
227    #[tokio::test]
228    async fn missing_file_returns_not_found() {
229        let zip_file = make_test_zip();
230        let result = get_file_from_archive(
231            zip_file.path().to_path_buf(),
232            Some("does_not_exist.txt".into()),
233            None,
234            false,
235        )
236        .await
237        .unwrap();
238
239        assert!(matches!(result, ArchiveQueryResult::NotFound));
240    }
241
242    #[tokio::test]
243    async fn list_all_files_no_dirs() {
244        let zip_file = make_test_zip();
245        let result =
246            get_file_from_archive(zip_file.path().to_path_buf(), None, None, false)
247                .await
248                .unwrap();
249
250        match result {
251            ArchiveQueryResult::Names(names) => {
252                assert!(names.contains(&"META-INF/MANIFEST.MF".to_string()));
253                assert!(names.contains(&"data/hello.txt".to_string()));
254                assert!(names.contains(&"data/world.txt".to_string()));
255                // directory entries excluded
256                assert!(!names.iter().any(|n| n == "META-INF/"));
257            }
258            other => panic!("unexpected result: {other:?}"),
259        }
260    }
261
262    #[tokio::test]
263    async fn list_with_prefix() {
264        let zip_file = make_test_zip();
265        let result = get_file_from_archive(
266            zip_file.path().to_path_buf(),
267            None,
268            Some("data/".into()),
269            false,
270        )
271        .await
272        .unwrap();
273
274        match result {
275            ArchiveQueryResult::Names(names) => {
276                assert_eq!(names.len(), 2);
277                assert!(names.contains(&"data/hello.txt".to_string()));
278                assert!(names.contains(&"data/world.txt".to_string()));
279            }
280            other => panic!("unexpected result: {other:?}"),
281        }
282    }
283
284    #[tokio::test]
285    async fn list_all_entries_include_dirs() {
286        let zip_file = make_test_zip();
287        let result =
288            get_file_from_archive(zip_file.path().to_path_buf(), None, None, true)
289                .await
290                .unwrap();
291
292        match result {
293            ArchiveQueryResult::Entries(entries) => {
294                assert!(entries.iter().any(|e| e.is_dir && e.name == "META-INF/"));
295                assert!(entries.iter().any(|e| !e.is_dir && e.name == "data/hello.txt"));
296            }
297            other => panic!("unexpected result: {other:?}"),
298        }
299    }
300
301    #[tokio::test]
302    async fn extract_tar_gz_strips_root() {
303        // Build a simple .tar.gz with one nested file: root/file.txt
304        let dest = TempDir::new().unwrap();
305        let src = {
306            use flate2::write::GzEncoder;
307            use flate2::Compression;
308
309            let mut tar_data = Vec::new();
310            {
311                let enc = GzEncoder::new(&mut tar_data, Compression::fast());
312                let mut builder = tar::Builder::new(enc);
313
314                let content = b"tar content";
315                let mut header = tar::Header::new_gnu();
316                header.set_path("jdk-21/file.txt").unwrap();
317                header.set_size(content.len() as u64);
318                header.set_mode(0o644);
319                header.set_cksum();
320                builder.append(&header, content.as_ref()).unwrap();
321                builder.finish().unwrap();
322            }
323
324            let mut f = NamedTempFile::new().unwrap();
325            f.write_all(&tar_data).unwrap();
326            f
327        };
328
329        extract_tar_gz(src.path().to_path_buf(), dest.path().to_path_buf(), 1)
330            .await
331            .unwrap();
332
333        let out = dest.path().join("file.txt");
334        assert!(out.exists(), "file.txt should exist after extraction");
335        assert_eq!(std::fs::read(&out).unwrap(), b"tar content");
336    }
337}