Skip to main content

minecraft_java_rs_core/game/
bundle.rs

1use std::path::PathBuf;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::Arc;
4
5use tokio::sync::mpsc::Sender;
6use tokio::sync::Semaphore;
7use tokio::task::JoinSet;
8
9use crate::error::LaunchError;
10use crate::launcher::events::LaunchEvent;
11use crate::models::minecraft::AssetItem;
12use crate::net::downloader::DownloadItem;
13use crate::utils::hash::{get_file_hash, HashAlgorithm};
14
15// ── Public API ────────────────────────────────────────────────────────────────
16
17/// Sum of `size` fields for all `Asset` and `NativeAsset` items.
18pub fn get_total_size(bundle: &[AssetItem]) -> u64 {
19    bundle
20        .iter()
21        .map(|item| match item {
22            AssetItem::Asset { size, .. } | AssetItem::NativeAsset { size, .. } => *size,
23            AssetItem::CFile { .. } => 0,
24        })
25        .sum()
26}
27
28/// Write every `CFile` to disk and return a `Vec<DownloadItem>` for every
29/// `Asset` / `NativeAsset` that is either missing from disk or whose SHA-1
30/// does not match the expected value.
31///
32/// Emits `LaunchEvent::Check` progress events as each file is evaluated.
33/// Up to `concurrency` files are checked in parallel.
34pub async fn check_bundle(
35    bundle: &[AssetItem],
36    event_tx: &Sender<LaunchEvent>,
37    concurrency: u32,
38) -> Result<Vec<DownloadItem>, LaunchError> {
39    let total = bundle.len();
40    let semaphore = Arc::new(Semaphore::new(concurrency as usize));
41    let counter = Arc::new(AtomicUsize::new(0));
42    let mut tasks: JoinSet<Result<Option<DownloadItem>, LaunchError>> = JoinSet::new();
43
44    for item in bundle.iter().cloned() {
45        let sem = Arc::clone(&semaphore);
46        let tx = event_tx.clone();
47        let counter = Arc::clone(&counter);
48
49        tasks.spawn(async move {
50            let _permit = sem.acquire().await.unwrap();
51
52            let current = counter.fetch_add(1, Ordering::Relaxed) + 1;
53            let _ = tx
54                .send(LaunchEvent::Check {
55                    current,
56                    total,
57                    kind: "bundle".into(),
58                })
59                .await;
60
61            match item {
62                AssetItem::CFile { path, content } => {
63                    let dest = PathBuf::from(&path);
64                    if let Some(parent) = dest.parent() {
65                        tokio::fs::create_dir_all(parent).await?;
66                    }
67                    if !dest.exists() {
68                        tokio::fs::write(&dest, content).await?;
69                    }
70                    Ok(None)
71                }
72
73                AssetItem::Asset {
74                    ref path,
75                    ref sha1,
76                    size,
77                    ref url,
78                }
79                | AssetItem::NativeAsset {
80                    ref path,
81                    ref sha1,
82                    size,
83                    ref url,
84                } => {
85                    let dest = PathBuf::from(path);
86                    let needs_download = if dest.exists() {
87                        if sha1.is_empty() {
88                            false
89                        } else {
90                            let dest_clone = dest.clone();
91                            let expected = sha1.clone();
92                            tokio::task::spawn_blocking(move || -> bool {
93                                match get_file_hash(&dest_clone, HashAlgorithm::Sha1) {
94                                    Ok(actual) => actual != expected,
95                                    Err(_) => true,
96                                }
97                            })
98                            .await
99                            .unwrap_or(true)
100                        }
101                    } else {
102                        true
103                    };
104
105                    if needs_download {
106                        let folder = dest
107                            .parent()
108                            .map(|p| p.to_path_buf())
109                            .unwrap_or_else(|| PathBuf::from("."));
110
111                        let kind = match item {
112                            AssetItem::NativeAsset { .. } => "natives",
113                            _ => "assets",
114                        };
115
116                        Ok(Some(DownloadItem {
117                            url: url.clone(),
118                            path: dest.clone(),
119                            folder,
120                            name: dest
121                                .file_name()
122                                .map(|n| n.to_string_lossy().into_owned())
123                                .unwrap_or_default(),
124                            size,
125                            r#type: Some(kind.into()),
126                            sha1: if sha1.is_empty() {
127                                None
128                            } else {
129                                Some(sha1.clone())
130                            },
131                        }))
132                    } else {
133                        Ok(None)
134                    }
135                }
136            }
137        });
138    }
139
140    let mut pending: Vec<DownloadItem> = Vec::new();
141    while let Some(result) = tasks.join_next().await {
142        match result {
143            Ok(Ok(Some(item))) => pending.push(item),
144            Ok(Ok(None)) => {}
145            Ok(Err(e)) => return Err(e),
146            Err(e) => {
147                return Err(LaunchError::Io(std::io::Error::new(
148                    std::io::ErrorKind::Other,
149                    e.to_string(),
150                )))
151            }
152        }
153    }
154
155    Ok(pending)
156}
157
158/// Verify the SHA-1 of every `Asset` / `NativeAsset` that is present on disk.
159///
160/// Emits `LaunchEvent::Check` events and returns the paths of any files whose
161/// digest does not match the expected value. Up to `concurrency` files are
162/// hashed in parallel; use a lower value than `download_concurrency` to avoid
163/// seek thrashing on HDDs.
164pub async fn check_files(
165    bundle: &[AssetItem],
166    event_tx: &Sender<LaunchEvent>,
167    concurrency: u32,
168) -> Result<Vec<String>, LaunchError> {
169    let items: Vec<(PathBuf, String)> = bundle
170        .iter()
171        .filter_map(|item| match item {
172            AssetItem::Asset { path, sha1, .. } | AssetItem::NativeAsset { path, sha1, .. } => {
173                let p = PathBuf::from(path);
174                if p.exists() && !sha1.is_empty() {
175                    Some((p, sha1.clone()))
176                } else {
177                    None
178                }
179            }
180            AssetItem::CFile { .. } => None,
181        })
182        .collect();
183
184    let total = items.len();
185    let semaphore = Arc::new(Semaphore::new(concurrency as usize));
186    let counter = Arc::new(AtomicUsize::new(0));
187    let mut tasks: JoinSet<Result<Option<String>, LaunchError>> = JoinSet::new();
188
189    for (path, expected_sha1) in items {
190        let sem = Arc::clone(&semaphore);
191        let tx = event_tx.clone();
192        let counter = Arc::clone(&counter);
193
194        tasks.spawn(async move {
195            let _permit = sem.acquire().await.unwrap();
196
197            let current = counter.fetch_add(1, Ordering::Relaxed) + 1;
198            let _ = tx
199                .send(LaunchEvent::Check {
200                    current,
201                    total,
202                    kind: "verify".into(),
203                })
204                .await;
205
206            let path_clone = path.clone();
207            let actual = tokio::task::spawn_blocking(move || {
208                get_file_hash(&path_clone, HashAlgorithm::Sha1)
209            })
210            .await
211            .map_err(|e| {
212                LaunchError::Io(std::io::Error::new(
213                    std::io::ErrorKind::Other,
214                    e.to_string(),
215                ))
216            })??;
217
218            if actual != expected_sha1 {
219                Ok(Some(path.to_string_lossy().into_owned()))
220            } else {
221                Ok(None)
222            }
223        });
224    }
225
226    let mut bad: Vec<String> = Vec::new();
227    while let Some(result) = tasks.join_next().await {
228        match result {
229            Ok(Ok(Some(path))) => bad.push(path),
230            Ok(Ok(None)) => {}
231            Ok(Err(e)) => return Err(e),
232            Err(e) => {
233                return Err(LaunchError::Io(std::io::Error::new(
234                    std::io::ErrorKind::Other,
235                    e.to_string(),
236                )))
237            }
238        }
239    }
240
241    Ok(bad)
242}
243
244// ── Tests ─────────────────────────────────────────────────────────────────────
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use tempfile::TempDir;
250    use tokio::sync::mpsc;
251
252    fn asset(path: &str, sha1: &str, size: u64, url: &str) -> AssetItem {
253        AssetItem::Asset {
254            path: path.into(),
255            sha1: sha1.into(),
256            size,
257            url: url.into(),
258        }
259    }
260
261    fn cfile(path: &str, content: &str) -> AssetItem {
262        AssetItem::CFile {
263            path: path.into(),
264            content: content.into(),
265        }
266    }
267
268    #[test]
269    fn get_total_size_sums_assets() {
270        let bundle = vec![
271            asset("/a", "aa", 100, "http://x"),
272            asset("/b", "bb", 200, "http://x"),
273            cfile("/c", "data"),
274        ];
275        assert_eq!(get_total_size(&bundle), 300);
276    }
277
278    #[test]
279    fn get_total_size_empty() {
280        assert_eq!(get_total_size(&[]), 0);
281    }
282
283    #[tokio::test]
284    async fn check_bundle_writes_cfile() {
285        let dir = TempDir::new().unwrap();
286        let path = dir.path().join("indexes").join("test.json");
287        let bundle = vec![cfile(&path.to_string_lossy(), r#"{"objects":{}}"#)];
288        let (tx, _rx) = mpsc::channel(16);
289        let pending = check_bundle(&bundle, &tx, 4).await.unwrap();
290        assert!(pending.is_empty());
291        assert!(path.exists());
292        let written = std::fs::read_to_string(&path).unwrap();
293        assert_eq!(written, r#"{"objects":{}}"#);
294    }
295
296    #[tokio::test]
297    async fn check_bundle_skips_existing_cfile() {
298        let dir = TempDir::new().unwrap();
299        let path = dir.path().join("file.json");
300        tokio::fs::write(&path, b"original").await.unwrap();
301
302        let bundle = vec![cfile(&path.to_string_lossy(), "new content")];
303        let (tx, _rx) = mpsc::channel(16);
304        check_bundle(&bundle, &tx, 4).await.unwrap();
305
306        let content = std::fs::read_to_string(&path).unwrap();
307        assert_eq!(content, "original");
308    }
309
310    #[tokio::test]
311    async fn check_bundle_missing_asset_added_to_pending() {
312        let dir = TempDir::new().unwrap();
313        let path = dir.path().join("missing.jar");
314        let bundle = vec![asset(
315            &path.to_string_lossy(),
316            "deadbeef",
317            42,
318            "http://example.com/a.jar",
319        )];
320
321        let (tx, _rx) = mpsc::channel(16);
322        let pending = check_bundle(&bundle, &tx, 4).await.unwrap();
323        assert_eq!(pending.len(), 1);
324        assert_eq!(pending[0].url, "http://example.com/a.jar");
325    }
326
327    #[tokio::test]
328    async fn check_bundle_correct_hash_skips_download() {
329        use sha1::{Digest, Sha1};
330
331        let dir = TempDir::new().unwrap();
332        let path = dir.path().join("asset.dat");
333        let content = b"hello world";
334        tokio::fs::write(&path, content).await.unwrap();
335
336        let mut hasher = Sha1::new();
337        hasher.update(content);
338        let sha1 = format!("{:x}", hasher.finalize());
339
340        let bundle = vec![asset(
341            &path.to_string_lossy(),
342            &sha1,
343            11,
344            "http://example.com/x",
345        )];
346        let (tx, _rx) = mpsc::channel(16);
347        let pending = check_bundle(&bundle, &tx, 4).await.unwrap();
348        assert!(pending.is_empty());
349    }
350
351    #[tokio::test]
352    async fn check_bundle_wrong_hash_queues_download() {
353        let dir = TempDir::new().unwrap();
354        let path = dir.path().join("asset.dat");
355        tokio::fs::write(&path, b"stale content").await.unwrap();
356
357        let bundle = vec![asset(
358            &path.to_string_lossy(),
359            "0000000000000000000000000000000000000000",
360            13,
361            "http://example.com/x",
362        )];
363        let (tx, _rx) = mpsc::channel(16);
364        let pending = check_bundle(&bundle, &tx, 4).await.unwrap();
365        assert_eq!(pending.len(), 1);
366    }
367
368    #[tokio::test]
369    async fn check_files_returns_empty_for_correct_files() {
370        use sha1::{Digest, Sha1};
371
372        let dir = TempDir::new().unwrap();
373        let path = dir.path().join("lib.jar");
374        let content = b"jar content";
375        tokio::fs::write(&path, content).await.unwrap();
376
377        let mut hasher = Sha1::new();
378        hasher.update(content);
379        let sha1 = format!("{:x}", hasher.finalize());
380
381        let bundle = vec![asset(&path.to_string_lossy(), &sha1, 11, "http://x")];
382        let (tx, _rx) = mpsc::channel(16);
383        let bad = check_files(&bundle, &tx, 4).await.unwrap();
384        assert!(bad.is_empty());
385    }
386
387    #[tokio::test]
388    async fn check_files_reports_corrupted_file() {
389        let dir = TempDir::new().unwrap();
390        let path = dir.path().join("lib.jar");
391        tokio::fs::write(&path, b"corrupted").await.unwrap();
392
393        let bundle = vec![asset(
394            &path.to_string_lossy(),
395            "0000000000000000000000000000000000000000",
396            9,
397            "http://x",
398        )];
399        let (tx, _rx) = mpsc::channel(16);
400        let bad = check_files(&bundle, &tx, 4).await.unwrap();
401        assert_eq!(bad.len(), 1);
402    }
403
404    #[tokio::test]
405    async fn check_files_skips_missing_files() {
406        let dir = TempDir::new().unwrap();
407        let path = dir.path().join("nonexistent.jar");
408        let bundle = vec![asset(&path.to_string_lossy(), "abc", 0, "http://x")];
409
410        let (tx, _rx) = mpsc::channel(16);
411        let bad = check_files(&bundle, &tx, 4).await.unwrap();
412        assert!(bad.is_empty());
413    }
414}