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 = 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
114pub 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 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#[cfg(test)]
179mod tests {
180 use super::*;
181 use std::io::Write;
182 use tempfile::{NamedTempFile, TempDir};
183
184 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 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 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}