From 22a78530c0030bacc55408201f43cbfe6afa2f2e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 18:58:07 +0300 Subject: [PATCH] fix(install): reconcile and repair one verified snapshot under a game lease --- src-tauri/Cargo.lock | 107 +++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/game.rs | 426 ++++++++------- src-tauri/src/commands/profiles.rs | 107 +++- src-tauri/src/installation_lock.rs | 271 ++++++++++ src-tauri/src/inventory.rs | 602 ++++++++++++++++++++++ src-tauri/src/launch.rs | 8 + src-tauri/src/lib.rs | 8 +- src-tauri/src/neoforge.rs | 145 ++---- src-tauri/src/neoforge_repair.rs | 761 +++++++++++++++++++++++++++ src-tauri/src/profile.rs | 797 ++++++++++++++++++++++++++--- src-tauri/src/remote.rs | 24 +- src-tauri/src/shacraft_account.rs | 132 ++++- 13 files changed, 3022 insertions(+), 367 deletions(-) create mode 100644 src-tauri/src/installation_lock.rs create mode 100644 src-tauri/src/inventory.rs create mode 100644 src-tauri/src/neoforge_repair.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2570698..191fe81 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2057,6 +2057,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2221,6 +2230,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -3227,6 +3246,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "sysinfo", "tar", "tauri", "tauri-build", @@ -3446,6 +3466,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3493,7 +3527,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3575,7 +3609,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3662,7 +3696,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3687,7 +3721,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4434,7 +4468,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4458,7 +4492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4514,11 +4548,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -4530,6 +4576,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4564,7 +4619,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -4611,6 +4677,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4723,6 +4799,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -4906,7 +4991,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64a2ba6..b3cb7a2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,3 +27,4 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", flate2 = "1" tar = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } +sysinfo = { version = "0.39.6", default-features = false, features = ["system"] } diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 267c25d..8754300 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,7 +1,7 @@ -use super::{data_dir, shacraft::resolve_identity}; +use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity}; use crate::{ - java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime, - settings, + installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge, + operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account, }; use reqwest::blocking::Client; use serde::Serialize; @@ -15,132 +15,156 @@ pub(crate) struct InstallProgress { current_bytes: u64, total_bytes: u64, } +fn progress(app: &AppHandle, stage: &'static str) -> mojang::ProgressCallback { + let app = app.clone(); + Arc::new(move |current, total| { + let _ = app.emit( + "game-install-progress", + InstallProgress { + stage, + current_bytes: current, + total_bytes: total, + }, + ); + }) +} -/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and -/// merges them, ensuring a Java runtime and (for NeoForge profiles) running -/// the installer along the way. Shared by `ensure_game_installed` and -/// `launch_game` so both always agree on exactly what "installed" means. -/// `on_progress` is forwarded to the NeoForge installer when one runs; -/// callers that don't display progress (e.g. `launch_game`, which only -/// hits this after `ensure_game_installed` already installed everything) -/// pass a no-op callback. fn resolve_merged_version( client: &Client, manifest: &manifest::Manifest, - java_executable: &Path, + java: &Path, game_dir: &Path, cache_dir: &Path, on_progress: &mojang::ProgressCallback, ) -> Result { - let mojang_manifest = - mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?; - let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version) - .ok_or_else(|| { - format!( - "Mojang does not list Minecraft version {}", - manifest.minecraft.version - ) - })?; - let vanilla = - mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?; - + let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?; + let entry = mojang::find_version(&catalog, &manifest.minecraft.version) + .ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?; + let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?; + // The installer may reuse an existing vanilla JAR without verifying it. + // Establish provider integrity BEFORE any NeoForge processor uses that input. + mojang::ensure_client_jar( + client, + game_dir, + &vanilla.id, + &vanilla + .downloads + .as_ref() + .ok_or("Vanilla client download metadata is missing")? + .client, + ) + .map_err(|e| e.to_string())?; if manifest.minecraft.loader.kind == "neoforge" { - let installer_client = neoforge::http_client().map_err(|error| error.to_string())?; - let neoforge_version = neoforge::ensure_client_installed( + let installer_client = neoforge::http_client().map_err(|e| e.to_string())?; + let loader = neoforge::ensure_client_installed( &installer_client, - java_executable, + java, game_dir, cache_dir, &manifest.minecraft.loader.version, + &vanilla, on_progress, ) - .map_err(|error| error.to_string())?; - mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string()) + .map_err(|e| e.to_string())?; + mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.to_string()) } else { - mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string()) + mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string()) } } -/// Downloads and installs everything needed to run `profile_id`: the -/// exact Minecraft/loader version the ShaCraft-signed manifest specifies, -/// a Java runtime if none is already usable, and game assets. Emits -/// `game-install-progress` throughout with real progress for every stage: -/// download bytes for Java, installer-confirmed library/processor counts -/// for NeoForge, and download bytes for libraries/assets. +/// Internal stages receive the same verified snapshot; none can refetch it. +fn prepare( + directory: &Path, + snapshot: &remote::VerifiedSnapshot, + progress: &impl Fn(&'static str) -> mojang::ProgressCallback, +) -> Result< + ( + mojang::MergedVersion, + java::JavaInstallation, + profile::ProfileInspection, + ), + String, +> { + let manifest = &snapshot.manifest; + let root = directory.join("profiles").join(&manifest.id); + progress("mods")(0, 0); + profile::sync_snapshot(&root, snapshot).map_err(|e| e.to_string())?; + let inspection = profile::inspect(&root, manifest).map_err(|e| e.to_string())?; + if !inspection.up_to_date { + return Err( + "Синхронизация не завершена; запуск остановлен. Проверьте конфликты файлов.".into(), + ); + } + let game_dir = directory.join("game"); + let client = mojang::http_client().map_err(|e| e.to_string())?; + let runtime_client = runtime::http_client().map_err(|e| e.to_string())?; + let java = java::ensure_java( + &runtime_client, + &game_dir.join("runtime"), + manifest.minecraft.java_major, + &progress("java"), + ) + .map_err(|e| e.to_string())?; + let merged = resolve_merged_version( + &client, + manifest, + Path::new(&java.executable), + &game_dir, + &game_dir.join("cache"), + &progress("neoforge"), + )?; + let libraries_client = mojang::library_http_client().map_err(|e| e.to_string())?; + mojang::ensure_client_jar( + &client, + &game_dir, + &merged.client_jar_version_id, + &merged.client, + ) + .map_err(|e| e.to_string())?; + mojang::ensure_libraries( + &libraries_client, + &game_dir, + &merged.libraries, + &progress("libraries"), + ) + .map_err(|e| e.to_string())?; + let index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index) + .map_err(|e| e.to_string())?; + mojang::ensure_assets(&client, &game_dir, &index, &progress("assets")) + .map_err(|e| e.to_string())?; + Ok((merged, java, inspection)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PreparationResult { + inspection: profile::ProfileInspection, + metadata: ProfileMetadata, + onboarding: Option, +} + +/// Full repair, using one snapshot and one writer lock for mods AND the game. #[tauri::command] pub(crate) async fn ensure_game_installed( app: AppHandle, state: State<'_, LauncherOperations>, profile_id: String, -) -> Result<(), String> { - let game_dir = data_dir(&app)?.join("game"); - let runtime_root = game_dir.join("runtime"); - let cache_dir = game_dir.join("cache"); +) -> Result { + let directory = data_dir(&app)?; let permit = state.installation.acquire("Installation")?; - - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { let _permit = permit; - let client = mojang::http_client().map_err(|error| error.to_string())?; - let runtime_client = runtime::http_client().map_err(|error| error.to_string())?; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - - let stage_progress = |stage: &'static str| -> mojang::ProgressCallback { - let app = app.clone(); - Arc::new(move |current, total| { - let _ = app.emit( - "game-install-progress", - InstallProgress { - stage, - current_bytes: current, - total_bytes: total, - }, - ); - }) - }; - - let java_install = java::ensure_java( - &runtime_client, - &runtime_root, - manifest.minecraft.java_major, - &stage_progress("java"), - ) - .map_err(|error| error.to_string())?; - - let merged = resolve_merged_version( - &client, - &manifest, - Path::new(&java_install.executable), - &game_dir, - &cache_dir, - &stage_progress("neoforge"), - )?; - // NeoForge may leave vanilla runtime libraries (including LWJGL) absent. - // Verify the full merged set, using the loader Maven only for libraries. - let library_client = mojang::library_http_client().map_err(|error| error.to_string())?; - mojang::ensure_client_jar( - &client, - &game_dir, - &merged.client_jar_version_id, - &merged.client, - ) - .map_err(|error| error.to_string())?; - mojang::ensure_libraries( - &library_client, - &game_dir, - &merged.libraries, - &stage_progress("libraries"), - ) - .map_err(|error| error.to_string())?; - - let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index) - .map_err(|error| error.to_string())?; - mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets")) - .map_err(|error| error.to_string())?; - - Ok(()) + let _lock = InstallationLock::acquire(&directory)?; + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?; + Ok(PreparationResult { + inspection, + metadata: (&snapshot).into(), + onboarding: None, + }) }) .await - .map_err(|error| format!("Install task failed: {error}"))? + .map_err(|e| e.to_string())? } #[derive(Clone, Serialize)] @@ -150,83 +174,141 @@ pub(crate) struct GameExited { exit_code: Option, } -/// Launches `profile_id` with the verified ShaCraft account's linked nickname. -/// Local legacy nickname/account-mode preferences cannot override the link. -/// Spawns the game detached; watches it on a -/// background thread only to emit `game-exited` when it eventually closes. +async fn play( + app: AppHandle, + state: &LauncherOperations, + profile_id: String, + onboarding_name: Option, +) -> Result { + let directory = data_dir(&app)?; + let permit = state.installation.acquire("Installation")?; + let account_operation = state.shacraft_account.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + let lock = InstallationLock::acquire(&directory)?; + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + if onboarding_name.is_some() && (profile_id != "aeronautics" || !snapshot.manifest.files.iter().any(|f| + f.path.starts_with("mods/shacraft-game-bridge-") && f.path.ends_with(".jar") && f.policy == manifest::FilePolicy::Managed)) { + return Err("Опубликованная сборка ещё не поддерживает первый вход. Нужен подписанный мод ShaCraft Game Bridge.".into()); + } + let (merged, java, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?; + // Refresh identity AFTER downloads; no long-lived cached permission. + let grant = if let Some(name) = onboarding_name { + let _account = account_operation.acquire("ShaCraft account operation")?; + let grant = shacraft_account::start_onboarding(&directory, &name).map_err(|e| e.to_string())?; + shacraft_account::validate_onboarding(&directory, &grant).map_err(|e| e.to_string())?; + Some(grant) + } else { None }; + let identity = if let Some(grant) = &grant { + session::PlayerIdentity::Offline { name: grant.challenge.mc_username.clone() } + } else { resolve_identity(&directory, &account_operation)? }; + let preferences = settings::load(&directory).map_err(|e| e.to_string())?; + let logs = directory.join("logs"); + std::fs::create_dir_all(&logs).map_err(|e| e.to_string())?; + let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + let game_dir = directory.join("game"); + let profile_dir = directory.join("profiles").join(&snapshot.manifest.id); + let log_path = logs.join(format!("{profile_id}-{timestamp}.log")); + let request = launch::LaunchRequest { java_executable: Path::new(&java.executable), game_dir: &game_dir, + profile_dir: &profile_dir, merged: &merged, identity: &identity, memory_mb: preferences.memory_mb, + log_path: &log_path, onboarding_token: grant.as_ref().map(|g|g.grant_token.as_str()) }; + progress(&app, "launch")(0,0); + lock.starting()?; + let mut child = match launch::launch(&request) { + Ok(child) => child, + Err(error) => { lock.finished()?; return Err(error.to_string()); } + }; + // Failure to record a living child, including an immediate exit, + // terminates and waits for it before releasing the writer lock. + if let Err(error) = lock.running(child.id()) { + let _ = child.kill(); + if child.wait().is_ok() { let _ = lock.finished(); } + return Err(error); + } + let watch_app = app.clone(); + std::thread::spawn(move || { + let exit = child.wait(); + if exit.is_ok() { let _ = lock.finished(); } + let _ = watch_app.emit("game-exited", GameExited {profile_id,exit_code: exit.ok().and_then(|s|s.code())}); + drop(lock); + }); + Ok(PreparationResult { inspection, metadata: (&snapshot).into(), onboarding: grant.map(|g|g.challenge) }) + }).await.map_err(|e|e.to_string())? +} + +/// Legacy command also performs the entire preparation; no public IPC can skip +/// reconciliation or substitute a fresh manifest between install and launch. #[tauri::command] pub(crate) async fn launch_game( app: AppHandle, state: State<'_, LauncherOperations>, profile_id: String, -) -> Result<(), String> { - let game_dir = data_dir(&app)?.join("game"); - let data_dir = data_dir(&app)?; - let permit = state.installation.acquire("Installation")?; - let account_operation = state.shacraft_account.clone(); +) -> Result { + play(app, &state, profile_id, None).await +} +#[tauri::command] +pub(crate) async fn launch_onboarding( + app: AppHandle, + state: State<'_, LauncherOperations>, + profile_id: String, + nickname: String, +) -> Result { + if !shacraft_account::valid_nickname(&nickname) { + return Err("Неверный игровой ник".into()); + } + play(app, &state, profile_id, Some(nickname)).await +} - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { - let _permit = permit; - let client = mojang::http_client().map_err(|error| error.to_string())?; - let runtime_client = runtime::http_client().map_err(|error| error.to_string())?; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - let profile_dir = data_dir.join("profiles").join(&manifest.id); - let settings = settings::load(&data_dir).map_err(|error| error.to_string())?; - let identity = resolve_identity(&data_dir, &account_operation)?; +#[cfg(test)] +mod tests { + use super::*; - // Everything here should already be installed by `ensure_game_installed`, - // so these are expected to hit their fast paths; no progress to show. - let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {}); - let java_install = java::ensure_java( - &runtime_client, - &game_dir.join("runtime"), - manifest.minecraft.java_major, - &no_progress, - ) - .map_err(|error| error.to_string())?; - let merged = resolve_merged_version( - &client, - &manifest, - Path::new(&java_install.executable), - &game_dir, - &game_dir.join("cache"), - &no_progress, - )?; - - let timestamp = SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let log_dir = data_dir.join("logs"); - std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?; - let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log")); - - let request = launch::LaunchRequest { - java_executable: Path::new(&java_install.executable), - game_dir: &game_dir, - profile_dir: &profile_dir, - merged: &merged, - identity: &identity, - memory_mb: settings.memory_mb, - log_path: &log_path, + /// Real provider downloads and installer execution; never logs in or joins + /// a server. Artifacts stay in a fresh temporary directory for diagnosis. + #[test] + #[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"] + fn live_cold_install_and_corruption_repair() { + let directory = std::env::temp_dir().join(format!( + "shacraft-cold-install-{}", + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + assert!(!directory.exists()); + eprintln!("Isolated installation: {}", directory.display()); + let _lock = InstallationLock::acquire(&directory).unwrap(); + let snapshot = remote::fetch_snapshot("aeronautics").unwrap(); + let callback = |stage| -> mojang::ProgressCallback { + eprintln!("Stage: {stage}"); + Arc::new(|_, _| {}) }; - let mut child = launch::launch(&request).map_err(|error| error.to_string())?; - - let watch_app = app.clone(); - let watch_profile_id = profile_id.clone(); - std::thread::spawn(move || { - let exit_code = child.wait().ok().and_then(|status| status.code()); - let _ = watch_app.emit( - "game-exited", - GameExited { - profile_id: watch_profile_id, - exit_code, - }, - ); - }); - - Ok(()) - }) - .await - .map_err(|error| format!("Launch task failed: {error}"))? + let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap(); + assert!(inspection.up_to_date); + assert_eq!(java.major, snapshot.manifest.minecraft.java_major); + let game = directory.join("game"); + let version = &snapshot.manifest.minecraft.loader.version; + let json = neoforge::installed_version_json_path(&game, version); + let jar = game.join(format!( + "libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar" + )); + let original_json = std::fs::read(&json).unwrap(); + std::fs::write(&json, b"nonempty corrupted version JSON").unwrap(); + std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap(); + let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap(); + assert!(repaired.up_to_date); + assert_eq!(std::fs::read(&json).unwrap(), original_json); + assert!(std::fs::metadata(&jar).unwrap().len() > 1024); + // A third preparation verifies the receipt and all downloads again. + assert!( + prepare(&directory, &snapshot, &callback) + .unwrap() + .2 + .up_to_date + ); + eprintln!( + "Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}", + directory.display() + ); + } } diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 67c0d85..934f8d2 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -1,47 +1,124 @@ use super::data_dir; -use crate::{operations::LauncherOperations, profile, remote}; +use crate::{installation_lock::InstallationLock, operations::LauncherOperations, profile, remote}; +use serde::Serialize; use tauri::{AppHandle, State}; +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileMetadata { + pub snapshot: String, + pub minecraft_version: String, + pub loader_kind: String, + pub loader_version: String, + pub java_major: u8, +} +impl From<&remote::VerifiedSnapshot> for ProfileMetadata { + fn from(snapshot: &remote::VerifiedSnapshot) -> Self { + let game = &snapshot.manifest.minecraft; + Self { + snapshot: snapshot.digest.clone(), + minecraft_version: game.version.clone(), + loader_kind: game.loader.kind.clone(), + loader_version: game.loader.version.clone(), + java_major: game.java_major, + } + } +} + +#[tauri::command] +pub(crate) async fn profile_metadata(profile_id: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + Ok(ProfileMetadata::from(&snapshot)) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub(crate) async fn get_server_status(profile_id: String) -> Result { tauri::async_runtime::spawn_blocking(move || { - remote::fetch_server_status(&profile_id).map_err(|error| error.to_string()) + remote::fetch_server_status(&profile_id).map_err(|e| e.to_string()) }) .await - .map_err(|error| format!("Server-status task failed: {error}"))? + .map_err(|e| e.to_string())? } -/// Loads and validates the published ShaCraft manifest before inspecting a profile. #[tauri::command] pub(crate) async fn inspect_remote_profile( app: AppHandle, profile_id: String, ) -> Result { - let data_dir = data_dir(&app)?; + let directory = data_dir(&app)?; tauri::async_runtime::spawn_blocking(move || { - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest) - .map_err(|error| error.to_string()) + // Inspection must not report a partially applied journal as ready. + let _lock = InstallationLock::acquire(&directory)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; + profile::inspect(&directory.join("profiles").join(&manifest.id), &manifest) + .map_err(|e| e.to_string()) }) .await - .map_err(|error| format!("Profile inspection task failed: {error}"))? + .map_err(|e| e.to_string())? } -/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint. #[tauri::command] pub(crate) async fn sync_remote_profile( app: AppHandle, state: State<'_, LauncherOperations>, profile_id: String, ) -> Result { - let data_dir = data_dir(&app)?; + let directory = data_dir(&app)?; let permit = state.installation.acquire("Installation")?; tauri::async_runtime::spawn_blocking(move || { let _permit = permit; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest) - .map_err(|error| error.to_string()) + let _lock = InstallationLock::acquire(&directory)?; + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + profile::sync_snapshot( + &directory.join("profiles").join(&snapshot.manifest.id), + &snapshot, + ) + .map_err(|e| e.to_string()) }) .await - .map_err(|error| format!("Profile synchronization task failed: {error}"))? + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn legacy_mods( + app: AppHandle, + profile_id: String, +) -> Result, String> { + let directory = data_dir(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let _lock = InstallationLock::acquire(&directory)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; + profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn backup_legacy_mods( + app: AppHandle, + state: State<'_, LauncherOperations>, + profile_id: String, + selections: Vec, +) -> Result { + let directory = data_dir(&app)?; + let permit = state.installation.acquire("Legacy migration")?; + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + let _lock = InstallationLock::acquire(&directory)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; + profile::backup_legacy_mods( + &directory.join("profiles").join(&manifest.id), + &manifest, + &selections, + ) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } diff --git a/src-tauri/src/installation_lock.rs b/src-tauri/src/installation_lock.rs new file mode 100644 index 0000000..38e029c --- /dev/null +++ b/src-tauri/src/installation_lock.rs @@ -0,0 +1,271 @@ +//! One writer for the entire shared game tree, across launcher instances. +//! The OS lock is retained by the child watcher. A durable process lease also +//! protects a detached Minecraft after the launcher exits (PID + start time, +//! never PID alone). An interrupted spawn with no recorded child fails closed. +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +struct ProcessIdentity { + pid: u32, + started: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "state")] +enum Lease { + Starting { launcher: ProcessIdentity }, + Running { game: ProcessIdentity }, +} + +fn process_identity(pid: u32) -> Result, String> { + let me = Pid::from_u32(std::process::id()); + let target = Pid::from_u32(pid); + let mut system = System::new(); + // sysinfo resets each refreshed process's updated flag while removing dead + // entries. Repeating a PID makes the second pass remove that live entry. + let pids = if me == target { + vec![me] + } else { + vec![me, target] + }; + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&pids), + true, + ProcessRefreshKind::nothing().without_tasks(), + ); + // An unsupported/failed process inspection must not permit file mutation. + if system.process(me).is_none() { + return Err("Не удалось проверить запущенные процессы; запись файлов заблокирована".into()); + } + system + .process(target) + .map(|process| { + let started = process.start_time(); + if started == 0 { + return Err("Не удалось определить время запуска игры".into()); + } + Ok(ProcessIdentity { pid, started }) + }) + .transpose() +} + +fn ordinary_path(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + Err("Служебный путь блокировки является ссылкой".into()) + } + Ok(_) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +pub(crate) struct InstallationLock { + _file: File, + lease: PathBuf, +} + +impl InstallationLock { + pub fn acquire(data_dir: &Path) -> Result { + ordinary_path(data_dir)?; + fs::create_dir_all(data_dir).map_err(|e| e.to_string())?; + let directory = data_dir.join("installation-state"); + ordinary_path(&directory)?; + fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let path = directory.join("writer.lock"); + ordinary_path(&path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|e| e.to_string())?; + file.try_lock().map_err(|_| "Сборка используется другим экземпляром лаунчера или игрой. Закройте игру и дождитесь завершения операции.".to_string())?; + let guard = Self { + _file: file, + lease: directory.join("game-lease.json"), + }; + ordinary_path(&guard.lease)?; + match fs::read(&guard.lease) { + Ok(bytes) => { + let lease: Lease = serde_json::from_slice(&bytes).map_err(|_| "Повреждена запись запущенной игры; запись файлов остановлена. Закройте Minecraft и восстановите служебную запись по инструкции.".to_string())?; + match lease { + Lease::Starting { .. } => return Err("Предыдущий запуск прервался до регистрации процесса. Запись файлов заблокирована: сначала завершите Minecraft и выполните ручное восстановление game-lease.json по инструкции.".into()), + Lease::Running { game } => { + if process_identity(game.pid)?.as_ref() == Some(&game) { + return Err("Minecraft ещё работает. Перед обновлением или восстановлением закройте игру.".into()); + } + fs::remove_file(&guard.lease).map_err(|e| e.to_string())?; + } + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } + Ok(guard) + } + + fn store(&self, value: &Lease) -> Result<(), String> { + ordinary_path(&self.lease)?; + crate::storage::write_atomic( + &self.lease, + &serde_json::to_vec(value).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) + } + + /// Write ahead of spawn, while holding the OS lock, closing the crash window + /// in which a child could exist with no durable evidence whatsoever. + pub fn starting(&self) -> Result<(), String> { + let launcher = + process_identity(std::process::id())?.ok_or("Launcher process disappeared")?; + self.store(&Lease::Starting { launcher }) + } + + pub fn running(&self, pid: u32) -> Result<(), String> { + let game = process_identity(pid)?.ok_or("Игра завершилась во время запуска")?; + self.store(&Lease::Running { game }) + } + + /// Only the owner, after failed spawn or wait() proving child termination, + /// clears the lease. Drop intentionally does not clear it. + pub fn finished(&self) -> Result<(), String> { + match fs::remove_file(&self.lease) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + fn dir() -> PathBuf { + let p = std::env::temp_dir().join(format!( + "shacraft-lock-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&p).unwrap(); + p + } + #[test] + fn separate_file_descriptions_exclude_writers() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + assert!(InstallationLock::acquire(&p).is_err()); + drop(a); + assert!(InstallationLock::acquire(&p).is_ok()); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn live_game_lease_survives_dropping_launcher_lock() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + a.starting().unwrap(); + a.running(std::process::id()).unwrap(); + drop(a); + assert!(InstallationLock::acquire(&p) + .unwrap_err_string() + .contains("Minecraft")); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn reused_pid_with_different_start_does_not_block_forever() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + a.store(&Lease::Running { + game: ProcessIdentity { + pid: std::process::id(), + started: 1, + }, + }) + .unwrap(); + drop(a); + assert!(InstallationLock::acquire(&p).is_ok()); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn interrupted_spawn_fails_closed() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + a.starting().unwrap(); + drop(a); + assert!(InstallationLock::acquire(&p).is_err()); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn current_process_identity_is_detected_without_duplicate_pid_removal() { + let pid = std::process::id(); + let identity = process_identity(pid).unwrap().unwrap(); + assert_eq!(identity.pid, pid); + assert!(identity.started > 0); + } + + #[test] + fn real_child_lease_blocks_until_child_exits_after_launcher_guard_drops() { + const CHILD_MODE: &str = "SHACRAFT_LEASE_TEST_CHILD"; + if std::env::var_os(CHILD_MODE).is_some() { + use std::io::Read; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + return; + } + // Launch this one test in child mode; stdin keeps it alive without a + // platform shell, installed external program or arbitrary sleep. + struct TestChild(std::process::Child); + impl Drop for TestChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let mut child = TestChild(std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("installation_lock::tests::real_child_lease_blocks_until_child_exits_after_launcher_guard_drops") + .env(CHILD_MODE, "1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn().unwrap()); + let directory = dir(); + let guard = InstallationLock::acquire(&directory).unwrap(); + guard.starting().unwrap(); + guard.running(child.0.id()).unwrap(); + drop(guard); + assert!(InstallationLock::acquire(&directory) + .unwrap_err_string() + .contains("Minecraft")); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + let guard = InstallationLock::acquire(&directory).unwrap(); + assert!(!directory + .join("installation-state/game-lease.json") + .exists()); + drop(guard); + fs::remove_dir_all(directory).unwrap(); + } + + trait ErrorText { + fn unwrap_err_string(self) -> String; + } + impl ErrorText for Result { + fn unwrap_err_string(self) -> String { + match self { + Err(e) => e, + Ok(_) => panic!("expected lock refusal"), + } + } + } +} diff --git a/src-tauri/src/inventory.rs b/src-tauri/src/inventory.rs new file mode 100644 index 0000000..022231a --- /dev/null +++ b/src-tauri/src/inventory.rs @@ -0,0 +1,602 @@ +//! Launcher-owned profile state lives next to, never inside, the payload tree. +//! Callers hold the installation lock. Local records are not remote manifests: +//! they describe completed launcher writes, and never adopt an existing file. +use crate::{download, manifest::is_portable_component, storage}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + io::{self, Read}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024; +static NEXT_TRANSACTION: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct Fingerprint { + pub size: u64, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct OwnedFile { + pub fingerprint: Fingerprint, + pub snapshot: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Inventory { + pub version: u32, + pub files: BTreeMap, +} +impl Default for Inventory { + fn default() -> Self { + Self { + version: 1, + files: BTreeMap::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Change { + pub path: String, + pub before: Option, + pub after: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Journal { + version: u32, + pub transaction: String, + pub changes: Vec, + pub next: Inventory, +} + +pub(crate) struct Store { + pub root: PathBuf, + profile: PathBuf, +} + +pub(crate) fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +pub(crate) fn safe_relative(relative: &str) -> bool { + !relative.is_empty() && relative.split('/').all(is_portable_component) +} + +// Personal game data is never eligible for automated profile management. +pub(crate) fn protected(relative: &str) -> bool { + matches!( + relative + .split('/') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str(), + "saves" | "worlds" | "screenshots" | "logs" | "crash-reports" + ) +} + +pub(crate) fn checked_path(root: &Path, relative: &str) -> io::Result { + if !safe_relative(relative) { + return Err(invalid("Unsafe stored profile path")); + } + reject_links(root)?; + let mut path = root.to_path_buf(); + for component in relative.split('/') { + path.push(component); + match fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(invalid("Profile path contains a symbolic link")) + } + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(path) +} + +fn reject_links(path: &Path) -> io::Result<()> { + // The caller supplies the trusted profile/state root. Check that root and + // its immediate parent; checked_path walks every descendant separately. + // System ancestors may legitimately be links (e.g. /var on macOS). + for ancestor in path.ancestors().take(2) { + match fs::symlink_metadata(ancestor) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(invalid( + "Launcher state/profile path contains a symbolic link", + )) + } + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +pub(crate) fn fingerprint(path: &Path) -> io::Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(invalid("Expected a regular profile file")); + } + Ok(Some(Fingerprint { + size: metadata.len(), + sha256: download::file_hashes(path)?.1, + })) +} + +fn valid_fingerprint(value: &Fingerprint) -> bool { + value.sha256.len() == 64 && value.sha256.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn validate_inventory(inventory: &Inventory) -> io::Result<()> { + if inventory.version != 1 + || inventory.files.iter().any(|(path, record)| { + !safe_relative(path) + || protected(path) + || !valid_fingerprint(&record.fingerprint) + || record.snapshot.len() != 64 + || !record.snapshot.bytes().all(|b| b.is_ascii_hexdigit()) + }) + { + return Err(invalid( + "Invalid launcher ownership inventory; existing files were preserved", + )); + } + Ok(()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> io::Result> { + reject_links(path)?; + let file = match fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + let mut bytes = Vec::new(); + file.take(MAX_STATE_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid("Launcher state is too large")); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| invalid("Invalid launcher state; existing files were preserved")) +} + +impl Store { + pub fn open(profile: &Path) -> io::Result { + reject_links(profile)?; + let name = profile + .file_name() + .and_then(|s| s.to_str()) + .filter(|s| is_portable_component(s)) + .ok_or_else(|| invalid("Invalid profile directory"))?; + let parent = profile + .parent() + .ok_or_else(|| invalid("Profile needs a parent directory"))?; + let root = parent.join(format!(".{name}.shacraft-state")); + reject_links(&root)?; + Ok(Self { + root, + profile: profile.to_path_buf(), + }) + } + pub fn load(&self) -> io::Result { + let inventory = read_json(&self.root.join("inventory.json"))?.unwrap_or_default(); + validate_inventory(&inventory)?; + Ok(inventory) + } + pub fn pending(&self) -> io::Result { + Ok(self.read_journal()?.is_some()) + } + fn read_journal(&self) -> io::Result> { + let journal: Option = read_json(&self.root.join("pending.json"))?; + if let Some(journal) = &journal { + validate_inventory(&journal.next)?; + let mut paths = std::collections::HashSet::new(); + if journal.version != 1 + || !is_portable_component(&journal.transaction) + || !journal.transaction.starts_with("tx-") + || journal.changes.iter().any(|change| { + !safe_relative(&change.path) + || protected(&change.path) + || !paths.insert(change.path.to_lowercase()) + || change + .before + .as_ref() + .is_some_and(|f| !valid_fingerprint(f)) + || change.after.as_ref().is_some_and(|f| !valid_fingerprint(f)) + }) + { + return Err(invalid( + "Invalid pending update; existing files were preserved", + )); + } + } + Ok(journal) + } + pub fn transaction(&self) -> io::Result { + reject_links(&self.root)?; + fs::create_dir_all(&self.root)?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let name = format!( + "tx-{timestamp}-{}-{}", + std::process::id(), + NEXT_TRANSACTION.fetch_add(1, Ordering::Relaxed) + ); + fs::create_dir(self.root.join(&name))?; + fs::create_dir(self.root.join(&name).join("staged"))?; + fs::create_dir(self.root.join(&name).join("backup"))?; + Ok(name) + } + pub fn stage(&self, transaction: &str, index: usize) -> io::Result { + checked_path(&self.root, &format!("{transaction}/staged/{index}")) + } + pub fn prepare( + &self, + transaction: String, + changes: Vec, + next: Inventory, + ) -> io::Result<()> { + if self.pending()? { + return Err(invalid("A previous profile update needs recovery")); + } + validate_inventory(&next)?; + let journal = Journal { + version: 1, + transaction, + changes, + next, + }; + let bytes = serde_json::to_vec(&journal).map_err(|e| invalid(e.to_string()))?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid("Profile update journal is too large")); + } + let transaction_root = checked_path(&self.root, &journal.transaction)?; + storage::write_atomic(&transaction_root.join("receipt.json"), &bytes)?; + sync_directory(&transaction_root.join("staged"))?; + sync_directory(&transaction_root)?; + storage::write_atomic(&self.root.join("pending.json"), &bytes)?; + sync_directory(&self.root) + } + /// Roll forward only when every affected path still matches its before or + /// after image. Staged bytes are rehashed; unexpected local changes stop + /// recovery without overwriting them. Backups remain available to the user. + pub fn recover(&self) -> io::Result<()> { + let Some(journal) = self.read_journal()? else { + return Ok(()); + }; + // Check every transition first, before moving any remaining file. + for (index, change) in journal.changes.iter().enumerate() { + self.check_change(&journal.transaction, index, change)?; + } + for (index, change) in journal.changes.iter().enumerate() { + self.apply_change(&journal.transaction, index, change)?; + } + let bytes = serde_json::to_vec(&journal.next).map_err(|e| invalid(e.to_string()))?; + storage::write_atomic(&self.root.join("inventory.json"), &bytes)?; + sync_directory(&self.root)?; + fs::remove_file(self.root.join("pending.json"))?; + sync_directory(&self.root) + } + fn check_change(&self, transaction: &str, index: usize, change: &Change) -> io::Result<()> { + let target = checked_path(&self.profile, &change.path)?; + let actual = fingerprint(&target)?; + let backup = checked_path(&self.root, &format!("{transaction}/backup/{index}"))?; + let saved = fingerprint(&backup)?; + if actual == change.after && (change.before.is_none() || saved == change.before) { + // A consumed stage proves that the launcher completed the rename. + // If it is still present, identical bytes may have been created by + // the user during download; do not silently adopt that file. + if change.after.is_some() && fingerprint(&self.stage(transaction, index)?)?.is_some() { + return Err(invalid(format!( + "Update conflict: {} appeared during staging; file preserved", + change.path + ))); + } + return Ok(()); + } + if actual != change.before && !(actual.is_none() && saved == change.before) { + return Err(invalid(format!( + "Update conflict: {} changed; file preserved", + change.path + ))); + } + if actual.is_some() && saved.is_some() { + return Err(invalid(format!( + "Update conflict: {} and its backup both exist", + change.path + ))); + } + if let Some(after) = &change.after { + if fingerprint(&self.stage(transaction, index)?)?.as_ref() != Some(after) { + return Err(invalid(format!( + "Staged file is missing or corrupt: {}; retry needs recovery", + change.path + ))); + } + } + Ok(()) + } + fn apply_change(&self, transaction: &str, index: usize, change: &Change) -> io::Result<()> { + self.check_change(transaction, index, change)?; + let target = checked_path(&self.profile, &change.path)?; + let actual = fingerprint(&target)?; + let backup = checked_path(&self.root, &format!("{transaction}/backup/{index}"))?; + if actual == change.after { + return Ok(()); + } + if actual.is_some() { + fs::rename(&target, &backup)?; + sync_directory(target.parent().unwrap())?; + sync_directory(backup.parent().unwrap())?; + } + if change.after.is_some() { + fs::create_dir_all(target.parent().unwrap())?; + fs::rename(self.stage(transaction, index)?, &target)?; + sync_directory(target.parent().unwrap())?; + } + Ok(()) + } +} + +fn sync_directory(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + fs::File::open(path)?.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + struct Fixture { + base: PathBuf, + profile: PathBuf, + store: Store, + } + impl Fixture { + fn new() -> Self { + let base = std::env::temp_dir().join(format!( + "shacraft-journal-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT_TRANSACTION.fetch_add(1, Ordering::Relaxed) + )); + let profile = base.join("profiles/aeronautics"); + fs::create_dir_all(profile.join("mods")).unwrap(); + let store = Store::open(&profile).unwrap(); + Self { + base, + profile, + store, + } + } + fn replacement(&self) -> (String, Change) { + fs::write(self.profile.join("mods/current.jar"), b"old").unwrap(); + let transaction = self.store.transaction().unwrap(); + fs::write(self.store.stage(&transaction, 0).unwrap(), b"new").unwrap(); + let change = Change { + path: "mods/current.jar".into(), + before: Some(fp(b"old")), + after: Some(fp(b"new")), + }; + let mut next = Inventory::default(); + next.files.insert( + change.path.clone(), + OwnedFile { + fingerprint: fp(b"new"), + snapshot: "a".repeat(64), + }, + ); + self.store + .prepare(transaction.clone(), vec![change.clone()], next) + .unwrap(); + (transaction, change) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.base).unwrap(); + } + } + fn fp(bytes: &[u8]) -> Fingerprint { + Fingerprint { + size: bytes.len() as u64, + sha256: format!("{:x}", Sha256::digest(bytes)), + } + } + + #[test] + fn crash_after_backing_up_old_file_finishes_replacement_and_ownership() { + let f = Fixture::new(); + let (transaction, _) = f.replacement(); + let backup = f.store.root.join(&transaction).join("backup/0"); + fs::rename(f.profile.join("mods/current.jar"), &backup).unwrap(); + assert!(f.store.pending().unwrap()); + f.store.recover().unwrap(); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"new" + ); + assert_eq!(fs::read(backup).unwrap(), b"old"); + assert_eq!( + f.store.load().unwrap().files["mods/current.jar"].fingerprint, + fp(b"new") + ); + assert!(!f.store.pending().unwrap()); + f.store.recover().unwrap(); // idempotent repeated recovery + } + #[test] + fn crash_after_payload_commit_before_inventory_is_recoverable() { + let f = Fixture::new(); + let (transaction, change) = f.replacement(); + f.store.apply_change(&transaction, 0, &change).unwrap(); + assert!(f.store.load().unwrap().files.is_empty()); + assert!(f.store.pending().unwrap()); + f.store.recover().unwrap(); + assert_eq!(f.store.load().unwrap().files.len(), 1); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"new" + ); + } + #[test] + fn corrupt_staging_or_locally_changed_target_preserves_payload_and_journal() { + let f = Fixture::new(); + let (transaction, _) = f.replacement(); + fs::write(f.store.stage(&transaction, 0).unwrap(), b"corrupt").unwrap(); + assert!(f.store.recover().is_err()); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"old" + ); + assert!(f.store.pending().unwrap()); + fs::write(f.store.stage(&transaction, 0).unwrap(), b"new").unwrap(); + fs::write(f.profile.join("mods/current.jar"), b"user").unwrap(); + assert!(f.store.recover().is_err()); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"user" + ); + assert!(f.store.pending().unwrap()); + } + #[test] + fn validates_all_transitions_before_resuming_any_remaining_move() { + let f = Fixture::new(); + fs::write(f.profile.join("mods/first.jar"), b"first").unwrap(); + fs::write(f.profile.join("mods/second.jar"), b"second").unwrap(); + let transaction = f.store.transaction().unwrap(); + let changes = vec![ + Change { + path: "mods/first.jar".into(), + before: Some(fp(b"first")), + after: None, + }, + Change { + path: "mods/second.jar".into(), + before: Some(fp(b"second")), + after: None, + }, + ]; + f.store + .prepare(transaction, changes, Inventory::default()) + .unwrap(); + fs::write(f.profile.join("mods/second.jar"), b"edits").unwrap(); + assert!(f.store.recover().is_err()); + assert_eq!( + fs::read(f.profile.join("mods/first.jar")).unwrap(), + b"first" + ); + assert_eq!( + fs::read(f.profile.join("mods/second.jar")).unwrap(), + b"edits" + ); + } + #[test] + fn pending_transaction_prevents_ready_even_if_current_manifest_files_match() { + let f = Fixture::new(); + let (transaction, change) = f.replacement(); + f.store.apply_change(&transaction, 0, &change).unwrap(); + let manifest = crate::manifest::validate_json(&format!(r#"{{"schemaVersion":1,"id":"aeronautics","displayName":"Test","minecraft":{{"version":"1.21.1","loader":{{"kind":"neoforge","version":"21.1.248"}},"javaMajor":21}},"files":[{{"path":"mods/current.jar","url":"https://cdn.shacraft.ru/current.jar","size":3,"sha256":"{}","policy":"managed"}}]}}"#, fp(b"new").sha256)).unwrap(); + let inspection = crate::profile::inspect(&f.profile, &manifest).unwrap(); + assert!(inspection.pending_update); + assert!(!inspection.up_to_date); + f.store.recover().unwrap(); + assert!( + crate::profile::inspect(&f.profile, &manifest) + .unwrap() + .up_to_date + ); + } + #[test] + fn matching_file_created_during_staging_is_not_adopted() { + let f = Fixture::new(); + let transaction = f.store.transaction().unwrap(); + fs::write(f.store.stage(&transaction, 0).unwrap(), b"new").unwrap(); + let mut next = Inventory::default(); + next.files.insert( + "mods/new.jar".into(), + OwnedFile { + fingerprint: fp(b"new"), + snapshot: "a".repeat(64), + }, + ); + f.store + .prepare( + transaction, + vec![Change { + path: "mods/new.jar".into(), + before: None, + after: Some(fp(b"new")), + }], + next, + ) + .unwrap(); + fs::write(f.profile.join("mods/new.jar"), b"new").unwrap(); + assert!(f.store.recover().is_err()); + assert!(f.store.load().unwrap().files.is_empty()); + assert_eq!(fs::read(f.profile.join("mods/new.jar")).unwrap(), b"new"); + assert!(f.store.pending().unwrap()); + } + + #[test] + fn corrupt_or_unsafe_inventory_fails_closed_without_adoption() { + let f = Fixture::new(); + f.store.transaction().unwrap(); + fs::write(f.store.root.join("inventory.json"), b"broken JSON").unwrap(); + assert!(f.store.load().is_err()); + let mut inventory = Inventory::default(); + inventory.files.insert( + "../outside.jar".into(), + OwnedFile { + fingerprint: fp(b"outside"), + snapshot: "a".repeat(64), + }, + ); + fs::write( + f.store.root.join("inventory.json"), + serde_json::to_vec(&inventory).unwrap(), + ) + .unwrap(); + assert!(f.store.load().is_err()); + } + #[cfg(unix)] + #[test] + fn linked_state_and_payload_are_refused() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + let outside = f.base.join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, &f.store.root).unwrap(); + assert!(Store::open(&f.profile).is_err()); + fs::remove_file(&f.store.root).unwrap(); + symlink(&outside, f.profile.join("mods/linked.jar")).unwrap(); + assert!(checked_path(&f.profile, "mods/linked.jar").is_err()); + } +} diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 71ae513..070f9b6 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -47,6 +47,8 @@ pub struct LaunchRequest<'a> { pub identity: &'a PlayerIdentity, pub memory_mb: u16, pub log_path: &'a Path, + /// Short-lived onboarding grant; never persisted or placed in command-line arguments. + pub onboarding_token: Option<&'a str>, } fn classpath_separator() -> &'static str { @@ -244,6 +246,12 @@ pub fn launch(request: &LaunchRequest) -> Result { command.arg(substitute(&argument, &vars)); } command.current_dir(request.profile_dir); + // Do not inherit a stale grant from the launcher process environment. + command.env_remove("SHACRAFT_ONBOARDING_TOKEN"); + if let Some(token) = request.onboarding_token { + command.env("SHACRAFT_ONBOARDING_TOKEN", token); + } + command.stdin(Stdio::null()); let log_file = fs::File::create(request.log_path)?; command.stdout(Stdio::from(log_file.try_clone()?)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 325ad9f..4650d8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ mod download; +mod installation_lock; +mod inventory; mod java; mod launch; mod manifest; @@ -28,6 +30,9 @@ pub fn run() { commands::profiles::inspect_remote_profile, commands::profiles::sync_remote_profile, commands::profiles::get_server_status, + commands::profiles::profile_metadata, + commands::profiles::legacy_mods, + commands::profiles::backup_legacy_mods, commands::preferences::load_settings, commands::preferences::save_settings, commands::shacraft::shacraft_authenticate, @@ -39,7 +44,8 @@ pub fn run() { commands::account::get_account, commands::account::logout, commands::game::ensure_game_installed, - commands::game::launch_game + commands::game::launch_game, + commands::game::launch_onboarding ]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index b33f61e..25b45c5 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -25,6 +25,9 @@ //! arguments already present on the merged profile) and is intentionally //! never added to our own classpath. +#[path = "neoforge_repair.rs"] +mod repair; + use crate::download::{self, Checksum, DownloadError, ProgressCallback}; use crate::mojang::VersionJson; use reqwest::blocking::Client; @@ -56,6 +59,7 @@ pub enum NeoForgeError { Download(DownloadError), Io(io::Error), InvalidJson(serde_json::Error), + InvalidInstallation(String), InstallerFailed { exit_code: Option, output_tail: String, @@ -76,6 +80,9 @@ impl fmt::Display for NeoForgeError { Self::Download(error) => write!(formatter, "{error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"), Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"), + Self::InvalidInstallation(message) => { + write!(formatter, "invalid NeoForge installation: {message}") + } Self::InstallerFailed { exit_code, output_tail, @@ -166,25 +173,8 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat .join(format!("neoforge-{loader_version}.json")) } -fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf { - game_dir - .join("libraries/net/neoforged/neoforge") - .join(loader_version) - .join(format!("neoforge-{loader_version}-client.jar")) -} - -fn is_nonempty_file(path: &Path) -> bool { - path.metadata() - .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) -} - -fn installation_complete(game_dir: &Path, loader_version: &str) -> bool { - is_nonempty_file(&installed_version_json_path(game_dir, loader_version)) - && is_nonempty_file(&patched_client_path(game_dir, loader_version)) -} - /// The installer jar bundles its own `install_profile.json`, which lists -/// exactly which libraries it will download and which processors it will +/// which libraries it may download and which processors it may /// run to patch the client — the same manifest the installer itself reads. /// Reading it upfront gives a real, version-agnostic total for progress /// reporting instead of a guessed constant. @@ -316,66 +306,51 @@ fn run_installer_with_progress( Ok((status.code(), tail)) } -/// Ensures NeoForge `loader_version` is installed into the shared -/// `game_dir` (vanilla libraries/version must already be there so the -/// installer can reuse them). No-op if already installed. Runs the -/// installer headlessly with `java_executable`; its own network calls go -/// straight to `maven.neoforged.net`/Mojang, outside our control, which is -/// an accepted trust delegation to NeoForge's official tooling once the -/// installer binary itself is SHA-256 verified. `on_progress` reports real -/// progress (installer-confirmed library downloads plus patch-processor -/// steps, read from the installer's own `install_profile.json`) while it -/// runs; it fires once with `(1, 1)` when already installed. +/// Verifies a generated installation against its provenance receipt. Legacy +/// installations and corrupt outputs are rebuilt by the verified official +/// installer in an empty staging directory. The caller must ensure vanilla's +/// client JAR first; the staged copy is checked against `vanilla` again before +/// any processor runs. No existing generated artifacts are adopted as trusted. pub fn ensure_client_installed( client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, + vanilla: &VersionJson, on_progress: &ProgressCallback, ) -> Result { - let version_json_path = installed_version_json_path(game_dir, loader_version); - if !installation_complete(game_dir, loader_version) { - ensure_launcher_profiles_stub(game_dir)?; - let installer_path = ensure_installer(client, cache_dir, loader_version)?; - - // A leftover version JSON makes some installer versions treat the - // profile as already installed even when the patched client was - // deleted or quarantined. Remove only that generated marker so the - // official installer is forced to rebuild the incomplete profile. - match fs::remove_file(&version_json_path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(NeoForgeError::Io(error)), - } - - let (total_libraries, total_processors) = - read_install_profile_counts(&installer_path).unwrap_or((0, 0)); - let total = (total_libraries + total_processors).max(1); - on_progress(0, total); - - let (exit_code, tail) = run_installer_with_progress( - java_executable, - &installer_path, - game_dir, - cache_dir, - total_libraries, - total, - on_progress, - )?; - if !installation_complete(game_dir, loader_version) { - return Err(NeoForgeError::InstallerFailed { - exit_code, - output_tail: tail, - }); - } - on_progress(total, total); - } else { + let installer_path = ensure_installer(client, cache_dir, loader_version)?; + let mut rebuilt = false; + let version = repair::ensure( + &installer_path, + game_dir, + cache_dir, + loader_version, + vanilla, + |stage| { + rebuilt = true; + let (total_libraries, total_processors) = + read_install_profile_counts(&installer_path).unwrap_or((0, 0)); + let total = (total_libraries + total_processors).max(1); + on_progress(0, total); + run_installer_with_progress( + java_executable, + &installer_path, + stage, + cache_dir, + total_libraries, + total, + on_progress, + )?; + on_progress(total, total); + Ok(()) + }, + )?; + if !rebuilt { on_progress(1, 1); } - - let bytes = fs::read(&version_json_path)?; - serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson) + Ok(version) } #[cfg(test)] @@ -412,25 +387,6 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } - #[test] - fn incomplete_install_is_not_accepted() { - let dir = std::env::temp_dir().join(format!( - "shacraft-neoforge-completeness-test-{}", - std::process::id() - )); - let version = "21.1.248"; - let json = installed_version_json_path(&dir, version); - fs::create_dir_all(json.parent().unwrap()).unwrap(); - fs::write(&json, b"{}").unwrap(); - assert!(!installation_complete(&dir, version)); - - let client = patched_client_path(&dir, version); - fs::create_dir_all(client.parent().unwrap()).unwrap(); - fs::write(&client, b"patched").unwrap(); - assert!(installation_complete(&dir, version)); - fs::remove_dir_all(dir).unwrap(); - } - #[test] fn observe_installer_line_counts_downloads_and_processor_headers() { let downloads_done = AtomicU64::new(0); @@ -492,8 +448,7 @@ mod tests { /// Full live pipeline: provisions a real Java 21 (runtime.rs) if none /// is already usable, then runs the real NeoForge 21.1.248 installer - /// into an empty game dir (it fetches and patches vanilla 1.21.1 - /// itself — confirmed manually, no pre-seeding needed) and checks the + /// into a staging game dir with a verified vanilla 1.21.1 input and checks the /// installed profile merges into a launch-shaped spec together with a /// separately-fetched vanilla version JSON (mojang.rs), exactly as /// `lib.rs`'s `ensure_game_installed` command will do it. Not run by @@ -518,8 +473,14 @@ mod tests { let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap(); - // The installer fetches and patches vanilla itself; we don't - // pre-download it. It only needs a Java runtime and an empty dir. + // Verify vanilla before the installer is allowed to use it. + mojang::ensure_client_jar( + &client, + &game_dir, + &vanilla.id, + &vanilla.downloads.as_ref().unwrap().client, + ) + .unwrap(); let progress_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); let progress: ProgressCallback = { let progress_calls = Arc::clone(&progress_calls); @@ -531,6 +492,7 @@ mod tests { &game_dir, &cache_dir, "21.1.248", + &vanilla, &progress, ) .unwrap(); @@ -577,6 +539,7 @@ mod tests { &game_dir, &cache_dir, "21.1.248", + &vanilla, &no_progress, ) .unwrap(); diff --git a/src-tauri/src/neoforge_repair.rs b/src-tauri/src/neoforge_repair.rs new file mode 100644 index 0000000..d393c15 --- /dev/null +++ b/src-tauri/src/neoforge_repair.rs @@ -0,0 +1,761 @@ +//! Local provenance for outputs created by the verified official installer. +//! No receipt is ever bootstrapped by hashing an unknown legacy installation. +//! The receipt is a final commit marker, not a vendor signature for output jars. +use super::{ensure_launcher_profiles_stub, installed_version_json_path, NeoForgeError}; +use crate::{download, manifest::is_portable_component, mojang::VersionJson, storage}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, io, + io::Read, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +const MAX_METADATA: u64 = 4 * 1024 * 1024; +static NEXT_STAGE: AtomicU64 = AtomicU64::new(0); + +fn invalid(message: impl Into) -> NeoForgeError { + NeoForgeError::InvalidInstallation(message.into()) +} + +/// Refuse links in every existing ancestor, including launcher root ancestors. +/// Same-user concurrent path substitution remains outside the OS trust model. +fn safe_path(root: &Path, relative: &str) -> Result { + if relative.is_empty() || !relative.split('/').all(is_portable_component) { + return Err(invalid("unsafe NeoForge artifact path")); + } + let target = root.join(relative); + for path in target.ancestors() { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(invalid(format!( + "symlink in NeoForge path: {}", + path.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(target) +} + +fn bounded_read(path: &Path) -> Result, NeoForgeError> { + let mut bytes = Vec::new(); + fs::File::open(path)? + .take(MAX_METADATA + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_METADATA { + return Err(invalid("NeoForge metadata is too large")); + } + Ok(bytes) +} + +fn embedded(archive: &mut zip::ZipArchive, name: &str) -> Result, NeoForgeError> { + let entry = archive + .by_name(name) + .map_err(|error| invalid(error.to_string()))?; + let mut bytes = Vec::new(); + entry.take(MAX_METADATA + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_METADATA { + return Err(invalid("embedded NeoForge metadata is too large")); + } + Ok(bytes) +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn valid_version(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && is_portable_component(value) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)) +} + +/// Only provider-owned Maven outputs are published. Archive paths, absolute +/// arguments, ROOT substitutions and client-controlled filenames are rejected. +fn coordinate_path(coordinate: &str) -> Result { + let coordinate = coordinate + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else(|| invalid("unsupported NeoForge output coordinate"))?; + let (coordinate, extension) = coordinate.split_once('@').unwrap_or((coordinate, "jar")); + let parts: Vec<_> = coordinate.split(':').collect(); + if !(3..=4).contains(&parts.len()) + || !parts.iter().all(|s| valid_version(s)) + || !matches!(parts[0], "net.minecraft" | "net.neoforged") + || !matches!(extension, "jar" | "txt") + { + return Err(invalid("unsupported NeoForge output coordinate")); + } + let classifier = parts.get(3).map(|v| format!("-{v}")).unwrap_or_default(); + let relative = format!( + "libraries/{}/{}/{}/{}-{}{classifier}.{extension}", + parts[0].replace('.', "/"), + parts[1], + parts[2], + parts[1], + parts[2] + ); + if !relative.split('/').all(is_portable_component) { + return Err(invalid("unsafe generated NeoForge coordinate")); + } + Ok(relative) +} + +fn data_value<'a>(data: &'a Value, argument: &'a str) -> Result<&'a str, NeoForgeError> { + if let Some(key) = argument.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { + data.get(key) + .and_then(|v| v.get("client")) + .and_then(Value::as_str) + .ok_or_else(|| invalid(format!("missing client recipe value: {key}"))) + } else { + Ok(argument) + } +} + +struct Recipe { + version_bytes: Vec, + version_relative: String, + outputs: BTreeMap>, + identity: Identity, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +struct Identity { + schema: u32, + loader: String, + minecraft: String, + installer_sha256: String, + recipe_sha256: String, + vanilla_sha1: String, + vanilla_size: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Receipt { + identity: Identity, + files: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FileDigest { + size: u64, + sha256: String, +} + +impl Recipe { + fn read(installer: &Path, loader: &str, vanilla: &VersionJson) -> Result { + if !valid_version(loader) || !valid_version(&vanilla.id) { + return Err(invalid("invalid Minecraft or NeoForge version")); + } + let mut archive = zip::ZipArchive::new(fs::File::open(installer)?) + .map_err(|error| invalid(error.to_string()))?; + let profile_bytes = embedded(&mut archive, "install_profile.json")?; + let version_bytes = embedded(&mut archive, "version.json")?; + let profile: Value = + serde_json::from_slice(&profile_bytes).map_err(NeoForgeError::InvalidJson)?; + let version: Value = + serde_json::from_slice(&version_bytes).map_err(NeoForgeError::InvalidJson)?; + let id = format!("neoforge-{loader}"); + if profile["spec"] != 1 + || profile["version"] != id + || profile["minecraft"] != vanilla.id + || profile["json"] != "/version.json" + || version["id"] != id + || version["inheritsFrom"] != vanilla.id + { + return Err(invalid( + "installer recipe does not match selected Minecraft/NeoForge", + )); + } + serde_json::from_slice::(&version_bytes) + .map_err(NeoForgeError::InvalidJson)?; + let data = &profile["data"]; + let processors = profile["processors"] + .as_array() + .ok_or_else(|| invalid("missing processors"))?; + let mut outputs = BTreeMap::new(); + for processor in processors { + if let Some(sides) = processor.get("sides") { + let sides = sides + .as_array() + .ok_or_else(|| invalid("invalid processor sides"))?; + if !sides.iter().any(|side| side == "client") { + continue; + } + } + let args = processor["args"] + .as_array() + .ok_or_else(|| invalid("missing processor args"))?; + for pair in args.windows(2) { + if matches!(pair[0].as_str(), Some("--output" | "--slim" | "--extra")) { + let argument = pair[1] + .as_str() + .ok_or_else(|| invalid("invalid output argument"))?; + let path = coordinate_path(data_value(data, argument)?)?; + outputs.entry(path).or_insert(None); + } + } + if let Some(expected) = processor.get("outputs") { + for (argument, digest) in expected + .as_object() + .ok_or_else(|| invalid("invalid processor outputs"))? + { + let path = coordinate_path(data_value(data, argument)?)?; + let digest = data_value( + data, + digest + .as_str() + .ok_or_else(|| invalid("invalid output hash"))?, + )?; + let digest = digest + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(digest); + if digest.len() != 40 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(invalid("unsupported processor output checksum")); + } + if let Some(Some(existing)) = outputs.get(&path) { + if existing != &digest.to_ascii_lowercase() { + return Err(invalid("conflicting output hashes")); + } + } + outputs.insert(path, Some(digest.to_ascii_lowercase())); + } + } + } + let mut portable_paths = BTreeSet::new(); + if outputs + .keys() + .any(|path| !portable_paths.insert(path.to_ascii_lowercase())) + { + return Err(invalid( + "generated output paths collide on a case-insensitive filesystem", + )); + } + let patched = coordinate_path(data_value(data, "{PATCHED}")?)?; + let expected_patched = + format!("libraries/net/neoforged/neoforge/{loader}/neoforge-{loader}-client.jar"); + let extra = coordinate_path(data_value(data, "{MC_EXTRA}")?)?; + if patched != expected_patched + || !outputs.contains_key(&patched) + || !outputs.contains_key(&extra) + { + return Err(invalid( + "unsupported recipe: missing patched client or extra output", + )); + } + let client = &vanilla + .downloads + .as_ref() + .ok_or_else(|| invalid("missing verified vanilla download"))? + .client; + if client.size == 0 + || client.sha1.len() != 40 + || !client.sha1.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(invalid("invalid verified vanilla identity")); + } + Ok(Self { + version_relative: format!("versions/{id}/{id}.json"), + version_bytes, + outputs, + identity: Identity { + schema: 1, + loader: loader.into(), + minecraft: vanilla.id.clone(), + installer_sha256: download::file_hashes(installer)?.1, + recipe_sha256: hash_bytes(&profile_bytes), + vanilla_sha1: client.sha1.to_ascii_lowercase(), + vanilla_size: client.size, + }, + }) + } + + fn paths(&self) -> impl Iterator { + self.outputs + .keys() + .chain(std::iter::once(&self.version_relative)) + } + + fn current(&self, root: &Path, receipt_path: &Path) -> Result { + let receipt = match bounded_read(receipt_path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(receipt) => receipt, + Err(_) => return Ok(false), + }, + Err(NeoForgeError::Io(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(NeoForgeError::InvalidInstallation(_)) => return Ok(false), + Err(error) => return Err(error), + }; + if receipt.identity != self.identity || receipt.files.len() != self.outputs.len() + 1 { + return Ok(false); + } + // Enumerate trusted recipe paths, never paths claimed by the local receipt. + for relative in self.paths() { + let Some(digest) = receipt.files.get(relative) else { + return Ok(false); + }; + let path = safe_path(root, relative)?; + if !download::is_current( + &path, + Some(digest.size), + &download::Checksum::Sha256(digest.sha256.clone()), + )? { + return Ok(false); + } + } + Ok(bounded_read(&safe_path(root, &self.version_relative)?)? == self.version_bytes) + } +} + +struct Stage(PathBuf); +impl Stage { + fn new(cache: &Path) -> Result { + for _ in 0..128 { + let name = format!( + "neoforge-stage-{}-{}", + std::process::id(), + NEXT_STAGE.fetch_add(1, Ordering::Relaxed) + ); + let path = safe_path(cache, &name)?; + fs::create_dir_all(cache)?; + match fs::create_dir(&path) { + Ok(()) => return Ok(Self(path)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + Err(invalid("cannot create NeoForge staging directory")) + } +} +impl Drop for Stage { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn atomic_copy(source: &Path, target: &Path) -> Result<(), NeoForgeError> { + let mut source = fs::File::open(source)?; + let mut output = storage::AtomicFile::new(target)?; + io::copy(&mut source, output.writer())?; + output.commit()?; + Ok(()) +} + +fn validate_output(path: &Path, expected_sha1: Option<&str>) -> Result { + let metadata = fs::metadata(path)?; + if !metadata.is_file() || metadata.len() == 0 { + return Err(invalid("empty generated artifact")); + } + if path.extension().is_some_and(|ext| ext == "jar") { + let mut archive = zip::ZipArchive::new(fs::File::open(path)?) + .map_err(|error| invalid(error.to_string()))?; + if archive.is_empty() { + return Err(invalid("empty generated jar")); + } + // Reading every entry validates ZIP checksums, not just its directory. + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| invalid(error.to_string()))?; + io::copy(&mut entry, &mut io::sink())?; + } + } + let (sha1, sha256) = download::file_hashes(path)?; + if expected_sha1.is_some_and(|expected| !expected.eq_ignore_ascii_case(&sha1)) { + return Err(invalid( + "generated artifact differs from recipe output checksum", + )); + } + Ok(FileDigest { + size: metadata.len(), + sha256, + }) +} + +/// `installer` is supplied only by ensure_installer, after fixed-host SHA-256 +/// verification. Tests inject a synthetic archive and a bounded fake runner. +/// A failed promotion has no receipt; next invocation rebuilds from scratch. +pub(super) fn ensure( + installer: &Path, + game: &Path, + cache: &Path, + loader: &str, + vanilla: &VersionJson, + run: impl FnOnce(&Path) -> Result<(), NeoForgeError>, +) -> Result { + let recipe = Recipe::read(installer, loader, vanilla)?; + let receipt_path = safe_path(cache, &format!("neoforge-receipts/{loader}.json"))?; + if recipe.current(game, &receipt_path)? { + return serde_json::from_slice(&recipe.version_bytes).map_err(NeoForgeError::InvalidJson); + } + // Invalidate before changing any output. Even interruption during multi-file + // promotion cannot leave a complete receipt over a partial installation. + match fs::remove_file(&receipt_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + // Validate all destination paths before invoking the installer. + for relative in recipe.paths() { + safe_path(game, relative)?; + } + let stage = Stage::new(cache)?; + ensure_launcher_profiles_stub(&stage.0)?; + let vanilla_relative = format!("versions/{0}/{0}.jar", vanilla.id); + let source = safe_path(game, &vanilla_relative)?; + let input = safe_path(&stage.0, &vanilla_relative)?; + atomic_copy(&source, &input)?; + if !download::is_current( + &input, + Some(recipe.identity.vanilla_size), + &download::Checksum::Sha1(recipe.identity.vanilla_sha1.clone()), + )? { + return Err(invalid( + "vanilla input changed or was not verified before NeoForge installation", + )); + } + run(&stage.0)?; + let version = safe_path(&stage.0, &recipe.version_relative)?; + if bounded_read(&version)? != recipe.version_bytes { + return Err(invalid("installer produced unexpected version JSON")); + } + let mut files = BTreeMap::new(); + for (relative, sha1) in &recipe.outputs { + files.insert( + relative.clone(), + validate_output(&safe_path(&stage.0, relative)?, sha1.as_deref())?, + ); + } + files.insert( + recipe.version_relative.clone(), + FileDigest { + size: recipe.version_bytes.len() as u64, + sha256: hash_bytes(&recipe.version_bytes), + }, + ); + for relative in recipe.paths() { + atomic_copy(&safe_path(&stage.0, relative)?, &safe_path(game, relative)?)?; + } + let receipt = Receipt { + identity: recipe.identity, + files, + }; + storage::write_atomic( + &receipt_path, + &serde_json::to_vec(&receipt).map_err(NeoForgeError::InvalidJson)?, + )?; + // Use the same expected bytes for merge as for receipt validation. + debug_assert_eq!( + installed_version_json_path(game, loader), + game.join(&recipe.version_relative) + ); + serde_json::from_slice(&recipe.version_bytes).map_err(NeoForgeError::InvalidJson) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::Cell, + io::{Cursor, Write}, + }; + use zip::write::SimpleFileOptions; + + const LOADER: &str = "21.1.248"; + + fn jar_bytes(contents: &[u8]) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("fixture.class", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(contents).unwrap(); + zip.finish().unwrap().into_inner() + } + + struct Fixture { + _root: Stage, + installer: PathBuf, + game: PathBuf, + cache: PathBuf, + vanilla: VersionJson, + profile: Value, + version: Vec, + } + + impl Fixture { + fn new() -> Self { + let root = Stage::new(&std::env::temp_dir()).unwrap(); + let game = root.0.join("game"); + let cache = root.0.join("cache"); + let input = jar_bytes(b"verified vanilla"); + let input_path = game.join("versions/1.21.1/1.21.1.jar"); + fs::create_dir_all(input_path.parent().unwrap()).unwrap(); + fs::write(&input_path, &input).unwrap(); + let vanilla = serde_json::from_value(serde_json::json!({ + "id":"1.21.1", "mainClass":"Main", "downloads":{"client":{ + "sha1":download::file_hashes(&input_path).unwrap().0, + "size":input.len(), "url":"https://piston-data.mojang.com/client.jar" + }} + })) + .unwrap(); + let profile = serde_json::json!({ + "spec":1, "version":"neoforge-21.1.248", "minecraft":"1.21.1", "json":"/version.json", + "data":{ + "PATCHED":{"client":"[net.neoforged:neoforge:21.1.248:client]"}, + "MC_EXTRA":{"client":"[net.minecraft:client:1.21.1-20240808.144430:extra]"}, + "MAPPINGS":{"client":"[net.neoforged:neoform:1.21.1-20240808.144430:mappings@txt]"} + }, + "processors":[ + {"sides":["server"],"args":["--output","{ROOT}/run.sh"]}, + {"args":["--output","{MAPPINGS}"]}, + {"sides":["client"],"args":["--extra","{MC_EXTRA}"]}, + {"args":["--output","{PATCHED}"]} + ], "libraries":[] + }); + let version = serde_json::to_vec(&serde_json::json!({ + "id":"neoforge-21.1.248", "inheritsFrom":"1.21.1", "mainClass":"Main", "libraries":[] + })).unwrap(); + let fixture = Self { + installer: root.0.join("installer.jar"), + _root: root, + game, + cache, + vanilla, + profile, + version, + }; + fixture.write_installer(); + fixture + } + + fn write_installer(&self) { + let mut archive = zip::ZipWriter::new(fs::File::create(&self.installer).unwrap()); + for (name, bytes) in [ + ( + "install_profile.json", + serde_json::to_vec(&self.profile).unwrap(), + ), + ("version.json", self.version.clone()), + ] { + archive + .start_file(name, SimpleFileOptions::default()) + .unwrap(); + archive.write_all(&bytes).unwrap(); + } + archive.finish().unwrap(); + } + + fn receipt(&self) -> PathBuf { + self.cache.join("neoforge-receipts/21.1.248.json") + } + fn patched(&self) -> PathBuf { + self.game + .join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar") + } + fn run( + &self, + runner: impl FnOnce(&Path) -> Result<(), NeoForgeError>, + ) -> Result { + ensure( + &self.installer, + &self.game, + &self.cache, + LOADER, + &self.vanilla, + runner, + ) + } + fn produce(&self, stage: &Path) -> Result<(), NeoForgeError> { + let recipe = Recipe::read(&self.installer, LOADER, &self.vanilla)?; + for relative in recipe.outputs.keys() { + let path = stage.join(relative); + assert!( + !path.exists(), + "must never reuse old generated files in staging" + ); + fs::create_dir_all(path.parent().unwrap())?; + fs::write( + &path, + if relative.ends_with(".jar") { + jar_bytes(b"clean generated output") + } else { + b"mappings".to_vec() + }, + )?; + } + let version = stage.join(recipe.version_relative); + fs::create_dir_all(version.parent().unwrap())?; + fs::write(version, &self.version)?; + Ok(()) + } + } + + #[test] + fn legacy_is_rebuilt_and_healthy_receipt_skips_runner() { + let f = Fixture::new(); + fs::create_dir_all(f.patched().parent().unwrap()).unwrap(); + fs::write(f.patched(), b"legacy corrupt nonempty jar").unwrap(); + let profile_file = f._root.0.join("profiles/aeronautics/mods/user.jar"); + fs::create_dir_all(profile_file.parent().unwrap()).unwrap(); + fs::write(&profile_file, b"user mod").unwrap(); + f.run(|stage| f.produce(stage)).unwrap(); + assert!(f.receipt().exists()); + assert_ne!( + fs::read(f.patched()).unwrap(), + b"legacy corrupt nonempty jar" + ); + f.run(|_| panic!("healthy receipt must not run installer")) + .unwrap(); + assert_eq!(fs::read(profile_file).unwrap(), b"user mod"); + } + + #[test] + fn nonempty_json_and_jar_corruption_trigger_clean_rebuild() { + let f = Fixture::new(); + f.run(|stage| f.produce(stage)).unwrap(); + for bytes in [ + b"broken json".as_slice(), + br#"{"id":"neoforge-21.1.248","mainClass":"Wrong"}"#, + ] { + fs::write(installed_version_json_path(&f.game, LOADER), bytes).unwrap(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + } + for bytes in [ + b"not a zip".to_vec(), + jar_bytes(b"changed but valid zip"), + Vec::new(), + ] { + fs::write(f.patched(), bytes).unwrap(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + } + fs::remove_file(f.patched()).unwrap(); + f.run(|stage| f.produce(stage)).unwrap(); + } + + #[test] + fn corrupt_vanilla_input_is_rejected_before_processors() { + let f = Fixture::new(); + fs::write(f.game.join("versions/1.21.1/1.21.1.jar"), b"corrupt input").unwrap(); + assert!(f + .run(|_| panic!("must verify vanilla before running processors")) + .is_err()); + assert!(!f.receipt().exists()); + } + + #[test] + fn failed_runner_and_invalid_outputs_do_not_create_receipt() { + let f = Fixture::new(); + assert!(f + .run(|_| Err(invalid("simulated installer failure"))) + .is_err()); + assert!(!f.receipt().exists()); + assert!(f + .run(|stage| { + f.produce(stage)?; + fs::write( + stage.join( + "libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar", + ), + b"nonempty damaged jar", + )?; + Ok(()) + }) + .is_err()); + assert!(!f.receipt().exists()); + f.run(|stage| f.produce(stage)).unwrap(); + } + + #[test] + fn missing_commit_marker_after_partial_promotion_forces_rebuild() { + let f = Fixture::new(); + f.run(|stage| f.produce(stage)).unwrap(); + // Equivalent persisted state to interruption after one promoted file. + fs::remove_file(f.receipt()).unwrap(); + fs::write(f.patched(), jar_bytes(b"partially promoted generation")).unwrap(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + f.run(|_| panic!("recovered generation must be complete")) + .unwrap(); + } + + #[test] + fn receipt_cannot_invent_output_paths_and_changed_recipe_rebuilds() { + let mut f = Fixture::new(); + f.run(|stage| f.produce(stage)).unwrap(); + let mut receipt: Value = serde_json::from_slice(&fs::read(f.receipt()).unwrap()).unwrap(); + receipt["files"]["../../user.jar"] = serde_json::json!({"size":1,"sha256":"00"}); + fs::write(f.receipt(), serde_json::to_vec(&receipt).unwrap()).unwrap(); + f.run(|stage| f.produce(stage)).unwrap(); + f.profile["recipeChange"] = Value::Bool(true); + f.write_installer(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + } + + #[test] + fn recipe_version_output_paths_and_authoritative_hashes_are_enforced() { + let mut f = Fixture::new(); + f.profile["minecraft"] = Value::String("1.20.1".into()); + f.write_installer(); + assert!(f.run(|_| panic!("wrong version recipe")).is_err()); + f.profile["minecraft"] = Value::String("1.21.1".into()); + f.profile["data"]["PATCHED"]["client"] = + Value::String("[net.neoforged:neoforge:../escape:client]".into()); + f.write_installer(); + assert!(f.run(|_| panic!("escaping output")).is_err()); + f.profile["data"]["PATCHED"]["client"] = + Value::String("[net.neoforged:neoforge:21.1.248:client]".into()); + f.profile["processors"][3]["outputs"] = + serde_json::json!({"{PATCHED}":"0000000000000000000000000000000000000000"}); + f.write_installer(); + assert!(f.run(|stage| f.produce(stage)).is_err()); + assert!(!f.receipt().exists()); + } + + #[cfg(unix)] + #[test] + fn output_symlinks_are_rejected_without_touching_target() { + let f = Fixture::new(); + let outside = f._root.0.join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("user"), b"untouched").unwrap(); + std::os::unix::fs::symlink(&outside, f.game.join("libraries")).unwrap(); + assert!(f + .run(|_| panic!("symlink rejected before installer")) + .is_err()); + assert_eq!(fs::read(outside.join("user")).unwrap(), b"untouched"); + assert!(!f.receipt().exists()); + } +} diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 59e91ae..2557190 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -1,8 +1,12 @@ use crate::download::{self, Checksum, DownloadError}; +use crate::inventory::{self, Change, Fingerprint, Inventory, OwnedFile, Store}; use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest}; use reqwest::{blocking::Client, redirect::Policy}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +#[cfg(test)] +use sha2::{Digest, Sha256}; use std::{ + collections::{BTreeSet, HashSet}, fmt, fs, io, path::{Path, PathBuf}, time::Duration, @@ -15,6 +19,10 @@ pub struct ProfileInspection { pub managed_files: usize, pub missing_files: usize, pub mismatched_files: usize, + pub stale_files: usize, + pub conflicts: Vec, + pub pending_update: bool, + pub legacy_files: usize, pub up_to_date: bool, } @@ -25,6 +33,7 @@ pub struct SyncResult { pub downloaded_files: usize, pub reused_files: usize, pub downloaded_bytes: u64, + pub removed_files: usize, } #[derive(Debug)] @@ -33,54 +42,121 @@ pub enum ProfileError { Network(reqwest::Error), Download { path: String, source: DownloadError }, UnsafePath(PathBuf), + Conflict(Vec), } - impl fmt::Display for ProfileError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(error) => write!(formatter, "Cannot access profile: {error}"), Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"), - Self::Download { path, source } => { - write!(formatter, "Download failed for {path}: {source}") - } - Self::UnsafePath(path) => write!( - formatter, - "Profile path contains a symbolic link: {}", - path.display() - ), + Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"), + Self::UnsafePath(path) => write!(formatter, "Unsafe or protected profile path: {}", path.display()), + Self::Conflict(paths) => write!(formatter, "Файлы изменены или не принадлежат лаунчеру; сохранены без изменений: {}. Проверьте список пользовательских модов.", paths.join(", ")), } } } +fn expected_fingerprint(file: &ManagedFile) -> Fingerprint { + Fingerprint { + size: file.size, + sha256: file.sha256.to_ascii_lowercase(), + } +} +#[cfg(test)] +fn manifest_snapshot(manifest: &Manifest) -> String { + // Internal identity of the already verified manifest's installation inputs. + // This fingerprint never substitutes for remote signature verification. + let mut hash = Sha256::new(); + for value in [ + &manifest.id, + &manifest.minecraft.version, + &manifest.minecraft.loader.kind, + &manifest.minecraft.loader.version, + ] { + hash.update(value.as_bytes()); + hash.update([0]); + } + hash.update([manifest.minecraft.java_major]); + for file in &manifest.files { + for value in [&file.path, &file.url, &file.sha256] { + hash.update(value.as_bytes()); + hash.update([0]); + } + hash.update(file.size.to_le_bytes()); + hash.update([if file.policy == FilePolicy::Managed { + 1 + } else { + 2 + }]); + } + format!("{:x}", hash.finalize()) +} +fn current(root: &Path, relative: &str) -> Result, ProfileError> { + inventory::fingerprint(&managed_target(root, relative)?).map_err(ProfileError::Io) +} + pub fn inspect(root: &Path, manifest: &Manifest) -> Result { + let store = Store::open(root).map_err(ProfileError::Io)?; + let owned = store.load().map_err(ProfileError::Io)?; + let pending_update = store.pending().map_err(ProfileError::Io)?; let mut missing_files = 0; let mut mismatched_files = 0; - + let mut conflicts = Vec::new(); + let paths: HashSet<_> = manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect(); for expected in &manifest.files { - let path = managed_target(root, &expected.path)?; - if !path.try_exists().map_err(ProfileError::Io)? { + let actual = current(root, &expected.path)?; + if actual.is_none() { missing_files += 1; continue; } - if matches!(expected.policy, FilePolicy::Seed) && path.is_file() { + if expected.policy == FilePolicy::Seed { continue; } - let checksum = Checksum::Sha256(expected.sha256.clone()); - if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? { + if actual.as_ref() != Some(&expected_fingerprint(expected)) { mismatched_files += 1; + if owned.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() { + conflicts.push(expected.path.clone()); + } } } - + let mut stale_files = 0; + for (path, previous) in &owned.files { + if paths.contains(path.as_str()) { + continue; + } + if let Some(actual) = current(root, path)? { + stale_files += 1; + if actual != previous.fingerprint { + conflicts.push(path.clone()); + } + } + } + let legacy_files = legacy_mods(root, manifest, &owned)?.len(); 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, + stale_files, + pending_update, + legacy_files, + up_to_date: missing_files == 0 + && mismatched_files == 0 + && stale_files == 0 + && conflicts.is_empty() + && !pending_update, + conflicts, }) } -pub fn sync(root: &Path, manifest: &Manifest) -> Result { +pub fn sync_snapshot( + root: &Path, + snapshot: &crate::remote::VerifiedSnapshot, +) -> Result { let client = Client::builder() .connect_timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(10 * 60)) @@ -95,64 +171,309 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result Result { - let mut path = root.to_path_buf(); - if let Some(parent) = root.parent() { - reject_symlink(parent)?; - } - reject_symlink(&path)?; - for component in relative.split('/') { - path.push(component); - reject_symlink(&path)?; - } - Ok(path) +#[cfg(test)] +fn sync_with( + root: &Path, + manifest: &Manifest, + download: impl FnMut(&ManagedFile, &Path) -> Result, +) -> Result { + sync_with_snapshot(root, manifest, &manifest_snapshot(manifest), download) } -fn reject_symlink(path: &Path) -> Result<(), ProfileError> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - Err(ProfileError::UnsafePath(path.to_path_buf())) +fn sync_with_snapshot( + root: &Path, + manifest: &Manifest, + snapshot: &str, + mut download: impl FnMut(&ManagedFile, &Path) -> Result, +) -> Result { + let store = Store::open(root).map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + let previous = store.load().map_err(ProfileError::Io)?; + let mut next = previous.clone(); + let mut conflicts = Vec::new(); + let mut changes = Vec::new(); + let mut downloads: Vec<(usize, &ManagedFile)> = Vec::new(); + let mut reused_files = 0; + let mut removed_files = 0; + let paths: HashSet<_> = manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect(); + for expected in &manifest.files { + let actual = current(root, &expected.path)?; + let fingerprint = expected_fingerprint(expected); + if expected.policy == FilePolicy::Seed && actual.is_some() { + next.files.remove(&expected.path); // relinquish managed -> seed, preserving edits + reused_files += 1; + continue; + } + if actual.as_ref() == Some(&fingerprint) { + // Matching legacy bytes prove content, never launcher ownership. + if previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() { + next.files.remove(&expected.path); + } + reused_files += 1; + continue; + } + if actual.is_some() + && previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() + { + conflicts.push(expected.path.clone()); + continue; + } + downloads.push((changes.len(), expected)); + changes.push(Change { + path: expected.path.clone(), + before: actual, + after: Some(fingerprint.clone()), + }); + if expected.policy == FilePolicy::Managed { + next.files.insert( + expected.path.clone(), + OwnedFile { + fingerprint, + snapshot: snapshot.to_owned(), + }, + ); + } else { + next.files.remove(&expected.path); } - Ok(_) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(ProfileError::Io(error)), } + for (path, previous_file) in &previous.files { + if paths.contains(path.as_str()) { + continue; + } + match current(root, path)? { + None => { + next.files.remove(path); + } + Some(actual) if actual == previous_file.fingerprint => { + changes.push(Change { + path: path.clone(), + before: Some(actual), + after: None, + }); + next.files.remove(path); + removed_files += 1; + } + Some(_) => conflicts.push(path.clone()), + } + } + if !conflicts.is_empty() { + return Err(ProfileError::Conflict(conflicts)); + } + if changes.is_empty() && next == previous { + return Ok(SyncResult { + root: root.display().to_string(), + downloaded_files: 0, + reused_files, + downloaded_bytes: 0, + removed_files: 0, + }); + } + let transaction = store.transaction().map_err(ProfileError::Io)?; + let mut downloaded_bytes = 0; + for (index, file) in &downloads { + let stage = store + .stage(&transaction, *index) + .map_err(ProfileError::Io)?; + downloaded_bytes += download(file, &stage)?; + if inventory::fingerprint(&stage) + .map_err(ProfileError::Io)? + .as_ref() + != Some(&expected_fingerprint(file)) + { + return Err(ProfileError::Io(inventory::invalid( + "Staged profile file failed verification", + ))); + } + } + // Persist the whole future ownership set before the first payload change. + store + .prepare(transaction, changes, next) + .map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + Ok(SyncResult { + root: root.display().to_string(), + downloaded_files: downloads.len(), + reused_files, + downloaded_bytes, + removed_files, + }) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMod { + pub path: String, + pub size: u64, + pub sha256: String, + pub reason: &'static str, +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LegacySelection { + pub path: String, + pub sha256: String, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyBackup { + pub backup_root: String, + pub files: Vec, +} + +fn legacy_mods( + root: &Path, + manifest: &Manifest, + owned: &Inventory, +) -> Result, ProfileError> { + let mods = managed_target(root, "mods")?; + let entries = match fs::read_dir(mods) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(ProfileError::Io(e)), + }; + let mut result = Vec::new(); + for entry in entries { + let entry = entry.map_err(ProfileError::Io)?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if !name.to_ascii_lowercase().ends_with(".jar") + || !crate::manifest::is_portable_component(&name) + { + continue; + } + let path = format!("mods/{name}"); + let expected = manifest + .files + .iter() + .find(|f| f.path.eq_ignore_ascii_case(&path)); + if expected.is_some_and(|f| f.policy == FilePolicy::Seed) { + continue; + } + let actual = current(root, &path)? + .ok_or_else(|| ProfileError::Io(inventory::invalid("Mod changed during inspection")))?; + if owned + .files + .get(&path) + .is_some_and(|f| f.fingerprint == actual) + { + continue; + } + if expected.is_some_and(|f| expected_fingerprint(f) == actual) { + continue; + } + let reason = if owned.files.contains_key(&path) { + "changed_managed" + } else if expected.is_some() { + "conflicts_with_pack" + } else { + "not_in_pack" + }; + result.push(LegacyMod { + path, + size: actual.size, + sha256: actual.sha256, + reason, + }); + } + result.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(result) +} + +/// Informational list, not ownership evidence. Unknown extra mods remain in +/// place; callers must show the file/hash and obtain an explicit selection. +pub fn list_legacy_mods(root: &Path, manifest: &Manifest) -> Result, ProfileError> { + let store = Store::open(root).map_err(ProfileError::Io)?; + if store.pending().map_err(ProfileError::Io)? { + return Err(ProfileError::Io(inventory::invalid( + "Finish recovery before reviewing legacy mods", + ))); + } + legacy_mods(root, manifest, &store.load().map_err(ProfileError::Io)?) +} + +/// Moves only currently listed, explicitly chosen JARs with the reviewed hash. +/// No arbitrary local path, ownership adoption, seed or personal data cleanup. +pub fn backup_legacy_mods( + root: &Path, + manifest: &Manifest, + selections: &[LegacySelection], +) -> Result { + if selections.is_empty() { + return Err(ProfileError::Io(inventory::invalid( + "Select at least one reviewed mod", + ))); + } + let store = Store::open(root).map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + let owned = store.load().map_err(ProfileError::Io)?; + let candidates = legacy_mods(root, manifest, &owned)?; + let mut unique = BTreeSet::new(); + let mut changes = Vec::new(); + for selection in selections { + if !unique.insert(selection.path.clone()) { + return Err(ProfileError::Io(inventory::invalid( + "Duplicate selected mod", + ))); + } + let candidate = candidates + .iter() + .find(|c| c.path == selection.path && c.sha256 == selection.sha256) + .ok_or_else(|| { + ProfileError::Io(inventory::invalid( + "Selected mod changed or is no longer eligible; review the list again", + )) + })?; + changes.push(Change { + path: candidate.path.clone(), + before: Some(Fingerprint { + size: candidate.size, + sha256: candidate.sha256.clone(), + }), + after: None, + }); + } + let transaction = store.transaction().map_err(ProfileError::Io)?; + let backup_root = store + .root + .join(&transaction) + .join("backup") + .display() + .to_string(); + // Retain a path-to-index map alongside backups after the journal completes. + let map = serde_json::to_vec(&selections.iter().map(|s| &s.path).collect::>()) + .map_err(|e| ProfileError::Io(inventory::invalid(e.to_string())))?; + crate::storage::write_atomic(&store.root.join(&transaction).join("files.json"), &map) + .map_err(ProfileError::Io)?; + let mut next = owned; + for selection in selections { + next.files.remove(&selection.path); + } + store + .prepare(transaction, changes, next) + .map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + Ok(LegacyBackup { + backup_root, + files: selections.iter().map(|s| s.path.clone()).collect(), + }) +} + +fn managed_target(root: &Path, relative: &str) -> Result { + if inventory::protected(relative) { + return Err(ProfileError::UnsafePath(root.join(relative))); + } + inventory::checked_path(root, relative) + .map_err(|_| ProfileError::UnsafePath(root.join(relative))) } fn download_managed_file( @@ -160,18 +481,17 @@ fn download_managed_file( expected: &ManagedFile, target: &Path, ) -> Result { - let checksum = Checksum::Sha256(expected.sha256.clone()); download::download_verified( client, &expected.url, target, Some(expected.size), - &checksum, + &Checksum::Sha256(expected.sha256.clone()), |_, _| {}, ) - .map_err(|error| ProfileError::Download { + .map_err(|source| ProfileError::Download { path: expected.path.clone(), - source: error, + source, }) } @@ -179,6 +499,7 @@ fn download_managed_file( mod tests { use super::inspect; use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft}; + #[cfg(test)] use sha2::{Digest, Sha256}; use std::{ fs, process, @@ -297,3 +618,331 @@ mod tests { fs::remove_dir_all(root).unwrap(); } } + +#[cfg(test)] +mod update_tests { + use super::*; + use crate::manifest::{Loader, Minecraft}; + use std::{ + collections::BTreeMap, + sync::atomic::{AtomicU64, Ordering}, + }; + static NEXT: AtomicU64 = AtomicU64::new(0); + struct Fixture { + base: PathBuf, + root: PathBuf, + } + impl Fixture { + fn new() -> Self { + let base = std::env::temp_dir().join(format!( + "shacraft-update-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let root = base.join("profiles/aeronautics"); + fs::create_dir_all(&root).unwrap(); + Self { base, root } + } + fn write(&self, path: &str, bytes: &[u8]) { + let target = self.root.join(path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, bytes).unwrap(); + } + fn bytes(&self, path: &str) -> Vec { + fs::read(self.root.join(path)).unwrap() + } + fn sync( + &self, + manifest: &Manifest, + files: &[(&str, &[u8])], + ) -> Result { + let content: BTreeMap<_, _> = files.iter().copied().collect(); + sync_with(&self.root, manifest, |file, target| { + let bytes = content + .get(file.path.as_str()) + .expect("unexpected download"); + fs::write(target, bytes).map_err(ProfileError::Io)?; + Ok(bytes.len() as u64) + }) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.base).unwrap(); + } + } + fn pack(files: &[(&str, &[u8], FilePolicy)]) -> 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: files + .iter() + .map(|(path, bytes, policy)| ManagedFile { + path: (*path).into(), + url: format!("https://cdn.shacraft.ru/{path}"), + sha256: format!("{:x}", Sha256::digest(bytes)), + size: bytes.len() as u64, + policy: *policy, + }) + .collect(), + } + } + + #[test] + fn renamed_owned_mod_is_backed_up_and_user_mod_and_seed_survive() { + let f = Fixture::new(); + let a = pack(&[ + ("mods/one-1.jar", b"old", FilePolicy::Managed), + ("config/seed.txt", b"default", FilePolicy::Seed), + ]); + f.sync( + &a, + &[("mods/one-1.jar", b"old"), ("config/seed.txt", b"default")], + ) + .unwrap(); + f.write("mods/personal.jar", b"mine"); + f.write("config/seed.txt", b"edits"); + let b = pack(&[("mods/one-2.jar", b"new", FilePolicy::Managed)]); + let inspection = inspect(&f.root, &b).unwrap(); + assert_eq!(inspection.stale_files, 1); + assert!(!inspection.up_to_date); + let synced = f.sync(&b, &[("mods/one-2.jar", b"new")]).unwrap(); + assert_eq!(synced.removed_files, 1); + assert!(!f.root.join("mods/one-1.jar").exists()); + assert_eq!(f.bytes("mods/one-2.jar"), b"new"); + assert_eq!(f.bytes("mods/personal.jar"), b"mine"); + assert_eq!(f.bytes("config/seed.txt"), b"edits"); + let store = Store::open(&f.root).unwrap(); + let owned = store.load().unwrap(); + assert_eq!( + owned.files.keys().collect::>(), + vec!["mods/one-2.jar"] + ); + let state_backups: Vec<_> = fs::read_dir(&store.root) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.path().join("backup/1")) + .filter(|p| p.exists()) + .collect(); + assert_eq!(state_backups.len(), 1); + assert_eq!(fs::read(&state_backups[0]).unwrap(), b"old"); + assert!(inspect(&f.root, &b).unwrap().up_to_date); + } + #[test] + fn missing_inventory_never_adopts_matching_or_extra_legacy_files() { + let f = Fixture::new(); + f.write("mods/current.jar", b"pack"); + f.write("mods/old.jar", b"legacy"); + let a = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]); + let result = f.sync(&a, &[]).unwrap(); + assert_eq!(result.reused_files, 1); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .is_empty()); + f.sync(&pack(&[]), &[]).unwrap(); + assert_eq!(f.bytes("mods/current.jar"), b"pack"); + assert_eq!(f.bytes("mods/old.jar"), b"legacy"); + let list = list_legacy_mods(&f.root, &a).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].path, "mods/old.jar"); + } + #[test] + fn changed_owned_file_blocks_replacement_and_retirement_without_mutation() { + let f = Fixture::new(); + let a = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]); + f.sync(&a, &[("mods/current.jar", b"pack")]).unwrap(); + f.write("mods/current.jar", b"player edits"); + let b = pack(&[("mods/current.jar", b"next", FilePolicy::Managed)]); + assert!(matches!(f.sync(&b, &[]), Err(ProfileError::Conflict(_)))); + assert!(matches!( + f.sync(&pack(&[]), &[]), + Err(ProfileError::Conflict(_)) + )); + assert_eq!(f.bytes("mods/current.jar"), b"player edits"); + let inspection = inspect(&f.root, &b).unwrap(); + assert!(!inspection.up_to_date); + assert_eq!(inspection.conflicts, vec!["mods/current.jar"]); + assert_eq!( + list_legacy_mods(&f.root, &b).unwrap()[0].reason, + "changed_managed" + ); + } + #[test] + fn failed_staging_changes_no_payload_or_ownership() { + let f = Fixture::new(); + let a = pack(&[("mods/current.jar", b"old", FilePolicy::Managed)]); + f.sync(&a, &[("mods/current.jar", b"old")]).unwrap(); + let b = pack(&[ + ("mods/current.jar", b"new", FilePolicy::Managed), + ("mods/second.jar", b"two", FilePolicy::Managed), + ]); + let mut downloads = 0; + let result = sync_with(&f.root, &b, |_, path| { + downloads += 1; + if downloads == 2 { + return Err(ProfileError::Io(io::Error::other("network failed"))); + } + fs::write(path, b"new").unwrap(); + Ok(3) + }); + assert!(result.is_err()); + assert_eq!(f.bytes("mods/current.jar"), b"old"); + assert!(!f.root.join("mods/second.jar").exists()); + assert!(!Store::open(&f.root).unwrap().pending().unwrap()); + assert!(inspect(&f.root, &a).unwrap().up_to_date); + f.sync( + &b, + &[("mods/current.jar", b"new"), ("mods/second.jar", b"two")], + ) + .unwrap(); + assert!(inspect(&f.root, &b).unwrap().up_to_date); + } + #[test] + fn seed_policy_transitions_preserve_user_edits_and_do_not_adopt_seed() { + let f = Fixture::new(); + let a = pack(&[("config/file.txt", b"old", FilePolicy::Managed)]); + f.sync(&a, &[("config/file.txt", b"old")]).unwrap(); + f.write("config/file.txt", b"custom"); + let seeded = pack(&[("config/file.txt", b"default", FilePolicy::Seed)]); + f.sync(&seeded, &[]).unwrap(); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .is_empty()); + assert_eq!(f.bytes("config/file.txt"), b"custom"); + assert!(matches!(f.sync(&a, &[]), Err(ProfileError::Conflict(_)))); + f.sync(&pack(&[]), &[]).unwrap(); + assert_eq!(f.bytes("config/file.txt"), b"custom"); + } + #[test] + fn explicit_legacy_backup_requires_current_hash_and_preserves_every_other_file() { + let f = Fixture::new(); + f.write("mods/old.jar", b"old"); + f.write("mods/keep.jar", b"keep"); + f.write("mods/seed.jar", b"seed edits"); + f.write("saves/world/level.dat", b"world"); + let manifest = pack(&[("mods/seed.jar", b"seed", FilePolicy::Seed)]); + let candidates = list_legacy_mods(&f.root, &manifest).unwrap(); + assert_eq!(candidates.len(), 2); + let old = candidates + .iter() + .find(|c| c.path == "mods/old.jar") + .unwrap(); + let invalid = [LegacySelection { + path: old.path.clone(), + sha256: "0".repeat(64), + }]; + assert!(backup_legacy_mods(&f.root, &manifest, &invalid).is_err()); + let traversal = [LegacySelection { + path: "../outside.jar".into(), + sha256: old.sha256.clone(), + }]; + assert!(backup_legacy_mods(&f.root, &manifest, &traversal).is_err()); + let chosen = [LegacySelection { + path: old.path.clone(), + sha256: old.sha256.clone(), + }]; + let backup = backup_legacy_mods(&f.root, &manifest, &chosen).unwrap(); + assert_eq!( + fs::read(Path::new(&backup.backup_root).join("0")).unwrap(), + b"old" + ); + assert!(!f.root.join("mods/old.jar").exists()); + assert_eq!(f.bytes("mods/keep.jar"), b"keep"); + assert_eq!(f.bytes("mods/seed.jar"), b"seed edits"); + assert_eq!(f.bytes("saves/world/level.dat"), b"world"); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .is_empty()); + } + #[test] + fn new_publication_recovers_and_retires_files_from_interrupted_previous_update() { + let f = Fixture::new(); + let store = Store::open(&f.root).unwrap(); + let transaction = store.transaction().unwrap(); + let before = pack(&[("mods/intermediate.jar", b"middle", FilePolicy::Managed)]); + let fingerprint = expected_fingerprint(&before.files[0]); + let stage = store.stage(&transaction, 0).unwrap(); + fs::write(&stage, b"middle").unwrap(); + let mut next = Inventory::default(); + next.files.insert( + "mods/intermediate.jar".into(), + OwnedFile { + fingerprint: fingerprint.clone(), + snapshot: manifest_snapshot(&before), + }, + ); + store + .prepare( + transaction, + vec![Change { + path: "mods/intermediate.jar".into(), + before: None, + after: Some(fingerprint), + }], + next, + ) + .unwrap(); + fs::create_dir_all(f.root.join("mods")).unwrap(); + fs::rename(stage, f.root.join("mods/intermediate.jar")).unwrap(); + let after = pack(&[("mods/final.jar", b"final", FilePolicy::Managed)]); + let result = f.sync(&after, &[("mods/final.jar", b"final")]).unwrap(); + assert_eq!(result.removed_files, 1); + assert!(!f.root.join("mods/intermediate.jar").exists()); + assert_eq!(f.bytes("mods/final.jar"), b"final"); + assert!(inspect(&f.root, &after).unwrap().up_to_date); + assert!(!store.pending().unwrap()); + } + + #[test] + fn unknown_collision_requires_review_before_install_and_protected_paths_are_refused() { + let f = Fixture::new(); + f.write("mods/current.jar", b"unknown"); + let manifest = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]); + assert!(matches!( + f.sync(&manifest, &[]), + Err(ProfileError::Conflict(_)) + )); + let list = list_legacy_mods(&f.root, &manifest).unwrap(); + backup_legacy_mods( + &f.root, + &manifest, + &[LegacySelection { + path: list[0].path.clone(), + sha256: list[0].sha256.clone(), + }], + ) + .unwrap(); + f.sync(&manifest, &[("mods/current.jar", b"pack")]).unwrap(); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .contains_key("mods/current.jar")); + let unsafe_manifest = pack(&[("screenshots/player.png", b"bad", FilePolicy::Managed)]); + assert!(f.sync(&unsafe_manifest, &[]).is_err()); + assert!(!f.root.join("screenshots/player.png").exists()); + } +} diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 0cd269f..2e77155 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -4,6 +4,7 @@ use ed25519_dalek::{Signature, VerifyingKey}; use reqwest::header::ACCEPT_ENCODING; use reqwest::{blocking::Client, redirect::Policy}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::{ fmt, io::{self, Read}, @@ -68,7 +69,16 @@ impl fmt::Display for RemoteError { } } +pub struct VerifiedSnapshot { + pub manifest: Manifest, + pub digest: String, +} + pub fn fetch_manifest(profile_id: &str) -> Result { + Ok(fetch_snapshot(profile_id)?.manifest) +} + +pub fn fetch_snapshot(profile_id: &str) -> Result { let url = match profile_id { "aeronautics" => AERONAUTICS_MANIFEST, _ => return Err(RemoteError::UnknownProfile), @@ -90,7 +100,7 @@ pub fn fetch_manifest(profile_id: &str) -> Result { .expect("embedded public key must be 32 bytes"), ) .expect("embedded public key must be valid"); - verify_envelope(&source, profile_id, &public_key) + verify_snapshot(&source, profile_id, &public_key) } fn fetch_manifest_bytes(client: &Client, url: &str) -> Result, RemoteError> { @@ -154,11 +164,20 @@ fn read_envelope(source: impl Read) -> Result, RemoteError> { Ok(bytes) } +#[cfg(test)] fn verify_envelope( source: &[u8], profile_id: &str, public_key: &VerifyingKey, ) -> Result { + Ok(verify_snapshot(source, profile_id, public_key)?.manifest) +} + +fn verify_snapshot( + source: &[u8], + profile_id: &str, + public_key: &VerifyingKey, +) -> Result { if source.len() > MAX_ENVELOPE_BYTES { return Err(RemoteError::TooLarge); } @@ -178,12 +197,13 @@ fn verify_envelope( public_key .verify_strict(&payload, &signature) .map_err(|_| RemoteError::InvalidSignature)?; + let digest = format!("{:x}", Sha256::digest(&payload)); let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?; if manifest.id != profile_id { return Err(RemoteError::ProfileMismatch); } - Ok(manifest) + Ok(VerifiedSnapshot { manifest, digest }) } #[cfg(test)] diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs index 39fb801..56a9fbf 100644 --- a/src-tauri/src/shacraft_account.rs +++ b/src-tauri/src/shacraft_account.rs @@ -45,9 +45,21 @@ pub struct LoginResult { #[derive(Clone, Deserialize, Serialize)] pub struct LinkStart { + pub proof_version: u32, + pub server_id: String, pub challenge_id: i64, pub expires_in_seconds: u64, pub registered_on_server: bool, + pub proof_code: String, + pub mc_username: String, + pub player_uuid: String, +} + +#[derive(Deserialize)] +pub struct OnboardingGrant { + #[serde(flatten)] + pub challenge: LinkStart, + pub grant_token: String, } #[derive(Clone, Deserialize, Serialize)] @@ -203,7 +215,92 @@ pub fn start_link( if !response.status().is_success() { return Err(api_error(response)); } - response.json::().map_err(AccountError::Network) + let value = response + .json::() + .map_err(AccountError::Network)?; + validate_challenge(&value, server_id, nickname)?; + Ok(value) +} + +pub fn valid_nickname(name: &str) -> bool { + (3..=16).contains(&name.len()) && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') +} + +fn validate_challenge( + value: &LinkStart, + server_id: &str, + requested: &str, +) -> Result<(), AccountError> { + if value.proof_version != 1 + || value.server_id != server_id + || !valid_nickname(requested) + || value.mc_username != requested + || value.player_uuid != crate::session::offline_uuid(requested) + || value.challenge_id <= 0 + || value.expires_in_seconds == 0 + || value.expires_in_seconds > 600 + || value.proof_code.len() != 32 + || !value.proof_code.bytes().all(|c| c.is_ascii_hexdigit()) + { + return Err(AccountError::Api( + "Сервер вернул неподходящее подтверждение ника".into(), + )); + } + Ok(()) +} + +pub fn start_onboarding(data_dir: &Path, nickname: &str) -> Result { + if !valid_nickname(nickname) { + return Err(AccountError::Api("Неверный игровой ник".into())); + } + let response = client()? + .post(format!("{API_ORIGIN}/api/launcher/onboarding/start")) + .bearer_auth(load_session(data_dir)?) + .json(&serde_json::json!({"server_id":"aoc","mc_username":nickname})) + .send() + .map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + let grant: OnboardingGrant = response.json().map_err(AccountError::Network)?; + validate_challenge(&grant.challenge, "aoc", nickname)?; + if grant.grant_token.len() < 32 + || grant.grant_token.len() > 256 + || !grant + .grant_token + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err(AccountError::Api( + "Некорректное разрешение первого входа".into(), + )); + } + Ok(grant) +} + +pub fn validate_onboarding(data_dir: &Path, grant: &OnboardingGrant) -> Result<(), AccountError> { + let response = client()?.post(format!("{API_ORIGIN}/api/launcher/onboarding/validate")) + .bearer_auth(load_session(data_dir)?) + .json(&serde_json::json!({"challenge_id":grant.challenge.challenge_id,"grant_token":grant.grant_token})) + .send().map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + let data: serde_json::Value = response.json().map_err(AccountError::Network)?; + if data["server_id"] != "aoc" + || data["mc_username"] != grant.challenge.mc_username + || data["player_uuid"] != grant.challenge.player_uuid + || data["challenge_id"] != grant.challenge.challenge_id + || data["proof_version"] != 1 + || !data["expires_in_seconds"] + .as_u64() + .is_some_and(|n| n > 0 && n <= 600) + { + return Err(AccountError::Api( + "Первый вход не подтверждён сервером".into(), + )); + } + Ok(()) } pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result { @@ -238,6 +335,39 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + #[test] + fn challenge_requires_matching_server_exact_nickname_uuid_and_bounded_nonce() { + let valid = super::LinkStart { + proof_version: 1, + server_id: "aoc".into(), + challenge_id: 1, + expires_in_seconds: 600, + registered_on_server: false, + proof_code: "a".repeat(32), + mc_username: "ShaCraft_Test".into(), + player_uuid: crate::session::offline_uuid("ShaCraft_Test"), + }; + assert!(super::validate_challenge(&valid, "aoc", "ShaCraft_Test").is_ok()); + assert!(super::validate_challenge(&valid, "create", "ShaCraft_Test").is_err()); + assert!(super::validate_challenge(&valid, "aoc", "shacraft_test").is_err()); + let mut wrong = valid.clone(); + wrong.player_uuid = crate::session::offline_uuid("shacraft_test"); + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + for ttl in [0, 601] { + wrong = valid.clone(); + wrong.expires_in_seconds = ttl; + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + } + wrong = valid.clone(); + wrong.proof_version = 0; + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + for code in ["a".repeat(31), "g".repeat(32), "a".repeat(33)] { + wrong = valid.clone(); + wrong.proof_code = code; + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + } + } + fn temporary_directory() -> std::path::PathBuf { std::env::temp_dir().join(format!( "shacraft-account-test-{}-{}",