minecraft_java_rs_core/utils/
archive.rs1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use crate::error::LaunchError;
5
6#[derive(Debug, Clone)]
9pub struct ArchiveEntry {
10 pub name: String,
11 pub size: u64,
12 pub is_dir: bool,
13}
14
15#[derive(Debug)]
23pub enum ArchiveQueryResult {
24 FileData(Vec<u8>),
26 Entries(Vec<ArchiveEntry>),
28 Names(Vec<String>),
30 NotFound,
32}
33
34pub 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
115pub 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 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#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::io::Write;
180 use tempfile::{NamedTempFile, TempDir};
181
182 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 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 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}