refactor: modularize launcher UI and native services with verified IO

This commit is contained in:
emil28092005
2026-09-09 12:55:15 +03:00
parent cc19a24e45
commit cde13c4489
51 changed files with 3307 additions and 1118 deletions
+168
View File
@@ -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)
);
}
}
+234
View File
@@ -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}"))?
}
+38
View File
@@ -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())
}
+15
View File
@@ -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}"))
}
+24
View File
@@ -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())
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}
}
+5 -2
View File
@@ -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)]
+8 -8
View File
@@ -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> {
+5 -2
View File
@@ -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 {
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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)
));
}
}
+56
View File
@@ -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
View File
@@ -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]
+135
View File
@@ -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();
}
}
+55
View File
@@ -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}");
}
}
}