fix(install): reconcile and repair one verified snapshot under a game lease

This commit is contained in:
Codex
2026-09-09 18:58:07 +03:00
parent cd5c675637
commit 22a78530c0
13 changed files with 3022 additions and 367 deletions
+96 -11
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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()
);
}
}
+92 -15
View File
@@ -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())?
}
+271
View File
@@ -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"),
}
}
}
}
+602
View File
@@ -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());
}
}
+8
View File
@@ -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()?));
+7 -1
View File
@@ -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
View File
@@ -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();
+761
View File
@@ -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
View File
@@ -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
View File
@@ -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)]
+131 -1
View File
@@ -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-{}-{}",