fix(install): reconcile and repair one verified snapshot under a game lease
This commit is contained in:
Generated
+96
-11
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+254
-172
@@ -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<mojang::MergedVersion, String> {
|
||||
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<shacraft_account::LinkStart>,
|
||||
}
|
||||
|
||||
/// 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<PreparationResult, String> {
|
||||
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<i32>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
) -> Result<PreparationResult, String> {
|
||||
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<PreparationResult, String> {
|
||||
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<PreparationResult, String> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProfileMetadata, String> {
|
||||
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<remote::ServerStatus, String> {
|
||||
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<profile::ProfileInspection, String> {
|
||||
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<profile::SyncResult, String> {
|
||||
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<Vec<profile::LegacyMod>, 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<profile::LegacySelection>,
|
||||
) -> Result<profile::LegacyBackup, String> {
|
||||
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())?
|
||||
}
|
||||
|
||||
@@ -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<Option<ProcessIdentity>, 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<Self, String> {
|
||||
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<InstallationLock, String> {
|
||||
fn unwrap_err_string(self) -> String {
|
||||
match self {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected lock refusal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, OwnedFile>,
|
||||
}
|
||||
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<Fingerprint>,
|
||||
pub after: Option<Fingerprint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Journal {
|
||||
version: u32,
|
||||
pub transaction: String,
|
||||
pub changes: Vec<Change>,
|
||||
pub next: Inventory,
|
||||
}
|
||||
|
||||
pub(crate) struct Store {
|
||||
pub root: PathBuf,
|
||||
profile: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) fn invalid(message: impl Into<String>) -> 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<PathBuf> {
|
||||
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<Option<Fingerprint>> {
|
||||
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<T: for<'de> Deserialize<'de>>(path: &Path) -> io::Result<Option<T>> {
|
||||
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<Self> {
|
||||
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<Inventory> {
|
||||
let inventory = read_json(&self.root.join("inventory.json"))?.unwrap_or_default();
|
||||
validate_inventory(&inventory)?;
|
||||
Ok(inventory)
|
||||
}
|
||||
pub fn pending(&self) -> io::Result<bool> {
|
||||
Ok(self.read_journal()?.is_some())
|
||||
}
|
||||
fn read_journal(&self) -> io::Result<Option<Journal>> {
|
||||
let journal: Option<Journal> = 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<String> {
|
||||
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<PathBuf> {
|
||||
checked_path(&self.root, &format!("{transaction}/staged/{index}"))
|
||||
}
|
||||
pub fn prepare(
|
||||
&self,
|
||||
transaction: String,
|
||||
changes: Vec<Change>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<Child, LaunchError> {
|
||||
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()?));
|
||||
|
||||
@@ -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");
|
||||
|
||||
+54
-91
@@ -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<i32>,
|
||||
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<VersionJson, NeoForgeError> {
|
||||
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<Mutex<Vec<(u64, u64)>>> = 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();
|
||||
|
||||
@@ -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<String>) -> 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<PathBuf, NeoForgeError> {
|
||||
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<Vec<u8>, 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<fs::File>, name: &str) -> Result<Vec<u8>, 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<String, NeoForgeError> {
|
||||
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<u8>,
|
||||
version_relative: String,
|
||||
outputs: BTreeMap<String, Option<String>>,
|
||||
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<String, FileDigest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct FileDigest {
|
||||
size: u64,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
fn read(installer: &Path, loader: &str, vanilla: &VersionJson) -> Result<Self, NeoForgeError> {
|
||||
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::<VersionJson>(&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<Item = &String> {
|
||||
self.outputs
|
||||
.keys()
|
||||
.chain(std::iter::once(&self.version_relative))
|
||||
}
|
||||
|
||||
fn current(&self, root: &Path, receipt_path: &Path) -> Result<bool, NeoForgeError> {
|
||||
let receipt = match bounded_read(receipt_path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<Receipt>(&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<Self, NeoForgeError> {
|
||||
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<FileDigest, NeoForgeError> {
|
||||
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<VersionJson, NeoForgeError> {
|
||||
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<u8> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<VersionJson, NeoForgeError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+723
-74
@@ -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<String>,
|
||||
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<String>),
|
||||
}
|
||||
|
||||
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<Option<Fingerprint>, ProfileError> {
|
||||
inventory::fingerprint(&managed_target(root, relative)?).map_err(ProfileError::Io)
|
||||
}
|
||||
|
||||
pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, ProfileError> {
|
||||
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<SyncResult, ProfileError> {
|
||||
pub fn sync_snapshot(
|
||||
root: &Path,
|
||||
snapshot: &crate::remote::VerifiedSnapshot,
|
||||
) -> Result<SyncResult, ProfileError> {
|
||||
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<SyncResult, ProfileError
|
||||
}))
|
||||
.build()
|
||||
.map_err(ProfileError::Network)?;
|
||||
|
||||
let mut downloaded_files = 0;
|
||||
let mut reused_files = 0;
|
||||
let mut downloaded_bytes = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let target = managed_target(root, &expected.path)?;
|
||||
if matches!(expected.policy, FilePolicy::Seed) && target.is_file() {
|
||||
reused_files += 1;
|
||||
continue;
|
||||
}
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
if download::is_current(&target, Some(expected.size), &checksum)
|
||||
.map_err(ProfileError::Io)?
|
||||
{
|
||||
reused_files += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes = download_managed_file(&client, expected, &target)?;
|
||||
downloaded_files += 1;
|
||||
downloaded_bytes += bytes;
|
||||
}
|
||||
|
||||
Ok(SyncResult {
|
||||
root: root.display().to_string(),
|
||||
downloaded_files,
|
||||
reused_files,
|
||||
downloaded_bytes,
|
||||
sync_with_snapshot(root, &snapshot.manifest, &snapshot.digest, |file, stage| {
|
||||
download_managed_file(&client, file, stage)
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject pre-existing links in the managed subtree before inspecting or
|
||||
/// replacing files. A signed relative path must not follow a local link into
|
||||
/// an unrelated directory. This is not a sandbox against a hostile local user
|
||||
/// changing directories concurrently under the launcher's OS identity.
|
||||
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
|
||||
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<u64, ProfileError>,
|
||||
) -> Result<SyncResult, ProfileError> {
|
||||
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<u64, ProfileError>,
|
||||
) -> Result<SyncResult, ProfileError> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
fn legacy_mods(
|
||||
root: &Path,
|
||||
manifest: &Manifest,
|
||||
owned: &Inventory,
|
||||
) -> Result<Vec<LegacyMod>, 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<Vec<LegacyMod>, 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<LegacyBackup, ProfileError> {
|
||||
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::<Vec<_>>())
|
||||
.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<PathBuf, ProfileError> {
|
||||
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<u64, ProfileError> {
|
||||
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<u8> {
|
||||
fs::read(self.root.join(path)).unwrap()
|
||||
}
|
||||
fn sync(
|
||||
&self,
|
||||
manifest: &Manifest,
|
||||
files: &[(&str, &[u8])],
|
||||
) -> Result<SyncResult, ProfileError> {
|
||||
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<_>>(),
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -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<Manifest, RemoteError> {
|
||||
Ok(fetch_snapshot(profile_id)?.manifest)
|
||||
}
|
||||
|
||||
pub fn fetch_snapshot(profile_id: &str) -> Result<VerifiedSnapshot, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_MANIFEST,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
@@ -90,7 +100,7 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
.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<Vec<u8>, RemoteError> {
|
||||
@@ -154,11 +164,20 @@ fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn verify_envelope(
|
||||
source: &[u8],
|
||||
profile_id: &str,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<Manifest, RemoteError> {
|
||||
Ok(verify_snapshot(source, profile_id, public_key)?.manifest)
|
||||
}
|
||||
|
||||
fn verify_snapshot(
|
||||
source: &[u8],
|
||||
profile_id: &str,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<VerifiedSnapshot, RemoteError> {
|
||||
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)]
|
||||
|
||||
@@ -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::<LinkStart>().map_err(AccountError::Network)
|
||||
let value = response
|
||||
.json::<LinkStart>()
|
||||
.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<OnboardingGrant, AccountError> {
|
||||
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<LinkStatus, AccountError> {
|
||||
@@ -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-{}-{}",
|
||||
|
||||
Reference in New Issue
Block a user