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