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