merge: preserve ShaCraft 0.1.1 account and Windows features in modular launcher

This commit is contained in:
emil28092005
2026-09-09 13:10:17 +03:00
54 changed files with 2130 additions and 551 deletions
+1 -56
View File
@@ -1,11 +1,6 @@
use super::data_dir;
use crate::{
msa,
operations::{LauncherOperations, Operation},
session, settings,
};
use crate::{msa, operations::LauncherOperations};
use serde::Serialize;
use std::path::Path;
use tauri::{AppHandle, Emitter, State};
#[derive(Clone, Serialize)]
@@ -116,53 +111,3 @@ pub(crate) async fn logout(
.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)
);
}
}
+23 -25
View File
@@ -1,4 +1,4 @@
use super::{account::resolve_identity, data_dir};
use super::{data_dir, shacraft::resolve_identity};
use crate::{
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
settings,
@@ -114,24 +114,23 @@ pub(crate) async fn ensure_game_installed(
&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())?;
};
// NeoForge may leave vanilla runtime libraries (including LWJGL) absent.
// Verify the full merged set, using the loader Maven only for libraries.
let library_client = mojang::library_http_client().map_err(|error| error.to_string())?;
mojang::ensure_client_jar(
&client,
&game_dir,
&merged.client_jar_version_id,
&merged.client,
)
.map_err(|error| error.to_string())?;
mojang::ensure_libraries(
&library_client,
&game_dir,
&merged.libraries,
&stage_progress("libraries"),
)
.map_err(|error| error.to_string())?;
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
.map_err(|error| error.to_string())?;
@@ -151,10 +150,9 @@ pub(crate) struct GameExited {
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
/// Launches `profile_id` with the verified ShaCraft account's linked nickname.
/// Local legacy nickname/account-mode preferences cannot override the link.
/// Spawns the game detached; watches it on a
/// background thread only to emit `game-exited` when it eventually closes.
#[tauri::command]
pub(crate) async fn launch_game(
@@ -165,7 +163,7 @@ pub(crate) async fn launch_game(
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();
let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let _permit = permit;
@@ -174,7 +172,7 @@ pub(crate) async fn launch_game(
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)?;
let identity = resolve_identity(&data_dir, &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.
+6 -1
View File
@@ -1,5 +1,5 @@
use super::data_dir;
use crate::{java, manifest};
use crate::{java, manifest, msa};
use serde::Serialize;
use tauri::AppHandle;
@@ -29,6 +29,11 @@ pub(crate) fn detect_java() -> Option<java::JavaInstallation> {
java::detect()
}
#[tauri::command]
pub(crate) fn microsoft_login_available() -> bool {
msa::is_configured()
}
/// Validates an untrusted profile manifest before any file is downloaded.
#[tauri::command]
pub(crate) fn validate_manifest(manifest_json: String) -> Result<(), String> {
+1
View File
@@ -4,6 +4,7 @@ pub(crate) mod game;
pub(crate) mod host;
pub(crate) mod preferences;
pub(crate) mod profiles;
pub(crate) mod shacraft;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
+9
View File
@@ -2,6 +2,15 @@ use super::data_dir;
use crate::{operations::LauncherOperations, profile, remote};
use tauri::{AppHandle, State};
#[tauri::command]
pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
tauri::async_runtime::spawn_blocking(move || {
remote::fetch_server_status(&profile_id).map_err(|error| error.to_string())
})
.await
.map_err(|error| format!("Server-status task failed: {error}"))?
}
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
#[tauri::command]
pub(crate) async fn inspect_remote_profile(
+120
View File
@@ -0,0 +1,120 @@
//! ShaCraft sessions and verified account links are the launch identity source.
use super::data_dir;
use crate::{
operations::{LauncherOperations, Operation},
session, shacraft_account,
};
use std::path::Path;
use tauri::{AppHandle, State};
async fn account_task<T: Send + 'static>(
app: &AppHandle,
operations: &LauncherOperations,
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
) -> Result<T, String> {
let directory = data_dir(app)?;
let permit = operations
.shacraft_account
.acquire("ShaCraft account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
work(&directory).map_err(|error| error.to_string())
})
.await
.map_err(|error| format!("ShaCraft account task failed: {error}"))?
}
#[tauri::command]
pub(crate) async fn shacraft_authenticate(
app: AppHandle,
state: State<'_, LauncherOperations>,
username: String,
password: String,
register: bool,
) -> Result<shacraft_account::LoginResult, String> {
account_task(&app, &state, move |directory| {
shacraft_account::authenticate(directory, &username, &password, register)
})
.await
}
#[tauri::command]
pub(crate) async fn get_shacraft_account(
app: AppHandle,
state: State<'_, LauncherOperations>,
) -> Result<Option<shacraft_account::Account>, String> {
account_task(
&app,
&state,
|directory| match shacraft_account::get_account(directory) {
Ok(account) => Ok(Some(account)),
Err(shacraft_account::AccountError::InvalidSession) => Ok(None),
Err(error) => Err(error),
},
)
.await
}
#[tauri::command]
pub(crate) async fn shacraft_logout(
app: AppHandle,
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
account_task(&app, &state, shacraft_account::logout).await
}
#[tauri::command]
pub(crate) async fn shacraft_start_link(
app: AppHandle,
state: State<'_, LauncherOperations>,
nickname: String,
) -> Result<shacraft_account::LinkStart, String> {
account_task(&app, &state, move |directory| {
shacraft_account::start_link(directory, "aoc", &nickname)
})
.await
}
#[tauri::command]
pub(crate) async fn shacraft_link_status(
app: AppHandle,
state: State<'_, LauncherOperations>,
challenge_id: i64,
) -> Result<shacraft_account::LinkStatus, String> {
account_task(&app, &state, move |directory| {
shacraft_account::link_status(directory, challenge_id)
})
.await
}
/// Always revalidate the server session and its aoc link. Legacy local settings
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
pub(super) fn resolve_identity(
directory: &Path,
operation: &Operation,
) -> Result<session::PlayerIdentity, String> {
let _permit = operation.acquire("ShaCraft account operation")?;
let name =
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
Ok(session::PlayerIdentity::Offline { name })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
let directory = std::env::temp_dir().join(format!(
"shacraft-identity-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
assert!(resolve_identity(&directory, &Operation::default()).is_err());
std::fs::remove_dir_all(directory).unwrap();
}
}
+26 -9
View File
@@ -2,7 +2,11 @@ use crate::download::ProgressCallback;
use crate::runtime::{self, RuntimeError};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
use std::{
env, fmt,
path::{Path, PathBuf},
process::Command,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -21,9 +25,14 @@ pub enum EnsureJavaError {
impl fmt::Display for EnsureJavaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"),
Self::Provisioning(error) => {
write!(formatter, "Cannot install a Java runtime: {error}")
}
Self::ProvisionedButUnrecognised(path) => {
write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version")
write!(
formatter,
"Installed a Java runtime at {path:?}, but it did not report a usable version"
)
}
}
}
@@ -38,21 +47,29 @@ pub fn detect() -> Option<JavaInstallation> {
candidates().into_iter().find_map(check_candidate)
}
/// Returns a Java runtime with at least `required_major`, preferring
/// whatever the user already has installed. Only downloads and extracts a
/// Returns a Java runtime with exactly `required_major`, preferring a matching
/// installation already on the machine. Newer JVM majors are not assumed to
/// be compatible with the selected NeoForge/modpack version. Only downloads and extracts a
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
/// the user's own Java) when nothing suitable is already on the machine.
/// `on_progress` reports real download bytes when a JRE actually needs
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result<JavaInstallation, EnsureJavaError> {
pub fn ensure_java(
client: &Client,
runtime_root: &Path,
required_major: u8,
on_progress: &ProgressCallback,
) -> Result<JavaInstallation, EnsureJavaError> {
if let Some(installation) = detect() {
if installation.major >= required_major {
if installation.major == required_major {
on_progress(1, 1);
return Ok(installation);
}
}
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
.map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone())
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
}
fn candidates() -> Vec<PathBuf> {
+128 -15
View File
@@ -8,7 +8,7 @@ use crate::mojang::{self, MergedVersion};
use crate::session::PlayerIdentity;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
fmt, fs, io,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
@@ -57,17 +57,37 @@ fn classpath_separator() -> &'static str {
}
}
fn unique_classpath_entries(mut entries: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
entries.retain(|path| seen.insert(path.clone()));
entries
}
fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String {
let no_features = HashMap::new();
let mut entries: Vec<PathBuf> = merged
.libraries
.iter()
.filter(|library| mojang::rule_allows(&library.rules, &no_features))
.filter_map(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()))
.filter_map(|library| {
library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
})
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
.collect();
entries.push(client_jar.to_path_buf());
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
// NeoForge's inherited profile can repeat vanilla libraries verbatim.
// Passing the same jar twice makes SecureJarHandler abort during startup
// (for example on gson-2.10.1.jar), so preserve order and keep each path
// only once.
let entries = unique_classpath_entries(entries);
entries
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(classpath_separator())
}
/// A persistent-but-not-security-sensitive per-install identifier for the
@@ -93,7 +113,13 @@ static UUID_COUNTER: AtomicU64 = AtomicU64::new(0);
fn random_uuid_v4() -> String {
let mut hasher = Sha256::new();
hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos().to_le_bytes());
hasher.update(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
hasher.update(std::process::id().to_le_bytes());
hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
let stack_marker = 0_u8;
@@ -103,7 +129,10 @@ fn random_uuid_v4() -> String {
bytes.copy_from_slice(&digest[0..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
let hex = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
crate::session::format_uuid_with_dashes(&hex)
}
@@ -118,6 +147,36 @@ fn substitute(template: &str, vars: &HashMap<&str, String>) -> String {
result
}
/// Java's argument-file syntax is independent of the platform shell. Keeping
/// the large JVM/module/classpath portion in an argfile avoids Windows'
/// 32,767 UTF-16 command-line limit while leaving account tokens out of it.
fn quote_argfile_argument(argument: &str) -> String {
let mut quoted = String::with_capacity(argument.len() + 2);
quoted.push('"');
for character in argument.chars() {
match character {
'\\' => quoted.push_str("\\\\"),
'"' => quoted.push_str("\\\""),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
other => quoted.push(other),
}
}
quoted.push('"');
quoted
}
fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
let mut contents = arguments
.iter()
.map(|argument| quote_argfile_argument(argument))
.collect::<Vec<_>>()
.join("\n");
contents.push('\n');
fs::write(path, contents)
}
/// Builds the full `java` command line for `request.merged` and spawns it
/// detached, with stdout/stderr both redirected to `request.log_path`.
/// Never blocks on the child exiting — the caller decides how to observe
@@ -129,17 +188,26 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
fs::create_dir_all(&natives_dir)?;
let assets_root = request.game_dir.join("assets");
let libraries_dir = request.game_dir.join("libraries");
let client_jar = mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
let client_jar =
mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", request.identity.name().to_string());
vars.insert("version_name", request.merged.id.clone());
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
// `-DignoreList`. The actual client jar belongs to the vanilla parent
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
// token must identify the parent or both vanilla and patched Minecraft
// modules are loaded and Java aborts with a ResolutionException.
vars.insert("version_name", request.merged.client_jar_version_id.clone());
vars.insert("game_directory", request.profile_dir.display().to_string());
vars.insert("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
vars.insert("auth_uuid", request.identity.uuid());
vars.insert("auth_access_token", request.identity.access_token().to_string());
vars.insert(
"auth_access_token",
request.identity.access_token().to_string(),
);
vars.insert("clientid", launcher_client_id(request.game_dir)?);
vars.insert("auth_xuid", request.identity.xuid().to_string());
vars.insert("user_type", request.identity.user_type().to_string());
@@ -152,13 +220,24 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
vars.insert("classpath_separator", classpath_separator().to_string());
let no_features = HashMap::new();
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features);
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features)
.into_iter()
.map(|argument| substitute(&argument, &vars))
.collect::<Vec<_>>();
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
let mut command = Command::new(request.java_executable);
command.arg(format!("-Xmx{}M", request.memory_mb));
for argument in jvm_args {
command.arg(substitute(&argument, &vars));
let memory_argument = format!("-Xmx{}M", request.memory_mb);
if cfg!(windows) {
let argfile = request.profile_dir.join(".shacraft-jvm.args");
let mut argfile_arguments = Vec::with_capacity(jvm_args.len() + 1);
argfile_arguments.push(memory_argument);
argfile_arguments.extend(jvm_args);
write_jvm_argfile(&argfile, &argfile_arguments)?;
command.arg(format!("@{}", argfile.display()));
} else {
command.arg(memory_argument);
command.args(jvm_args);
}
command.arg(&request.merged.main_class);
for argument in game_args {
@@ -183,12 +262,18 @@ mod tests {
let parts: Vec<&str> = id.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[2].chars().next().unwrap(), '4');
assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b'));
assert!(matches!(
parts[3].chars().next().unwrap(),
'8' | '9' | 'a' | 'b'
));
}
#[test]
fn client_id_is_persisted_across_calls() {
let dir = std::env::temp_dir().join(format!("shacraft-launch-clientid-test-{}", std::process::id()));
let dir = std::env::temp_dir().join(format!(
"shacraft-launch-clientid-test-{}",
std::process::id()
));
let first = launcher_client_id(&dir).unwrap();
let second = launcher_client_id(&dir).unwrap();
assert_eq!(first, second);
@@ -201,6 +286,34 @@ mod tests {
vars.insert("auth_player_name", "Steve".to_string());
assert_eq!(substitute("--username", &vars), "--username");
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}");
assert_eq!(
substitute("-Djava.library.path=${natives_directory}", &vars),
"-Djava.library.path=${natives_directory}"
);
}
#[test]
fn classpath_entries_are_unique() {
let entries = unique_classpath_entries(vec![
PathBuf::from("gson.jar"),
PathBuf::from("gson.jar"),
PathBuf::from("client.jar"),
]);
assert_eq!(
entries,
vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]
);
}
#[test]
fn quotes_java_argfile_arguments() {
assert_eq!(
quote_argfile_argument(r#"-Dpath=C:\\Users\\Jane Doe\\game"#),
r#""-Dpath=C:\\\\Users\\\\Jane Doe\\\\game""#
);
assert_eq!(
quote_argfile_argument(r#"-Dname="ShaCraft""#),
r#""-Dname=\"ShaCraft\"""#
);
}
}
+8
View File
@@ -10,6 +10,7 @@ mod remote;
mod runtime;
mod session;
mod settings;
mod shacraft_account;
mod storage;
mod trusted_http;
@@ -22,11 +23,18 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::host::native_host,
commands::host::detect_java,
commands::host::microsoft_login_available,
commands::host::validate_manifest,
commands::profiles::inspect_remote_profile,
commands::profiles::sync_remote_profile,
commands::profiles::get_server_status,
commands::preferences::load_settings,
commands::preferences::save_settings,
commands::shacraft::shacraft_authenticate,
commands::shacraft::get_shacraft_account,
commands::shacraft::shacraft_logout,
commands::shacraft::shacraft_start_link,
commands::shacraft::shacraft_link_status,
commands::account::start_microsoft_login,
commands::account::get_account,
commands::account::logout,
+223 -42
View File
@@ -26,7 +26,8 @@ use std::{
},
};
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const VERSION_MANIFEST_URL: &str =
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const MOJANG_HOSTS: [&str; 4] = [
"piston-meta.mojang.com",
"piston-data.mojang.com",
@@ -47,6 +48,28 @@ pub fn http_client() -> Result<Client, reqwest::Error> {
crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60))
}
/// The verified merged profile can contain both Mojang and NeoForge artifacts.
/// This broader client is used only for that library list, never metadata.
pub fn library_http_client() -> Result<Client, reqwest::Error> {
crate::trusted_http::client(
&[
MOJANG_HOSTS[0],
MOJANG_HOSTS[1],
MOJANG_HOSTS[2],
MOJANG_HOSTS[3],
crate::neoforge::NEOFORGE_HOST,
],
std::time::Duration::from_secs(10 * 60),
)
}
/// Library entries in a merged loader profile may point at the loader's
/// own fixed Maven. The profile itself comes from the SHA-256-verified
/// NeoForge installer, never from the ShaCraft manifest.
fn is_allowed_library_host(url: &str) -> bool {
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
}
#[derive(Debug)]
pub enum MojangError {
Network(reqwest::Error),
@@ -55,6 +78,7 @@ pub enum MojangError {
ChecksumMismatch(String),
DisallowedHost(String),
MissingField(String),
ConflictingLibrary(String),
Download(DownloadError),
Io(io::Error),
}
@@ -66,8 +90,14 @@ impl fmt::Display for MojangError {
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"),
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised Mojang host: {url}"),
Self::DisallowedHost(url) => {
write!(formatter, "URL is not a recognised Mojang host: {url}")
}
Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"),
Self::ConflictingLibrary(path) => write!(
formatter,
"merged version contains conflicting library entries for {path}"
),
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
}
@@ -106,7 +136,10 @@ pub fn fetch_version_manifest(client: &Client) -> Result<VersionManifest, Mojang
fetch_json(client, VERSION_MANIFEST_URL, None)
}
pub fn find_version<'a>(manifest: &'a VersionManifest, id: &str) -> Option<&'a VersionManifestEntry> {
pub fn find_version<'a>(
manifest: &'a VersionManifest,
id: &str,
) -> Option<&'a VersionManifestEntry> {
manifest.versions.iter().find(|entry| entry.id == id)
}
@@ -141,7 +174,10 @@ pub struct Arguments {
#[serde(untagged)]
pub enum ArgumentValue {
Plain(String),
Conditional { rules: Vec<Rule>, value: StringOrList },
Conditional {
rules: Vec<Rule>,
value: StringOrList,
},
}
#[derive(Debug, Deserialize, Clone)]
@@ -227,7 +263,11 @@ fn current_os_name() -> &'static str {
}
fn arch_matches(expected: &str) -> bool {
let normalized = if expected == "arm64" { "aarch64" } else { expected };
let normalized = if expected == "arm64" {
"aarch64"
} else {
expected
};
normalized == std::env::consts::ARCH
}
@@ -246,7 +286,9 @@ fn os_matches(os: &RuleOs) -> bool {
}
fn features_match(required: &HashMap<String, bool>, active: &HashMap<String, bool>) -> bool {
required.iter().all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
required
.iter()
.all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
}
/// Evaluates a Mojang-style rule list: no rules means always allowed;
@@ -261,7 +303,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> b
let mut allowed = false;
for rule in rules {
let os_ok = rule.os.as_ref().is_none_or(os_matches);
let features_ok = rule.features.as_ref().is_none_or(|required| features_match(required, active_features));
let features_ok = rule
.features
.as_ref()
.is_none_or(|required| features_match(required, active_features));
if os_ok && features_ok {
allowed = rule.action == RuleAction::Allow;
}
@@ -271,7 +316,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> b
/// Flattens an argument list into plain strings, dropping conditional
/// entries whose rules don't match this platform/feature set.
pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<String, bool>) -> Vec<String> {
pub fn resolve_arguments(
arguments: &[ArgumentValue],
active_features: &HashMap<String, bool>,
) -> Vec<String> {
let mut resolved = Vec::new();
for argument in arguments {
match argument {
@@ -289,11 +337,18 @@ pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<
resolved
}
pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result<VersionJson, MojangError> {
pub fn fetch_version_json(
client: &Client,
entry: &VersionManifestEntry,
) -> Result<VersionJson, MojangError> {
fetch_json(client, &entry.url, Some(&entry.sha1))
}
fn fetch_json<T: DeserializeOwned>(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result<T, MojangError> {
fn fetch_json<T: DeserializeOwned>(
client: &Client,
url: &str,
expected_sha1: Option<&str>,
) -> Result<T, MojangError> {
if !is_allowed_host(url) {
return Err(MojangError::DisallowedHost(url.to_string()));
}
@@ -344,8 +399,14 @@ pub struct MergedVersion {
/// the parent's, and its libraries are appended after the parent's.
/// `assetIndex`/`downloads.client` always come from the parent, since
/// modloader profiles don't redeclare them.
pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result<MergedVersion, MojangError> {
let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
pub fn merge_versions(
parent: &VersionJson,
child: Option<&VersionJson>,
) -> Result<MergedVersion, MojangError> {
let asset_index = parent
.asset_index
.clone()
.ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
let client = parent
.downloads
.as_ref()
@@ -397,34 +458,69 @@ pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf {
}
pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf {
game_dir.join("versions").join(version_id).join(format!("{version_id}.jar"))
game_dir
.join("versions")
.join(version_id)
.join(format!("{version_id}.jar"))
}
pub fn ensure_client_jar(client: &Client, game_dir: &Path, version_id: &str, download_ref: &DownloadRef) -> Result<PathBuf, MojangError> {
pub fn ensure_client_jar(
client: &Client,
game_dir: &Path,
version_id: &str,
download_ref: &DownloadRef,
) -> Result<PathBuf, MojangError> {
if !is_allowed_host(&download_ref.url) {
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
}
let target = client_jar_path(game_dir, version_id);
let checksum = Checksum::Sha1(download_ref.sha1.clone());
if !download::is_current(&target, Some(download_ref.size), &checksum)? {
download::download_verified(client, &download_ref.url, &target, Some(download_ref.size), &checksum, |_, _| {})?;
download::download_verified(
client,
&download_ref.url,
&target,
Some(download_ref.size),
&checksum,
|_, _| {},
)?;
}
Ok(target)
}
/// Downloads every rule-allowed library with a `downloads.artifact`,
/// returning the resulting jar paths in the same order as `libraries`.
pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], on_progress: &ProgressCallback) -> Result<Vec<PathBuf>, MojangError> {
pub fn ensure_libraries(
client: &Client,
game_dir: &Path,
libraries: &[Library],
on_progress: &ProgressCallback,
) -> Result<Vec<PathBuf>, MojangError> {
let mut paths = Vec::new();
let mut tasks = Vec::new();
let mut seen: HashMap<PathBuf, (String, u64, String)> = HashMap::new();
for library in libraries {
if !rule_allows(&library.rules, &HashMap::new()) {
continue;
}
let Some(artifact) = library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()) else {
let Some(artifact) = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
else {
continue;
};
let target = game_dir.join("libraries").join(&artifact.path);
let identity = (artifact.url.clone(), artifact.size, artifact.sha1.clone());
if let Some(existing) = seen.get(&target) {
if existing != &identity {
return Err(MojangError::ConflictingLibrary(
target.display().to_string(),
));
}
continue;
}
seen.insert(target.clone(), identity);
paths.push(target.clone());
tasks.push(DownloadTask {
url: artifact.url.clone(),
@@ -433,7 +529,7 @@ pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library],
checksum: Checksum::Sha1(artifact.sha1.clone()),
});
}
download_many(client, tasks, on_progress)?;
download_many(client, tasks, on_progress, is_allowed_library_host)?;
Ok(paths)
}
@@ -448,20 +544,39 @@ pub struct AssetObject {
pub size: u64,
}
pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result<AssetIndex, MojangError> {
pub fn ensure_asset_index(
client: &Client,
game_dir: &Path,
asset_index: &AssetIndexRef,
) -> Result<AssetIndex, MojangError> {
if !is_allowed_host(&asset_index.url) {
return Err(MojangError::DisallowedHost(asset_index.url.clone()));
}
let target = game_dir.join("assets").join("indexes").join(format!("{}.json", asset_index.id));
let target = game_dir
.join("assets")
.join("indexes")
.join(format!("{}.json", asset_index.id));
let checksum = Checksum::Sha1(asset_index.sha1.clone());
if !download::is_current(&target, Some(asset_index.size), &checksum)? {
download::download_verified(client, &asset_index.url, &target, Some(asset_index.size), &checksum, |_, _| {})?;
download::download_verified(
client,
&asset_index.url,
&target,
Some(asset_index.size),
&checksum,
|_, _| {},
)?;
}
let bytes = fs::read(&target)?;
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
}
pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_progress: &ProgressCallback) -> Result<(), MojangError> {
pub fn ensure_assets(
client: &Client,
game_dir: &Path,
index: &AssetIndex,
on_progress: &ProgressCallback,
) -> Result<(), MojangError> {
let objects_dir = game_dir.join("assets").join("objects");
let tasks = index
.objects
@@ -469,14 +584,17 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
.map(|object| {
let prefix = &object.hash[0..2];
DownloadTask {
url: format!("https://resources.download.minecraft.net/{prefix}/{}", object.hash),
url: format!(
"https://resources.download.minecraft.net/{prefix}/{}",
object.hash
),
target: objects_dir.join(prefix).join(&object.hash),
size: object.size,
checksum: Checksum::Sha1(object.hash.clone()),
}
})
.collect();
download_many(client, tasks, on_progress)
download_many(client, tasks, on_progress, is_allowed_host)
}
struct DownloadTask {
@@ -496,7 +614,14 @@ const MAX_DOWNLOAD_ATTEMPTS: u32 = 5;
fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, DownloadError> {
let mut last_error = None;
for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS {
match download::download_verified(client, &task.url, &task.target, Some(task.size), &task.checksum, |_, _| {}) {
match download::download_verified(
client,
&task.url,
&task.target,
Some(task.size),
&task.checksum,
|_, _| {},
) {
Ok(bytes) => return Ok(bytes),
Err(error) => {
last_error = Some(error);
@@ -512,7 +637,12 @@ fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, Do
/// Downloads `tasks` using a small worker pool, calling `on_progress` with
/// cumulative (downloaded, total) bytes as each file completes. Stops
/// spawning new work once the first error is seen and returns it.
fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &ProgressCallback) -> Result<(), MojangError> {
fn download_many(
client: &Client,
tasks: Vec<DownloadTask>,
on_progress: &ProgressCallback,
is_allowed: fn(&str) -> bool,
) -> Result<(), MojangError> {
let total: u64 = tasks.iter().map(|task| task.size).sum();
if total == 0 {
return Ok(());
@@ -531,12 +661,16 @@ fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &Progre
if first_error.lock().unwrap().is_some() {
break;
}
let Some(task) = queue.lock().unwrap().pop() else { break };
if !is_allowed_host(&task.url) {
let Some(task) = queue.lock().unwrap().pop() else {
break;
};
if !is_allowed(&task.url) {
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
continue;
}
let already_current = download::is_current(&task.target, Some(task.size), &task.checksum).unwrap_or(false);
let already_current =
download::is_current(&task.target, Some(task.size), &task.checksum)
.unwrap_or(false);
if !already_current {
if let Err(error) = download_with_retries(client, &task) {
*first_error.lock().unwrap() = Some(MojangError::Download(error));
@@ -562,7 +696,10 @@ mod tests {
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
Rule {
action,
os: os_name.map(|name| RuleOs { name: Some(name.into()), arch: None }),
os: os_name.map(|name| RuleOs {
name: Some(name.into()),
arch: None,
}),
features: None,
}
}
@@ -580,7 +717,11 @@ mod tests {
#[test]
fn non_matching_os_rule_disallows() {
let other = if current_os_name() == "windows" { "linux" } else { "windows" };
let other = if current_os_name() == "windows" {
"linux"
} else {
"windows"
};
let rules = vec![rule(RuleAction::Allow, Some(other))];
assert!(!rule_allows(&rules, &HashMap::new()));
}
@@ -589,7 +730,11 @@ mod tests {
fn unsupported_feature_is_excluded_by_default() {
let mut features = HashMap::new();
features.insert("is_demo_user".to_string(), true);
let rules = vec![Rule { action: RuleAction::Allow, os: None, features: Some(features) }];
let rules = vec![Rule {
action: RuleAction::Allow,
os: None,
features: Some(features),
}];
// We never activate optional features, so a rule requiring one
// must not match even though there's no OS constraint.
assert!(!rule_allows(&rules, &HashMap::new()));
@@ -610,7 +755,10 @@ mod tests {
},
];
let resolved = resolve_arguments(&args, &HashMap::new());
assert_eq!(resolved, vec!["--username", "${auth_player_name}", "--this-os-only"]);
assert_eq!(
resolved,
vec!["--username", "${auth_player_name}", "--this-os-only"]
);
}
#[test]
@@ -640,17 +788,39 @@ mod tests {
let merged = merge_versions(&parent, Some(&child)).unwrap();
assert_eq!(merged.id, "neoforge-21.1.248");
assert_eq!(merged.client_jar_version_id, "1.21.1");
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]);
assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]);
assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::<Vec<_>>(), vec!["parent:lib:1", "child:lib:1"]);
assert_eq!(
merged.main_class,
"cpw.mods.bootstraplauncher.BootstrapLauncher"
);
assert_eq!(
resolve_arguments(&merged.game_arguments, &HashMap::new()),
vec!["--parentGame", "--childGame"]
);
assert_eq!(
resolve_arguments(&merged.jvm_arguments, &HashMap::new()),
vec!["--parentJvm", "--childJvm"]
);
assert_eq!(
merged
.libraries
.iter()
.map(|library| library.name.as_str())
.collect::<Vec<_>>(),
vec!["parent:lib:1", "child:lib:1"]
);
assert_eq!(merged.asset_index.id, "17");
}
#[test]
fn disallowed_host_is_rejected() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar"));
assert!(is_allowed_host(
"https://piston-data.mojang.com/v1/objects/x/client.jar"
));
assert!(is_allowed_library_host(
"https://maven.neoforged.net/releases/net/neoforged/example.jar"
));
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
}
/// Live smoke test against the real Mojang CDN: manifest -> version JSON
@@ -666,7 +836,8 @@ mod tests {
let version = fetch_version_json(&client, entry).unwrap();
assert_eq!(version.main_class, "net.minecraft.client.main.Main");
let game_dir = std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
let game_dir =
std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
let merged = merge_versions(&version, None).unwrap();
let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
@@ -678,11 +849,20 @@ mod tests {
let mut small_libraries: Vec<Library> = merged
.libraries
.iter()
.filter(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()).is_some_and(|artifact| artifact.size < 200_000))
.filter(|library| {
library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.is_some_and(|artifact| artifact.size < 200_000)
})
.take(5)
.cloned()
.collect();
assert!(!small_libraries.is_empty(), "expected at least one small library to sanity-check downloads with");
assert!(
!small_libraries.is_empty(),
"expected at least one small library to sanity-check downloads with"
);
small_libraries.truncate(5);
let progress: ProgressCallback = Arc::new(|_, _| {});
let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap();
@@ -692,7 +872,8 @@ mod tests {
// Re-running against already-downloaded files must be a no-op (the
// `is_current` fast path), not re-download or fail.
let client_jar_again = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
let client_jar_again =
ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
assert_eq!(client_jar, client_jar_again);
fs::remove_dir_all(&game_dir).ok();
+133 -37
View File
@@ -33,7 +33,7 @@ use std::{
/// access. Replace this before shipping login — see the module doc above.
const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000";
fn client_id_is_configured() -> bool {
pub fn is_configured() -> bool {
MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000"
}
@@ -41,15 +41,21 @@ const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
const MINECRAFT_LOGIN_URL: &str =
"https://api.minecraftservices.com/authentication/login_with_xbox";
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))
crate::trusted_http::client(
&[
"login.microsoftonline.com",
"user.auth.xboxlive.com",
"xsts.auth.xboxlive.com",
"api.minecraftservices.com",
],
Duration::from_secs(30),
)
}
#[derive(Debug)]
@@ -113,12 +119,15 @@ struct DeviceCodeResponse {
}
pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
if !client_id_is_configured() {
if !is_configured() {
return Err(MsaError::NotConfigured);
}
let response = client
.post(DEVICE_CODE_URL)
.form(&[("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access")])
.form(&[
("client_id", MSA_CLIENT_ID),
("scope", "XboxLive.signin offline_access"),
])
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
@@ -151,7 +160,10 @@ struct TokenResponse {
/// decline. This is the slow step in the whole login flow — the caller
/// should already have shown `verification_uri`/`user_code` to the user
/// before calling this (see `start_device_code`).
pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<MicrosoftTokens, MsaError> {
pub fn poll_device_code(
client: &Client,
start: &DeviceCodeStart,
) -> Result<MicrosoftTokens, MsaError> {
let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds);
let mut interval = Duration::from_secs(start.interval_seconds);
@@ -174,10 +186,16 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
if status.is_success() {
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
return Err(MsaError::UnexpectedResponse("token response missing access_token/refresh_token".into()));
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token)
else {
return Err(MsaError::UnexpectedResponse(
"token response missing access_token/refresh_token".into(),
));
};
return Ok(MicrosoftTokens { access_token, refresh_token });
return Ok(MicrosoftTokens {
access_token,
refresh_token,
});
}
match body.error.as_deref() {
@@ -188,13 +206,20 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
}
Some("authorization_declined") => return Err(MsaError::AuthorizationDeclined),
Some("expired_token") => return Err(MsaError::AuthorizationExpired),
other => return Err(MsaError::UnexpectedResponse(other.unwrap_or("unknown device code error").into())),
other => {
return Err(MsaError::UnexpectedResponse(
other.unwrap_or("unknown device code error").into(),
))
}
}
}
}
pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<MicrosoftTokens, MsaError> {
if !client_id_is_configured() {
pub fn refresh_microsoft_tokens(
client: &Client,
refresh_token: &str,
) -> Result<MicrosoftTokens, MsaError> {
if !is_configured() {
return Err(MsaError::NotConfigured);
}
let response = client
@@ -212,9 +237,14 @@ pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<
}
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
return Err(MsaError::UnexpectedResponse("refresh response missing access_token/refresh_token".into()));
return Err(MsaError::UnexpectedResponse(
"refresh response missing access_token/refresh_token".into(),
));
};
Ok(MicrosoftTokens { access_token, refresh_token })
Ok(MicrosoftTokens {
access_token,
refresh_token,
})
}
// ---------------------------------------------------------------------
@@ -281,7 +311,10 @@ struct XboxUserHash {
xid: Option<String>,
}
fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result<(String, String), MsaError> {
fn xbox_live_user_token(
client: &Client,
microsoft_access_token: &str,
) -> Result<(String, String), MsaError> {
let request = XboxUserAuthRequest {
properties: XboxUserAuthProperties {
auth_method: "RPS",
@@ -291,22 +324,42 @@ fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result
relying_party: "http://auth.xboxlive.com",
token_type: "JWT",
};
let response = client.post(XBOX_USER_AUTH_URL).json(&request).send().map_err(MsaError::Network)?;
let response = client
.post(XBOX_USER_AUTH_URL)
.json(&request)
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
let uhs = body.display_claims.xui.into_iter().next().map(|claim| claim.uhs).ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
let uhs = body
.display_claims
.xui
.into_iter()
.next()
.map(|claim| claim.uhs)
.ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
Ok((body.token, uhs))
}
fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, String, Option<String>), MsaError> {
fn xsts_authorize(
client: &Client,
xbox_live_token: &str,
) -> Result<(String, String, Option<String>), MsaError> {
let request = XstsRequest {
properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token] },
properties: XstsProperties {
sandbox_id: "RETAIL",
user_tokens: [xbox_live_token],
},
relying_party: "rp://api.minecraftservices.com/",
token_type: "JWT",
};
let response = client.post(XSTS_AUTHORIZE_URL).json(&request).send().map_err(MsaError::Network)?;
let response = client
.post(XSTS_AUTHORIZE_URL)
.json(&request)
.send()
.map_err(MsaError::Network)?;
let status = response.status();
if status.as_u16() == 401 {
// XErr 2148916233 means the account has no Xbox profile at all
@@ -319,7 +372,12 @@ fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, Str
return Err(MsaError::HttpStatus(status));
}
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
let claim = body.display_claims.xui.into_iter().next().ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
let claim = body
.display_claims
.xui
.into_iter()
.next()
.ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
Ok((body.token, claim.uhs, claim.xid))
}
@@ -335,8 +393,14 @@ struct MinecraftLoginResponse {
}
fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result<String, MsaError> {
let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}") };
let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?;
let request = MinecraftLoginRequest {
identity_token: format!("XBL3.0 x={user_hash};{xsts_token}"),
};
let response = client
.post(MINECRAFT_LOGIN_URL)
.json(&request)
.send()
.map_err(MsaError::Network)?;
if !response.status().is_success() {
return Err(MsaError::HttpStatus(response.status()));
}
@@ -354,7 +418,10 @@ pub struct MinecraftProfile {
/// Confirms game ownership. A 404 here means the account has no Java
/// Edition profile — i.e. doesn't own the game — and nothing should
/// install or launch.
fn fetch_minecraft_profile(client: &Client, minecraft_access_token: &str) -> Result<MinecraftProfile, MsaError> {
fn fetch_minecraft_profile(
client: &Client,
minecraft_access_token: &str,
) -> Result<MinecraftProfile, MsaError> {
let response = client
.get(MINECRAFT_PROFILE_URL)
.bearer_auth(minecraft_access_token)
@@ -383,15 +450,26 @@ fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result<LoginResul
let (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?;
let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?;
let profile = fetch_minecraft_profile(client, &minecraft_access_token)?;
Ok(LoginResult { minecraft_access_token, profile, refresh_token: tokens.refresh_token, xuid })
Ok(LoginResult {
minecraft_access_token,
profile,
refresh_token: tokens.refresh_token,
xuid,
})
}
pub fn login_with_device_code(client: &Client, start: &DeviceCodeStart) -> Result<LoginResult, MsaError> {
pub fn login_with_device_code(
client: &Client,
start: &DeviceCodeStart,
) -> Result<LoginResult, MsaError> {
let tokens = poll_device_code(client, start)?;
complete_login(client, tokens)
}
pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result<LoginResult, MsaError> {
pub fn login_with_refresh_token(
client: &Client,
refresh_token: &str,
) -> Result<LoginResult, MsaError> {
let tokens = refresh_microsoft_tokens(client, refresh_token)?;
complete_login(client, tokens)
}
@@ -408,8 +486,15 @@ struct StoredAccount {
pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> {
fs::create_dir_all(data_dir)?;
let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
let saved_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
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);
crate::storage::write_atomic(&target, &contents)
@@ -438,7 +523,10 @@ mod tests {
let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id()));
assert!(load_refresh_token(&dir).is_none());
save_refresh_token(&dir, "super-secret-refresh-token").unwrap();
assert_eq!(load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token"));
assert_eq!(
load_refresh_token(&dir).as_deref(),
Some("super-secret-refresh-token")
);
clear_account(&dir).unwrap();
assert!(load_refresh_token(&dir).is_none());
fs::remove_dir_all(&dir).ok();
@@ -448,18 +536,26 @@ mod tests {
#[test]
fn stored_account_file_is_not_world_or_group_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
let dir =
std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
save_refresh_token(&dir, "secret").unwrap();
let mode = fs::metadata(dir.join(ACCOUNT_FILE)).unwrap().permissions().mode() & 0o777;
let mode = fs::metadata(dir.join(ACCOUNT_FILE))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn refuses_to_run_with_placeholder_client_id() {
assert!(!client_id_is_configured());
assert!(!is_configured());
let client = Client::builder().build().unwrap();
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured)));
assert!(matches!(
start_device_code(&client),
Err(MsaError::NotConfigured)
));
}
/// Live smoke test: requests a real device code from Microsoft and
+233 -40
View File
@@ -40,7 +40,7 @@ use std::{
thread,
};
const NEOFORGE_HOST: &str = "maven.neoforged.net";
pub(crate) const NEOFORGE_HOST: &str = "maven.neoforged.net";
/// Minimal `launcher_profiles.json` accepted by the legacy NeoForge/Forge
/// installer as proof that a directory is a legitimate launcher data
@@ -56,21 +56,34 @@ pub enum NeoForgeError {
Download(DownloadError),
Io(io::Error),
InvalidJson(serde_json::Error),
InstallerFailed { exit_code: Option<i32>, output_tail: String },
InstallerFailed {
exit_code: Option<i32>,
output_tail: String,
},
}
impl fmt::Display for NeoForgeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised NeoForge host: {url}"),
Self::DisallowedHost(url) => {
write!(formatter, "URL is not a recognised NeoForge host: {url}")
}
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"),
Self::InvalidChecksum(text) => write!(formatter, "unexpected checksum response: {text}"),
Self::InvalidChecksum(text) => {
write!(formatter, "unexpected checksum response: {text}")
}
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
Self::InstallerFailed { exit_code, output_tail } => {
write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}")
Self::InstallerFailed {
exit_code,
output_tail,
} => {
write!(
formatter,
"NeoForge installer failed (exit {exit_code:?}):\n{output_tail}"
)
}
}
}
@@ -87,7 +100,7 @@ impl From<io::Error> for NeoForgeError {
}
}
fn is_allowed_host(url: &str) -> bool {
pub(crate) fn is_allowed_host(url: &str) -> bool {
crate::trusted_http::allows(url, &[NEOFORGE_HOST])
}
@@ -102,18 +115,29 @@ fn installer_jar_url(loader_version: &str) -> String {
/// Downloads (or reuses a cached, still-valid) NeoForge installer jar,
/// verified against the `.sha256` sidecar Maven publishes next to every
/// artifact.
pub fn ensure_installer(client: &Client, cache_dir: &Path, loader_version: &str) -> Result<PathBuf, NeoForgeError> {
pub fn ensure_installer(
client: &Client,
cache_dir: &Path,
loader_version: &str,
) -> Result<PathBuf, NeoForgeError> {
let jar_url = installer_jar_url(loader_version);
let checksum_url = format!("{jar_url}.sha256");
if !is_allowed_host(&jar_url) {
return Err(NeoForgeError::DisallowedHost(jar_url));
}
let response = client.get(&checksum_url).send().map_err(NeoForgeError::Network)?;
let response = client
.get(&checksum_url)
.send()
.map_err(NeoForgeError::Network)?;
if !response.status().is_success() {
return Err(NeoForgeError::HttpStatus(response.status()));
}
let sha256 = response.text().map_err(NeoForgeError::Network)?.trim().to_ascii_lowercase();
let sha256 = response
.text()
.map_err(NeoForgeError::Network)?
.trim()
.to_ascii_lowercase();
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(NeoForgeError::InvalidChecksum(sha256));
}
@@ -142,6 +166,23 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat
.join(format!("neoforge-{loader_version}.json"))
}
fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf {
game_dir
.join("libraries/net/neoforged/neoforge")
.join(loader_version)
.join(format!("neoforge-{loader_version}-client.jar"))
}
fn is_nonempty_file(path: &Path) -> bool {
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
fn installation_complete(game_dir: &Path, loader_version: &str) -> bool {
is_nonempty_file(&installed_version_json_path(game_dir, loader_version))
&& is_nonempty_file(&patched_client_path(game_dir, loader_version))
}
/// The installer jar bundles its own `install_profile.json`, which lists
/// exactly which libraries it will download and which processors it will
/// run to patch the client — the same manifest the installer itself reads.
@@ -164,7 +205,14 @@ fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> {
/// installer logging a couple of extra non-library downloads) never exceeds
/// or exceeds `total` by much. `total_libraries` caps the download half so
/// those extra lines cannot crowd out the processor half of the bar.
fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback) {
fn observe_installer_line(
line: &str,
downloads_done: &AtomicU64,
processors_done: &AtomicU64,
total_libraries: u64,
total: u64,
on_progress: &ProgressCallback,
) {
let trimmed = line.trim_start();
if trimmed.starts_with("Download completed") {
downloads_done.fetch_add(1, Ordering::Relaxed);
@@ -176,12 +224,19 @@ fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_don
} else {
return;
}
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed);
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries)
+ processors_done.load(Ordering::Relaxed);
on_progress(current.min(total), total);
}
fn truncate_tail(text: &str) -> String {
text.chars().rev().take(4000).collect::<String>().chars().rev().collect()
text.chars()
.rev()
.take(4000)
.collect::<String>()
.chars()
.rev()
.collect()
}
/// Runs the installer with piped output, reporting live progress as its own
@@ -222,7 +277,14 @@ fn run_installer_with_progress(
let on_progress = Arc::clone(on_progress);
thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
observe_installer_line(&line, &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line(
&line,
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
let mut log = combined_log.lock().unwrap();
log.push_str(&line);
log.push('\n');
@@ -246,7 +308,10 @@ fn run_installer_with_progress(
let tail = truncate_tail(&combined_log.lock().unwrap());
if !status.success() {
return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail });
return Err(NeoForgeError::InstallerFailed {
exit_code: status.code(),
output_tail: tail,
});
}
Ok((status.code(), tail))
}
@@ -261,19 +326,48 @@ fn run_installer_with_progress(
/// progress (installer-confirmed library downloads plus patch-processor
/// steps, read from the installer's own `install_profile.json`) while it
/// runs; it fires once with `(1, 1)` when already installed.
pub fn ensure_client_installed(client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback) -> Result<VersionJson, NeoForgeError> {
pub fn ensure_client_installed(
client: &Client,
java_executable: &Path,
game_dir: &Path,
cache_dir: &Path,
loader_version: &str,
on_progress: &ProgressCallback,
) -> Result<VersionJson, NeoForgeError> {
let version_json_path = installed_version_json_path(game_dir, loader_version);
if !version_json_path.exists() {
if !installation_complete(game_dir, loader_version) {
ensure_launcher_profiles_stub(game_dir)?;
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0));
// A leftover version JSON makes some installer versions treat the
// profile as already installed even when the patched client was
// deleted or quarantined. Remove only that generated marker so the
// official installer is forced to rebuild the incomplete profile.
match fs::remove_file(&version_json_path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(NeoForgeError::Io(error)),
}
let (total_libraries, total_processors) =
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
let (exit_code, tail) = run_installer_with_progress(java_executable, &installer_path, game_dir, cache_dir, total_libraries, total, on_progress)?;
if !version_json_path.exists() {
return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail });
let (exit_code, tail) = run_installer_with_progress(
java_executable,
&installer_path,
game_dir,
cache_dir,
total_libraries,
total,
on_progress,
)?;
if !installation_complete(game_dir, loader_version) {
return Err(NeoForgeError::InstallerFailed {
exit_code,
output_tail: tail,
});
}
on_progress(total, total);
} else {
@@ -299,12 +393,15 @@ mod tests {
#[test]
fn rejects_non_neoforge_hosts() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host("https://maven.neoforged.net/releases/x.jar"));
assert!(is_allowed_host(
"https://maven.neoforged.net/releases/x.jar"
));
}
#[test]
fn launcher_profiles_stub_is_idempotent() {
let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
let dir =
std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
ensure_launcher_profiles_stub(&dir).unwrap();
let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap();
@@ -315,6 +412,25 @@ mod tests {
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn incomplete_install_is_not_accepted() {
let dir = std::env::temp_dir().join(format!(
"shacraft-neoforge-completeness-test-{}",
std::process::id()
));
let version = "21.1.248";
let json = installed_version_json_path(&dir, version);
fs::create_dir_all(json.parent().unwrap()).unwrap();
fs::write(&json, b"{}").unwrap();
assert!(!installation_complete(&dir, version));
let client = patched_client_path(&dir, version);
fs::create_dir_all(client.parent().unwrap()).unwrap();
fs::write(&client, b"patched").unwrap();
assert!(installation_complete(&dir, version));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn observe_installer_line_counts_downloads_and_processor_headers() {
let downloads_done = AtomicU64::new(0);
@@ -329,12 +445,47 @@ mod tests {
// A "Downloading library from ..." start line reports nothing by
// itself; only its "Download completed" confirmation counts.
observe_installer_line("Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line(
"Downloading library from https://example/a.jar",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
observe_installer_line(
"Download completed: Checksum validated.",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
observe_installer_line(
"Download completed: Checksum validated.",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
observe_installer_line(
"Processor: net.neoforged.installertools:jarsplitter",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
// A processor's sub-step lines (three colons) must not double-count.
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress);
observe_installer_line(
"Processor: net.neoforged.installertools:jarsplitter: Loading patch files",
&downloads_done,
&processors_done,
total_libraries,
total,
&on_progress,
);
assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]);
}
@@ -353,7 +504,8 @@ mod tests {
use crate::{java, mojang};
let client = Client::builder().build().unwrap();
let root = std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let root =
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let game_dir = root.join("game");
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).unwrap();
@@ -363,7 +515,8 @@ mod tests {
let vanilla = mojang::fetch_version_json(&client, entry).unwrap();
let no_progress: ProgressCallback = Arc::new(|_, _| {});
let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
let java_install =
java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
// The installer fetches and patches vanilla itself; we don't
// pre-download it. It only needs a Java runtime and an empty dir.
@@ -372,25 +525,65 @@ mod tests {
let progress_calls = Arc::clone(&progress_calls);
Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total)))
};
let neoforge_version = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress).unwrap();
let neoforge_version = ensure_client_installed(
&client,
Path::new(&java_install.executable),
&game_dir,
&cache_dir,
"21.1.248",
&progress,
)
.unwrap();
let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap();
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len());
assert_eq!(
merged.main_class,
"cpw.mods.bootstraplauncher.BootstrapLauncher"
);
assert!(
merged.libraries.len() > 100,
"expected vanilla (97) + neoforge (47) libraries, got {}",
merged.libraries.len()
);
let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath");
let patched_client =
game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
assert!(
patched_client.exists(),
"FancyModLoader needs this at runtime even though it is not on the generic classpath"
);
let calls = progress_calls.lock().unwrap();
assert!(calls.len() > 5, "expected many incremental progress calls, got {}", calls.len());
assert!(
calls.len() > 5,
"expected many incremental progress calls, got {}",
calls.len()
);
let (last_current, last_total) = *calls.last().unwrap();
assert_eq!(last_current, last_total, "progress must reach 100% on success");
assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards");
assert_eq!(
last_current, last_total,
"progress must reach 100% on success"
);
assert!(
calls.windows(2).all(|pair| pair[0].0 <= pair[1].0),
"reported progress must never go backwards"
);
drop(calls);
// Re-running must skip straight to reading the cached version JSON
// rather than invoking the installer again.
let neoforge_again = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress).unwrap();
assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len());
let neoforge_again = ensure_client_installed(
&client,
Path::new(&java_install.executable),
&game_dir,
&cache_dir,
"21.1.248",
&no_progress,
)
.unwrap();
assert_eq!(
neoforge_again.libraries.len(),
neoforge_version.libraries.len()
);
fs::remove_dir_all(&root).ok();
}
+1
View File
@@ -11,6 +11,7 @@ use std::sync::{
pub(crate) struct LauncherOperations {
pub installation: Operation,
pub account: Operation,
pub shacraft_account: Operation,
}
#[derive(Clone, Default)]
+21
View File
@@ -254,6 +254,27 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn preserves_changed_seed_files_as_current() {
let root = std::env::temp_dir().join(format!(
"shacraft-launcher-seed-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut expected = manifest("0".repeat(64), 42);
expected.files[0].policy = FilePolicy::Seed;
fs::create_dir_all(root.join("mods")).unwrap();
fs::write(root.join("mods/example.jar"), b"player customization").unwrap();
let inspection = inspect(&root, &expected).unwrap();
assert_eq!(inspection.missing_files, 0);
assert_eq!(inspection.mismatched_files, 0);
assert!(inspection.up_to_date);
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn refuses_linked_profile_directories() {
+100 -14
View File
@@ -1,20 +1,32 @@
use crate::manifest::{self, Manifest};
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, VerifyingKey};
use reqwest::header::ACCEPT_ENCODING;
use reqwest::{blocking::Client, redirect::Policy};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::{
fmt,
io::{self, Read},
thread,
time::Duration,
};
const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
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;
const MAX_ENVELOPE_BYTES: usize = 2 * 1024 * 1024;
const MANIFEST_ATTEMPTS: u32 = 3;
/// Display-only status: never used to select executable files or versions.
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerStatus {
pub online: Option<u32>,
pub max: Option<u32>,
pub reachable: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -62,21 +74,13 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
.https_only(true)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.redirect(Policy::none())
.build()
.map_err(RemoteError::Network)?;
let response = client.get(url).send().map_err(RemoteError::Network)?;
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
if response
.content_length()
.is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64)
{
return Err(RemoteError::TooLarge);
}
let source = read_envelope(response)?;
let source = fetch_manifest_bytes(&client, url)?;
let public_key_bytes = STANDARD
.decode(MANIFEST_PUBLIC_KEY)
.expect("embedded public key must be valid");
@@ -89,6 +93,55 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
verify_envelope(&source, profile_id, &public_key)
}
fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteError> {
for attempt in 1..=MANIFEST_ATTEMPTS {
let request = || {
let response = client
.get(url)
.header(ACCEPT_ENCODING, "identity")
.send()
.map_err(RemoteError::Network)?;
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
if response
.content_length()
.is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64)
{
return Err(RemoteError::TooLarge);
}
read_envelope(response)
};
match request() {
Err(RemoteError::Network(_) | RemoteError::Read(_)) if attempt < MANIFEST_ATTEMPTS => {
thread::sleep(Duration::from_millis(250 * attempt as u64));
}
result => return result,
}
}
unreachable!("the last attempt always returns")
}
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_ONLINE,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
.https_only(true)
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.build()
.map_err(RemoteError::Network)?;
let response = client.get(url).send().map_err(RemoteError::Network)?;
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
response
.json::<ServerStatus>()
.map_err(RemoteError::Network)
}
fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
let mut bytes = Vec::new();
source
@@ -213,4 +266,37 @@ mod tests {
Err(RemoteError::TooLarge)
));
}
#[test]
fn retries_truncated_manifest_transfers_and_requests_identity_encoding() {
use std::{
io::{Read, Write},
net::TcpListener,
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/manifest", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
for body in [
b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nbad".as_slice(),
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".as_slice(),
] {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut request = [0_u8; 4096];
let length = stream.read(&mut request).unwrap();
assert!(String::from_utf8_lossy(&request[..length])
.to_ascii_lowercase()
.contains("accept-encoding: identity"));
stream.write_all(body).unwrap();
}
});
let client = Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap();
assert_eq!(fetch_manifest_bytes(&client, &url).unwrap(), b"{}");
server.join().unwrap();
}
}
+278
View File
@@ -0,0 +1,278 @@
//! ShaCraft local-account client.
//!
//! The API origin is fixed in the binary. Passwords are sent only over HTTPS
//! and are never persisted; only the random, revocable session token is kept.
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use std::{fmt, fs, io, path::Path, time::Duration};
const API_ORIGIN: &str = "https://shacraft.ru";
const SESSION_FILE: &str = "shacraft-session";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AccountLink {
pub server_id: String,
pub mc_username: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Account {
pub username: String,
pub links: Vec<AccountLink>,
}
#[derive(Deserialize)]
struct AuthResponse {
session_token: String,
account: Account,
recovery_codes: Vec<String>,
}
#[derive(Serialize)]
struct Credentials<'a> {
username: &'a str,
password: &'a str,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginResult {
pub account: Account,
pub recovery_codes: Vec<String>,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStart {
pub challenge_id: i64,
pub expires_in_seconds: u64,
pub registered_on_server: bool,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStatus {
pub status: String,
pub detail: Option<String>,
}
#[derive(Debug)]
pub enum AccountError {
Network(reqwest::Error),
Api(String),
Io(io::Error),
InvalidSession,
NoLinkedNickname,
}
impl fmt::Display for AccountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(error) => write!(formatter, "Нет связи с аккаунтами ShaCraft: {error}"),
Self::Api(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
Self::NoLinkedNickname => {
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
}
}
}
}
fn client() -> Result<Client, AccountError> {
Client::builder()
.https_only(true)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(20))
.redirect(Policy::none())
.build()
.map_err(AccountError::Network)
}
fn api_error(response: Response) -> AccountError {
#[derive(Deserialize)]
struct ErrorBody {
detail: Option<String>,
}
let status = response.status();
let detail = response
.json::<ErrorBody>()
.ok()
.and_then(|body| body.detail);
AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}")))
}
fn session_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(SESSION_FILE)
}
fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> {
fs::create_dir_all(data_dir).map_err(AccountError::Io)?;
let path = session_path(data_dir);
crate::storage::write_atomic(&path, token.as_bytes()).map_err(AccountError::Io)
}
fn load_session(data_dir: &Path) -> Result<String, AccountError> {
let token = fs::read_to_string(session_path(data_dir)).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
AccountError::InvalidSession
} else {
AccountError::Io(error)
}
})?;
let token = token.trim();
if token.len() < 32 || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
return Err(AccountError::InvalidSession);
}
Ok(token.to_owned())
}
pub fn authenticate(
data_dir: &Path,
username: &str,
password: &str,
register: bool,
) -> Result<LoginResult, AccountError> {
let endpoint = if register {
"/api/launcher/auth/register"
} else {
"/api/launcher/auth/login"
};
let response = client()?
.post(format!("{API_ORIGIN}{endpoint}"))
.json(&Credentials { username, password })
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
let payload = response
.json::<AuthResponse>()
.map_err(AccountError::Network)?;
save_session(data_dir, &payload.session_token)?;
Ok(LoginResult {
account: payload.account,
recovery_codes: payload.recovery_codes,
})
}
pub fn get_account(data_dir: &Path) -> Result<Account, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.get(format!("{API_ORIGIN}/api/launcher/account"))
.bearer_auth(token)
.send()
.map_err(AccountError::Network)?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = fs::remove_file(session_path(data_dir));
return Err(AccountError::InvalidSession);
}
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<Account>().map_err(AccountError::Network)
}
pub fn logout(data_dir: &Path) -> Result<(), AccountError> {
if let Ok(token) = load_session(data_dir) {
let _ = client()?
.post(format!("{API_ORIGIN}/api/launcher/auth/logout"))
.bearer_auth(token)
.json(&serde_json::json!({}))
.send();
}
match fs::remove_file(session_path(data_dir)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AccountError::Io(error)),
}
}
pub fn start_link(
data_dir: &Path,
server_id: &str,
nickname: &str,
) -> Result<LinkStart, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.post(format!("{API_ORIGIN}/api/account/link/start"))
.bearer_auth(token)
.json(&serde_json::json!({"server_id": server_id, "mc_username": nickname}))
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<LinkStart>().map_err(AccountError::Network)
}
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.get(format!(
"{API_ORIGIN}/api/account/link/status/{challenge_id}"
))
.bearer_auth(token)
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<LinkStatus>().map_err(AccountError::Network)
}
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
get_account(data_dir)?
.links
.into_iter()
.find(|link| link.server_id == "aoc")
.map(|link| link.mc_username)
.ok_or(AccountError::NoLinkedNickname)
}
#[cfg(test)]
mod tests {
use super::{load_session, save_session, session_path};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"shacraft-account-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn session_round_trips_without_password_storage() {
let directory = temporary_directory();
let token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
save_session(&directory, token).unwrap();
assert_eq!(load_session(&directory).unwrap(), token);
assert_eq!(fs::read_to_string(session_path(&directory)).unwrap(), token);
fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn session_is_private_on_unix() {
use std::os::unix::fs::PermissionsExt;
let directory = temporary_directory();
save_session(&directory, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG").unwrap();
assert_eq!(
fs::metadata(session_path(&directory))
.unwrap()
.permissions()
.mode()
& 0o077,
0
);
fs::remove_dir_all(directory).unwrap();
}
}
+56 -1
View File
@@ -68,7 +68,7 @@ impl AtomicFile {
pub fn commit(mut self) -> io::Result<()> {
self.writer().sync_all()?;
drop(self.file.take());
fs::rename(&self.temporary, &self.target)?;
replace_file(&self.temporary, &self.target)?;
self.committed = true;
Ok(())
}
@@ -89,6 +89,43 @@ pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
output.commit()
}
/// Prefer the platform's atomic replacement. If Windows refuses an existing
/// destination, retain the upstream recoverable replacement fallback, using
/// this transaction's unique temporary name instead of a shared backup path.
fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> {
#[cfg(not(windows))]
{
fs::rename(temporary, target)
}
#[cfg(windows)]
{
match fs::rename(temporary, target) {
Ok(()) => return Ok(()),
Err(error) if !target.is_file() => return Err(error),
Err(_) => {}
}
let mut backup_name = temporary.as_os_str().to_os_string();
backup_name.push(".backup");
let backup = PathBuf::from(backup_name);
// Never overwrite a previous failed transaction's recovery file.
let reservation = OpenOptions::new()
.write(true)
.create_new(true)
.open(&backup)?;
drop(reservation);
fs::remove_file(&backup)?;
fs::rename(target, &backup)?;
if let Err(error) = fs::rename(temporary, target) {
if let Err(restore_error) = fs::rename(&backup, target) {
return Err(io::Error::new(error.kind(), format!("Cannot replace file: {error}; cannot restore it: {restore_error}; previous file is recoverable at {}", backup.display())));
}
return Err(error);
}
let _ = fs::remove_file(backup);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -132,4 +169,22 @@ mod tests {
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn replaces_an_existing_file() {
let root = std::env::temp_dir().join(format!(
"shacraft-replacement-test-{}-{}",
std::process::id(),
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&root).unwrap();
let target = root.join("old.txt");
let temporary = root.join("new.part");
fs::write(&target, b"old").unwrap();
fs::write(&temporary, b"new").unwrap();
replace_file(&temporary, &target).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!(!temporary.exists());
fs::remove_dir_all(root).unwrap();
}
}