From ace375b35c35f8be01332365f12dbeff1cab58fd Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Sun, 6 Sep 2026 00:35:41 +0300 Subject: [PATCH] feat: inspect local profile file integrity --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 20 +++++- src-tauri/src/profile.rs | 129 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/profile.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 26e953c..787b26e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2799,6 +2799,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2", "tauri", "tauri-build", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 491935d..fc167c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,4 +15,5 @@ tauri-build = { version = "2", features = [] } [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tauri = { version = "2", features = [] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a49c5ea..8af60b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod manifest; +mod profile; use serde::Serialize; use tauri::{AppHandle, Manager}; @@ -33,9 +34,26 @@ fn validate_manifest(manifest_json: String) -> Result<(), String> { manifest::validate_json(&manifest_json).map(|_| ()).map_err(|error| error.to_string()) } +/// Inspects the local profile without changing player files. +#[tauri::command] +async fn inspect_profile(app: AppHandle, manifest_json: String) -> Result { + let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?; + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("Cannot resolve launcher data directory: {error}"))? + .join("profiles") + .join(&manifest.id); + + tauri::async_runtime::spawn_blocking(move || profile::inspect(&root, &manifest)) + .await + .map_err(|error| format!("Profile inspection task failed: {error}"))? + .map_err(|error| error.to_string()) +} + pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![native_host, validate_manifest]) + .invoke_handler(tauri::generate_handler![native_host, validate_manifest, inspect_profile]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); } diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs new file mode 100644 index 0000000..1b0b0c0 --- /dev/null +++ b/src-tauri/src/profile.rs @@ -0,0 +1,129 @@ +use crate::manifest::Manifest; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{fmt, fs::File, io::{self, Read}, path::Path}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileInspection { + pub root: String, + pub managed_files: usize, + pub missing_files: usize, + pub mismatched_files: usize, + pub up_to_date: bool, +} + +#[derive(Debug)] +pub enum ProfileError { + Io(io::Error), +} + +impl fmt::Display for ProfileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"), + } + } +} + +pub fn inspect(root: &Path, manifest: &Manifest) -> Result { + let mut missing_files = 0; + let mut mismatched_files = 0; + + for expected in &manifest.files { + let path = root.join(&expected.path); + let metadata = match path.metadata() { + Ok(metadata) if metadata.is_file() => metadata, + Ok(_) => { + mismatched_files += 1; + continue; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + missing_files += 1; + continue; + } + Err(error) => return Err(ProfileError::Io(error)), + }; + + if metadata.len() != expected.size || sha256(&path)? != expected.sha256.to_ascii_lowercase() { + mismatched_files += 1; + } + } + + Ok(ProfileInspection { + root: root.display().to_string(), + managed_files: manifest.files.len(), + missing_files, + mismatched_files, + up_to_date: missing_files == 0 && mismatched_files == 0, + }) +} + +fn sha256(path: &Path) -> Result { + let mut file = File::open(path).map_err(ProfileError::Io)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + + loop { + let read = file.read(&mut buffer).map_err(ProfileError::Io)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + + Ok(format!("{:x}", digest.finalize())) +} + +#[cfg(test)] +mod tests { + use super::inspect; + use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft}; + use sha2::{Digest, Sha256}; + use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + + fn manifest(hash: String, size: u64) -> Manifest { + Manifest { + schema_version: 1, + id: "aeronautics".into(), + display_name: "Aeronautics".into(), + minecraft: Minecraft { + version: "1.21.1".into(), + loader: Loader { kind: "neoforge".into(), version: "21.1.248".into() }, + java_major: 21, + }, + files: vec![ManagedFile { + path: "mods/example.jar".into(), + url: "https://cdn.shacraft.ru/example.jar".into(), + sha256: hash, + size, + policy: FilePolicy::Managed, + }], + } + } + + #[test] + fn reports_missing_and_matching_files() { + let root = std::env::temp_dir().join(format!( + "shacraft-launcher-test-{}-{}", + process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + let bytes = b"ShaCraft test file"; + let digest = format!("{:x}", Sha256::digest(bytes)); + let expected = manifest(digest, bytes.len() as u64); + + let missing = inspect(&root, &expected).unwrap(); + assert_eq!(missing.missing_files, 1); + assert!(!missing.up_to_date); + + fs::create_dir_all(root.join("mods")).unwrap(); + fs::write(root.join("mods/example.jar"), bytes).unwrap(); + let current = inspect(&root, &expected).unwrap(); + assert_eq!(current.missing_files, 0); + assert_eq!(current.mismatched_files, 0); + assert!(current.up_to_date); + + fs::remove_dir_all(root).unwrap(); + } +}