refactor: modularize launcher UI and native services with verified IO
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
msa,
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, settings,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeviceCodePayload {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LoginResultPayload {
|
||||
ok: bool,
|
||||
profile: Option<msa::MinecraftProfile>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Starts a Microsoft device-code login in the background. Emits
|
||||
/// `msa-login-code` as soon as the user code is available (show it to the
|
||||
/// player immediately — they have a limited time to enter it), then
|
||||
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
|
||||
/// immediately; it does not wait for the user to finish signing in.
|
||||
#[tauri::command]
|
||||
pub(crate) fn start_microsoft_login(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
let directory = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let login = || -> Result<msa::MinecraftProfile, String> {
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
let start = msa::start_device_code(&client).map_err(|error| error.to_string())?;
|
||||
let _ = app.emit(
|
||||
"msa-login-code",
|
||||
DeviceCodePayload {
|
||||
verification_uri: start.verification_uri.clone(),
|
||||
user_code: start.user_code.clone(),
|
||||
expires_in_seconds: start.expires_in_seconds,
|
||||
},
|
||||
);
|
||||
let result =
|
||||
msa::login_with_device_code(&client, &start).map_err(|error| error.to_string())?;
|
||||
msa::save_refresh_token(&directory, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(result.profile)
|
||||
};
|
||||
let payload = match login() {
|
||||
Ok(profile) => LoginResultPayload {
|
||||
ok: true,
|
||||
profile: Some(profile),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LoginResultPayload {
|
||||
ok: false,
|
||||
profile: None,
|
||||
error: Some(error),
|
||||
},
|
||||
};
|
||||
let _ = app.emit("msa-login-result", payload);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tries to restore a session from a previously saved refresh token
|
||||
/// (silent, no browser/user code). Returns `None` if there is none saved
|
||||
/// or it no longer works — the UI should fall back to offering login.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_account(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
match msa::login_with_refresh_token(&client, &refresh_token) {
|
||||
Ok(result) => {
|
||||
msa::save_refresh_token(&data_dir, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Some(result.profile))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Account restore task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn logout(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
msa::clear_account(&data_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Logout task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Resolves the identity to launch as, based on the persisted `account_mode`.
|
||||
/// In `Microsoft` mode this requires a real signed-in session (see
|
||||
/// `msa::login_with_refresh_token`) and returns an error if there is none;
|
||||
/// in `Offline` mode it uses the local nickname from settings, so no
|
||||
/// Microsoft account is needed at all. Offline is never silently used in
|
||||
/// place of a missing Microsoft session.
|
||||
pub(super) fn resolve_identity(
|
||||
data_dir: &Path,
|
||||
settings: &settings::LauncherSettings,
|
||||
account_operation: &Operation,
|
||||
) -> Result<session::PlayerIdentity, String> {
|
||||
match settings.account_mode {
|
||||
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline {
|
||||
name: settings.nickname.clone(),
|
||||
}),
|
||||
settings::AccountMode::Microsoft => {
|
||||
let _permit = account_operation.acquire("Account operation")?;
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
let refresh_token = msa::load_refresh_token(data_dir)
|
||||
.ok_or("Not signed in with a Microsoft account")?;
|
||||
let result = msa::login_with_refresh_token(&client, &refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
msa::save_refresh_token(data_dir, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(session::PlayerIdentity::Microsoft(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offline_identity_does_not_wait_for_microsoft_refresh() {
|
||||
let operation = Operation::default();
|
||||
let _refresh = operation.acquire("Account operation").unwrap();
|
||||
let settings = settings::LauncherSettings::default();
|
||||
let identity = resolve_identity(
|
||||
Path::new("/unused-account-directory"),
|
||||
&settings,
|
||||
&operation,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(identity, session::PlayerIdentity::Offline { name } if name == settings.nickname)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::{account::resolve_identity, data_dir};
|
||||
use crate::{
|
||||
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::SystemTime};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InstallProgress {
|
||||
stage: &'static str,
|
||||
current_bytes: u64,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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())?;
|
||||
|
||||
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(
|
||||
&installer_client,
|
||||
java_executable,
|
||||
game_dir,
|
||||
cache_dir,
|
||||
&manifest.minecraft.loader.version,
|
||||
on_progress,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.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.
|
||||
#[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");
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
|
||||
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 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"),
|
||||
)?;
|
||||
if manifest.minecraft.loader.kind != "neoforge" {
|
||||
// Vanilla-only profiles skip the installer, which normally
|
||||
// downloads vanilla itself; do it ourselves here instead.
|
||||
mojang::ensure_client_jar(
|
||||
&client,
|
||||
&game_dir,
|
||||
&merged.client_jar_version_id,
|
||||
&merged.client,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
mojang::ensure_libraries(
|
||||
&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(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Install task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GameExited {
|
||||
profile_id: String,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Launches `profile_id` as the account chosen in settings (`account_mode`).
|
||||
/// In `Microsoft` mode a real session is required (see `resolve_identity`);
|
||||
/// in `Offline` mode the local nickname from settings is used, so no
|
||||
/// Microsoft account is needed. Spawns the game detached; watches it on a
|
||||
/// background thread only to emit `game-exited` when it eventually closes.
|
||||
#[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.account.clone();
|
||||
|
||||
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, &settings, &account_operation)?;
|
||||
|
||||
// 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,
|
||||
};
|
||||
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}"))?
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::data_dir;
|
||||
use crate::{java, manifest};
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NativeHost {
|
||||
platform: &'static str,
|
||||
data_dir: String,
|
||||
launcher_version: &'static str,
|
||||
}
|
||||
|
||||
/// Returns non-sensitive environment information needed by the interface.
|
||||
#[tauri::command]
|
||||
pub(crate) fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
|
||||
Ok(NativeHost {
|
||||
platform: std::env::consts::OS,
|
||||
data_dir: data_dir.display().to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detects an existing Java installation. This is read-only and never downloads Java.
|
||||
#[tauri::command]
|
||||
pub(crate) fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
pub(crate) fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Thin Tauri adapters grouped by the domain they expose.
|
||||
pub(crate) mod account;
|
||||
pub(crate) mod game;
|
||||
pub(crate) mod host;
|
||||
pub(crate) mod preferences;
|
||||
pub(crate) mod profiles;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::data_dir;
|
||||
use crate::settings;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn save_settings(
|
||||
app: AppHandle,
|
||||
settings: settings::LauncherSettings,
|
||||
) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::data_dir;
|
||||
use crate::{operations::LauncherOperations, profile, remote};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
/// 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)?;
|
||||
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())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// 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 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())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
+109
-31
@@ -1,11 +1,12 @@
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use crate::storage::AtomicFile;
|
||||
use reqwest::blocking::Client;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -73,12 +74,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
|
||||
sha1.update(&buffer[..read]);
|
||||
sha256.update(&buffer[..read]);
|
||||
}
|
||||
Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize())))
|
||||
Ok((
|
||||
format!("{:x}", sha1.finalize()),
|
||||
format!("{:x}", sha256.finalize()),
|
||||
))
|
||||
}
|
||||
|
||||
/// True if `path` already exists, matches `expected_size` (when given) and
|
||||
/// `checksum`. Used to skip re-downloading files that are already current.
|
||||
pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum) -> io::Result<bool> {
|
||||
pub fn is_current(
|
||||
path: &Path,
|
||||
expected_size: Option<u64>,
|
||||
checksum: &Checksum,
|
||||
) -> io::Result<bool> {
|
||||
let metadata = match path.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||
@@ -96,14 +104,6 @@ pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum)
|
||||
Ok(checksum.matches(&sha1_hex, &sha256_hex))
|
||||
}
|
||||
|
||||
fn temp_path(target: &Path) -> Result<PathBuf, DownloadError> {
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or(DownloadError::InvalidTargetPath)?;
|
||||
Ok(target.with_file_name(format!(".{file_name}.shacraft.part")))
|
||||
}
|
||||
|
||||
/// Downloads `url` to `target`, verifying size (if known ahead of time) and
|
||||
/// `checksum` before atomically renaming the temporary file into place.
|
||||
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk;
|
||||
@@ -126,30 +126,34 @@ pub fn download_verified(
|
||||
let total = expected_size.or_else(|| response.content_length());
|
||||
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
|
||||
if expected != length {
|
||||
return Err(DownloadError::SizeMismatch { expected, actual: length });
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let temporary = temp_path(target)?;
|
||||
let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress);
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
let bytes = result.unwrap();
|
||||
fs::rename(&temporary, target).map_err(DownloadError::Io)?;
|
||||
let mut output = AtomicFile::new(target).map_err(DownloadError::Io)?;
|
||||
let bytes = write_and_verify(
|
||||
&mut response,
|
||||
output.writer(),
|
||||
expected_size,
|
||||
checksum,
|
||||
total,
|
||||
&mut on_progress,
|
||||
)?;
|
||||
output.commit().map_err(DownloadError::Io)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn write_and_verify(
|
||||
response: &mut Response,
|
||||
temporary: &Path,
|
||||
response: &mut impl Read,
|
||||
output: &mut impl Write,
|
||||
expected_size: Option<u64>,
|
||||
checksum: &Checksum,
|
||||
total: Option<u64>,
|
||||
on_progress: &mut impl FnMut(u64, Option<u64>),
|
||||
) -> Result<u64, DownloadError> {
|
||||
let mut output = File::create(temporary).map_err(DownloadError::Io)?;
|
||||
let mut sha1 = Sha1::new();
|
||||
let mut sha256 = Sha256::new();
|
||||
let mut bytes = 0_u64;
|
||||
@@ -160,17 +164,29 @@ fn write_and_verify(
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
output.write_all(&buffer[..read]).map_err(DownloadError::Io)?;
|
||||
bytes += read as u64;
|
||||
if let Some(expected) = expected_size {
|
||||
if bytes > expected {
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
output
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(DownloadError::Io)?;
|
||||
sha1.update(&buffer[..read]);
|
||||
sha256.update(&buffer[..read]);
|
||||
bytes += read as u64;
|
||||
on_progress(bytes, total);
|
||||
}
|
||||
output.sync_all().map_err(DownloadError::Io)?;
|
||||
|
||||
if let Some(expected) = expected_size {
|
||||
if bytes != expected {
|
||||
return Err(DownloadError::SizeMismatch { expected, actual: bytes });
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
let sha1_hex = format!("{:x}", sha1.finalize());
|
||||
@@ -184,13 +200,19 @@ fn write_and_verify(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{file_hashes, is_current, Checksum};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"shacraft-download-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
@@ -201,7 +223,10 @@ mod tests {
|
||||
let path = temp_file(b"hello shacraft");
|
||||
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
|
||||
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
|
||||
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d");
|
||||
assert_eq!(
|
||||
sha256_hex,
|
||||
"d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"
|
||||
);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
@@ -220,4 +245,57 @@ mod tests {
|
||||
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
|
||||
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_download_keeps_existing_file_and_cleans_temporary() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
let path = temp_file(b"previous version");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/test.jar", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\ncorrupt",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let error = super::download_verified(
|
||||
&reqwest::blocking::Client::new(),
|
||||
&url,
|
||||
&path,
|
||||
Some(7),
|
||||
&Checksum::Sha256("0".repeat(64)),
|
||||
|_, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, super::DownloadError::ChecksumMismatch));
|
||||
assert_eq!(fs::read(&path).unwrap(), b"previous version");
|
||||
server.join().unwrap();
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_body_is_stopped_before_writing_excess() {
|
||||
let mut output = Vec::new();
|
||||
let error = super::write_and_verify(
|
||||
&mut std::io::repeat(b'x'),
|
||||
&mut output,
|
||||
Some(2),
|
||||
&Checksum::Sha256("0".repeat(64)),
|
||||
Some(2),
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::DownloadError::SizeMismatch { expected: 2, .. }
|
||||
));
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+17
-388
@@ -10,399 +10,28 @@ mod remote;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod storage;
|
||||
mod trusted_http;
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::SystemTime};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::new()
|
||||
}
|
||||
|
||||
fn game_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("game"))
|
||||
}
|
||||
|
||||
fn profile_dir(app: &AppHandle, profile_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("profiles").join(profile_id))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NativeHost {
|
||||
platform: &'static str,
|
||||
data_dir: String,
|
||||
launcher_version: &'static str,
|
||||
}
|
||||
|
||||
/// Returns non-sensitive environment information needed by the interface.
|
||||
/// File access and child-process launching are deliberately not exposed yet.
|
||||
#[tauri::command]
|
||||
fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
|
||||
Ok(NativeHost {
|
||||
platform: std::env::consts::OS,
|
||||
data_dir: data_dir.display().to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detects an existing Java installation. This is read-only and never downloads Java.
|
||||
#[tauri::command]
|
||||
fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json).map(|_| ()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Inspects the local profile without changing player files.
|
||||
#[tauri::command]
|
||||
async fn inspect_profile(app: AppHandle, manifest_json: String) -> Result<profile::ProfileInspection, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::inspect(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Synchronizes launcher-managed files after manifest validation.
|
||||
#[tauri::command]
|
||||
async fn sync_profile(app: AppHandle, manifest_json: String) -> Result<profile::SyncResult, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::sync(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
||||
#[tauri::command]
|
||||
async fn inspect_remote_profile(app: AppHandle, profile_id: String) -> Result<profile::ProfileInspection, String> {
|
||||
let data_dir = app.path().app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
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())
|
||||
}).await.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
|
||||
#[tauri::command]
|
||||
async fn sync_remote_profile(app: AppHandle, profile_id: String) -> Result<profile::SyncResult, String> {
|
||||
let data_dir = app.path().app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
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())
|
||||
}).await.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Microsoft account login
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeviceCodePayload {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LoginResultPayload {
|
||||
ok: bool,
|
||||
profile: Option<msa::MinecraftProfile>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Starts a Microsoft device-code login in the background. Emits
|
||||
/// `msa-login-code` as soon as the user code is available (show it to the
|
||||
/// player immediately — they have a limited time to enter it), then
|
||||
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
|
||||
/// immediately; it does not wait for the user to finish signing in.
|
||||
#[tauri::command]
|
||||
fn start_microsoft_login(app: AppHandle) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client = http_client();
|
||||
let start = match msa::start_device_code(&client) {
|
||||
Ok(start) => start,
|
||||
Err(error) => {
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = app.emit(
|
||||
"msa-login-code",
|
||||
DeviceCodePayload { verification_uri: start.verification_uri.clone(), user_code: start.user_code.clone(), expires_in_seconds: start.expires_in_seconds },
|
||||
);
|
||||
|
||||
match msa::login_with_device_code(&client, &start) {
|
||||
Ok(result) => {
|
||||
if let Ok(data_dir) = app.path().app_data_dir() {
|
||||
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
|
||||
}
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: true, profile: Some(result.profile), error: None });
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tries to restore a session from a previously saved refresh token
|
||||
/// (silent, no browser/user code). Returns `None` if there is none saved
|
||||
/// or it no longer works — the UI should fall back to offering login.
|
||||
#[tauri::command]
|
||||
async fn get_account(app: AppHandle) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None) };
|
||||
let client = http_client();
|
||||
match msa::login_with_refresh_token(&client, &refresh_token) {
|
||||
Ok(result) => {
|
||||
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
|
||||
Ok(Some(result.profile))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Account restore task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn logout(app: AppHandle) -> Result<(), String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || msa::clear_account(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Logout task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Resolves the identity to launch as, based on the persisted `account_mode`.
|
||||
/// In `Microsoft` mode this requires a real signed-in session (see
|
||||
/// `msa::login_with_refresh_token`) and returns an error if there is none;
|
||||
/// in `Offline` mode it uses the local nickname from settings, so no
|
||||
/// Microsoft account is needed at all. Offline is never silently used in
|
||||
/// place of a missing Microsoft session.
|
||||
fn resolve_identity(client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
|
||||
let settings = settings::load(data_dir).map_err(|error| error.to_string())?;
|
||||
match settings.account_mode {
|
||||
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }),
|
||||
settings::AccountMode::Microsoft => {
|
||||
let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?;
|
||||
let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?;
|
||||
let _ = msa::save_refresh_token(data_dir, &result.refresh_token);
|
||||
Ok(session::PlayerIdentity::Microsoft(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Game install + launch
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstallProgress {
|
||||
stage: &'static str,
|
||||
current_bytes: u64,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// 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, 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())?;
|
||||
|
||||
if manifest.minecraft.loader.kind == "neoforge" {
|
||||
let neoforge_version = neoforge::ensure_client_installed(client, java_executable, game_dir, cache_dir, &manifest.minecraft.loader.version, on_progress).map_err(|error| error.to_string())?;
|
||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.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.
|
||||
#[tauri::command]
|
||||
async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let game_dir = game_dir(&app)?;
|
||||
let runtime_root = game_dir.join("runtime");
|
||||
let cache_dir = game_dir.join("cache");
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let client = http_client();
|
||||
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(&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"))?;
|
||||
if manifest.minecraft.loader.kind != "neoforge" {
|
||||
// Vanilla-only profiles skip the installer, which normally
|
||||
// downloads vanilla itself; do it ourselves here instead.
|
||||
mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?;
|
||||
mojang::ensure_libraries(&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(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Install task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameExited {
|
||||
profile_id: String,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Launches `profile_id` as the account chosen in settings (`account_mode`).
|
||||
/// In `Microsoft` mode a real session is required (see `resolve_identity`);
|
||||
/// in `Offline` mode the local nickname from settings is used, so no
|
||||
/// Microsoft account is needed. Spawns the game detached; watches it on a
|
||||
/// background thread only to emit `game-exited` when it eventually closes.
|
||||
#[tauri::command]
|
||||
async fn launch_game(app: AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let game_dir = game_dir(&app)?;
|
||||
let profile_dir = profile_dir(&app, &profile_id)?;
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let client = http_client();
|
||||
let identity = resolve_identity(&client, &data_dir)?;
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
|
||||
// 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(&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,
|
||||
};
|
||||
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}"))?
|
||||
}
|
||||
mod commands;
|
||||
mod operations;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(operations::LauncherOperations::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
native_host,
|
||||
detect_java,
|
||||
validate_manifest,
|
||||
inspect_profile,
|
||||
sync_profile,
|
||||
inspect_remote_profile,
|
||||
sync_remote_profile,
|
||||
load_settings,
|
||||
save_settings,
|
||||
start_microsoft_login,
|
||||
get_account,
|
||||
logout,
|
||||
ensure_game_installed,
|
||||
launch_game
|
||||
commands::host::native_host,
|
||||
commands::host::detect_java,
|
||||
commands::host::validate_manifest,
|
||||
commands::profiles::inspect_remote_profile,
|
||||
commands::profiles::sync_remote_profile,
|
||||
commands::preferences::load_settings,
|
||||
commands::preferences::save_settings,
|
||||
commands::account::start_microsoft_login,
|
||||
commands::account::get_account,
|
||||
commands::account::logout,
|
||||
commands::game::ensure_game_installed,
|
||||
commands::game::launch_game
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running ShaCraft Launcher");
|
||||
|
||||
+104
-23
@@ -1,6 +1,5 @@
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashSet, fmt};
|
||||
use url::Url;
|
||||
|
||||
const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
const CURRENT_SCHEMA_VERSION: u32 = 1;
|
||||
@@ -82,19 +81,27 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
)));
|
||||
}
|
||||
if !is_identifier(&manifest.id) {
|
||||
return Err(ManifestError::Invalid("Profile id must contain only lowercase letters, numbers and hyphens".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Profile id must contain only lowercase letters, numbers and hyphens".into(),
|
||||
));
|
||||
}
|
||||
if manifest.display_name.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid("Profile displayName cannot be empty".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Profile displayName cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if manifest.minecraft.version.trim().is_empty()
|
||||
if !is_version(&manifest.minecraft.version)
|
||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||
|| manifest.minecraft.loader.version.trim().is_empty()
|
||||
|| !is_version(&manifest.minecraft.loader.version)
|
||||
{
|
||||
return Err(ManifestError::Invalid("Minecraft version and loader must be specified".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Minecraft version and loader must be specified".into(),
|
||||
));
|
||||
}
|
||||
if !(8..=25).contains(&manifest.minecraft.java_major) {
|
||||
return Err(ManifestError::Invalid("Unsupported Java major version".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Unsupported Java major version".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut paths = HashSet::new();
|
||||
@@ -103,19 +110,35 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
FilePolicy::Managed | FilePolicy::Seed => {}
|
||||
}
|
||||
if !is_safe_relative_path(&file.path) {
|
||||
return Err(ManifestError::Invalid(format!("Unsafe file path: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Unsafe file path: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if !paths.insert(&file.path) {
|
||||
return Err(ManifestError::Invalid(format!("Duplicate file path: {}", file.path)));
|
||||
// A manifest must resolve to the same distinct files on Windows/macOS.
|
||||
if !paths.insert(file.path.to_lowercase()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Duplicate file path: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if !is_allowed_download_url(&file.url) {
|
||||
return Err(ManifestError::Invalid(format!("File URL must use HTTPS and a ShaCraft host: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"File URL must use HTTPS and a ShaCraft host: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if file.size == 0 {
|
||||
return Err(ManifestError::Invalid(format!("File has zero size: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"File has zero size: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(ManifestError::Invalid(format!("Invalid SHA-256 for {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Invalid SHA-256 for {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -124,22 +147,49 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
fn is_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 48
|
||||
&& value.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
fn is_version(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte))
|
||||
}
|
||||
|
||||
fn is_safe_relative_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.starts_with('/')
|
||||
&& !value.starts_with('\\')
|
||||
&& !value.contains('\\')
|
||||
&& !value.split('/').any(|part| part.is_empty() || part == "." || part == "..")
|
||||
!value.is_empty() && value.split('/').all(is_portable_component)
|
||||
}
|
||||
|
||||
pub(crate) fn is_portable_component(value: &str) -> bool {
|
||||
if value.is_empty()
|
||||
|| value.ends_with(['.', ' '])
|
||||
|| value
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || "\\:<>\"|?*".contains(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let stem = value
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
!matches!(
|
||||
stem.as_str(),
|
||||
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
|
||||
) && !(stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
pub(crate) fn is_allowed_download_url(value: &str) -> bool {
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https" && url.host_str().is_some_and(|host| DOWNLOAD_HOSTS.contains(&host))
|
||||
crate::trusted_http::allows(value, &DOWNLOAD_HOSTS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -179,4 +229,35 @@ mod tests {
|
||||
fn rejects_third_party_download_hosts() {
|
||||
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", "example.com")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonportable_paths_and_version_traversal() {
|
||||
for path in [
|
||||
"C:/escape.jar",
|
||||
"mods/file.jar:stream",
|
||||
"mods/CON.jar",
|
||||
"mods/LPT1",
|
||||
"mods/file.jar.",
|
||||
"mods/file.jar ",
|
||||
"mods//file.jar",
|
||||
"mods/../file.jar",
|
||||
] {
|
||||
assert!(
|
||||
validate_json(&VALID.replace("mods/example.jar", path)).is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
assert!(validate_json(&VALID.replace("21.1.248", "../../escape")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_download_authorities() {
|
||||
for host in [
|
||||
"user@cdn.shacraft.ru",
|
||||
"cdn.shacraft.ru:444",
|
||||
"cdn.shacraft.ru.evil.example",
|
||||
] {
|
||||
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", host)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ use std::{
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||
const MOJANG_HOSTS: [&str; 4] = [
|
||||
@@ -41,7 +40,11 @@ const MOJANG_HOSTS: [&str; 4] = [
|
||||
const ASSET_WORKERS: usize = 48;
|
||||
|
||||
pub fn is_allowed_host(url: &str) -> bool {
|
||||
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false)
|
||||
crate::trusted_http::allows(url, &MOJANG_HOSTS)
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -45,6 +45,13 @@ const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authenticat
|
||||
const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
const ACCOUNT_FILE: &str = "account.json";
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&[
|
||||
"login.microsoftonline.com", "user.auth.xboxlive.com",
|
||||
"xsts.auth.xboxlive.com", "api.minecraftservices.com",
|
||||
], Duration::from_secs(30))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MsaError {
|
||||
NotConfigured,
|
||||
@@ -405,14 +412,7 @@ pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()
|
||||
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
|
||||
|
||||
let target = data_dir.join(ACCOUNT_FILE);
|
||||
let temporary = data_dir.join(".account.json.shacraft.part");
|
||||
fs::write(&temporary, contents)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
fs::rename(temporary, target)
|
||||
crate::storage::write_atomic(&target, &contents)
|
||||
}
|
||||
|
||||
pub fn load_refresh_token(data_dir: &Path) -> Option<String> {
|
||||
|
||||
@@ -39,7 +39,6 @@ use std::{
|
||||
},
|
||||
thread,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const NEOFORGE_HOST: &str = "maven.neoforged.net";
|
||||
|
||||
@@ -89,7 +88,11 @@ impl From<io::Error> for NeoForgeError {
|
||||
}
|
||||
|
||||
fn is_allowed_host(url: &str) -> bool {
|
||||
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false)
|
||||
crate::trusted_http::allows(url, &[NEOFORGE_HOST])
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&[NEOFORGE_HOST], std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
fn installer_jar_url(loader_version: &str) -> String {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Process-local exclusion for operations that share installation/account files.
|
||||
//!
|
||||
//! Acquire before scheduling the worker and move the permit into it. Dropping
|
||||
//! the caller's future cannot unlock an operation that is still running.
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LauncherOperations {
|
||||
pub installation: Operation,
|
||||
pub account: Operation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Operation(Arc<AtomicBool>);
|
||||
|
||||
impl Operation {
|
||||
pub fn acquire(&self, label: &str) -> Result<Permit, String> {
|
||||
self.0
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.map_err(|_| format!("{label} is already in progress; wait for it to finish"))?;
|
||||
Ok(Permit(self.0.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Permit(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for Permit {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Operation;
|
||||
|
||||
#[test]
|
||||
fn rejects_overlap_and_releases_on_worker_error() {
|
||||
let operation = Operation::default();
|
||||
let worker = || -> Result<(), String> {
|
||||
let _permit = operation.acquire("Installation")?;
|
||||
assert!(operation.acquire("Installation").is_err());
|
||||
Err("simulated worker failure".into())
|
||||
};
|
||||
assert!(worker().is_err());
|
||||
assert!(operation.acquire("Installation").is_ok());
|
||||
}
|
||||
}
|
||||
+126
-15
@@ -2,7 +2,11 @@ use crate::download::{self, Checksum, DownloadError};
|
||||
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Serialize;
|
||||
use std::{fmt, io, path::Path};
|
||||
use std::{
|
||||
fmt, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -28,14 +32,22 @@ pub enum ProfileError {
|
||||
Io(io::Error),
|
||||
Network(reqwest::Error),
|
||||
Download { path: String, source: DownloadError },
|
||||
UnsafePath(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
|
||||
Self::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::Download { path, source } => {
|
||||
write!(formatter, "Download failed for {path}: {source}")
|
||||
}
|
||||
Self::UnsafePath(path) => write!(
|
||||
formatter,
|
||||
"Profile path contains a symbolic link: {}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,11 +57,14 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
let mut mismatched_files = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let path = root.join(&expected.path);
|
||||
if !path.exists() {
|
||||
let path = managed_target(root, &expected.path)?;
|
||||
if !path.try_exists().map_err(ProfileError::Io)? {
|
||||
missing_files += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
|
||||
mismatched_files += 1;
|
||||
@@ -67,8 +82,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
|
||||
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
|
||||
let client = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(Duration::from_secs(10 * 60))
|
||||
.redirect(Policy::custom(|attempt| {
|
||||
if is_allowed_download_url(attempt.url().as_str()) {
|
||||
if attempt.previous().len() >= 10 {
|
||||
attempt.error("too many redirects")
|
||||
} else if is_allowed_download_url(attempt.url().as_str()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
@@ -82,13 +101,15 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
||||
let mut downloaded_bytes = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let target = root.join(&expected.path);
|
||||
if matches!(expected.policy, FilePolicy::Seed) && target.exists() {
|
||||
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)? {
|
||||
if download::is_current(&target, Some(expected.size), &checksum)
|
||||
.map_err(ProfileError::Io)?
|
||||
{
|
||||
reused_files += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -106,10 +127,51 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
||||
})
|
||||
}
|
||||
|
||||
fn download_managed_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result<u64, ProfileError> {
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(ProfileError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn download_managed_file(
|
||||
client: &Client,
|
||||
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, |_, _| {}).map_err(|error| {
|
||||
ProfileError::Download { path: expected.path.clone(), source: error }
|
||||
download::download_verified(
|
||||
client,
|
||||
&expected.url,
|
||||
target,
|
||||
Some(expected.size),
|
||||
&checksum,
|
||||
|_, _| {},
|
||||
)
|
||||
.map_err(|error| ProfileError::Download {
|
||||
path: expected.path.clone(),
|
||||
source: error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,7 +180,10 @@ mod tests {
|
||||
use super::inspect;
|
||||
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn manifest(hash: String, size: u64) -> Manifest {
|
||||
Manifest {
|
||||
@@ -127,7 +192,10 @@ mod tests {
|
||||
display_name: "Aeronautics".into(),
|
||||
minecraft: Minecraft {
|
||||
version: "1.21.1".into(),
|
||||
loader: Loader { kind: "neoforge".into(), version: "21.1.248".into() },
|
||||
loader: Loader {
|
||||
kind: "neoforge".into(),
|
||||
version: "21.1.248".into(),
|
||||
},
|
||||
java_major: 21,
|
||||
},
|
||||
files: vec![ManagedFile {
|
||||
@@ -145,7 +213,10 @@ mod tests {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-launcher-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let bytes = b"ShaCraft test file";
|
||||
let digest = format!("{:x}", Sha256::digest(bytes));
|
||||
@@ -164,4 +235,44 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_seed_files_remain_up_to_date() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-seed-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("mods")).unwrap();
|
||||
fs::write(root.join("mods/example.jar"), b"player's edits").unwrap();
|
||||
let mut expected = manifest("0".repeat(64), 42);
|
||||
expected.files[0].policy = FilePolicy::Seed;
|
||||
assert!(inspect(&root, &expected).unwrap().up_to_date);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_linked_profile_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-link-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("outside")).unwrap();
|
||||
fs::create_dir_all(root.join("profile")).unwrap();
|
||||
symlink(root.join("outside"), root.join("profile/mods")).unwrap();
|
||||
assert!(matches!(
|
||||
inspect(&root.join("profile"), &manifest("0".repeat(64), 42)),
|
||||
Err(super::ProfileError::UnsafePath(_))
|
||||
));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+155
-15
@@ -1,13 +1,20 @@
|
||||
use crate::manifest::{self, Manifest};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Deserialize;
|
||||
use std::{fmt, time::Duration};
|
||||
use std::{
|
||||
fmt,
|
||||
io::{self, Read},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||
const MANIFEST_KEY_ID: &str = "2026-09-06";
|
||||
// Allow the base64 envelope around a payload of up to 2 MiB.
|
||||
const MAX_ENVELOPE_BYTES: usize = 3 * 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -24,7 +31,9 @@ pub enum RemoteError {
|
||||
Network(reqwest::Error),
|
||||
Status(reqwest::StatusCode),
|
||||
TooLarge,
|
||||
Read(io::Error),
|
||||
InvalidSignature,
|
||||
ProfileMismatch,
|
||||
InvalidManifest(manifest::ManifestError),
|
||||
}
|
||||
|
||||
@@ -35,8 +44,14 @@ impl fmt::Display for RemoteError {
|
||||
Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"),
|
||||
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
|
||||
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"),
|
||||
Self::Read(error) => write!(formatter, "Cannot read ShaCraft manifest: {error}"),
|
||||
Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"),
|
||||
Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"),
|
||||
Self::ProfileMismatch => {
|
||||
formatter.write_str("Signed manifest does not match the requested profile")
|
||||
}
|
||||
Self::InvalidManifest(error) => {
|
||||
write!(formatter, "ShaCraft manifest is invalid: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,22 +70,147 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
if !response.status().is_success() {
|
||||
return Err(RemoteError::Status(response.status()));
|
||||
}
|
||||
if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64)
|
||||
{
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let source = response.text().map_err(RemoteError::Network)?;
|
||||
let envelope = serde_json::from_str::<SignedManifest>(&source)
|
||||
let source = read_envelope(response)?;
|
||||
let public_key_bytes = STANDARD
|
||||
.decode(MANIFEST_PUBLIC_KEY)
|
||||
.expect("embedded public key must be valid");
|
||||
let public_key = VerifyingKey::from_bytes(
|
||||
&public_key_bytes
|
||||
.try_into()
|
||||
.expect("embedded public key must be 32 bytes"),
|
||||
)
|
||||
.expect("embedded public key must be valid");
|
||||
verify_envelope(&source, profile_id, &public_key)
|
||||
}
|
||||
|
||||
fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
|
||||
let mut bytes = Vec::new();
|
||||
source
|
||||
.take(MAX_ENVELOPE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(RemoteError::Read)?;
|
||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn verify_envelope(
|
||||
source: &[u8],
|
||||
profile_id: &str,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<Manifest, RemoteError> {
|
||||
if source.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let envelope = serde_json::from_slice::<SignedManifest>(source)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" {
|
||||
if envelope.schema_version != 1 || envelope.key_id != MANIFEST_KEY_ID {
|
||||
return Err(RemoteError::InvalidSignature);
|
||||
}
|
||||
let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid");
|
||||
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes"))
|
||||
.expect("embedded public key must be valid");
|
||||
let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let payload = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature_bytes = STANDARD
|
||||
.decode(envelope.signature)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature =
|
||||
Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
public_key
|
||||
.verify_strict(&payload, &signature)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
|
||||
let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?;
|
||||
if manifest.id != profile_id {
|
||||
return Err(RemoteError::ProfileMismatch);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
#[ignore = "read-only check of the production signed manifest; requires network"]
|
||||
fn live_validates_production_aeronautics_manifest() {
|
||||
let manifest = fetch_manifest("aeronautics").unwrap();
|
||||
assert_eq!(manifest.id, "aeronautics");
|
||||
assert!(!manifest.files.is_empty());
|
||||
}
|
||||
|
||||
fn signed_fixture() -> (Value, VerifyingKey) {
|
||||
let key = SigningKey::from_bytes(&[17; 32]);
|
||||
let payload = serde_json::to_vec(&json!({
|
||||
"schemaVersion": 1, "id": "aeronautics", "displayName": "Aeronautics",
|
||||
"minecraft": {"version": "1.21.1", "loader": {"kind": "neoforge", "version": "21.1.248"}, "javaMajor": 21},
|
||||
"files": []
|
||||
})).unwrap();
|
||||
let envelope = json!({
|
||||
"schemaVersion": 1, "keyId": MANIFEST_KEY_ID,
|
||||
"payload": STANDARD.encode(&payload),
|
||||
"signature": STANDARD.encode(key.sign(&payload).to_bytes())
|
||||
});
|
||||
(envelope, key.verifying_key())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_signature_and_binds_requested_profile() {
|
||||
let (envelope, key) = signed_fixture();
|
||||
let bytes = serde_json::to_vec(&envelope).unwrap();
|
||||
assert_eq!(
|
||||
verify_envelope(&bytes, "aeronautics", &key).unwrap().id,
|
||||
"aeronautics"
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_envelope(&bytes, "another-profile", &key),
|
||||
Err(RemoteError::ProfileMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_modified_payload_signature_key_and_schema() {
|
||||
let (original, key) = signed_fixture();
|
||||
for (field, value) in [
|
||||
("payload", json!(STANDARD.encode(b"{}"))),
|
||||
("signature", json!(STANDARD.encode([0; 64]))),
|
||||
("keyId", json!("unknown")),
|
||||
("schemaVersion", json!(2)),
|
||||
] {
|
||||
let mut envelope = original.clone();
|
||||
envelope[field] = value;
|
||||
assert!(
|
||||
matches!(
|
||||
verify_envelope(&serde_json::to_vec(&envelope).unwrap(), "aeronautics", &key),
|
||||
Err(RemoteError::InvalidSignature)
|
||||
),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let other_key = SigningKey::from_bytes(&[18; 32]).verifying_key();
|
||||
assert!(matches!(
|
||||
verify_envelope(
|
||||
&serde_json::to_vec(&original).unwrap(),
|
||||
"aeronautics",
|
||||
&other_key
|
||||
),
|
||||
Err(RemoteError::InvalidSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_stream_without_content_length() {
|
||||
assert!(matches!(
|
||||
read_envelope(io::repeat(b'x')),
|
||||
Err(RemoteError::TooLarge)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,18 @@ use serde::Deserialize;
|
||||
use std::{fmt, fs, io, path::{Path, PathBuf}};
|
||||
|
||||
const ADOPTIUM_HOST: &str = "api.adoptium.net";
|
||||
const RUNTIME_HOSTS: [&str; 4] = [ADOPTIUM_HOST, "github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"];
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&RUNTIME_HOSTS, std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RuntimeError {
|
||||
Network(reqwest::Error),
|
||||
HttpStatus(reqwest::StatusCode),
|
||||
NoRelease,
|
||||
UntrustedPackage,
|
||||
UnexpectedArchiveLayout,
|
||||
Download(DownloadError),
|
||||
Io(io::Error),
|
||||
@@ -30,6 +36,7 @@ impl fmt::Display for RuntimeError {
|
||||
Self::Network(error) => write!(formatter, "network error: {error}"),
|
||||
Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"),
|
||||
Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"),
|
||||
Self::UntrustedPackage => formatter.write_str("Adoptium package has an unsafe archive name, URL or checksum"),
|
||||
Self::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"),
|
||||
Self::Download(error) => write!(formatter, "{error}"),
|
||||
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
||||
@@ -76,6 +83,18 @@ struct AdoptiumPackage {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn validate_package(package: &AdoptiumPackage) -> Result<(), RuntimeError> {
|
||||
if !crate::manifest::is_portable_component(&package.name)
|
||||
|| package.name.contains('/')
|
||||
|| !(package.name.ends_with(".tar.gz") || package.name.ends_with(".zip"))
|
||||
|| !crate::trusted_http::allows(&package.link, &RUNTIME_HOSTS)
|
||||
|| package.checksum.len() != 64
|
||||
|| !package.checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(RuntimeError::UntrustedPackage);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn adoptium_os() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
@@ -138,6 +157,7 @@ pub fn ensure_runtime(client: &Client, runtime_root: &Path, major: u8, on_progre
|
||||
}
|
||||
let assets: Vec<AdoptiumAsset> = response.json()?;
|
||||
let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?;
|
||||
validate_package(&package)?;
|
||||
|
||||
fs::create_dir_all(runtime_root)?;
|
||||
let archive_path = runtime_root.join(&package.name);
|
||||
@@ -210,6 +230,42 @@ mod tests {
|
||||
assert!(path.ends_with(java_executable_name()));
|
||||
}
|
||||
|
||||
fn package() -> AdoptiumPackage {
|
||||
AdoptiumPackage {
|
||||
name: "OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz".into(),
|
||||
link: "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/runtime.tar.gz".into(),
|
||||
checksum: "a".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_portable_runtime_archive_names() {
|
||||
assert!(validate_package(&package()).is_ok());
|
||||
let mut windows = package();
|
||||
windows.name = "OpenJDK21U-jre_x64_windows_hotspot.zip".into();
|
||||
assert!(validate_package(&windows).is_ok());
|
||||
for name in ["../runtime.tar.gz", "/runtime.zip", "C:\\runtime.zip", "runtime.zip:stream", "CON.zip", "LPT1.zip", "runtime.zip.", "runtime.zip ", "runtime.exe"] {
|
||||
let mut malicious = package();
|
||||
malicious.name = name.into();
|
||||
assert!(matches!(validate_package(&malicious), Err(RuntimeError::UntrustedPackage)), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_runtime_urls_and_invalid_hashes() {
|
||||
for link in ["http://github.com/runtime.zip", "https://evil.example/runtime.zip", "https://github.com.evil.example/runtime.zip", "https://user@github.com/runtime.zip"] {
|
||||
let mut malicious = package();
|
||||
malicious.link = link.into();
|
||||
assert!(validate_package(&malicious).is_err());
|
||||
}
|
||||
let mut malicious = package();
|
||||
malicious.checksum = "not-a-checksum".into();
|
||||
assert!(validate_package(&malicious).is_err());
|
||||
for host in RUNTIME_HOSTS {
|
||||
assert!(crate::trusted_http::allows(&format!("https://{host}/release.tar.gz"), &RUNTIME_HOSTS));
|
||||
}
|
||||
}
|
||||
|
||||
/// Live smoke test: resolves the current platform's latest Temurin 21
|
||||
/// JRE from Adoptium, downloads it, verifies the checksum, and extracts
|
||||
/// it. Not run by default; `cargo test -- --ignored ensure_runtime`.
|
||||
|
||||
+68
-16
@@ -38,7 +38,11 @@ fn default_nickname() -> String {
|
||||
|
||||
impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline }
|
||||
Self {
|
||||
memory_mb: DEFAULT_MEMORY_MB,
|
||||
nickname: DEFAULT_NICKNAME.into(),
|
||||
account_mode: AccountMode::Offline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +59,13 @@ impl fmt::Display for SettingsError {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
|
||||
Self::InvalidJson(error) => write!(formatter, "Cannot read launcher settings: {error}"),
|
||||
Self::InvalidMemory => write!(formatter, "Memory allocation must be between 3 and 12 GiB"),
|
||||
Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"),
|
||||
Self::InvalidMemory => {
|
||||
write!(formatter, "Memory allocation must be between 3 and 12 GiB")
|
||||
}
|
||||
Self::InvalidNickname => write!(
|
||||
formatter,
|
||||
"Nickname must be 3-16 ASCII letters, numbers, or underscores"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +74,9 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
let source = match fs::read_to_string(path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(LauncherSettings::default())
|
||||
}
|
||||
Err(error) => return Err(SettingsError::Io(error)),
|
||||
};
|
||||
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?;
|
||||
@@ -73,23 +84,31 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSettings, SettingsError> {
|
||||
pub fn save(
|
||||
data_dir: &Path,
|
||||
settings: LauncherSettings,
|
||||
) -> Result<LauncherSettings, SettingsError> {
|
||||
validate(&settings)?;
|
||||
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
|
||||
|
||||
let target = data_dir.join(SETTINGS_FILE);
|
||||
let temporary = data_dir.join(".settings.json.shacraft.part");
|
||||
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
|
||||
fs::write(&temporary, contents).map_err(SettingsError::Io)?;
|
||||
fs::rename(temporary, target).map_err(SettingsError::Io)?;
|
||||
crate::storage::write_atomic(&target, &contents).map_err(SettingsError::Io)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 {
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb)
|
||||
|| settings.memory_mb % 1024 != 0
|
||||
{
|
||||
return Err(SettingsError::InvalidMemory);
|
||||
}
|
||||
if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
|
||||
if !(3..=16).contains(&settings.nickname.len())
|
||||
|| !settings
|
||||
.nickname
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(SettingsError::InvalidNickname);
|
||||
}
|
||||
Ok(())
|
||||
@@ -98,13 +117,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load, save, AccountMode, LauncherSettings};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temporary_directory() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"shacraft-settings-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -116,9 +141,20 @@ mod tests {
|
||||
assert_eq!(default.nickname, "Emil");
|
||||
assert_eq!(default.account_mode, AccountMode::Offline);
|
||||
|
||||
let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap();
|
||||
let saved = save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 8 * 1024,
|
||||
nickname: "Emil".into(),
|
||||
account_mode: AccountMode::Microsoft,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.memory_mb, 8 * 1024);
|
||||
assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft);
|
||||
assert_eq!(
|
||||
load(&directory).unwrap().account_mode,
|
||||
AccountMode::Microsoft
|
||||
);
|
||||
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
@@ -126,8 +162,24 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_unsafe_memory_values() {
|
||||
let directory = temporary_directory();
|
||||
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err());
|
||||
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err());
|
||||
assert!(save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 512,
|
||||
nickname: "Emil".into(),
|
||||
account_mode: AccountMode::Offline
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
assert!(save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 6 * 1024,
|
||||
nickname: "невалидный".into(),
|
||||
account_mode: AccountMode::Offline
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Same-directory atomic replacement shared by downloads and durable settings.
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Owns a unique file until commit. Failed writes never replace the destination,
|
||||
/// and dropping the transaction removes only the temporary file it created.
|
||||
pub(crate) struct AtomicFile {
|
||||
temporary: PathBuf,
|
||||
target: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl AtomicFile {
|
||||
pub fn new(target: &Path) -> io::Result<Self> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no parent"))?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no filename"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
for _ in 0..128 {
|
||||
let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed);
|
||||
let mut temporary_name = OsString::from(".");
|
||||
temporary_name.push(name);
|
||||
temporary_name.push(format!(".shacraft-{}-{sequence}.part", std::process::id()));
|
||||
let temporary = parent.join(temporary_name);
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&temporary) {
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
temporary,
|
||||
target: target.to_path_buf(),
|
||||
file: Some(file),
|
||||
committed: false,
|
||||
})
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"cannot allocate a unique temporary file",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn writer(&mut self) -> &mut File {
|
||||
self.file
|
||||
.as_mut()
|
||||
.expect("atomic file is open until commit")
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> io::Result<()> {
|
||||
self.writer().sync_all()?;
|
||||
drop(self.file.take());
|
||||
fs::rename(&self.temporary, &self.target)?;
|
||||
self.committed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AtomicFile {
|
||||
fn drop(&mut self) {
|
||||
drop(self.file.take());
|
||||
if !self.committed {
|
||||
let _ = fs::remove_file(&self.temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let mut output = AtomicFile::new(target)?;
|
||||
output.writer().write_all(bytes)?;
|
||||
output.commit()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn competing_writers_do_not_share_temporary_files() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-storage-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let target = root.join("settings.json");
|
||||
write_atomic(&target, b"original").unwrap();
|
||||
let mut first = AtomicFile::new(&target).unwrap();
|
||||
let mut second = AtomicFile::new(&target).unwrap();
|
||||
assert_ne!(first.temporary, second.temporary);
|
||||
first.writer().write_all(b"first").unwrap();
|
||||
second.writer().write_all(b"second").unwrap();
|
||||
first.commit().unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"first");
|
||||
drop(second);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"first");
|
||||
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn persisted_secrets_are_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-secret-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let target = root.join("account.json");
|
||||
write_atomic(&target, b"token").unwrap();
|
||||
assert_eq!(
|
||||
target.metadata().unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The same origin policy applies to initial artifact URLs and every redirect.
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) fn allows(value: &str, hosts: &[&str]) -> bool {
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https"
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.host_str().is_some_and(|host| hosts.contains(&host))
|
||||
}
|
||||
|
||||
pub(crate) fn client(
|
||||
hosts: &'static [&'static str],
|
||||
timeout: Duration,
|
||||
) -> Result<Client, reqwest::Error> {
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(timeout)
|
||||
.redirect(Policy::custom(move |attempt| {
|
||||
if attempt.previous().len() >= 10 {
|
||||
attempt.error("too many redirects")
|
||||
} else if allows(attempt.url().as_str(), hosts) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.error("redirect leaves the trusted download hosts")
|
||||
}
|
||||
}))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_https_origins_reject_authority_ambiguity_and_cross_domain_redirects() {
|
||||
let hosts = ["piston-meta.mojang.com"];
|
||||
assert!(allows("https://piston-meta.mojang.com/game.json", &hosts));
|
||||
for url in [
|
||||
"http://piston-meta.mojang.com/game.json",
|
||||
"https://piston-meta.mojang.com.attacker.test/game.json",
|
||||
"https://user@piston-meta.mojang.com/game.json",
|
||||
"https://piston-meta.mojang.com:444/game.json",
|
||||
"https://maven.neoforged.net/game.json",
|
||||
] {
|
||||
assert!(!allows(url, &hosts), "{url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user