From dbc38b60174726826da2ca4f04611a65f7a45e5b Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Sun, 6 Sep 2026 00:41:19 +0300 Subject: [PATCH] feat: add verified profile synchronizer --- src-tauri/Cargo.lock | 304 +++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 19 ++- src-tauri/src/manifest.rs | 4 +- src-tauri/src/profile.rs | 143 +++++++++++++++++- 5 files changed, 464 insertions(+), 7 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a48185..a106c28 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -309,6 +309,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -390,6 +407,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -846,6 +872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1026,8 +1053,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1049,8 +1078,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1300,6 +1332,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1754,6 +1802,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2377,6 +2431,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2398,6 +2508,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2473,6 +2609,46 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2507,6 +2683,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2522,12 +2712,53 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2718,6 +2949,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.22.0" @@ -2789,7 +3032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2797,6 +3040,7 @@ dependencies = [ name = "shacraft-launcher" version = "0.1.0" dependencies = [ + "reqwest 0.12.28", "serde", "serde_json", "sha2", @@ -2929,6 +3173,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3092,7 +3342,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3393,6 +3643,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3683,6 +3943,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3870,6 +4136,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -3926,6 +4202,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4156,6 +4441,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4468,6 +4762,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d1e9082..abc1d59 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,3 +18,4 @@ serde_json = "1" sha2 = "0.10" tauri = { version = "2", features = [] } url = "2" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8af60b6..2eaf8a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,9 +51,26 @@ async fn inspect_profile(app: AppHandle, manifest_json: String) -> Result 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::sync(&root, &manifest)) + .await + .map_err(|error| format!("Profile synchronization task failed: {error}"))? + .map_err(|error| error.to_string()) +} + pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![native_host, validate_manifest, inspect_profile]) + .invoke_handler(tauri::generate_handler![native_host, validate_manifest, inspect_profile, sync_profile]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); } diff --git a/src-tauri/src/manifest.rs b/src-tauri/src/manifest.rs index 1b65391..d4d8ab1 100644 --- a/src-tauri/src/manifest.rs +++ b/src-tauri/src/manifest.rs @@ -40,7 +40,7 @@ pub struct ManagedFile { pub policy: FilePolicy, } -#[derive(Debug, Deserialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FilePolicy { Managed, @@ -135,7 +135,7 @@ fn is_safe_relative_path(value: &str) -> bool { && !value.split('/').any(|part| part.is_empty() || part == "." || part == "..") } -fn is_allowed_download_url(value: &str) -> bool { +pub(crate) fn is_allowed_download_url(value: &str) -> bool { let Ok(url) = Url::parse(value) else { return false; }; diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 1b0b0c0..4b2f951 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -1,7 +1,8 @@ -use crate::manifest::Manifest; +use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest}; +use reqwest::{blocking::Client, redirect::Policy}; use serde::Serialize; use sha2::{Digest, Sha256}; -use std::{fmt, fs::File, io::{self, Read}, path::Path}; +use std::{fmt, fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf}}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -13,15 +14,32 @@ pub struct ProfileInspection { pub up_to_date: bool, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncResult { + pub root: String, + pub downloaded_files: usize, + pub reused_files: usize, + pub downloaded_bytes: u64, +} + #[derive(Debug)] pub enum ProfileError { Io(io::Error), + Network(reqwest::Error), + HttpStatus { path: String, status: reqwest::StatusCode }, + InvalidResponse { path: String, message: String }, + Integrity { path: String, message: String }, } 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}"), + Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"), + Self::HttpStatus { path, status } => write!(formatter, "Download failed for {path}: server returned {status}"), + Self::InvalidResponse { path, message } => write!(formatter, "Invalid response for {path}: {message}"), + Self::Integrity { path, message } => write!(formatter, "Integrity check failed for {path}: {message}"), } } } @@ -59,6 +77,127 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result Result { + let client = Client::builder() + .redirect(Policy::custom(|attempt| { + if is_allowed_download_url(attempt.url().as_str()) { + attempt.follow() + } else { + attempt.stop() + } + })) + .build() + .map_err(ProfileError::Network)?; + + let mut downloaded_files = 0; + let mut reused_files = 0; + let mut downloaded_bytes = 0; + + for expected in &manifest.files { + let target = root.join(&expected.path); + if matches!(expected.policy, FilePolicy::Seed) && target.exists() { + reused_files += 1; + continue; + } + if is_current(&target, expected)? { + reused_files += 1; + continue; + } + + let bytes = download_file(&client, expected, &target)?; + downloaded_files += 1; + downloaded_bytes += bytes; + } + + Ok(SyncResult { + root: root.display().to_string(), + downloaded_files, + reused_files, + downloaded_bytes, + }) +} + +fn is_current(path: &Path, expected: &ManagedFile) -> Result { + let metadata = match path.metadata() { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(ProfileError::Io(error)), + }; + Ok(metadata.is_file() && metadata.len() == expected.size && sha256(path)? == expected.sha256.to_ascii_lowercase()) +} + +fn download_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result { + let parent = target.parent().ok_or_else(|| ProfileError::Integrity { + path: expected.path.clone(), + message: "target has no parent directory".into(), + })?; + fs::create_dir_all(parent).map_err(ProfileError::Io)?; + + let mut response = client.get(&expected.url).send().map_err(ProfileError::Network)?; + if !response.status().is_success() { + return Err(ProfileError::HttpStatus { path: expected.path.clone(), status: response.status() }); + } + if let Some(length) = response.content_length() { + if length != expected.size { + return Err(ProfileError::InvalidResponse { + path: expected.path.clone(), + message: format!("expected {} bytes, received Content-Length {length}", expected.size), + }); + } + } + + let temporary = temp_path(target)?; + let result = write_and_verify(&mut response, &temporary, expected); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error); + } + + fs::rename(&temporary, target).map_err(ProfileError::Io)?; + Ok(expected.size) +} + +fn temp_path(target: &Path) -> Result { + let file_name = target.file_name().and_then(|name| name.to_str()).ok_or_else(|| ProfileError::Integrity { + path: target.display().to_string(), + message: "target has no valid filename".into(), + })?; + Ok(target.with_file_name(format!(".{file_name}.shacraft.part"))) +} + +fn write_and_verify(response: &mut reqwest::blocking::Response, temporary: &Path, expected: &ManagedFile) -> Result<(), ProfileError> { + let mut output = File::create(temporary).map_err(ProfileError::Io)?; + let mut digest = Sha256::new(); + let mut bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + + loop { + let read = response.read(&mut buffer).map_err(ProfileError::Io)?; + if read == 0 { + break; + } + output.write_all(&buffer[..read]).map_err(ProfileError::Io)?; + digest.update(&buffer[..read]); + bytes += read as u64; + } + output.sync_all().map_err(ProfileError::Io)?; + + if bytes != expected.size { + return Err(ProfileError::Integrity { + path: expected.path.clone(), + message: format!("expected {} bytes, downloaded {bytes}", expected.size), + }); + } + let actual = format!("{:x}", digest.finalize()); + if actual != expected.sha256.to_ascii_lowercase() { + return Err(ProfileError::Integrity { + path: expected.path.clone(), + message: "SHA-256 does not match manifest".into(), + }); + } + Ok(()) +} + fn sha256(path: &Path) -> Result { let mut file = File::open(path).map_err(ProfileError::Io)?; let mut digest = Sha256::new();