uranium_rs/version_checker.rs
1use std::path::{Path, PathBuf};
2
3use log::{error, info, warn};
4use mine_data_structs::minecraft::{
5 AssetIndex, DownloadData, Library, ObjectData, Os, Resources, Root,
6};
7use rayon::iter::{ParallelBridge, ParallelIterator};
8
9use crate::downloaders::list_instances;
10use crate::error::{Result, UraniumError};
11
12// I know this is duplicated, idc.
13const ASSETS_PATH: &str = "assets/";
14const OBJECTS_PATH: &str = "objects";
15
16/// Manages Minecraft installation verification and integrity checks.
17///
18/// This struct owns the primary data structures needed for verifying
19/// a Minecraft installation, including the installation path, instance
20/// configuration, and game resources. It serves as the central component
21/// for performing comprehensive verification operations on Minecraft
22/// installations.
23///
24/// # Example Usage
25///
26/// Basic verification workflow:
27/// ```ignore
28/// let verifier = InstallationVerifier::new(minecraft_path);
29/// let result = verifier.verify_version();
30///
31/// if result.is_valid() {
32/// println!("Installation is valid!");
33/// } else {
34/// println!("Found problems: {}", result.total_problems());
35/// }
36/// ```
37pub struct InstallationVerifier {
38 minecraft_path: PathBuf,
39 minecraft_instance: Root,
40 resources: Resources,
41}
42
43impl InstallationVerifier {
44 pub async fn new(minecraft_dir: &Path, version_id: &str) -> Result<Self> {
45 let instances = list_instances()
46 .await
47 .unwrap();
48
49 let instance_url = instances
50 .get_instance_url(version_id)
51 .ok_or(UraniumError::OtherWithReason(format!(
52 "Version {version_id} doesn't exist"
53 )))?;
54
55 let requester = reqwest::Client::new();
56
57 let minecraft_instance: Root = requester
58 .get(instance_url)
59 .send()
60 .await?
61 .json()
62 .await?;
63
64 let resources: Resources = requester
65 .get(
66 &minecraft_instance
67 .asset_index
68 .url,
69 )
70 .send()
71 .await?
72 .json::<Resources>()
73 .await?;
74
75 Ok(Self {
76 minecraft_path: minecraft_dir.to_path_buf(),
77 minecraft_instance,
78 resources,
79 })
80 }
81
82 /// Performs a comprehensive verification of the Minecraft installation.
83 ///
84 /// Verifies both libraries and objects in the installation and returns
85 /// references to any problematic files found.
86 ///
87 /// # Returns
88 ///
89 /// A `VersionCheckResult` containing references to any problematic
90 /// libraries and objects found during verification. If the installation
91 /// is completely valid, both arrays in the result will be empty.
92 ///
93 /// # Example
94 /// ```ignore
95 /// let mut verifier = InstallationVerifier::new(minecraft_path);
96 /// let result = verifier.verify();
97 ///
98 /// if result.is_valid() {
99 /// println!("Installation verified successfully!");
100 /// } else {
101 /// println!("Verification failed: {}", result.summary());
102 /// }
103 /// ```
104 pub fn verify(&self) -> VersionCheckResult<'_> {
105 let libs = self.verify_libs();
106 let objects = self.verify_objects();
107 let index = self.very_index();
108 let client = self.verify_client();
109 info!("Wrong files: {}", libs.len() + objects.len());
110 if let Some(index) = index {
111 info!("Wrong index: {}", index.id);
112 }
113 if let Some(client) = client {
114 info!("Wrong client: {}", client.sha1);
115 }
116
117 VersionCheckResult {
118 objects,
119 libs,
120 index,
121 client,
122 }
123 }
124
125 /// Verifies the integrity of the Minecraft client JAR file and returns
126 /// download data if verification fails.
127 ///
128 /// This function checks whether the client JAR file exists and verifies its
129 /// SHA1 hash against the expected hash from the download data.
130 ///
131 /// # Returns
132 ///
133 /// * `Some(&DownloadData)` - When the client JAR file is missing or has an
134 /// incorrect hash, indicating that the client needs to be downloaded or
135 /// re-downloaded
136 /// * `None` - When the client JAR file exists and passes hash verification,
137 /// meaning the local copy is valid and up-to-date, or if client download
138 /// data is not available
139 fn verify_client(&self) -> Option<&DownloadData> {
140 let client_path = self
141 .minecraft_path
142 .join("versions")
143 .join(&self.minecraft_instance.id)
144 .join(
145 self.minecraft_instance
146 .id
147 .clone()
148 + ".jar",
149 );
150
151 let client = self
152 .minecraft_instance
153 .downloads
154 .get("client")?;
155
156 if !client_path.exists() {
157 error!("Client doesn't exist: {client_path:?}");
158 Some(client)
159 } else if let Ok(false) = verify_file_hash(&client_path, &client.sha1) {
160 error!("Wrong hash for {:?}, {}", &client_path, &client.sha1);
161 Some(client)
162 } else {
163 None
164 }
165 }
166
167 /// Verifies the integrity of the asset index file and returns it if
168 /// verification fails.
169 ///
170 /// This function checks whether the asset index file exists and verifies
171 /// its SHA1 hash against the expected hash from the Minecraft instance
172 /// configuration.
173 ///
174 /// # Returns
175 ///
176 /// * `Some(&AssetIndex)` - When the asset index file is missing or has an
177 /// incorrect hash, indicating that the index needs to be downloaded or
178 /// re-downloaded
179 /// * `None` - When the asset index file exists and passes hash
180 /// verification, meaning the local copy is valid and up-to-date
181 fn very_index(&self) -> Option<&AssetIndex> {
182 let index = &self
183 .minecraft_instance
184 .asset_index;
185
186 let index_path = self
187 .minecraft_path
188 .join(ASSETS_PATH)
189 .join("indexes")
190 .join(&index.id)
191 .with_extension("json");
192
193 if !index_path.exists() {
194 return Some(index);
195 }
196
197 use std::fs;
198
199 // Mojang json comes with spaces after ',' and ':', so we need to
200 // replace them with the trimmed version.
201 let data = fs::read_to_string(&index_path)
202 .ok()?
203 .replace(":", ": ")
204 .replace(",", ", ");
205
206 use sha1::{Digest, Sha1};
207 let mut hasher = Sha1::new();
208 hasher.update(data.as_bytes());
209
210 let h = format!("{:x}", hasher.finalize());
211 if index.sha1 != h {
212 error!("Wrong hash for {:?}, {}-{}", &index_path, &index.sha1, h);
213 return Some(index);
214 }
215
216 //if let Ok(false) = verify_file_hash(&index_path, &index.sha1) {
217 // error!("Wrong hash for {:?}, {}", &index_path, &index.sha1);
218 // return Some(index);
219 //}
220 None
221 }
222
223 fn verify_libs(&self) -> Box<[&Library]> {
224 let current_os = match std::env::consts::OS {
225 "linux" => Os::Linux,
226 "windows" => Os::Windows,
227 _ => Os::Other,
228 };
229
230 // Extract libs for the current os
231 let os_libs = self
232 .minecraft_instance
233 .libraries
234 .iter()
235 .filter(|l| {
236 l.get_os()
237 .is_none_or(|os| os == current_os)
238 });
239
240 // Set up an iterator with all the data needed
241 let raw_data = os_libs.filter_map(|lib| {
242 lib.downloads
243 .as_ref()
244 .map(|d| (&d.artifact.path, &d.artifact.sha1, lib))
245 });
246
247 // Fix the lib's paths
248 let fixed_data = raw_data.map(|(path, sha1, lib)| {
249 (
250 self.minecraft_path
251 .join("libraries")
252 .join(path),
253 sha1,
254 lib,
255 )
256 });
257
258 // Verify libraries in parallel with rayon
259 let bad_objects: Vec<_> = fixed_data
260 .par_bridge()
261 .filter_map(|(lib_path, hash, lib)| {
262 if let Ok(false) = verify_file_hash(&lib_path, hash) {
263 error!("Wrong hash for {lib_path:?}, {hash}");
264 Some(lib)
265 } else {
266 None
267 }
268 })
269 .collect();
270
271 Box::from(bad_objects)
272 }
273
274 /// This method verify the objects under `assets/objects`.
275 ///
276 /// Returns:
277 /// Err(UraniumError) If something went wrong
278 /// Ok(Box<[&str]>) A boxed array of the names of the wrong files, if the
279 /// box is empty then all objects are ok.
280 fn verify_objects(&self) -> Box<[&ObjectData]> {
281 use rayon::prelude::*;
282 let base = self
283 .minecraft_path
284 .join(ASSETS_PATH)
285 .join(OBJECTS_PATH);
286
287 let bad_objects = self
288 .resources
289 .objects
290 .par_iter()
291 .flat_map(|(_, data)| {
292 let object_path = base.join(data.get_path());
293 match verify_file_hash(&object_path, &data.hash) {
294 Ok(false) => {
295 warn!("Wrong hash for {object_path:?}, {}", data.hash);
296 Some(data)
297 }
298 Err(e) => {
299 error!("Error verifying: {}", e);
300 None
301 }
302 _ => None,
303 }
304 })
305 .collect::<Vec<&ObjectData>>();
306 Box::from(bad_objects)
307 }
308}
309
310/// Result of a version check operation containing references to problematic
311/// files.
312///
313/// This structure holds references to objects and libraries that were
314/// identified as having errors or inconsistencies during the verification
315/// process. The lifetime parameter 'a ensures that the references remain valid
316/// as long as the original data in the InstallationVerifier exists.
317///
318/// # Fields
319///
320/// * objects - References to problematic object data files
321/// * libs - References to problematic library files
322///
323/// # Example Usage
324///
325/// ```ignore
326/// let verifier = InstallationVerifier::new(path);
327/// let result = verifier.verify_version();
328/// // Process problematic objects
329/// for object in result.objects.iter() {
330/// println!("Problematic object: {:?}", object);
331/// }
332/// // Check problematic libs...
333/// ```
334pub struct VersionCheckResult<'a> {
335 pub objects: Box<[&'a ObjectData]>,
336 pub libs: Box<[&'a Library]>,
337 pub index: Option<&'a AssetIndex>,
338 pub client: Option<&'a DownloadData>,
339}
340
341impl VersionCheckResult<'_> {
342 /// Returns true if the verification found no problems.
343 ///
344 /// This is a convenience method that checks if both the objects
345 /// and libraries arrays are empty, indicating a successful verification.
346 ///
347 /// # Returns
348 ///
349 /// `true` if no problematic objects or libraries were found, `false`
350 /// otherwise.
351 ///
352 /// # Example
353 ///```ignore
354 /// let result = verifier.verify_version();
355 /// if result.is_valid() {
356 /// println!("Installation is clean!");
357 /// }
358 /// ```
359 pub fn is_valid(&self) -> bool {
360 self.objects.is_empty()
361 && self.libs.is_empty()
362 && self.index.is_none()
363 && self.client.is_none()
364 }
365
366 /// Returns the total number of problematic items found.
367 ///
368 /// This combines the count of problematic objects and libraries
369 /// into a single number for quick assessment of verification results.
370 ///
371 /// # Returns
372 ///
373 /// The total count of problematic files.
374 pub fn total_problems(&self) -> usize {
375 self.objects.len()
376 + self.libs.len()
377 + self
378 .index
379 .map(|_| 1)
380 .unwrap_or_default()
381 + self
382 .client
383 .map(|_| 1)
384 .unwrap_or_default()
385 }
386
387 /// Returns the number of problematic objects found.
388 ///
389 /// # Returns
390 ///
391 /// The count of problematic objects.
392 pub fn object_count(&self) -> usize {
393 self.objects.len()
394 }
395
396 /// Returns the number of problematic libraries found.
397 ///
398 /// # Returns
399 ///
400 /// The count of problematic libraries.
401 pub fn lib_count(&self) -> usize {
402 self.libs.len()
403 }
404}
405
406// What do you think this function does eh ?
407// Duh... of course it hashes the file verifier...
408fn verify_file_hash(file_path: &Path, expected_hash: &str) -> Result<bool> {
409 // Rinth hash is sha1
410 use crate::hashes::rinth_hash;
411
412 if !file_path.exists() {
413 return Err(UraniumError::FileNotFound(
414 file_path
415 .to_string_lossy()
416 .to_string(),
417 ));
418 }
419 let actual_hash = rinth_hash(file_path);
420 Ok(actual_hash.to_lowercase() == expected_hash.to_lowercase())
421}
422
423#[cfg(test)]
424mod tests {
425 use std::sync::{Arc, Condvar, Mutex, LazyLock};
426 use tokio::sync::Notify;
427
428 use crate::{
429 downloaders::{Downloader, MinecraftDownloader},
430 variables::constants::TEMP_DIR,
431 };
432
433 use super::*;
434
435 static PAIR: LazyLock<Arc<(Mutex<bool>, Condvar)>> =
436 LazyLock::new(|| Arc::new((Mutex::new(false), Condvar::new())));
437
438 const VERSION: &str = "1.21.1";
439
440 #[tokio::test]
441 async fn a_download_minecraft() -> Result<()> {
442 let (lock, cvar) = &*(PAIR.clone());
443 let mut downloader = MinecraftDownloader::<Downloader>::init(TEMP_DIR.as_path(), VERSION)
444 .await
445 .unwrap();
446 let _ = downloader.start().await;
447 *lock.lock().unwrap() = true;
448 cvar.notify_all();
449
450 Ok(())
451 }
452
453 #[tokio::test]
454 async fn check_file() {
455 let (lock, cvar) = &*(PAIR.clone());
456 let mut started = lock.lock().unwrap();
457 while !*started {
458 started = cvar.wait(started).unwrap();
459 }
460
461 let checker = InstallationVerifier::new(&TEMP_DIR, VERSION)
462 .await
463 .unwrap();
464 let result = checker.verify();
465 assert_eq!(result.total_problems(), 0);
466 }
467
468 #[tokio::test]
469 async fn check_missing_client() {
470 let (lock, cvar) = &*(PAIR.clone());
471 let mut started = lock.lock().unwrap();
472 while !*started {
473 started = cvar.wait(started).unwrap();
474 }
475
476 let checker = InstallationVerifier::new(&TEMP_DIR, VERSION)
477 .await
478 .unwrap();
479 if let Err(e) =
480 std::fs::remove_file("/home/sergio/.local/state/uranium/versions/1.21.1/1.21.1.jar")
481 {
482 panic!("Could not remove {e}");
483 }
484 let result = checker.verify();
485 assert_eq!(result.total_problems(), 1);
486 }
487}